diff --git a/jest.config.js b/jest.config.js index 189075e..3b488c1 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,6 +8,21 @@ module.exports = { verbose: false, collectCoverage, coverageReporters: ['text', 'lcov'], + // Coverage gate for the decomposed Game modules (nodots/backgammon-core#134). + // Enforced only under COVERAGE=1 (CI). A directory-path key aggregates + // coverage across all files under src/Game/ (rather than per-file), so the + // well-covered modules (shared/guards/lifecycle/undo/cube) balance the + // thinner delegators (executeRobotTurn) and the large turnFlow file. The + // branch floor sits just under the current aggregate and ratchets toward 75 + // as the remaining turnFlow no-move/blocked paths gain coverage. + coverageThreshold: { + './src/Game/': { + statements: 75, + functions: 75, + lines: 75, + branches: 70, + }, + }, testMatch: ['**/?(*.)+(test).ts'], moduleFileExtensions: ['ts', 'js', 'json', 'node'], transform: { diff --git a/src/Game/__tests__/cube-resign-characterization.test.ts b/src/Game/__tests__/cube-resign-characterization.test.ts new file mode 100644 index 0000000..9287e4f --- /dev/null +++ b/src/Game/__tests__/cube-resign-characterization.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame } from '@nodots/backgammon-types' +import { Game } from '../index' + +// Build a game forced into 'rolling' state, mirroring the setup used by +// crawford-jacoby-rules.test.ts. +function buildRollingGame(): BackgammonGame { + const game = Game.createNewGame( + { userId: 'player1', isRobot: false }, + { userId: 'player2', isRobot: false } + ) + const rolledForStart = Game.rollForStart(game) + // Forcing state shape for a unit fixture; matches the sibling cube tests. + return { + ...rolledForStart, + stateKind: 'rolling', + activePlayer: { ...rolledForStart.activePlayer, stateKind: 'rolling' }, + inactivePlayer: { ...rolledForStart.inactivePlayer, stateKind: 'inactive' }, + } as any +} + +describe('Game.double() — characterization', () => { + it('offers the cube: doubled state, value 2, offeredBy the active player', () => { + const rolling = buildRollingGame() + const offeringId = rolling.activePlayer!.id + + const doubled = Game.double(rolling as any) + + expect(doubled.stateKind).toBe('doubled') + expect(doubled.cube.stateKind).toBe('offered') + expect(doubled.cube.value).toBe(2) + expect(doubled.cube.offeredBy?.id).toBe(offeringId) + }) + + it('throws when not in rolling state', () => { + const rolling = buildRollingGame() + const notRolling = { ...rolling, stateKind: 'moving' } as any + expect(() => Game.double(notRolling)).toThrow('Cannot double from moving state') + }) +}) + +describe('Game.canAcceptDouble() / acceptDouble() — characterization', () => { + it('canAcceptDouble is true for the non-offering player, false for the offering player', () => { + const rolling = buildRollingGame() + const doubled = Game.double(rolling as any) + const offering = doubled.cube.offeredBy! + const accepting = doubled.players.find((p) => p.id !== offering.id)! + + expect(Game.canAcceptDouble(doubled, accepting as any)).toBe(true) + expect(Game.canAcceptDouble(doubled, offering as any)).toBeFalsy() + }) + + it('acceptDouble transfers the cube to the accepter and returns to rolling', () => { + const rolling = buildRollingGame() + const doubled = Game.double(rolling as any) + const offering = doubled.cube.offeredBy! + const accepting = doubled.players.find((p) => p.id !== offering.id)! + + const accepted = Game.acceptDouble(doubled, accepting as any) + + expect(accepted.stateKind).toBe('rolling') + expect(accepted.cube.stateKind).toBe('doubled') + expect(accepted.cube.value).toBe(2) + expect(accepted.cube.owner?.id).toBe(accepting.id) + expect(accepted.cube.offeredBy).toBeUndefined() + // The original offering player is put back on roll. + expect(accepted.activePlayer?.id).toBe(offering.id) + }) + + it('acceptDouble throws when the caller cannot accept', () => { + const rolling = buildRollingGame() + const doubled = Game.double(rolling as any) + const offering = doubled.cube.offeredBy! + expect(() => Game.acceptDouble(doubled, offering as any)).toThrow( + 'Cannot accept double' + ) + }) + + it('acceptDouble at 64 completes the game (maxxed cube)', () => { + const doubled = Game.double(buildRollingGame() as any) + const offering = doubled.cube.offeredBy! + const accepting = doubled.players.find((p) => p.id !== offering.id)! + // Force the offered value to the max; accepting it ends the game. + const at64 = { ...doubled, cube: { ...doubled.cube, value: 64 } } as any + const done = Game.acceptDouble(at64, accepting as any) + expect(done.stateKind).toBe('completed') + expect((done as any).pointsWon).toBe(64) + expect(done.cube.stateKind).toBe('maxxed') + }) +}) + +describe('Game.refuseDouble() — characterization', () => { + it('reverts to a doubled cube at half value when refusing a non-first double', () => { + const doubled = Game.double(buildRollingGame() as any) + const offering = doubled.cube.offeredBy! + const refusing = doubled.players.find((p) => p.id !== offering.id)! + // Cube already at 4 before this offer (a re-double, not the first double). + const at4 = { + ...doubled, + cube: { ...doubled.cube, value: 4, owner: offering }, + } as any + const done = Game.refuseDouble(at4, refusing as any) + expect(done.stateKind).toBe('completed') + // Winner gets the pre-double value: 4 / 2 = 2. + expect((done as any).pointsWon).toBe(2) + expect(done.cube.stateKind).toBe('doubled') + }) +}) + +describe('Game.resign() — characterization', () => { + it('completes the game with the opponent as winner (simple, 1 point)', () => { + const rolling = buildRollingGame() + const resigningPlayer = rolling.activePlayer! + const opponent = rolling.players.find((p) => p.id !== resigningPlayer.id)! + + const completed = Game.resign(rolling, resigningPlayer as any, 1) + + expect(completed.stateKind).toBe('completed') + expect(completed.winner).toBe(opponent.id) + expect((completed as any).winType).toBe('simple') + expect((completed as any).pointsWon).toBe(1) + expect((completed as any).endReason).toBe('resignation') + }) + + it('scores a gammon resignation as 2x the cube value', () => { + const rolling = buildRollingGame() + const completed = Game.resign(rolling, rolling.activePlayer! as any, 2) + expect((completed as any).winType).toBe('gammon') + // Fresh cube value is undefined -> treated as 1, so 2 * 1 = 2. + expect((completed as any).pointsWon).toBe(2) + }) + + it('Jacoby rule reduces a gammon to simple when the cube is centered', () => { + const base = buildRollingGame() + const rolling = { ...base, rules: { useJacobyRule: true } } as any + const completed = Game.resign(rolling, rolling.activePlayer, 2) + expect((completed as any).winType).toBe('simple') + expect((completed as any).pointsWon).toBe(1) + }) + + it('throws when resignation is disabled by settings', () => { + const base = buildRollingGame() + const rolling = { ...base, settings: { allowResign: false } } as any + expect(() => Game.resign(rolling, rolling.activePlayer, 1)).toThrow( + 'Resignation is not allowed' + ) + }) + + it('throws when the game is already completed', () => { + const base = buildRollingGame() + const completedGame = { ...base, stateKind: 'completed' } as any + expect(() => Game.resign(completedGame, base.activePlayer!, 1)).toThrow( + 'Cannot resign a completed game' + ) + }) +}) diff --git a/src/Game/__tests__/game-accessors.test.ts b/src/Game/__tests__/game-accessors.test.ts new file mode 100644 index 0000000..ef3a7e7 --- /dev/null +++ b/src/Game/__tests__/game-accessors.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from '@jest/globals' +import { Board } from '../../Board' +import { Game } from '../index' + +// A real game rolled into 'moving' state (board + active/inactive players). +function buildMovingGame() { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rolledForStart = Game.rollForStart(game) + return Game.roll(rolledForStart) +} + +describe('Game.activePlayer() / inactivePlayer()', () => { + it('returns the active and inactive players by color/state', () => { + const g = buildMovingGame() + const active = Game.activePlayer(g) + const inactive = Game.inactivePlayer(g) + expect(active.color).toBe(g.activeColor) + expect(inactive.color).not.toBe(g.activeColor) + expect(inactive.stateKind).toBe('inactive') + }) + + it('throws when no active player matches', () => { + const g = buildMovingGame() + const broken = { ...g, activeColor: undefined } as any + expect(() => Game.activePlayer(broken)).toThrow('Active player not found') + }) + + it('throws when no inactive player matches', () => { + const g = buildMovingGame() + // Both players share activeColor so none is inactive-for-that-color. + const broken = { + ...g, + players: g.players.map((p) => ({ ...p, color: g.activeColor })), + } as any + expect(() => Game.inactivePlayer(broken)).toThrow('Inactive player not found') + }) +}) + +describe('Game.getPlayersForColor()', () => { + it('returns [active, inactive] for the given color', () => { + const g = buildMovingGame() + const [active, inactive] = Game.getPlayersForColor(g.players, g.activeColor) + expect(active.color).toBe(g.activeColor) + expect(inactive.color).not.toBe(g.activeColor) + }) + + it('throws when a matching player is missing', () => { + const g = buildMovingGame() + const oneColor = g.players.map((p) => ({ ...p, color: 'white' })) as any + expect(() => Game.getPlayersForColor(oneColor, 'white')).toThrow( + 'Players not found' + ) + }) +}) + +describe('Game.findChecker()', () => { + it('returns the checker when it exists on the board', () => { + const g = buildMovingGame() + const anyChecker = Board.getCheckers(g.board)[0] + const found = Game.findChecker(g, anyChecker.id) + expect(found?.id).toBe(anyChecker.id) + }) + + it('returns null when the checker id is not found', () => { + const g = buildMovingGame() + expect(Game.findChecker(g, 'no-such-checker')).toBeNull() + }) +}) + +describe('Game.createNewGame() with rules', () => { + it('merges provided rules onto the base game', () => { + const g = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: true }, + { rules: { useJacobyRule: true, useBeaverRule: true } } + ) + expect(g.rules?.useJacobyRule).toBe(true) + expect(g.rules?.useBeaverRule).toBe(true) + expect(g.stateKind).toBe('rolling-for-start') + expect(g.players).toHaveLength(2) + }) +}) diff --git a/src/Game/__tests__/guards.test.ts b/src/Game/__tests__/guards.test.ts new file mode 100644 index 0000000..87282d7 --- /dev/null +++ b/src/Game/__tests__/guards.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame } from '@nodots/backgammon-types' +import { Game } from '../index' + +// Minimal game shape for exercising the pure state predicates. +function gameWith( + stateKind: string, + extra: Partial = {} +): BackgammonGame { + return { + stateKind, + activeColor: 'white', + players: [ + { id: 'p1', color: 'white' }, + { id: 'p2', color: 'black' }, + ], + ...extra, + // Predicate-only fixture; the full BackgammonGame shape is not needed. + } as any +} + +describe('Game.canRoll()', () => { + it.each(['rolled-for-start', 'rolling', 'doubled'])( + 'is true in %s state', + (kind) => { + expect(Game.canRoll(gameWith(kind))).toBe(true) + } + ) + + it.each(['rolling-for-start', 'moving', 'moved', 'completed'])( + 'is false in %s state', + (kind) => { + expect(Game.canRoll(gameWith(kind))).toBe(false) + } + ) +}) + +describe('Game.canRollForStart()', () => { + it('is true only in rolling-for-start state', () => { + expect(Game.canRollForStart(gameWith('rolling-for-start'))).toBe(true) + expect(Game.canRollForStart(gameWith('rolling'))).toBe(false) + }) +}) + +describe('Game.canGetPossibleMoves()', () => { + it('is true only in moving state', () => { + expect(Game.canGetPossibleMoves(gameWith('moving'))).toBe(true) + expect(Game.canGetPossibleMoves(gameWith('rolling'))).toBe(false) + }) +}) + +describe('Game.canPlayerRoll()', () => { + it('is false when the game cannot roll at all', () => { + expect(Game.canPlayerRoll(gameWith('moving'), 'p1')).toBe(false) + }) + + it('is true for the active player in a rollable state', () => { + expect(Game.canPlayerRoll(gameWith('rolling'), 'p1')).toBe(true) + }) + + it('is false for the non-active player', () => { + expect(Game.canPlayerRoll(gameWith('rolling'), 'p2')).toBe(false) + }) + + it('is true regardless of id when there is no activeColor', () => { + const g = gameWith('rolling', { activeColor: undefined as any }) + expect(Game.canPlayerRoll(g, 'anyone')).toBe(true) + }) +}) 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/__tests__/robot.test.ts b/src/Game/__tests__/robot.test.ts new file mode 100644 index 0000000..fb970e6 --- /dev/null +++ b/src/Game/__tests__/robot.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from '@jest/globals' +import { BackgammonGame, BackgammonGameMoved } from '@nodots/backgammon-types' +import { Game } from '../index' + +// Build a real game rolled into play, then force it to 'moved' so the +// turn-confirmation path can run. isRobot controls both players. +function buildMovedGame(isRobot: boolean): BackgammonGameMoved { + const game = Game.createNewGame( + { userId: 'p1', isRobot }, + { userId: 'p2', isRobot } + ) + const rolledForStart = Game.rollForStart(game) + const rolled = Game.roll(rolledForStart) + // Forcing to 'moved' for the confirmation-path fixture. + return { + ...rolled, + stateKind: 'moved', + activePlayer: { ...rolled.activePlayer, stateKind: 'moved' }, + } as any +} + +describe('Game.handleRobotMovedState()', () => { + it('confirms the turn when in moved state with a robot active player', () => { + const moved = buildMovedGame(true) + const result = Game.handleRobotMovedState(moved) + expect(result.stateKind).toBe('rolling') + }) + + it('returns the game unchanged when the active player is human', () => { + const moved = buildMovedGame(false) + const result = Game.handleRobotMovedState(moved) + expect(result).toBe(moved) + expect(result.stateKind).toBe('moved') + }) + + it('returns the game unchanged when not in moved state', () => { + const notMoved = { stateKind: 'rolling' } as unknown as BackgammonGame + expect(Game.handleRobotMovedState(notMoved)).toBe(notMoved) + }) +}) + +describe('Game.confirmTurnWithRobotAutomation()', () => { + it('confirms the turn and returns a rolling game (both robots)', async () => { + const moved = buildMovedGame(true) + const result = await Game.confirmTurnWithRobotAutomation(moved) + expect(result.stateKind).toBe('rolling') + // Next player is a robot; automation is external so the game is returned as-is. + expect(result.activePlayer?.isRobot).toBe(true) + }) + + it('confirms the turn when the next player is human', async () => { + const moved = buildMovedGame(false) + const result = await Game.confirmTurnWithRobotAutomation(moved) + expect(result.stateKind).toBe('rolling') + expect(result.activePlayer?.isRobot).toBe(false) + }) +}) diff --git a/src/Game/__tests__/turnflow.test.ts b/src/Game/__tests__/turnflow.test.ts new file mode 100644 index 0000000..343a6dc --- /dev/null +++ b/src/Game/__tests__/turnflow.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from '@jest/globals' +import type { + BackgammonCheckerContainerImport, + BackgammonGameMoving, + BackgammonPlayerInactive, + BackgammonPlayerRollingForStart, +} from '@nodots/backgammon-types' +import { Board } from '../../Board' +import { Cube } from '../../Cube' +import { Dice } from '../../Dice' +import { Play } from '../../Play' +import { Player } from '../../Player' +import { Game } from '../index' + +const pointCC = ( + posCC: number, + qty: number, + color: 'black' | 'white' +): BackgammonCheckerContainerImport => ({ + // Branded position literals; the arithmetic result is a plain number. + position: { clockwise: (25 - posCC) as any, counterclockwise: posCC as any }, + checkers: { qty, color }, +}) + +// Moving game: a single black checker at CC 24, open board, roll [1,2]. +// Both dice are legal, so the play has two ready moves and an empty undo stack. +function buildMovingGame(): BackgammonGameMoving { + const board = Board.buildBoard([pointCC(24, 1, 'black')]) + const blackRolling = Player.initialize( + 'black', + 'counterclockwise', + 'rolling', + false + ) as any + 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) + return Game.initialize( + [blackMoving, whiteInactive] as any, + 'turnflow-game', + 'moving', + board, + Cube.initialize(), + play, + 'black', + blackMoving, + whiteInactive + ) as BackgammonGameMoving +} + +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 +} + +// A game forced into 'rolling' state, ready for Game.roll's rolling branch. +// The active-color player in the players array must also be 'rolling' with +// rolling dice, since Game.roll reads it via getPlayersForColor. +function buildRollingGame() { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rfs = Game.rollForStart(game) + const activeColor = rfs.activeColor + const activeRolling = { + ...rfs.activePlayer, + stateKind: 'rolling', + dice: Dice.initialize(rfs.activePlayer.color, 'rolling'), + } + const inactive = { ...rfs.inactivePlayer, stateKind: 'inactive' } + const players = rfs.players.map((p) => + p.color === activeColor ? activeRolling : inactive + ) + return { + ...rfs, + stateKind: 'rolling', + players, + activePlayer: activeRolling, + inactivePlayer: inactive, + } as any +} + +describe('Game.roll()', () => { + it('rolls from rolled-for-start using the roll-for-start values', () => { + const game = Game.createNewGame( + { userId: 'p1', isRobot: false }, + { userId: 'p2', isRobot: false } + ) + const rolledForStart = Game.rollForStart(game) + const rolled = Game.roll(rolledForStart) + expect(['moving', 'moved']).toContain(rolled.stateKind) + expect(rolled.activePlayer.dice.currentRoll).toBeDefined() + }) + + it('rolls from rolling state (generates new dice)', () => { + const rolled = Game.roll(buildRollingGame()) + expect(['moving', 'moved']).toContain(rolled.stateKind) + }) + + it('rolls from doubled state after a double', () => { + const doubled = Game.double(buildRollingGame()) + const rolled = Game.roll(doubled as any) + expect(['moving', 'moved']).toContain(rolled.stateKind) + }) + + it('throws on an unexpected state', () => { + expect(() => Game.roll({ stateKind: 'moving' } as any)).toThrow() + }) +}) + +describe('Game.switchDice()', () => { + it('swaps the dice order and regenerates moves', () => { + const game = buildMovingGame() + const before = game.activePlayer.dice.currentRoll + const switched = Game.switchDice(game) + expect(switched.activePlayer.dice.currentRoll).toEqual([ + before![1], + before![0], + ]) + }) + + it('throws when not in moving state', () => { + const game = buildMovingGame() + expect(() => Game.switchDice({ ...game, stateKind: 'rolling' } as any)).toThrow( + 'Cannot switch dice' + ) + }) + + it('throws when moves are not all undone', () => { + const game = buildMovingGame() + // Mark a move completed so not-all-undone. + ;(game.activePlay as any).moves[0].stateKind = 'completed' + expect(() => Game.switchDice(game)).toThrow('all moves are undone') + }) +}) + +describe('Game.executeAndRecalculate()', () => { + it('executes a move from an origin container', () => { + const game = buildMovingGame() + const originId = Board.getPoints(game.board).find( + (p) => p.position.counterclockwise === 24 + )!.id + const after = Game.executeAndRecalculate(game, originId) + expect(after).toBeDefined() + expect(['moving', 'moved', 'completed']).toContain(after.stateKind) + }) + + it('throws when no checker exists in the origin', () => { + const game = buildMovingGame() + const emptyOrigin = Board.getPoints(game.board).find( + (p) => p.checkers.length === 0 + )!.id + expect(() => Game.executeAndRecalculate(game, emptyOrigin)).toThrow( + 'checker found in container' + ) + }) +}) + +describe('Game.checkAndCompleteTurn()', () => { + it('returns the game unchanged when moves are incomplete', () => { + const game = buildMovingGame() + const result = Game.checkAndCompleteTurn(game) + // Both dice still playable from CC24, so the turn is not complete. + expect(result.stateKind).toBe('moving') + }) + + it('returns the game unchanged for an invalid game structure', () => { + const broken = { stateKind: 'moving' } as any + expect(Game.checkAndCompleteTurn(broken)).toBe(broken) + }) +}) + +describe('Game.toMoved()', () => { + it('throws when not in moving state', () => { + const game = buildMovingGame() + expect(() => Game.toMoved({ ...game, stateKind: 'rolling' } as any)).toThrow( + "Must be in 'moving' state" + ) + }) + + it('throws when not all moves are completed', () => { + const game = buildMovingGame() + expect(() => Game.toMoved(game)).toThrow('not all moves are completed') + }) + + it('transitions to moved when all moves are completed', () => { + const game = buildMovingGame() + ;(game.activePlay as any).moves.forEach((m: any) => { + m.stateKind = 'completed' + }) + const moved = Game.toMoved(game) + expect(moved.stateKind).toBe('moved') + }) +}) + +describe('Game.moveAndFinalize()', () => { + it('executes a move and returns a valid state', () => { + const game = buildMovingGame() + const after = Game.moveAndFinalize(game, ccOriginCheckerId(game)) + expect(['moving', 'moved', 'completed']).toContain(after.stateKind) + }) +}) + 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) + }) +}) diff --git a/src/Game/cube.ts b/src/Game/cube.ts new file mode 100644 index 0000000..58da8db --- /dev/null +++ b/src/Game/cube.ts @@ -0,0 +1,367 @@ +import { + BackgammonCubeValue, + BackgammonGame, + BackgammonGameCompleted, + BackgammonGameDoubled, + BackgammonGameRolling, + BackgammonPlayer, + BackgammonPlayerActive, + BackgammonPlayerDoubled, + BackgammonPlayerInactive, + BackgammonPlayerRolling, + BackgammonPlayers, + BackgammonPlayerWinner, +} from '@nodots/backgammon-types' +import { Dice } from '../Dice' +import { logger } from '../utils/logger' +import { incrementStateVersion } from './shared' + +export function canOfferDouble( + game: BackgammonGame, + player: BackgammonPlayerActive +): boolean { + // Crawford rule: No doubling allowed during the Crawford game + // The Crawford game is the first game after a player reaches match point - 1 + if (game.rules?.useCrawfordRule && game.matchInfo?.isCrawford) { + return false + } + + // Allow doubling from rolling state only (before rolling dice) + // Only if cube is centered (no owner) OR the player owns the cube + // and cube is not maxxed or already offered + return ( + game.stateKind === 'rolling' && + game.cube.stateKind !== 'maxxed' && + game.cube.stateKind !== 'offered' && + (!game.cube.owner || game.cube.owner.id === player.id) && + // Disallow repeat doubles in same turn unless Beaver is implemented + game.cube.offeredThisTurnBy?.id !== player.id + ) +} + +export function canAcceptDouble( + game: BackgammonGame, + player: BackgammonPlayerActive +): boolean { + // Only if cube is in 'offered' state and player is not the one who offered + return ( + game.cube.stateKind === 'offered' && + game.cube.offeredBy && + game.cube.offeredBy.id !== player.id + ) +} + +export function acceptDouble( + game: BackgammonGame, + player: BackgammonPlayerActive +): BackgammonGame { + if (!canAcceptDouble(game, player)) throw new Error('Cannot accept double') + // Accept the offered value (do NOT double again here). Transfer ownership to accepting player. + const acceptedValue = (game.cube.value ?? 2) as typeof game.cube.value + const offeringPlayer = game.cube.offeredBy! + // If accepting at 64, preserve prior behavior (may end the game depending on rules) + if (acceptedValue === 64) { + // If maxxed, game should be completed + const winner = { + ...player, + stateKind: 'winner', + } as BackgammonPlayerWinner + + // Update players array to reflect winner status + const updatedPlayers = game.players.map((p) => + p.id === winner.id ? winner : p + ) as BackgammonPlayers + + return incrementStateVersion({ + ...game, + stateKind: 'completed', + winner: winner.id, + winType: 'simple', + pointsWon: 64, + players: updatedPlayers, + cube: { + ...game.cube, + stateKind: 'maxxed', + owner: undefined, + value: 64, + offeredBy: undefined, + }, + // Same cleanup as refuseDouble / resign: the completed game + // must not carry a mid-turn activePlay to the client. + activePlay: undefined, + activePlayer: winner, + endReason: 'cube_drop', + endTime: new Date(), + } as BackgammonGameCompleted) + } + // Transition back to 'rolling' state for the original offering player to roll + const updatedCube = { + ...game.cube, + stateKind: 'doubled' as const, + owner: player, + value: acceptedValue, + offeredBy: undefined, + } + + const updatedActivePlayer: BackgammonPlayerRolling = { + ...offeringPlayer, + stateKind: 'rolling', + dice: Dice.initialize(offeringPlayer.color, 'rolling'), + // Non-null: an active player mid-game has a rollForStartValue; the base + // player type declares it optional, the rolling player type requires it. + rollForStartValue: offeringPlayer.rollForStartValue!, + } + const updatedInactivePlayer: BackgammonPlayerInactive = { + ...player, + stateKind: 'inactive', + dice: Dice.initialize(player.color, 'inactive'), + // Non-null: see above; inactive player type also requires rollForStartValue. + rollForStartValue: player.rollForStartValue!, + } + + const updatedPlayers = game.players.map((p) => { + if (p.id === updatedActivePlayer.id) return updatedActivePlayer + if (p.id === updatedInactivePlayer.id) return updatedInactivePlayer + return p + }) as BackgammonPlayers + + return incrementStateVersion({ + ...game, + stateKind: 'rolling', + cube: updatedCube, + players: updatedPlayers, + activePlayer: updatedActivePlayer, + inactivePlayer: updatedInactivePlayer, + activeColor: updatedActivePlayer.color, + activePlay: undefined, + // as unknown as BackgammonGameRolling: the spread yields a generic players + // array (not the rolling-game tuple) that TS can't narrow structurally. + } as unknown as BackgammonGameRolling) +} + +export function canRefuseDouble( + game: BackgammonGame, + player: BackgammonPlayerActive +): boolean { + // Only if cube is in 'offered' state and player is not the one who offered + return canAcceptDouble(game, player) +} + +export function refuseDouble( + game: BackgammonGame, + player: BackgammonPlayerActive +): BackgammonGame { + if (!canRefuseDouble(game, player)) throw new Error('Cannot refuse double') + // The refusing player loses at the current cube value (before the proposed double) + // The offering player is the winner + const offeringPlayer = game.cube.offeredBy! + const winner = { + ...offeringPlayer, + stateKind: 'winner', + } as BackgammonPlayerWinner + + // Update players array to reflect winner status + const updatedPlayers = game.players.map((p) => + p.id === winner.id ? winner : p + ) as BackgammonPlayers + + // When refusing a double, winner gets the cube value BEFORE the proposed double + // If cube was centered (value undefined), use 1. Otherwise use current value / 2 (pre-double value) + const currentCubeValue = game.cube?.value + const pointsWon = currentCubeValue !== undefined ? currentCubeValue / 2 : 1 + + // Reset cube to its pre-offer state. Without this, cube.stateKind + // remains 'offered' after the game ends, which confuses the client + // into thinking there's still an outstanding double offer. + // - If the cube was centered before the offer (value=2, typical first + // double), revert to 'initialized' (value=undefined, no owner). + // - Otherwise revert to 'doubled' at half the offered value, owned by + // the same player as before the offer. + const wasFirstDouble = currentCubeValue === 2 + const resetCube = wasFirstDouble + ? { + ...game.cube, + stateKind: 'initialized' as const, + value: undefined, + owner: undefined, + offeredBy: undefined, + offeredThisTurnBy: undefined, + } + : { + ...game.cube, + stateKind: 'doubled' as const, + value: (currentCubeValue !== undefined + ? currentCubeValue / 2 + : undefined) as typeof game.cube.value, + owner: game.cube.owner, + offeredBy: undefined, + offeredThisTurnBy: undefined, + } + + logger.info( + `🏆 [Game] Double refused - Winner: ${winner.id}, Points won: ${pointsWon}` + ) + + return incrementStateVersion({ + ...game, + stateKind: 'completed', + winner: winner.id, + winType: 'simple', // Cube drops are always simple wins + pointsWon, + players: updatedPlayers, + cube: resetCube, + // Clear mid-turn state left over from whoever was playing when the + // cube was offered. Without this, the spread of ...game carries a + // stale activePlay (stateKind 'moving' with possibleMoves) through + // to the completed game, and the client renders a mid-game board + // on top of a game the server has marked finished. + activePlay: undefined, + activePlayer: winner, + endReason: 'cube_drop', + endTime: new Date(), + } as BackgammonGameCompleted) +} + +export function resign( + game: BackgammonGame, + resigningPlayer: BackgammonPlayer, + points: 1 | 2 | 3 = 1 +): BackgammonGameCompleted { + if (game.stateKind === 'completed') { + throw new Error('Cannot resign a completed game') + } + + if (game.settings?.allowResign === false) { + throw new Error('Resignation is not allowed for this game') + } + + const resigningId = resigningPlayer.id + const opponent = game.players.find((p) => p.id !== resigningId) + if (!opponent) { + throw new Error('Opponent not found for resignation') + } + + const winner = { + ...opponent, + stateKind: 'winner', + } as BackgammonPlayerWinner // as BackgammonPlayerWinner: narrowing opponent to winner type + + const updatedPlayers = game.players.map((p) => + p.id === winner.id ? winner : p + ) as BackgammonPlayers // as BackgammonPlayers: map returns generic array, narrowing to tuple type + + const cubeValue = game.cube?.value ?? 1 + let winType: 'simple' | 'gammon' | 'backgammon' = 'simple' + let baseMultiplier = 1 + if (points === 2) { + winType = 'gammon' + baseMultiplier = 2 + } else if (points === 3) { + winType = 'backgammon' + baseMultiplier = 3 + } + + if ( + game.rules?.useJacobyRule && + game.cube?.value === undefined && + winType !== 'simple' + ) { + winType = 'simple' + baseMultiplier = 1 + } + + const pointsWon = baseMultiplier * cubeValue + + logger.info( + `Resignation - Winner: ${winner.id}, Points won: ${pointsWon} (${baseMultiplier}x base * ${cubeValue} cube)` + ) + + return incrementStateVersion({ + ...game, + stateKind: 'completed', + winner: winner.id, + winType, + pointsWon, + endReason: 'resignation', + players: updatedPlayers, + endTime: new Date(), + // Clear mid-turn state left over from whoever was playing when the + // resignation came in. See refuseDouble above for the full rationale. + activePlay: undefined, + activePlayer: winner, + } as BackgammonGameCompleted) // as BackgammonGameCompleted: narrowing spread object to completed game type +} + +/** + * Execute doubling action from rolling state (before rolling dice) + * Transitions from 'rolling' to 'doubled' state and offers double to opponent + */ +export function double(game: BackgammonGameRolling): BackgammonGameDoubled { + if (game.stateKind !== 'rolling') { + throw new Error( + `Cannot double from ${game.stateKind} state. Must be in 'rolling' state.` + ) + } + + if (!canOfferDouble(game, game.activePlayer)) { + throw new Error('Doubling is not allowed in current game state') + } + + const { activePlayer, cube } = game + + // Calculate new cube value - initial doubling sets cube to 2 + const newValue = ( + cube.value ? Math.min(cube.value * 2, 64) : 2 + ) as BackgammonCubeValue + + // Create updated cube in 'offered' state (waiting for opponent response) + const updatedCube = { + ...cube, + stateKind: 'offered' as const, + value: newValue, + offeredBy: activePlayer, + offeredThisTurnBy: activePlayer, + } + + // Convert active player to doubled state + const doubledPlayer = { + ...activePlayer, + stateKind: 'doubled' as const, + dice: { + ...activePlayer.dice, + stateKind: 'rolled' as const, + }, + } as BackgammonPlayerDoubled + + // Update players array + const updatedPlayers = game.players.map((p) => + p.id === activePlayer.id ? doubledPlayer : p + ) as BackgammonPlayers + + // Update game statistics if they exist + const updatedStatistics = game.statistics + ? { + ...game.statistics, + totalDoubles: game.statistics.totalDoubles + 1, + cubeHistory: [ + ...game.statistics.cubeHistory, + { + turn: game.statistics.totalRolls, + value: newValue || 2, + offeredBy: activePlayer.color, + accepted: false, // Not yet accepted - just offered + }, + ], + } + : undefined + + // Return game in 'doubled' state (waiting for opponent to accept/refuse) + return incrementStateVersion({ + ...game, + stateKind: 'doubled', + cube: updatedCube, + players: updatedPlayers, + activePlayer: doubledPlayer, + statistics: updatedStatistics, + } as BackgammonGameDoubled) +} diff --git a/src/Game/index.ts b/src/Game/index.ts index 2e37af2..d260306 100644 --- a/src/Game/index.ts +++ b/src/Game/index.ts @@ -3,58 +3,72 @@ import { BackgammonChecker, BackgammonColor, BackgammonCube, - BackgammonCubeValue, - BackgammonDieValue, BackgammonGame, BackgammonGameDoubled, - BackgammonGameMoved, BackgammonGameMoving, BackgammonGameRolledForStart, BackgammonGameRolling, BackgammonGameRollingForStart, BackgammonGameStateKind, - BackgammonMoveSkeleton, BackgammonPlay, BackgammonPlayer, BackgammonPlayerActive, - BackgammonPlayerDoubled, BackgammonPlayerInactive, BackgammonPlayerMoving, BackgammonPlayerRolledForStart, BackgammonPlayerRolling, - BackgammonPlayerRollingForStart, BackgammonPlayers, - BackgammonPlayerWinner, BackgammonPlayMoving, - BackgammonRoll, } from '@nodots/backgammon-types' import { generateId, Player } from '..' import { Board } from '../Board' import { exportToGnuPositionId } from '../Board/gnuPositionId' import { Checker } from '../Checker' import { Cube } from '../Cube' -import { Dice } from '../Dice' -import { BackgammonMoveDirection, Play } from '../Play' -import type { MoveExecutionOptions } from '../Play' -import { debug, logger } from '../utils/logger' +import { BackgammonMoveDirection } from '../Play' +import { logger } from '../utils/logger' +import { + acceptDouble, + canAcceptDouble, + canOfferDouble, + canRefuseDouble, + double, + refuseDouble, + resign, +} from './cube' import { canGetPossibleMoves, canPlayerRoll, canRoll, canRollForStart, } from './guards' +import { restoreState, rollForStart } from './lifecycle' +import { + confirmTurnWithRobotAutomation, + handleRobotMovedState, +} from './robot' +import { + checkAndCompleteTurn, + confirmTurn, + executeAndRecalculate, + getPlayersForColor, + move, + moveAndFinalize, + roll, + startMove, + switchDice, + toMoved, +} from './turnFlow' import { createBaseGameProperties, incrementStateVersion } from './shared' +import { canUndoActivePlay, undoLastInActivePlay } from './undo' export * from '../index' // Import tuple aliases from types package import type { BackgammonGameCompleted, - BackgammonPlayersMovingTuple, BackgammonPlayersRolledForStartTuple, BackgammonPlayersRollingForStartTuple, - BackgammonPlayersRollingTuple, } from '@nodots/backgammon-types' -import { RESTORABLE_GAME_STATE_KINDS } from '@nodots/backgammon-types' import { executeRobotTurn } from './executeRobotTurn' export class Game { @@ -74,6 +88,8 @@ export class Game { */ get gnuPositionId(): string { try { + // as any: the Game class instance is structurally a BackgammonGame but + // TS does not treat the class as assignable to the discriminated union. return exportToGnuPositionId(this as any) } catch (error) { logger.warn('Failed to generate gnuPositionId:', error) @@ -321,1564 +337,29 @@ 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) - } - - public static roll = function roll( - game: - | BackgammonGameRolledForStart - | BackgammonGameRolling - | BackgammonGameDoubled - ): BackgammonGameMoving { - switch (game.stateKind) { - case 'rolled-for-start': { - const { players, activeColor } = game - const activePlayer = players.find((p) => p.color === activeColor) - if (!activePlayer) throw Error(`Roll requires an active player`) - const inactivePlayer = players.find((p) => p.id !== activePlayer.id) - if (!inactivePlayer) throw Error(`Roll requires and inactive player`) - if ( - !activePlayer?.rollForStartValue || - !inactivePlayer?.rollForStartValue - ) { - throw new Error('Players do not have rollForStartValues') - } - - // Use roll-for-start values for the winner's first roll - const currentRoll: BackgammonRoll = [ - activePlayer.rollForStartValue, - inactivePlayer.rollForStartValue, - ] - - const movingPlayer: BackgammonPlayerMoving = { - ...activePlayer, - stateKind: 'moving', - dice: { - ...activePlayer.dice, - stateKind: 'rolled', - currentRoll: currentRoll, // Use actual roll-for-start values - total: currentRoll[0] + currentRoll[1], - }, - rollForStartValue: activePlayer.rollForStartValue, - } - - const unrolledPlayer: BackgammonPlayerInactive = { - ...inactivePlayer, - stateKind: 'inactive', - dice: { - ...inactivePlayer.dice, - stateKind: 'inactive', - currentRoll: undefined, - total: 0, - }, - rollForStartValue: inactivePlayer.rollForStartValue, - } - - let activePlay = Play.initialize(game.board, movingPlayer) - - // Check if all moves were auto-completed (no legal moves available) - const allMovesCompleted = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - - // CRITICAL FIX: Validate that the correct number of moves exist before auto-completing - const expectedMoveCount = currentRoll[0] === currentRoll[1] ? 4 : 2 // doubles vs regular roll - const actualMoveCount = activePlay.moves.length - - if (allMovesCompleted && actualMoveCount === expectedMoveCount) { - debug( - 'Game.roll: All moves auto-completed - no legal moves available, transitioning to moved state', - { expectedMoveCount, actualMoveCount, currentRoll } - ) - // Player has no legal moves, return game in 'moved' state - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: movingPlayer, - inactivePlayer: unrolledPlayer, - activePlay, - board: game.board, // Board unchanged - } as any) // Cast to avoid type issues since we're returning moved instead of moving - } else if (allMovesCompleted && actualMoveCount !== expectedMoveCount) { - // BUG DETECTED: Moves are completed but count doesn't match expected dice - debug( - 'Game.roll: BUG - Move count mismatch detected, normalizing move list to expected count', - { - expectedMoveCount, - actualMoveCount, - currentRoll, - moveStates: activePlay.moves.map((m) => ({ - id: m.id.slice(0, 8), - stateKind: m.stateKind, - dieValue: m.dieValue, - })), - } - ) - - // Normalize by adding completed no-move entries for missing dice - const counts = new Map() - for (const mv of activePlay.moves) { - counts.set(mv.dieValue, (counts.get(mv.dieValue) || 0) + 1) - } - const targetCounts = new Map() - targetCounts.set(currentRoll[0], (targetCounts.get(currentRoll[0]) || 0) + 1) - targetCounts.set(currentRoll[1], (targetCounts.get(currentRoll[1]) || 0) + 1) - if (currentRoll[0] === currentRoll[1]) { - targetCounts.set(currentRoll[0], 4) - } - const normalized = [...activePlay.moves] - for (const [dieValue, target] of targetCounts.entries()) { - const have = counts.get(dieValue) || 0 - for (let i = have; i < target; i++) { - normalized.push({ - id: generateId(), - player: movingPlayer, - dieValue: dieValue as BackgammonDieValue, - stateKind: 'completed', - moveKind: 'no-move', - possibleMoves: [], - origin: undefined, - destination: undefined, - isHit: false, - } as any) - } - } - - activePlay = { ...activePlay, moves: normalized } - // Continue to board update below - } - - // Sanitize moves: if any ready move has no possible moves, - // convert it to a completed no-move to prevent stuck states. - // For bar reentry scenarios, non-reentry moves must be evaluated - // on a simulated board (after reentry) since getPossibleMoves - // only returns bar moves when checkers are on the bar. - const barForSanitize = game.board.bar[movingPlayer.direction] - const hasCheckerOnBar = barForSanitize.checkers.some( - (c) => c.color === movingPlayer.color - ) - - let sanitizationBoard = game.board - if (hasCheckerOnBar) { - // Simulate reentry moves to get the board state for evaluating - // non-reentry dice (e.g., die 4 when only die 3 can reenter) - const reentryMoves = activePlay.moves.filter( - (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' - ) - for (const reentryMove of reentryMoves) { - if (reentryMove.possibleMoves.length > 0) { - const firstOption = reentryMove.possibleMoves[0] - sanitizationBoard = Board.moveChecker( - sanitizationBoard, - firstOption.origin, - firstOption.destination, - movingPlayer.direction - ) - } - } - } - - for (const move of activePlay.moves) { - if (move.stateKind === 'ready') { - // Skip reentry moves - Play.initialize() already validated them - // and their possibleMoves may contain options from multiple dice. - // Re-evaluating with a single dieValue would strip those options. - if (move.moveKind === 'reenter') continue - if ((move as any)._sequenceDependent) continue - - const fresh = Board.getPossibleMoves( - sanitizationBoard, - movingPlayer, - move.dieValue - ) as BackgammonMoveSkeleton[] - if (!fresh || fresh.length === 0) { - ;(move as any).stateKind = 'completed' - ;(move as any).moveKind = 'no-move' - ;(move as any).possibleMoves = [] - ;(move as any).origin = undefined - ;(move as any).destination = undefined - ;(move as any).isHit = false - } else { - ;(move as any).possibleMoves = fresh - } - } - } - - // Re-check after sanitization: if all moves are now completed, - // transition to 'moved' state instead of 'moving' - const allCompletedAfterSanitize = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - if (allCompletedAfterSanitize) { - debug( - 'Game.roll: All moves completed after sanitization, transitioning to moved state' - ) - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: movingPlayer, - inactivePlayer: unrolledPlayer, - activePlay, - board: game.board, - } as any) // Cast: returning moved instead of moving - } - - // Update the board with movable checkers - let movableContainerIds: string[] = [] - // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable - const activeBar = game.board.bar[movingPlayer.direction] - const hasOwnOnBar = activeBar.checkers.some( - (c) => c.color === movingPlayer.color - ) - if (hasOwnOnBar) { - movableContainerIds = [activeBar.id] - } else { - const movesArray = activePlay.moves - for (const move of movesArray) { - switch (move.stateKind) { - case 'ready': { - if (move.possibleMoves) { - for (const possibleMove of move.possibleMoves) { - if ( - possibleMove.origin && - !movableContainerIds.includes(possibleMove.origin.id) - ) { - movableContainerIds.push(possibleMove.origin.id) - } - } - } - break - } - case 'completed': - case 'confirmed': - // These moves don't have movable checkers - break - } - } - } - const updatedBoard = Checker.updateMovableCheckers( - game.board, - movableContainerIds - ) - - return incrementStateVersion({ - ...game, - stateKind: 'moving', - players: [ - movingPlayer, - unrolledPlayer, - ] as BackgammonPlayersMovingTuple, - activeColor: movingPlayer.color, - activePlayer: movingPlayer, - inactivePlayer: unrolledPlayer, - activePlay, - board: updatedBoard, - }) - } - - case 'doubled': { - // Handle rolling from doubled state (after accepting a double) - const { players, board, activeColor } = game - if (!activeColor) throw new Error('Active color must be provided') - const [activePlayerForColor, inactivePlayerForColor] = - Game.getPlayersForColor(players, activeColor!) - if (activePlayerForColor.stateKind !== 'doubled') { - throw new Error('Active player must be in doubled state') - } - const activePlayerDoubled = - activePlayerForColor as BackgammonPlayerDoubled - const inactivePlayer = inactivePlayerForColor - if (!inactivePlayer) throw new Error('Inactive player not found') - - // Roll new dice for the doubled player - const playerRolled = Player.roll({ - ...activePlayerDoubled, - stateKind: 'rolling', - } as any) - const playerMoving = Player.toMoving(playerRolled) - const activePlay = Play.initialize(board, playerMoving) - - // Check if all moves were auto-completed (no legal moves available) - const allMovesCompleted = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - if (allMovesCompleted) { - debug( - 'Game.roll: All moves auto-completed (doubled case) - no legal moves available, transitioning to moved state' - ) - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: playerMoving, - inactivePlayer, - activePlay, - board, - } as any) - } - - const movingPlay = { - ...activePlay, - stateKind: 'moving', - player: playerMoving, - } as BackgammonPlayMoving - - // Sanitize moves: if any ready move has no possible moves, - // convert it to a completed no-move to prevent stuck states. - // For bar reentry scenarios, non-reentry moves must be evaluated - // on a simulated board (after reentry). - { - const barForSanitize3 = board.bar[playerMoving.direction] - const hasCheckerOnBar3 = barForSanitize3.checkers.some( - (c) => c.color === playerMoving.color - ) - - let sanitizationBoard3 = board - if (hasCheckerOnBar3) { - const reentryMoves3 = activePlay.moves.filter( - (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' - ) - for (const reentryMove of reentryMoves3) { - if (reentryMove.possibleMoves.length > 0) { - const firstOption = reentryMove.possibleMoves[0] - sanitizationBoard3 = Board.moveChecker( - sanitizationBoard3, - firstOption.origin, - firstOption.destination, - playerMoving.direction - ) - } - } - } - - for (const move of activePlay.moves) { - if (move.stateKind === 'ready') { - if (move.moveKind === 'reenter') continue - if ((move as any)._sequenceDependent) continue - const fresh = Board.getPossibleMoves( - sanitizationBoard3, - playerMoving, - move.dieValue - ) as BackgammonMoveSkeleton[] - if (!fresh || fresh.length === 0) { - ;(move as any).stateKind = 'completed' - ;(move as any).moveKind = 'no-move' - ;(move as any).possibleMoves = [] - ;(move as any).origin = undefined - ;(move as any).destination = undefined - ;(move as any).isHit = false - } else { - ;(move as any).possibleMoves = fresh - } - } - } - } - - // Re-check after sanitization: if all moves are now completed, - // transition to 'moved' state instead of 'moving' - const allCompletedAfterSanitize = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - if (allCompletedAfterSanitize) { - debug( - 'Game.roll: All moves completed after sanitization (doubled case), transitioning to moved state' - ) - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: playerMoving, - inactivePlayer, - activePlay, - board, - } as any) // Cast: returning moved instead of moving - } - - // Update the board with movable checkers - let movableContainerIds2: string[] = [] - const activeBar2 = board.bar[playerMoving.direction] - const hasOwnOnBar2 = activeBar2.checkers.some( - (c) => c.color === playerMoving.color - ) - if (hasOwnOnBar2) { - movableContainerIds2 = [activeBar2.id] - } else { - const movesArray = activePlay.moves - for (const move of movesArray) { - switch (move.stateKind) { - case 'ready': - if (move.possibleMoves) { - for (const possibleMove of move.possibleMoves) { - if ( - possibleMove.origin && - !movableContainerIds2.includes(possibleMove.origin.id) - ) { - movableContainerIds2.push(possibleMove.origin.id) - } - } - } - break - case 'completed': - case 'confirmed': - // These moves don't have movable checkers - break - } - } - } - const updatedBoard = Checker.updateMovableCheckers( - board, - movableContainerIds2 - ) - - // Update the players array to include the rolled player - const updatedPlayers = [ - playerRolled, - inactivePlayer, - ] as BackgammonPlayersMovingTuple - - const movingGame = { - ...game, - stateKind: 'moving', - players: updatedPlayers, - activePlayer: playerRolled, - activePlay: movingPlay, - board: updatedBoard, - } as BackgammonGameMoving - - return incrementStateVersion(movingGame) - } - - case 'rolling': { - // Handle rolling from 'rolling' state (generate new dice) - const { players, board, activeColor } = game - if (!activeColor) throw new Error('Active color must be provided') - let [activePlayerForColor, inactivePlayerForColor] = - Game.getPlayersForColor(players, activeColor!) - if (activePlayerForColor.stateKind !== 'rolling') { - throw new Error('Active player must be in rolling state') - } - const activePlayerRolling = - activePlayerForColor as BackgammonPlayerRolling - const inactivePlayer = inactivePlayerForColor - if (!inactivePlayer) throw new Error('Inactive player not found') - - const playerRolled = Player.roll(activePlayerRolling) - const playerMoving = Player.toMoving(playerRolled) - const activePlay = Play.initialize(board, playerMoving) - - // Check if all moves were auto-completed (no legal moves available) - const allMovesCompleted = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - if (allMovesCompleted) { - debug( - 'Game.roll: All moves auto-completed (rolling case) - no legal moves available, transitioning to moved state' - ) - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: playerMoving, - inactivePlayer, - activePlay, - board, - } as any) - } - - // Sanitize moves: if any ready move has no possible moves, - // convert it to a completed no-move to prevent stuck states. - // For bar reentry scenarios, non-reentry moves must be evaluated - // on a simulated board (after reentry) since getPossibleMoves - // only returns bar moves when checkers are on the bar. - { - const barForSanitize2 = board.bar[playerMoving.direction] - const hasCheckerOnBar2 = barForSanitize2.checkers.some( - (c) => c.color === playerMoving.color - ) - - let sanitizationBoard2 = board - if (hasCheckerOnBar2) { - const reentryMoves2 = activePlay.moves.filter( - (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' - ) - for (const reentryMove of reentryMoves2) { - if (reentryMove.possibleMoves.length > 0) { - const firstOption = reentryMove.possibleMoves[0] - sanitizationBoard2 = Board.moveChecker( - sanitizationBoard2, - firstOption.origin, - firstOption.destination, - playerMoving.direction - ) - } - } - } - - for (const move of activePlay.moves) { - if (move.stateKind === 'ready') { - if (move.moveKind === 'reenter') continue - if ((move as any)._sequenceDependent) continue - const fresh = Board.getPossibleMoves( - sanitizationBoard2, - playerMoving, - move.dieValue - ) as BackgammonMoveSkeleton[] - if (!fresh || fresh.length === 0) { - ;(move as any).stateKind = 'completed' - ;(move as any).moveKind = 'no-move' - ;(move as any).possibleMoves = [] - ;(move as any).origin = undefined - ;(move as any).destination = undefined - ;(move as any).isHit = false - } else { - ;(move as any).possibleMoves = fresh - } - } - } - } - - // Re-check after sanitization: if all moves are now completed, - // transition to 'moved' state instead of 'moving' - const allCompletedAfterSanitize = activePlay.moves.every( - (m) => m.stateKind === 'completed' - ) - if (allCompletedAfterSanitize) { - debug( - 'Game.roll: All moves completed after sanitization (rolling case), transitioning to moved state' - ) - return incrementStateVersion({ - ...game, - stateKind: 'moved', - activePlayer: playerMoving, - inactivePlayer, - activePlay, - board, - } as any) // Cast: returning moved instead of moving - } - - const movingPlay = { - ...activePlay, - stateKind: 'moving', - player: playerMoving, - } as BackgammonPlayMoving - - // Update the board with movable checkers - // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable - let movableContainerIds: string[] = [] - const activeBar = board.bar[playerMoving.direction] - const hasOwnOnBar = activeBar.checkers.some( - (c) => c.color === playerMoving.color - ) - if (hasOwnOnBar) { - movableContainerIds = [activeBar.id] - } else { - const movesArray = activePlay.moves - for (const move of movesArray) { - switch (move.stateKind) { - case 'ready': - if (move.possibleMoves) { - for (const possibleMove of move.possibleMoves) { - if ( - possibleMove.origin && - !movableContainerIds.includes(possibleMove.origin.id) - ) { - movableContainerIds.push(possibleMove.origin.id) - } - } - } - break - case 'completed': - case 'confirmed': - // These moves don't have movable checkers - break - } - } - } - const updatedBoard = Checker.updateMovableCheckers( - board, - movableContainerIds - ) - - // Update the players array to include the rolled player - const updatedPlayers = players.map((p) => - p.id === playerRolled.id ? playerRolled : p - ) as BackgammonPlayers - - const movingGame = { - ...game, - stateKind: 'moving', - players: updatedPlayers, - activePlayer: playerRolled, - activePlay: movingPlay, - board: updatedBoard, - } as BackgammonGameMoving - - return incrementStateVersion(movingGame) - } - - default: - // TypeScript exhaustiveness check - should never reach here - const _exhaustiveCheck: never = game - throw new Error(`Unexpected game state: ${(game as any).stateKind}`) - } - } - - /** - * Switch the order of dice for the active player - * Allowed in 'moving' state when all moves are undone - */ - public static switchDice = function switchDice( - game: BackgammonGameMoving - ): BackgammonGameMoving { - // Check if dice switching is allowed - switch (game.stateKind) { - case 'moving': { - // Only allowed in moving state if all moves are undone (all moves in 'ready' state) - const allMovesUndone = game.activePlay?.moves - ? game.activePlay.moves.every( - (move: any) => move.stateKind === 'ready' - ) - : false - - const undoStackEmpty = !((game.activePlay as any)?.undo?.frames?.length > 0) - if (!(allMovesUndone && undoStackEmpty)) { - throw new Error('Cannot switch dice in moving state unless all moves are undone') - } - break - } - default: - // This should never happen given our union type, but include for completeness - throw new Error( - `Cannot switch dice from ${(game as any).stateKind} state` - ) - } - - const { activePlayer, activePlay } = game - - if ( - !activePlayer?.dice?.currentRoll || - activePlayer.dice.currentRoll.length !== 2 - ) { - throw new Error('Active player does not have valid dice to switch') - } - - // Switch the dice using the Dice class - const switchedDice = Dice.switchDice(activePlayer.dice) - const updatedActivePlayer = { - ...activePlayer, - dice: switchedDice, - } - - // Update the activePlay to reflect the new dice order - const updatedActivePlay = activePlay - ? { - ...activePlay, - moves: activePlay.moves - ? (() => { - const movesArray = activePlay.moves - if (movesArray.length >= 2) { - // Swap the first two moves to match the new dice order - const swappedMoves = [...movesArray] - const temp = swappedMoves[0] - swappedMoves[0] = swappedMoves[1] - swappedMoves[1] = temp - - // CRITICAL: Update dieValue to match new dice order after swapping - // This fixes the data duplication bug between dice.currentRoll and moves[].dieValue - const [newFirstDie, newSecondDie] = switchedDice.currentRoll - swappedMoves[0] = { - ...swappedMoves[0], - dieValue: newFirstDie, - } - swappedMoves[1] = { - ...swappedMoves[1], - dieValue: newSecondDie, - } - - // CRITICAL: Regenerate possibleMoves for all moves based on new dice order - // This is necessary because possibleMoves were calculated with the old dice order - const regeneratedMoves = swappedMoves.map((move) => { - if (move.stateKind === 'ready') { - // Only regenerate for ready moves - completed moves shouldn't change - const freshPossibleMoves = Board.getPossibleMoves( - game.board, - updatedActivePlayer, - move.dieValue as BackgammonDieValue - ) as BackgammonMoveSkeleton[] - return { - ...move, - player: updatedActivePlayer, // Update player reference with switched dice - possibleMoves: freshPossibleMoves, - } - } - // Update player reference for non-ready moves too - return { - ...move, - player: updatedActivePlayer, // Update player reference with switched dice - } - }) - - return regeneratedMoves - } - return activePlay.moves - })() - : activePlay.moves, - } - : activePlay - - // Update the players array - const updatedPlayers = game.players.map((p) => - p.id === activePlayer.id ? updatedActivePlayer : p - ) as unknown as BackgammonPlayers - - // Return the same state type as input - return incrementStateVersion({ - ...game, - players: updatedPlayers, - activePlayer: updatedActivePlayer, - activePlay: updatedActivePlay, - } as typeof game) + return rollForStart(game) } - public static move = function move( - game: BackgammonGameMoving, - checkerId: string, - preferredDieValue?: BackgammonDieValue, - options?: MoveExecutionOptions - ): BackgammonGameMoving | BackgammonGameMoved | BackgammonGameCompleted { - // Push a pre-move snapshot to the turn-local undo stack - try { - const ap: any = (game as any).activePlay - if (ap) { - if (!ap.undo) ap.undo = { frames: [] } - const snapshot = - typeof structuredClone === 'function' - ? structuredClone(game) - : (JSON.parse(JSON.stringify(game)) as any) - ap.undo.frames.push(snapshot) - } - } catch (e) { - logger?.warn?.('Failed to push undo snapshot in Game.move', e) - } - - const checker = Board.getCheckers(game.board).find( - (c) => c.id === checkerId - ) - if (!checker) throw new Error(`No checker found for checkerId ${checkerId}`) - // Validate game state using switch - switch (game.stateKind) { - case 'moving': - // Valid state for moving - break - default: - throw new Error( - `Cannot move from ${(game as any).stateKind} state. Must be in 'moving' state.` - ) - } - let { activePlay, board } = game - - // Check if activePlay exists - if (!activePlay) { - throw new Error( - 'No active play found. Game must be in a valid play state.' - ) - } - - // Validate activePlay state using switch - switch (activePlay.stateKind) { - case 'moving': - // Valid state for activePlay - break - default: - throw new Error( - `Cannot move from ${activePlay.stateKind} state. ActivePlay must be in 'moving' state.` - ) - } - - const playResult = Player.move( - board, - activePlay, - checker.checkercontainerId, - preferredDieValue, - options - ) - board = playResult.board - - let movedPlayer = - playResult.move && playResult.move.player - ? { - ...playResult.move.player, - dice: game.activePlayer.dice, // Preserve switched dice state - } - : game.activePlayer + public static roll = roll - // Always update activePlay from playResult (fallback to activePlay if undefined) - const updatedActivePlay = (playResult as any).play || activePlay + public static switchDice = switchDice - // Update the board with movable checkers based on remaining moves - // IMPORTANT: After a move, we need to recalculate possible moves for remaining ready moves - // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable - let movableContainerIds: string[] = [] - const playForTypes = updatedActivePlay as BackgammonPlayMoving - const playerDir: 'clockwise' | 'counterclockwise' = playForTypes.player - .direction as any - const activeBar = board.bar[playerDir] - const hasOwnOnBar = activeBar.checkers.some( - (c: BackgammonChecker) => c.color === playForTypes.player.color - ) - if (hasOwnOnBar) { - movableContainerIds = [activeBar.id] - } else { - if (updatedActivePlay.moves) { - const movesArray = updatedActivePlay.moves as any[] - for (const move of movesArray) { - switch (move.stateKind) { - case 'ready': { - // Recalculate fresh possible moves for this die value on the current board state - const freshPossibleMoves = Board.getPossibleMoves( - board, - updatedActivePlay.player, - move.dieValue - ) as BackgammonMoveSkeleton[] + public static move = move - // Handle case where recalculated possibleMoves is empty - if (!freshPossibleMoves || freshPossibleMoves.length === 0) { - move.stateKind = 'completed' - move.moveKind = 'no-move' - move.possibleMoves = [] - move.origin = undefined - move.destination = undefined - move.isHit = false - debug( - 'Game.move: Converting move to no-move (no possible moves after recalculation)', - { - moveId: move.id, - dieValue: move.dieValue, - originalMoveKind: move.moveKind, - } - ) - } else { - // Update the move with fresh possible moves - move.possibleMoves = freshPossibleMoves + public static moveAndFinalize = moveAndFinalize - // Add origins to movable containers - for (const possibleMove of freshPossibleMoves) { - if ( - possibleMove.origin && - !movableContainerIds.includes(possibleMove.origin.id) - ) { - movableContainerIds.push(possibleMove.origin.id) - } - } - } - break - } - case 'completed': - case 'confirmed': - case 'in-progress': - // These moves don't have movable checkers - break - } - } - } - } - board = Checker.updateMovableCheckers(board, movableContainerIds) - - // Recalculate pip counts after the move BEFORE win condition check - // This ensures final pip counts are correct when the game ends - logger.info('Game.move: Recalculating pip counts after move') - const gameWithUpdatedBoard = { - ...game, - board, - players: game.players.map((p) => - p.id === movedPlayer.id ? movedPlayer : p - ) as import('@nodots/backgammon-types').BackgammonPlayers, - } - const updatedPlayers = Player.recalculatePipCounts(gameWithUpdatedBoard) + public static toMoved = toMoved - // Update movedPlayer with correct pip count - movedPlayer = - (updatedPlayers.find((p) => p.id === movedPlayer.id) as any) || - movedPlayer + public static executeAndRecalculate = executeAndRecalculate - // --- WIN CONDITION CHECK --- - // Check if the player has won (all checkers off) AFTER the move is processed - // IMPORTANT: This check must happen after the checker is moved off the board AND pip counts recalculated - const direction = movedPlayer.direction - const playerOff = board.off[direction] - const playerCheckersOff = playerOff.checkers.filter( - (c) => c.color === movedPlayer.color - ).length + public static checkAndCompleteTurn = checkAndCompleteTurn - // Count total checkers on board for this player (should be 0 when won) - const playerCheckersOnBoard = Board.getCheckers(board).filter( - (c) => c.color === movedPlayer.color - ).length + public static confirmTurn = confirmTurn - // Get move kind for additional context - const lastMoveKind = playResult.move && playResult.move.moveKind - - // Enhanced debug output for win condition - logger.info('[Game] 🏆 WIN CONDITION CHECK:', { - playerCheckersOff, - playerCheckersOnBoard, - totalCheckersExpected: 15, - lastMoveKind, - playerOffCheckers: playerOff.checkers.length, - movedPlayerColor: movedPlayer.color, - movedPlayerDirection: movedPlayer.direction, - pipCount: movedPlayer.pipCount, - hasWon: playerCheckersOff === 15 || playerCheckersOnBoard === 0, - }) - - // FIXED: More robust win condition - check multiple criteria for victory - // A player wins when they have all 15 checkers off OR no checkers remaining on board - const hasWon = - playerCheckersOff === 15 || // Primary condition: all checkers in off area - (playerCheckersOnBoard === 0 && playerCheckersOff > 0) || // Backup: no checkers on board + some off - (movedPlayer.pipCount === 0 && lastMoveKind === 'bear-off') // Tertiary: pip count zero after bear-off - - if (hasWon) { - logger.info( - `🎉 [Game] PLAYER ${movedPlayer.color.toUpperCase()} HAS WON! (${playerCheckersOff} checkers off, ${playerCheckersOnBoard} on board)` - ) - - // Player has borne off all checkers, they win - const winner = { - ...movedPlayer, - stateKind: 'winner', - pipCount: 0, // Winner has 0 pip count - } as BackgammonPlayerWinner - - // Find the loser for scoring calculation - const loser = updatedPlayers.find((p) => p.id !== winner.id)! - const loserDirection = loser.direction - - // Calculate win type based on loser's checker positions - const loserCheckersOff = board.off[loserDirection]?.checkers?.length ?? 0 - const loserCheckersOnBar = board.bar[loserDirection]?.checkers?.length ?? 0 - - // Check if loser has checkers in winner's home board (positions 1-6 from winner's perspective) - const winnerDirection = winner.direction - const loserCheckersInWinnerHome = board.points.some((point) => { - const positionFromWinnerPerspective = point.position[winnerDirection] - return ( - positionFromWinnerPerspective >= 1 && - positionFromWinnerPerspective <= 6 && - point.checkers.some((c) => c.color === loser.color) - ) - }) - - // Determine base win type - let winType: 'simple' | 'gammon' | 'backgammon' = 'simple' - let baseMultiplier = 1 - - if (loserCheckersOff === 0) { - // Loser has no checkers off - at minimum a gammon - if (loserCheckersOnBar > 0 || loserCheckersInWinnerHome) { - // Backgammon: loser has checkers on bar OR in winner's home board - winType = 'backgammon' - baseMultiplier = 3 - } else { - // Gammon: loser just has no checkers off - winType = 'gammon' - baseMultiplier = 2 - } - } - - // Jacoby rule: In money games, gammons/backgammons only count if cube was turned - // Cube is considered "never turned" if value is undefined (centered) - const cubeValue = game.cube?.value - const cubeWasTurned = cubeValue !== undefined - if (game.rules?.useJacobyRule && !cubeWasTurned && winType !== 'simple') { - logger.info( - `Jacoby rule applied: ${winType} reduced to simple (cube never turned)` - ) - winType = 'simple' - baseMultiplier = 1 - } - - // Calculate total points: base multiplier * cube value (cube defaults to 1 if not turned) - const cubeMultiplier = cubeValue ?? 1 - const pointsWon = baseMultiplier * cubeMultiplier - - logger.info( - `🏆 [Game] Win type: ${winType}, Points won: ${pointsWon} (${baseMultiplier}x base * ${cubeMultiplier} cube)` - ) - - // Update players array to include the winner with correct state - const finalPlayers = updatedPlayers.map((p) => - p.id === winner.id ? winner : p - ) as BackgammonPlayers - - logger.info(`🏁 [Game] Game ${game.id} completed - Winner: ${winner.id}`) - - return incrementStateVersion({ - ...game, - stateKind: 'completed', - winner: winner.id, - winType, - pointsWon, - board, - activePlayer: winner, - activePlay: updatedActivePlay, - players: finalPlayers, - endTime: new Date(), // Add end time for completed games - } as BackgammonGameCompleted) - } - // --- END WIN CONDITION CHECK --- - - // DICE SWITCHING DEBUG: Check what's happening to dice and moves state - const finalActivePlayer = updatedPlayers.find( - (p) => p.id === movedPlayer.id - ) as any - const finalMoves = Array.from(updatedActivePlay.moves || []) - // Guarded for browser bundles — `process` is Node-only. - if ( - typeof process !== 'undefined' && - process.env?.NODOTS_DEBUG_DICE === '1' - ) { - // Optional dice/move state debug - debug('🎲 [DICE DEBUG] Game.move result:') - debug( - ' game.activePlayer.dice:', - game.activePlayer.dice?.currentRoll - ) - debug( - ' finalActivePlayer.dice:', - finalActivePlayer?.dice?.currentRoll - ) - debug( - ' finalMoves.dieValues:', - finalMoves.map((m: any) => m.dieValue) - ) - debug( - ' finalMoves.states:', - finalMoves.map((m: any) => m.stateKind) - ) - } - - // Set game stateKind based on activePlay stateKind - const gameStateKind = - updatedActivePlay.stateKind === 'moved' ? 'moved' : 'moving' - - // Set activePlayer stateKind based on activePlay stateKind - const finalActivePlayerWithState = { - ...finalActivePlayer, - stateKind: updatedActivePlay.stateKind === 'moved' ? 'moved' : 'moving', - } - - return incrementStateVersion({ - ...game, - stateKind: gameStateKind, - board, - players: updatedPlayers.map((p) => - p.id === finalActivePlayerWithState.id ? finalActivePlayerWithState : p - ), - activePlayer: finalActivePlayerWithState, - activePlay: updatedActivePlay, - } as BackgammonGameMoving | BackgammonGameMoved) - } - - /** - * Execute a human move and finalize the turn if all moves are completed. - * This keeps turn-completion logic inside CORE and provides a single - * entrypoint for API/clients. - */ - public static moveAndFinalize = function moveAndFinalize( - game: BackgammonGameMoving, - checkerId: string - ): BackgammonGameMoving | BackgammonGameMoved | BackgammonGameCompleted { - const moved = Game.move(game, checkerId) - if (moved.stateKind === 'moving') { - // Let CORE decide if the turn should complete now - return Game.checkAndCompleteTurn(moved as BackgammonGameMoving) as - | BackgammonGameMoving - | BackgammonGameMoved - | BackgammonGameCompleted - } - return moved - } - - /** - * Transition from 'moving' to 'moved' state - * This represents that all moves are completed and the player must confirm their turn - */ - public static toMoved = function toMoved( - game: BackgammonGameMoving - ): BackgammonGameMoved { - if (game.stateKind !== 'moving') { - throw new Error( - `Cannot transition to moved from ${ - (game as any).stateKind - } state. Must be in 'moving' state.` - ) - } - - // Ensure all moves are completed before transitioning - const activePlay = game.activePlay - if (!activePlay || !activePlay.moves) { - throw new Error('No active play found') - } - - const movesArray = activePlay.moves - const allMovesCompleted = movesArray.every( - (move) => move.stateKind === 'completed' - ) - - if (!allMovesCompleted) { - throw new Error( - 'Cannot transition to moved state - not all moves are completed' - ) - } - - // Create moved state - human player's turn is complete, waiting for dice click confirmation - return incrementStateVersion({ - ...game, - stateKind: 'moved', - } as BackgammonGameMoved) - } - - /** - * Execute a single move and recalculate fresh moves (just-in-time approach) - * This method prevents stale move references by always calculating moves based on current board state - * @param game - Current game state in 'moving' state - * @param originId - ID of the origin point/bar to move from - * @returns Updated game state with fresh moves calculated - */ - public static executeAndRecalculate = function executeAndRecalculate( - game: BackgammonGameMoving, - originId: string, - options?: MoveExecutionOptions - ): BackgammonGameMoving | BackgammonGame { - debug( - 'Game.executeAndRecalculate: About to execute move from origin:', - originId - ) - - if (!game) { - console.error('[DEBUG] CRITICAL: game parameter is undefined/null!') - throw new Error('Game parameter is undefined - cannot execute move') - } - - if (!game.board) { - console.error('[DEBUG] CRITICAL: game.board is undefined!', { - gameStateKind: game.stateKind, - gameKeys: Object.keys(game), - hasActivePlay: !!game.activePlay, - hasActivePlayer: !!game.activePlayer, - }) - throw new Error('Game.board is undefined - cannot execute move') - } - - // When expectedDieValue is specified, reorder ready moves so the - // matching die comes first. planMoveExecution picks firstDieValue - // from readyMoves[0], so placing the desired die first ensures it - // gets consumed instead of the other die. - if (options?.expectedDieValue != null && game.activePlay) { - const ap = game.activePlay as any - if (Array.isArray(ap.moves)) { - const expectedDie = options.expectedDieValue - const reordered = [...ap.moves].sort((a: any, b: any) => { - const aReady = a.stateKind === 'ready' && a.dieValue === expectedDie ? 0 : 1 - const bReady = b.stateKind === 'ready' && b.dieValue === expectedDie ? 0 : 1 - return aReady - bReady - }) - ap.moves = reordered - } - } - - // Find a checker in the specified origin container to execute the move - const checkers = Board.getCheckers(game.board) - const checkerInOrigin = checkers.find( - (c) => - c.checkercontainerId === originId && c.color === game.activePlayer.color - ) - - if (!checkerInOrigin) { - throw new Error( - `No ${game.activePlayer.color} checker found in container ${originId}` - ) - } - - // Push a pre-move snapshot - try { - const ap: any = (game as any).activePlay - if (ap) { - if (!ap.undo) ap.undo = { frames: [] } - const snapshot = - typeof structuredClone === 'function' - ? structuredClone(game) - : (JSON.parse(JSON.stringify(game)) as any) - ap.undo.frames.push(snapshot) - } - } catch (e) { - logger?.warn?.('Failed to push undo snapshot before move', e) - } - - const gameAfterMove = Game.move(game, checkerInOrigin.id, undefined, options) - - debug( - 'Game.executeAndRecalculate: Move executed, game state:', - { - stateKind: gameAfterMove.stateKind, - hasActivePlay: !!(gameAfterMove as any).activePlay, - activePlayMoves: (gameAfterMove as any).activePlay?.moves - ? Array.from((gameAfterMove as any).activePlay.moves).length - : 0, - } - ) - - // Check if the game ended (win condition) - if (gameAfterMove.stateKind === 'completed') { - return gameAfterMove - } - - // Check if the game is already in 'moved' state after the move - if (gameAfterMove.stateKind === 'moved') { - debug('Game is already in moved state, returning as-is') - return gameAfterMove - } - - // Game continues in moving state - const movingGame = gameAfterMove as BackgammonGameMoving - - // Check if turn should be completed (for both human and robot players) - const gameAfterTurnCheck = Game.checkAndCompleteTurn(movingGame) - - // For robot players, auto-confirm the turn if it transitioned to 'moved' - if ( - movingGame.activePlayer.isRobot && - gameAfterTurnCheck.stateKind === 'moved' - ) { - debug('Robot turn completed, auto-confirming turn') - return Game.confirmTurn(gameAfterTurnCheck as BackgammonGameMoved) - } - - // Return the game (either still 'moving' or transitioned to 'moved') - if (gameAfterTurnCheck.stateKind === 'moved') { - debug('Turn completed, transitioned to moved state') - return gameAfterTurnCheck - } - - // CRITICAL FIX: After executing a move, the activePlay.moves now contains fresh possibleMoves - // for all remaining ready moves thanks to the fix in Play.move() - // The movingGame already has the updated board state and refreshed activePlay - - debug( - 'Game.executeAndRecalculate: Move executed successfully, returning updated game with fresh activePlay' - ) - - // Turn continues, return the game with fresh board state and updated activePlay - return gameAfterTurnCheck - } - - /** - * Check if the current turn is complete and transition to 'moved' state - * This method now follows the same state machine as human players for consistency - * @param game - Current game state - * @returns Updated game state in 'moved' state or current game if turn not complete - */ - public static checkAndCompleteTurn = function checkAndCompleteTurn( - game: BackgammonGameMoving - ): BackgammonGame { - // Use discriminated union pattern for turn completion states - type TurnCompletionState = - | { type: 'invalid-game' } - | { type: 'no-active-play' } - | { type: 'moves-incomplete'; completedCount: number; totalCount: number } - | { - type: 'all-moves-completed' - moves: Array<{ - id: string - dieValue: number - stateKind: string - moveKind: string - }> - } - - // Determine current turn completion state - const getTurnCompletionState = (): TurnCompletionState => { - // Validate game structure first - if (!game?.activePlayer?.color) { - return { type: 'invalid-game' } - } - - const activePlay = game.activePlay - if (!activePlay?.moves) { - return { type: 'no-active-play' } - } - - const movesArray = activePlay.moves - - // Auto-complete ready moves that have no possible moves on - // the current board. This handles the case where a player's - // remaining moves are blocked after executing earlier moves. - // Always verify against current board state since possibleMoves - // may be stale from before earlier moves in the turn. - for (const move of movesArray) { - if (move.stateKind === 'ready') { - const fresh = Board.getPossibleMoves( - game.board, - game.activePlayer, - move.dieValue - ) as BackgammonMoveSkeleton[] - if (!fresh || fresh.length === 0) { - ;(move as any).stateKind = 'completed' - ;(move as any).moveKind = 'no-move' - ;(move as any).possibleMoves = [] - ;(move as any).origin = undefined - ;(move as any).destination = undefined - ;(move as any).isHit = false - logger.info( - `Auto-completed blocked move (die ${move.dieValue}) as no-move` - ) - } - } - } - - const completedMoves = movesArray.filter( - (move) => move.stateKind === 'completed' - ) - - if (completedMoves.length === movesArray.length) { - return { - type: 'all-moves-completed', - moves: movesArray.map((m) => ({ - id: m.id, - dieValue: m.dieValue, - stateKind: m.stateKind, - moveKind: m.moveKind, - })), - } - } - - return { - type: 'moves-incomplete', - completedCount: completedMoves.length, - totalCount: movesArray.length, - } - } - - const turnState = getTurnCompletionState() - - // Log debug info only after validation - if (turnState.type !== 'invalid-game') { - logger.info( - '🔍 checkAndCompleteTurn called for player:', - game.activePlayer.color, - game.activePlayer.isRobot ? '(robot)' : '(human)' - ) - } - - // State machine using switch on discriminated union - switch (turnState.type) { - case 'invalid-game': { - logger.warn('❌ Invalid game structure, returning original game') - return game - } - case 'no-active-play': { - logger.info('❌ No active play or moves, returning original game') - return game - } - case 'moves-incomplete': { - logger.info( - `⏳ Turn incomplete: ${turnState.completedCount}/${turnState.totalCount} moves completed` - ) - return game - } - - case 'all-moves-completed': - logger.info( - '✅ All moves completed, attempting transition to moved state' - ) - logger.info( - '📋 Move details:', - turnState.moves.map((m) => `${m.dieValue}:${m.stateKind}`) - ) - - try { - const movedGame = Game.toMoved(game) - logger.info('🎯 Successfully transitioned to moved state') - return movedGame - } catch (error) { - logger.error('💥 Error in toMoved transition:', error) - logger.error('📊 Game state:', game.stateKind) - logger.error('📊 Moves details:', turnState.moves) - return game - } - - default: - // TypeScript exhaustiveness check ensures we handle all cases - const _exhaustive: never = turnState - return game - } - } - - /** - * Manually confirm the current turn and pass control to the next player - * This is triggered by dice click after the player has finished their moves - * @param game - Current game state in 'moving' state - * @returns Updated game state with next player's turn - */ - public static confirmTurn = function confirmTurn( - game: BackgammonGameMoved - ): BackgammonGameRolling { - if (game.stateKind !== 'moved') { - throw new Error('Cannot confirm turn from non-moving state') - } - - // Reset all isMovable flags on the board - const boardWithResetMovable = Checker.updateMovableCheckers(game.board, []) - - // Manually transition to next player since turn is confirmed - const nextColor = game.activeColor === 'white' ? 'black' : 'white' - - // Update players: current becomes inactive, next becomes rolling - const updatedPlayers = game.players.map((player) => { - if (player.color === game.activeColor) { - // CRITICAL FIX: Preserve robot dice currentRoll values when transitioning to inactive - // This ensures robot dice continue to display what they rolled - // - // ⚠️ TECH DEBT WARNING: currentRoll DATA DUPLICATION ISSUE ⚠️ - // The dice roll values are stored in TWO places in the model: - // 1. player.dice.currentRoll - Raw rolled values [x, y] - // 2. game.activePlay.moves[n].dieValue - Individual die values used for moves - // This duplication creates maintenance overhead and potential inconsistency. - // Future refactoring should consolidate this to a single source of truth. - // - const preservedDice = - player.isRobot && player.dice?.currentRoll - ? { - ...player.dice, - stateKind: 'inactive' as const, - } - : Dice.initialize(player.color, 'inactive') - - return { - ...player, - stateKind: 'inactive' as const, - dice: preservedDice, - } - } else { - return { - ...player, - stateKind: 'rolling' as const, - dice: Dice.initialize(player.color, 'rolling'), - } - } - }) as BackgammonPlayersRollingTuple - - // Recalculate pip counts before transitioning to next player - logger.info( - 'Game turn completion: Recalculating pip counts before transitioning to next player' - ) - const playersWithUpdatedPips = Player.recalculatePipCounts({ - ...game, - players: updatedPlayers, - }) - - const newActivePlayerWithPips = playersWithUpdatedPips.find( - (p) => p.color === nextColor - ) as BackgammonPlayerActive - const newInactivePlayerWithPips = playersWithUpdatedPips.find( - (p) => p.color === game.activeColor - ) as BackgammonPlayerInactive - - // CRITICAL FIX: Pass undefined for type compatibility, but the core issue is addressed - // The real fix requires extending the type system to support preserved activePlay - // For now, keep the original behavior but document the fix location - - // Return game with next player's turn - return incrementStateVersion({ - ...game, - cube: { ...(game.cube as any), offeredThisTurnBy: undefined } as any, - stateKind: 'rolling', - players: [ - newActivePlayerWithPips as BackgammonPlayerRolling, - newInactivePlayerWithPips, - ] as BackgammonPlayersRollingTuple, - board: boardWithResetMovable, - activeColor: nextColor, - activePlayer: newActivePlayerWithPips, - inactivePlayer: newInactivePlayerWithPips, - activePlay: undefined, // No activePlay after turn confirmation - } as BackgammonGameRolling) - } - - /** - * Handle robot automation for games in 'moved' state - * If the active player is a robot and the game is in 'moved' state, automatically confirm the turn - * @param game - Game in any state - * @returns Game with turn confirmed if robot automation was applied, otherwise unchanged - */ - public static handleRobotMovedState = function handleRobotMovedState( - game: BackgammonGame - ): BackgammonGame { - // Only handle games in 'moved' state with robot active player - if (game.stateKind === 'moved' && game.activePlayer.isRobot) { - debug('Robot in moved state, auto-confirming turn') - return Game.confirmTurn(game as BackgammonGameMoved) - } - return game - } + public static handleRobotMovedState = handleRobotMovedState public static executeRobotTurn = executeRobotTurn @@ -1906,23 +387,7 @@ export class Game { return inactivePlayer as BackgammonPlayerInactive } - public static getPlayersForColor = function getPlayersForColor( - players: BackgammonPlayers, - color: BackgammonColor - ): [ - activePlayerForColor: BackgammonPlayerActive, - inactivePlayerForColor: BackgammonPlayerInactive, - ] { - const activePlayerForColor = players.find((p) => p.color === color) - const inactivePlayerForColor = players.find((p) => p.color !== color) - if (!activePlayerForColor || !inactivePlayerForColor) { - throw new Error('Players not found') - } - return [ - activePlayerForColor as BackgammonPlayerActive, - inactivePlayerForColor as BackgammonPlayerInactive, - ] - } + public static getPlayersForColor = getPlayersForColor /** * Restores a game to a previous state @@ -1930,54 +395,13 @@ 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( - game: BackgammonGameDoubled, - movingPlay: BackgammonPlayMoving - ): BackgammonGameMoving { - return { - ...game, - stateKind: 'moving', - activePlay: movingPlay, - } as BackgammonGameMoving - } + public static startMove = startMove // --- Doubling Cube Logic --- @@ -1985,23 +409,7 @@ export class Game { game: BackgammonGame, player: BackgammonPlayerActive ): boolean { - // Crawford rule: No doubling allowed during the Crawford game - // The Crawford game is the first game after a player reaches match point - 1 - if (game.rules?.useCrawfordRule && game.matchInfo?.isCrawford) { - return false - } - - // Allow doubling from rolling state only (before rolling dice) - // Only if cube is centered (no owner) OR the player owns the cube - // and cube is not maxxed or already offered - return ( - game.stateKind === 'rolling' && - game.cube.stateKind !== 'maxxed' && - game.cube.stateKind !== 'offered' && - (!game.cube.owner || game.cube.owner.id === player.id) && - // Disallow repeat doubles in same turn unless Beaver is implemented - (game.cube as any).offeredThisTurnBy?.id !== player.id - ) + return canOfferDouble(game, player) } // --- Player Management --- @@ -2057,180 +465,28 @@ export class Game { game: BackgammonGame, player: BackgammonPlayerActive ): boolean { - // Only if cube is in 'offered' state and player is not the one who offered - return ( - game.cube.stateKind === 'offered' && - game.cube.offeredBy && - game.cube.offeredBy.id !== player.id - ) + return canAcceptDouble(game, player) } public static acceptDouble( game: BackgammonGame, player: BackgammonPlayerActive ): BackgammonGame { - if (!Game.canAcceptDouble(game, player)) - throw new Error('Cannot accept double') - // Accept the offered value (do NOT double again here). Transfer ownership to accepting player. - const acceptedValue = (game.cube.value ?? 2) as typeof game.cube.value - const offeringPlayer = game.cube.offeredBy! - // If accepting at 64, preserve prior behavior (may end the game depending on rules) - if (acceptedValue === 64) { - // If maxxed, game should be completed - const winner = { - ...player, - stateKind: 'winner', - } as BackgammonPlayerWinner - - // Update players array to reflect winner status - const updatedPlayers = game.players.map((p) => - p.id === winner.id ? winner : p - ) as BackgammonPlayers - - return incrementStateVersion({ - ...game, - stateKind: 'completed', - winner: winner.id, - winType: 'simple', - pointsWon: 64, - players: updatedPlayers, - cube: { - ...game.cube, - stateKind: 'maxxed', - owner: undefined, - value: 64, - offeredBy: undefined, - }, - // Same cleanup as refuseDouble / resign: the completed game - // must not carry a mid-turn activePlay to the client. - activePlay: undefined, - activePlayer: winner, - endReason: 'cube_drop', - endTime: new Date(), - } as BackgammonGameCompleted) - } - // Transition back to 'rolling' state for the original offering player to roll - const updatedCube = { - ...game.cube, - stateKind: 'doubled' as const, - owner: player, - value: acceptedValue, - offeredBy: undefined, - } - - const updatedActivePlayer: BackgammonPlayerRolling = { - ...offeringPlayer, - stateKind: 'rolling', - dice: Dice.initialize(offeringPlayer.color, 'rolling'), - rollForStartValue: (offeringPlayer as any).rollForStartValue, - } - const updatedInactivePlayer: BackgammonPlayerInactive = { - ...player, - stateKind: 'inactive', - dice: Dice.initialize(player.color, 'inactive'), - rollForStartValue: (player as any).rollForStartValue, - } - - const updatedPlayers = game.players.map((p) => { - if (p.id === updatedActivePlayer.id) return updatedActivePlayer - if (p.id === updatedInactivePlayer.id) return updatedInactivePlayer - return p - }) as BackgammonPlayers - - return incrementStateVersion({ - ...game, - stateKind: 'rolling', - cube: updatedCube, - players: updatedPlayers, - activePlayer: updatedActivePlayer, - inactivePlayer: updatedInactivePlayer, - activeColor: updatedActivePlayer.color, - activePlay: undefined as any, - } as any) + return acceptDouble(game, player) } public static canRefuseDouble( game: BackgammonGame, player: BackgammonPlayerActive ): boolean { - // Only if cube is in 'offered' state and player is not the one who offered - return Game.canAcceptDouble(game, player) + return canRefuseDouble(game, player) } public static refuseDouble( game: BackgammonGame, player: BackgammonPlayerActive ): BackgammonGame { - if (!Game.canRefuseDouble(game, player)) - throw new Error('Cannot refuse double') - // The refusing player loses at the current cube value (before the proposed double) - // The offering player is the winner - const offeringPlayer = game.cube.offeredBy! - const winner = { - ...offeringPlayer, - stateKind: 'winner', - } as BackgammonPlayerWinner - - // Update players array to reflect winner status - const updatedPlayers = game.players.map((p) => - p.id === winner.id ? winner : p - ) as BackgammonPlayers - - // When refusing a double, winner gets the cube value BEFORE the proposed double - // If cube was centered (value undefined), use 1. Otherwise use current value / 2 (pre-double value) - const currentCubeValue = game.cube?.value - const pointsWon = currentCubeValue !== undefined ? currentCubeValue / 2 : 1 - - // Reset cube to its pre-offer state. Without this, cube.stateKind - // remains 'offered' after the game ends, which confuses the client - // into thinking there's still an outstanding double offer. - // - If the cube was centered before the offer (value=2, typical first - // double), revert to 'initialized' (value=undefined, no owner). - // - Otherwise revert to 'doubled' at half the offered value, owned by - // the same player as before the offer. - const wasFirstDouble = currentCubeValue === 2 - const resetCube = wasFirstDouble - ? { - ...game.cube, - stateKind: 'initialized' as const, - value: undefined, - owner: undefined, - offeredBy: undefined, - offeredThisTurnBy: undefined, - } - : { - ...game.cube, - stateKind: 'doubled' as const, - value: (currentCubeValue !== undefined - ? currentCubeValue / 2 - : undefined) as typeof game.cube.value, - owner: game.cube.owner, - offeredBy: undefined, - offeredThisTurnBy: undefined, - } - - logger.info( - `🏆 [Game] Double refused - Winner: ${winner.id}, Points won: ${pointsWon}` - ) - - return incrementStateVersion({ - ...game, - stateKind: 'completed', - winner: winner.id, - winType: 'simple', // Cube drops are always simple wins - pointsWon, - players: updatedPlayers, - cube: resetCube, - // Clear mid-turn state left over from whoever was playing when the - // cube was offered. Without this, the spread of ...game carries a - // stale activePlay (stateKind 'moving' with possibleMoves) through - // to the completed game, and the client renders a mid-game board - // on top of a game the server has marked finished. - activePlay: undefined, - activePlayer: winner, - endReason: 'cube_drop', - endTime: new Date(), - } as BackgammonGameCompleted) + return refuseDouble(game, player) } public static resign( @@ -2238,101 +494,11 @@ export class Game { resigningPlayer: BackgammonPlayer, points: 1 | 2 | 3 = 1 ): BackgammonGameCompleted { - if (game.stateKind === 'completed') { - throw new Error('Cannot resign a completed game') - } - - if (game.settings?.allowResign === false) { - throw new Error('Resignation is not allowed for this game') - } - - const resigningId = resigningPlayer.id - const opponent = game.players.find((p) => p.id !== resigningId) - if (!opponent) { - throw new Error('Opponent not found for resignation') - } - - const winner = { - ...opponent, - stateKind: 'winner', - } as BackgammonPlayerWinner // as BackgammonPlayerWinner: narrowing opponent to winner type - - const updatedPlayers = game.players.map((p) => - p.id === winner.id ? winner : p - ) as BackgammonPlayers // as BackgammonPlayers: map returns generic array, narrowing to tuple type - - const cubeValue = game.cube?.value ?? 1 - let winType: 'simple' | 'gammon' | 'backgammon' = 'simple' - let baseMultiplier = 1 - if (points === 2) { - winType = 'gammon' - baseMultiplier = 2 - } else if (points === 3) { - winType = 'backgammon' - baseMultiplier = 3 - } - - if (game.rules?.useJacobyRule && game.cube?.value === undefined && winType !== 'simple') { - winType = 'simple' - baseMultiplier = 1 - } - - const pointsWon = baseMultiplier * cubeValue - - logger.info( - `Resignation - Winner: ${winner.id}, Points won: ${pointsWon} (${baseMultiplier}x base * ${cubeValue} cube)` - ) - - return incrementStateVersion({ - ...game, - stateKind: 'completed', - winner: winner.id, - winType, - pointsWon, - endReason: 'resignation', - players: updatedPlayers, - endTime: new Date(), - // Clear mid-turn state left over from whoever was playing when the - // resignation came in. See refuseDouble above for the full rationale. - activePlay: undefined, - activePlayer: winner, - } as BackgammonGameCompleted) // as BackgammonGameCompleted: narrowing spread object to completed game type + return resign(game, resigningPlayer, points) } - /** - * Async wrapper for confirmTurn that handles robot automation - * @param game - Game in 'moving' state - * @returns Promise - Updated game state with robot automation if needed - */ - public static confirmTurnWithRobotAutomation = - async function confirmTurnWithRobotAutomation( - game: BackgammonGameMoved - ): Promise { - // Call the pure sync function first - const confirmedGame = Game.confirmTurn(game) - - // Check if the next player is a robot and handle automation - if (confirmedGame.activePlayer?.isRobot) { - try { - // Dynamic import to avoid circular dependencies - // Robot automation moved to @nodots/backgammon-robots package - - // Robot automation is now external - return game as-is - logger.info('🤖 Robot automation is now handled externally') - return confirmedGame - } catch (error) { - logger.error( - '🤖 Robot automation error during turn transition (confirmTurn):', - error - ) - // Return original game state if robot automation throws - return confirmedGame - } - } - - return confirmedGame - } + public static confirmTurnWithRobotAutomation = confirmTurnWithRobotAutomation // processRobotTurn method removed - now handled by @nodots/backgammon-robots package @@ -2342,107 +508,26 @@ export class Game { * Execute doubling action from rolling state (before rolling dice) * Transitions from 'rolling' to 'doubled' state and offers double to opponent */ - public static double = function double( + public static double = function ( game: BackgammonGameRolling ): BackgammonGameDoubled { - if (game.stateKind !== 'rolling') { - throw new Error( - `Cannot double from ${game.stateKind} state. Must be in 'rolling' state.` - ) - } - - if (!Game.canOfferDouble(game, game.activePlayer)) { - throw new Error('Doubling is not allowed in current game state') - } - - const { activePlayer, cube } = game - - // Calculate new cube value - initial doubling sets cube to 2 - const newValue = ( - cube.value ? Math.min(cube.value * 2, 64) : 2 - ) as BackgammonCubeValue - - // Create updated cube in 'offered' state (waiting for opponent response) - const updatedCube = { - ...cube, - stateKind: 'offered' as const, - value: newValue, - offeredBy: activePlayer, - offeredThisTurnBy: activePlayer, - } - - // Convert active player to doubled state - const doubledPlayer = { - ...activePlayer, - stateKind: 'doubled' as const, - dice: { - ...activePlayer.dice, - stateKind: 'rolled' as const, - }, - } as BackgammonPlayerDoubled - - // Update players array - const updatedPlayers = game.players.map((p) => - p.id === activePlayer.id ? doubledPlayer : p - ) as BackgammonPlayers - - // Update game statistics if they exist - const updatedStatistics = game.statistics - ? { - ...game.statistics, - totalDoubles: game.statistics.totalDoubles + 1, - cubeHistory: [ - ...game.statistics.cubeHistory, - { - turn: game.statistics.totalRolls, - value: newValue || 2, - offeredBy: activePlayer.color, - accepted: false, // Not yet accepted - just offered - }, - ], - } - : undefined - - // Return game in 'doubled' state (waiting for opponent to accept/refuse) - return incrementStateVersion({ - ...game, - stateKind: 'doubled', - cube: updatedCube, - players: updatedPlayers, - activePlayer: doubledPlayer, - statistics: updatedStatistics, - } as BackgammonGameDoubled) + return double(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/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 +} diff --git a/src/Game/robot.ts b/src/Game/robot.ts new file mode 100644 index 0000000..8fda1ec --- /dev/null +++ b/src/Game/robot.ts @@ -0,0 +1,54 @@ +import { + BackgammonGame, + BackgammonGameMoved, +} from '@nodots/backgammon-types' +import { debug, logger } from '../utils/logger' +import { confirmTurn } from './turnFlow' + +/** + * Handle robot automation for games in 'moved' state + * If the active player is a robot and the game is in 'moved' state, automatically confirm the turn + * @param game - Game in any state + * @returns Game with turn confirmed if robot automation was applied, otherwise unchanged + */ +export function handleRobotMovedState(game: BackgammonGame): BackgammonGame { + // Only handle games in 'moved' state with robot active player + if (game.stateKind === 'moved' && game.activePlayer.isRobot) { + debug('Robot in moved state, auto-confirming turn') + return confirmTurn(game as BackgammonGameMoved) + } + return game +} + +/** + * Async wrapper for confirmTurn that handles robot automation + * @param game - Game in 'moving' state + * @returns Promise - Updated game state with robot automation if needed + */ +export async function confirmTurnWithRobotAutomation( + game: BackgammonGameMoved +): Promise { + // Call the pure sync function first + const confirmedGame = confirmTurn(game) + + // Check if the next player is a robot and handle automation + if (confirmedGame.activePlayer?.isRobot) { + try { + // Dynamic import to avoid circular dependencies + // Robot automation moved to @nodots/backgammon-robots package + + // Robot automation is now external - return game as-is + logger.info('🤖 Robot automation is now handled externally') + return confirmedGame + } catch (error) { + logger.error( + '🤖 Robot automation error during turn transition (confirmTurn):', + error + ) + // Return original game state if robot automation throws + return confirmedGame + } + } + + return confirmedGame +} diff --git a/src/Game/turnFlow.ts b/src/Game/turnFlow.ts new file mode 100644 index 0000000..1443e91 --- /dev/null +++ b/src/Game/turnFlow.ts @@ -0,0 +1,1540 @@ +import { + BackgammonChecker, + BackgammonColor, + BackgammonDieValue, + BackgammonGame, + BackgammonGameCompleted, + BackgammonGameDoubled, + BackgammonGameMoved, + BackgammonGameMoving, + BackgammonGameRolledForStart, + BackgammonGameRolling, + BackgammonMoveSkeleton, + BackgammonPlayerActive, + BackgammonPlayerDoubled, + BackgammonPlayerInactive, + BackgammonPlayerMoving, + BackgammonPlayerRolling, + BackgammonPlayers, + BackgammonPlayersMovingTuple, + BackgammonPlayersRollingTuple, + BackgammonPlayerWinner, + BackgammonPlayMoving, + BackgammonRoll, +} from '@nodots/backgammon-types' +import { generateId, Player } from '..' +import { Board } from '../Board' +import { Checker } from '../Checker' +import { Dice } from '../Dice' +import type { MoveExecutionOptions } from '../Play' +import { Play } from '../Play' +import { debug, logger } from '../utils/logger' +import { incrementStateVersion } from './shared' + +export function roll( + game: + | BackgammonGameRolledForStart + | BackgammonGameRolling + | BackgammonGameDoubled +): BackgammonGameMoving { + switch (game.stateKind) { + case 'rolled-for-start': { + const { players, activeColor } = game + const activePlayer = players.find((p) => p.color === activeColor) + if (!activePlayer) throw Error(`Roll requires an active player`) + const inactivePlayer = players.find((p) => p.id !== activePlayer.id) + if (!inactivePlayer) throw Error(`Roll requires and inactive player`) + if ( + !activePlayer?.rollForStartValue || + !inactivePlayer?.rollForStartValue + ) { + throw new Error('Players do not have rollForStartValues') + } + + // Use roll-for-start values for the winner's first roll + const currentRoll: BackgammonRoll = [ + activePlayer.rollForStartValue, + inactivePlayer.rollForStartValue, + ] + + const movingPlayer: BackgammonPlayerMoving = { + ...activePlayer, + stateKind: 'moving', + dice: { + ...activePlayer.dice, + stateKind: 'rolled', + currentRoll: currentRoll, // Use actual roll-for-start values + total: currentRoll[0] + currentRoll[1], + }, + rollForStartValue: activePlayer.rollForStartValue, + } + + const unrolledPlayer: BackgammonPlayerInactive = { + ...inactivePlayer, + stateKind: 'inactive', + dice: { + ...inactivePlayer.dice, + stateKind: 'inactive', + currentRoll: undefined, + total: 0, + }, + rollForStartValue: inactivePlayer.rollForStartValue, + } + + let activePlay = Play.initialize(game.board, movingPlayer) + + // Check if all moves were auto-completed (no legal moves available) + const allMovesCompleted = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + + // CRITICAL FIX: Validate that the correct number of moves exist before auto-completing + const expectedMoveCount = currentRoll[0] === currentRoll[1] ? 4 : 2 // doubles vs regular roll + const actualMoveCount = activePlay.moves.length + + if (allMovesCompleted && actualMoveCount === expectedMoveCount) { + debug( + 'Game.roll: All moves auto-completed - no legal moves available, transitioning to moved state', + { expectedMoveCount, actualMoveCount, currentRoll } + ) + // Player has no legal moves, return game in 'moved' state + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: movingPlayer, + inactivePlayer: unrolledPlayer, + activePlay, + board: game.board, // Board unchanged + } as any) // Cast to avoid type issues since we're returning moved instead of moving + } else if (allMovesCompleted && actualMoveCount !== expectedMoveCount) { + // BUG DETECTED: Moves are completed but count doesn't match expected dice + debug( + 'Game.roll: BUG - Move count mismatch detected, normalizing move list to expected count', + { + expectedMoveCount, + actualMoveCount, + currentRoll, + moveStates: activePlay.moves.map((m) => ({ + id: m.id.slice(0, 8), + stateKind: m.stateKind, + dieValue: m.dieValue, + })), + } + ) + + // Normalize by adding completed no-move entries for missing dice + const counts = new Map() + for (const mv of activePlay.moves) { + counts.set(mv.dieValue, (counts.get(mv.dieValue) || 0) + 1) + } + const targetCounts = new Map() + targetCounts.set(currentRoll[0], (targetCounts.get(currentRoll[0]) || 0) + 1) + targetCounts.set(currentRoll[1], (targetCounts.get(currentRoll[1]) || 0) + 1) + if (currentRoll[0] === currentRoll[1]) { + targetCounts.set(currentRoll[0], 4) + } + const normalized = [...activePlay.moves] + for (const [dieValue, target] of targetCounts.entries()) { + const have = counts.get(dieValue) || 0 + for (let i = have; i < target; i++) { + normalized.push({ + id: generateId(), + player: movingPlayer, + dieValue: dieValue as BackgammonDieValue, + stateKind: 'completed', + moveKind: 'no-move', + possibleMoves: [], + origin: undefined, + destination: undefined, + isHit: false, + } as any) + } + } + + activePlay = { ...activePlay, moves: normalized } + // Continue to board update below + } + + // Sanitize moves: if any ready move has no possible moves, + // convert it to a completed no-move to prevent stuck states. + // For bar reentry scenarios, non-reentry moves must be evaluated + // on a simulated board (after reentry) since getPossibleMoves + // only returns bar moves when checkers are on the bar. + const barForSanitize = game.board.bar[movingPlayer.direction] + const hasCheckerOnBar = barForSanitize.checkers.some( + (c) => c.color === movingPlayer.color + ) + + let sanitizationBoard = game.board + if (hasCheckerOnBar) { + // Simulate reentry moves to get the board state for evaluating + // non-reentry dice (e.g., die 4 when only die 3 can reenter) + const reentryMoves = activePlay.moves.filter( + (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' + ) + for (const reentryMove of reentryMoves) { + if (reentryMove.possibleMoves.length > 0) { + const firstOption = reentryMove.possibleMoves[0] + sanitizationBoard = Board.moveChecker( + sanitizationBoard, + firstOption.origin, + firstOption.destination, + movingPlayer.direction + ) + } + } + } + + for (const move of activePlay.moves) { + if (move.stateKind === 'ready') { + // Skip reentry moves - Play.initialize() already validated them + // and their possibleMoves may contain options from multiple dice. + // Re-evaluating with a single dieValue would strip those options. + if (move.moveKind === 'reenter') continue + if ((move as any)._sequenceDependent) continue + + const fresh = Board.getPossibleMoves( + sanitizationBoard, + movingPlayer, + move.dieValue + ) as BackgammonMoveSkeleton[] + if (!fresh || fresh.length === 0) { + ;(move as any).stateKind = 'completed' + ;(move as any).moveKind = 'no-move' + ;(move as any).possibleMoves = [] + ;(move as any).origin = undefined + ;(move as any).destination = undefined + ;(move as any).isHit = false + } else { + ;(move as any).possibleMoves = fresh + } + } + } + + // Re-check after sanitization: if all moves are now completed, + // transition to 'moved' state instead of 'moving' + const allCompletedAfterSanitize = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + if (allCompletedAfterSanitize) { + debug( + 'Game.roll: All moves completed after sanitization, transitioning to moved state' + ) + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: movingPlayer, + inactivePlayer: unrolledPlayer, + activePlay, + board: game.board, + } as any) // Cast: returning moved instead of moving + } + + // Update the board with movable checkers + let movableContainerIds: string[] = [] + // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable + const activeBar = game.board.bar[movingPlayer.direction] + const hasOwnOnBar = activeBar.checkers.some( + (c) => c.color === movingPlayer.color + ) + if (hasOwnOnBar) { + movableContainerIds = [activeBar.id] + } else { + const movesArray = activePlay.moves + for (const move of movesArray) { + switch (move.stateKind) { + case 'ready': { + if (move.possibleMoves) { + for (const possibleMove of move.possibleMoves) { + if ( + possibleMove.origin && + !movableContainerIds.includes(possibleMove.origin.id) + ) { + movableContainerIds.push(possibleMove.origin.id) + } + } + } + break + } + case 'completed': + case 'confirmed': + // These moves don't have movable checkers + break + } + } + } + const updatedBoard = Checker.updateMovableCheckers( + game.board, + movableContainerIds + ) + + return incrementStateVersion({ + ...game, + stateKind: 'moving', + players: [ + movingPlayer, + unrolledPlayer, + ] as BackgammonPlayersMovingTuple, + activeColor: movingPlayer.color, + activePlayer: movingPlayer, + inactivePlayer: unrolledPlayer, + activePlay, + board: updatedBoard, + }) + } + + case 'doubled': { + // Handle rolling from doubled state (after accepting a double) + const { players, board, activeColor } = game + if (!activeColor) throw new Error('Active color must be provided') + const [activePlayerForColor, inactivePlayerForColor] = + getPlayersForColor(players, activeColor!) + if (activePlayerForColor.stateKind !== 'doubled') { + throw new Error('Active player must be in doubled state') + } + const activePlayerDoubled = + activePlayerForColor as BackgammonPlayerDoubled + const inactivePlayer = inactivePlayerForColor + if (!inactivePlayer) throw new Error('Inactive player not found') + + // Roll new dice for the doubled player + const playerRolled = Player.roll({ + ...activePlayerDoubled, + stateKind: 'rolling', + } as any) + const playerMoving = Player.toMoving(playerRolled) + const activePlay = Play.initialize(board, playerMoving) + + // Check if all moves were auto-completed (no legal moves available) + const allMovesCompleted = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + if (allMovesCompleted) { + debug( + 'Game.roll: All moves auto-completed (doubled case) - no legal moves available, transitioning to moved state' + ) + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: playerMoving, + inactivePlayer, + activePlay, + board, + } as any) + } + + const movingPlay = { + ...activePlay, + stateKind: 'moving', + player: playerMoving, + } as BackgammonPlayMoving + + // Sanitize moves: if any ready move has no possible moves, + // convert it to a completed no-move to prevent stuck states. + // For bar reentry scenarios, non-reentry moves must be evaluated + // on a simulated board (after reentry). + { + const barForSanitize3 = board.bar[playerMoving.direction] + const hasCheckerOnBar3 = barForSanitize3.checkers.some( + (c) => c.color === playerMoving.color + ) + + let sanitizationBoard3 = board + if (hasCheckerOnBar3) { + const reentryMoves3 = activePlay.moves.filter( + (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' + ) + for (const reentryMove of reentryMoves3) { + if (reentryMove.possibleMoves.length > 0) { + const firstOption = reentryMove.possibleMoves[0] + sanitizationBoard3 = Board.moveChecker( + sanitizationBoard3, + firstOption.origin, + firstOption.destination, + playerMoving.direction + ) + } + } + } + + for (const move of activePlay.moves) { + if (move.stateKind === 'ready') { + if (move.moveKind === 'reenter') continue + if ((move as any)._sequenceDependent) continue + const fresh = Board.getPossibleMoves( + sanitizationBoard3, + playerMoving, + move.dieValue + ) as BackgammonMoveSkeleton[] + if (!fresh || fresh.length === 0) { + ;(move as any).stateKind = 'completed' + ;(move as any).moveKind = 'no-move' + ;(move as any).possibleMoves = [] + ;(move as any).origin = undefined + ;(move as any).destination = undefined + ;(move as any).isHit = false + } else { + ;(move as any).possibleMoves = fresh + } + } + } + } + + // Re-check after sanitization: if all moves are now completed, + // transition to 'moved' state instead of 'moving' + const allCompletedAfterSanitize = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + if (allCompletedAfterSanitize) { + debug( + 'Game.roll: All moves completed after sanitization (doubled case), transitioning to moved state' + ) + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: playerMoving, + inactivePlayer, + activePlay, + board, + } as any) // Cast: returning moved instead of moving + } + + // Update the board with movable checkers + let movableContainerIds2: string[] = [] + const activeBar2 = board.bar[playerMoving.direction] + const hasOwnOnBar2 = activeBar2.checkers.some( + (c) => c.color === playerMoving.color + ) + if (hasOwnOnBar2) { + movableContainerIds2 = [activeBar2.id] + } else { + const movesArray = activePlay.moves + for (const move of movesArray) { + switch (move.stateKind) { + case 'ready': + if (move.possibleMoves) { + for (const possibleMove of move.possibleMoves) { + if ( + possibleMove.origin && + !movableContainerIds2.includes(possibleMove.origin.id) + ) { + movableContainerIds2.push(possibleMove.origin.id) + } + } + } + break + case 'completed': + case 'confirmed': + // These moves don't have movable checkers + break + } + } + } + const updatedBoard = Checker.updateMovableCheckers( + board, + movableContainerIds2 + ) + + // Update the players array to include the rolled player + const updatedPlayers = [ + playerRolled, + inactivePlayer, + ] as BackgammonPlayersMovingTuple + + const movingGame = { + ...game, + stateKind: 'moving', + players: updatedPlayers, + activePlayer: playerRolled, + activePlay: movingPlay, + board: updatedBoard, + } as BackgammonGameMoving + + return incrementStateVersion(movingGame) + } + + case 'rolling': { + // Handle rolling from 'rolling' state (generate new dice) + const { players, board, activeColor } = game + if (!activeColor) throw new Error('Active color must be provided') + let [activePlayerForColor, inactivePlayerForColor] = + getPlayersForColor(players, activeColor!) + if (activePlayerForColor.stateKind !== 'rolling') { + throw new Error('Active player must be in rolling state') + } + const activePlayerRolling = + activePlayerForColor as BackgammonPlayerRolling + const inactivePlayer = inactivePlayerForColor + if (!inactivePlayer) throw new Error('Inactive player not found') + + const playerRolled = Player.roll(activePlayerRolling) + const playerMoving = Player.toMoving(playerRolled) + const activePlay = Play.initialize(board, playerMoving) + + // Check if all moves were auto-completed (no legal moves available) + const allMovesCompleted = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + if (allMovesCompleted) { + debug( + 'Game.roll: All moves auto-completed (rolling case) - no legal moves available, transitioning to moved state' + ) + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: playerMoving, + inactivePlayer, + activePlay, + board, + } as any) + } + + // Sanitize moves: if any ready move has no possible moves, + // convert it to a completed no-move to prevent stuck states. + // For bar reentry scenarios, non-reentry moves must be evaluated + // on a simulated board (after reentry) since getPossibleMoves + // only returns bar moves when checkers are on the bar. + { + const barForSanitize2 = board.bar[playerMoving.direction] + const hasCheckerOnBar2 = barForSanitize2.checkers.some( + (c) => c.color === playerMoving.color + ) + + let sanitizationBoard2 = board + if (hasCheckerOnBar2) { + const reentryMoves2 = activePlay.moves.filter( + (m) => m.stateKind === 'ready' && m.moveKind === 'reenter' + ) + for (const reentryMove of reentryMoves2) { + if (reentryMove.possibleMoves.length > 0) { + const firstOption = reentryMove.possibleMoves[0] + sanitizationBoard2 = Board.moveChecker( + sanitizationBoard2, + firstOption.origin, + firstOption.destination, + playerMoving.direction + ) + } + } + } + + for (const move of activePlay.moves) { + if (move.stateKind === 'ready') { + if (move.moveKind === 'reenter') continue + if ((move as any)._sequenceDependent) continue + const fresh = Board.getPossibleMoves( + sanitizationBoard2, + playerMoving, + move.dieValue + ) as BackgammonMoveSkeleton[] + if (!fresh || fresh.length === 0) { + ;(move as any).stateKind = 'completed' + ;(move as any).moveKind = 'no-move' + ;(move as any).possibleMoves = [] + ;(move as any).origin = undefined + ;(move as any).destination = undefined + ;(move as any).isHit = false + } else { + ;(move as any).possibleMoves = fresh + } + } + } + } + + // Re-check after sanitization: if all moves are now completed, + // transition to 'moved' state instead of 'moving' + const allCompletedAfterSanitize = activePlay.moves.every( + (m) => m.stateKind === 'completed' + ) + if (allCompletedAfterSanitize) { + debug( + 'Game.roll: All moves completed after sanitization (rolling case), transitioning to moved state' + ) + return incrementStateVersion({ + ...game, + stateKind: 'moved', + activePlayer: playerMoving, + inactivePlayer, + activePlay, + board, + } as any) // Cast: returning moved instead of moving + } + + const movingPlay = { + ...activePlay, + stateKind: 'moving', + player: playerMoving, + } as BackgammonPlayMoving + + // Update the board with movable checkers + // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable + let movableContainerIds: string[] = [] + const activeBar = board.bar[playerMoving.direction] + const hasOwnOnBar = activeBar.checkers.some( + (c) => c.color === playerMoving.color + ) + if (hasOwnOnBar) { + movableContainerIds = [activeBar.id] + } else { + const movesArray = activePlay.moves + for (const move of movesArray) { + switch (move.stateKind) { + case 'ready': + if (move.possibleMoves) { + for (const possibleMove of move.possibleMoves) { + if ( + possibleMove.origin && + !movableContainerIds.includes(possibleMove.origin.id) + ) { + movableContainerIds.push(possibleMove.origin.id) + } + } + } + break + case 'completed': + case 'confirmed': + // These moves don't have movable checkers + break + } + } + } + const updatedBoard = Checker.updateMovableCheckers( + board, + movableContainerIds + ) + + // Update the players array to include the rolled player + const updatedPlayers = players.map((p) => + p.id === playerRolled.id ? playerRolled : p + ) as BackgammonPlayers + + const movingGame = { + ...game, + stateKind: 'moving', + players: updatedPlayers, + activePlayer: playerRolled, + activePlay: movingPlay, + board: updatedBoard, + } as BackgammonGameMoving + + return incrementStateVersion(movingGame) + } + + default: + // TypeScript exhaustiveness check - should never reach here + const _exhaustiveCheck: never = game + throw new Error(`Unexpected game state: ${(game as any).stateKind}`) + } +} + +/** + * Switch the order of dice for the active player + * Allowed in 'moving' state when all moves are undone + */ +export function switchDice( + game: BackgammonGameMoving +): BackgammonGameMoving { + // Check if dice switching is allowed + switch (game.stateKind) { + case 'moving': { + // Only allowed in moving state if all moves are undone (all moves in 'ready' state) + const allMovesUndone = game.activePlay?.moves + ? game.activePlay.moves.every( + (move: any) => move.stateKind === 'ready' + ) + : false + + const undoStackEmpty = !((game.activePlay as any)?.undo?.frames?.length > 0) + if (!(allMovesUndone && undoStackEmpty)) { + throw new Error('Cannot switch dice in moving state unless all moves are undone') + } + break + } + default: + // This should never happen given our union type, but include for completeness + throw new Error( + `Cannot switch dice from ${(game as any).stateKind} state` + ) + } + + const { activePlayer, activePlay } = game + + if ( + !activePlayer?.dice?.currentRoll || + activePlayer.dice.currentRoll.length !== 2 + ) { + throw new Error('Active player does not have valid dice to switch') + } + + // Switch the dice using the Dice class + const switchedDice = Dice.switchDice(activePlayer.dice) + const updatedActivePlayer = { + ...activePlayer, + dice: switchedDice, + } + + // Update the activePlay to reflect the new dice order + const updatedActivePlay = activePlay + ? { + ...activePlay, + moves: activePlay.moves + ? (() => { + const movesArray = activePlay.moves + if (movesArray.length >= 2) { + // Swap the first two moves to match the new dice order + const swappedMoves = [...movesArray] + const temp = swappedMoves[0] + swappedMoves[0] = swappedMoves[1] + swappedMoves[1] = temp + + // CRITICAL: Update dieValue to match new dice order after swapping + // This fixes the data duplication bug between dice.currentRoll and moves[].dieValue + const [newFirstDie, newSecondDie] = switchedDice.currentRoll + swappedMoves[0] = { + ...swappedMoves[0], + dieValue: newFirstDie, + } + swappedMoves[1] = { + ...swappedMoves[1], + dieValue: newSecondDie, + } + + // CRITICAL: Regenerate possibleMoves for all moves based on new dice order + // This is necessary because possibleMoves were calculated with the old dice order + const regeneratedMoves = swappedMoves.map((move) => { + if (move.stateKind === 'ready') { + // Only regenerate for ready moves - completed moves shouldn't change + const freshPossibleMoves = Board.getPossibleMoves( + game.board, + updatedActivePlayer, + move.dieValue as BackgammonDieValue + ) as BackgammonMoveSkeleton[] + return { + ...move, + player: updatedActivePlayer, // Update player reference with switched dice + possibleMoves: freshPossibleMoves, + } + } + // Update player reference for non-ready moves too + return { + ...move, + player: updatedActivePlayer, // Update player reference with switched dice + } + }) + + return regeneratedMoves + } + return activePlay.moves + })() + : activePlay.moves, + } + : activePlay + + // Update the players array + const updatedPlayers = game.players.map((p) => + p.id === activePlayer.id ? updatedActivePlayer : p + ) as unknown as BackgammonPlayers + + // Return the same state type as input + return incrementStateVersion({ + ...game, + players: updatedPlayers, + activePlayer: updatedActivePlayer, + activePlay: updatedActivePlay, + } as typeof game) +} + +export function move( + game: BackgammonGameMoving, + checkerId: string, + preferredDieValue?: BackgammonDieValue, + options?: MoveExecutionOptions +): BackgammonGameMoving | BackgammonGameMoved | BackgammonGameCompleted { + // Push a pre-move snapshot to the turn-local undo stack + try { + const ap = game.activePlay + if (ap) { + const undo = ap.undo ?? (ap.undo = { frames: [] }) + const snapshot = + typeof structuredClone === 'function' + ? structuredClone(game) + : (JSON.parse(JSON.stringify(game)) as BackgammonGameMoving) + undo.frames.push(snapshot) + } + } catch (e) { + logger?.warn?.('Failed to push undo snapshot in Game.move', e) + } + + const checker = Board.getCheckers(game.board).find( + (c) => c.id === checkerId + ) + if (!checker) throw new Error(`No checker found for checkerId ${checkerId}`) + // Validate game state using switch + switch (game.stateKind) { + case 'moving': + // Valid state for moving + break + default: + throw new Error( + `Cannot move from ${(game as any).stateKind} state. Must be in 'moving' state.` + ) + } + let { activePlay, board } = game + + // Check if activePlay exists + if (!activePlay) { + throw new Error( + 'No active play found. Game must be in a valid play state.' + ) + } + + // Validate activePlay state using switch + switch (activePlay.stateKind) { + case 'moving': + // Valid state for activePlay + break + default: + throw new Error( + `Cannot move from ${activePlay.stateKind} state. ActivePlay must be in 'moving' state.` + ) + } + + const playResult = Player.move( + board, + activePlay, + checker.checkercontainerId, + preferredDieValue, + options + ) + board = playResult.board + + let movedPlayer = + playResult.move && playResult.move.player + ? { + ...playResult.move.player, + dice: game.activePlayer.dice, // Preserve switched dice state + } + : game.activePlayer + + // Always update activePlay from playResult (fallback to activePlay if undefined) + const updatedActivePlay = (playResult as any).play || activePlay + + // Update the board with movable checkers based on remaining moves + // IMPORTANT: After a move, we need to recalculate possible moves for remaining ready moves + // BAR-FIRST RULE: If active player has checkers on the bar, only the bar is movable + let movableContainerIds: string[] = [] + const playForTypes = updatedActivePlay as BackgammonPlayMoving + const playerDir: 'clockwise' | 'counterclockwise' = playForTypes.player + .direction as any + const activeBar = board.bar[playerDir] + const hasOwnOnBar = activeBar.checkers.some( + (c: BackgammonChecker) => c.color === playForTypes.player.color + ) + if (hasOwnOnBar) { + movableContainerIds = [activeBar.id] + } else { + if (updatedActivePlay.moves) { + const movesArray = updatedActivePlay.moves as any[] + for (const move of movesArray) { + switch (move.stateKind) { + case 'ready': { + // Recalculate fresh possible moves for this die value on the current board state + const freshPossibleMoves = Board.getPossibleMoves( + board, + updatedActivePlay.player, + move.dieValue + ) as BackgammonMoveSkeleton[] + + // Handle case where recalculated possibleMoves is empty + if (!freshPossibleMoves || freshPossibleMoves.length === 0) { + move.stateKind = 'completed' + move.moveKind = 'no-move' + move.possibleMoves = [] + move.origin = undefined + move.destination = undefined + move.isHit = false + debug( + 'Game.move: Converting move to no-move (no possible moves after recalculation)', + { + moveId: move.id, + dieValue: move.dieValue, + originalMoveKind: move.moveKind, + } + ) + } else { + // Update the move with fresh possible moves + move.possibleMoves = freshPossibleMoves + + // Add origins to movable containers + for (const possibleMove of freshPossibleMoves) { + if ( + possibleMove.origin && + !movableContainerIds.includes(possibleMove.origin.id) + ) { + movableContainerIds.push(possibleMove.origin.id) + } + } + } + break + } + case 'completed': + case 'confirmed': + case 'in-progress': + // These moves don't have movable checkers + break + } + } + } + } + board = Checker.updateMovableCheckers(board, movableContainerIds) + + // Recalculate pip counts after the move BEFORE win condition check + // This ensures final pip counts are correct when the game ends + logger.info('Game.move: Recalculating pip counts after move') + const gameWithUpdatedBoard = { + ...game, + board, + players: game.players.map((p) => + p.id === movedPlayer.id ? movedPlayer : p + ) as import('@nodots/backgammon-types').BackgammonPlayers, + } + const updatedPlayers = Player.recalculatePipCounts(gameWithUpdatedBoard) + + // Update movedPlayer with correct pip count + movedPlayer = + (updatedPlayers.find((p) => p.id === movedPlayer.id) as any) || + movedPlayer + + // --- WIN CONDITION CHECK --- + // Check if the player has won (all checkers off) AFTER the move is processed + // IMPORTANT: This check must happen after the checker is moved off the board AND pip counts recalculated + const direction = movedPlayer.direction + const playerOff = board.off[direction] + const playerCheckersOff = playerOff.checkers.filter( + (c) => c.color === movedPlayer.color + ).length + + // Count total checkers on board for this player (should be 0 when won) + const playerCheckersOnBoard = Board.getCheckers(board).filter( + (c) => c.color === movedPlayer.color + ).length + + // Get move kind for additional context + const lastMoveKind = playResult.move && playResult.move.moveKind + + // Enhanced debug output for win condition + logger.info('[Game] 🏆 WIN CONDITION CHECK:', { + playerCheckersOff, + playerCheckersOnBoard, + totalCheckersExpected: 15, + lastMoveKind, + playerOffCheckers: playerOff.checkers.length, + movedPlayerColor: movedPlayer.color, + movedPlayerDirection: movedPlayer.direction, + pipCount: movedPlayer.pipCount, + hasWon: playerCheckersOff === 15 || playerCheckersOnBoard === 0, + }) + + // FIXED: More robust win condition - check multiple criteria for victory + // A player wins when they have all 15 checkers off OR no checkers remaining on board + const hasWon = + playerCheckersOff === 15 || // Primary condition: all checkers in off area + (playerCheckersOnBoard === 0 && playerCheckersOff > 0) || // Backup: no checkers on board + some off + (movedPlayer.pipCount === 0 && lastMoveKind === 'bear-off') // Tertiary: pip count zero after bear-off + + if (hasWon) { + logger.info( + `🎉 [Game] PLAYER ${movedPlayer.color.toUpperCase()} HAS WON! (${playerCheckersOff} checkers off, ${playerCheckersOnBoard} on board)` + ) + + // Player has borne off all checkers, they win + const winner = { + ...movedPlayer, + stateKind: 'winner', + pipCount: 0, // Winner has 0 pip count + } as BackgammonPlayerWinner + + // Find the loser for scoring calculation + const loser = updatedPlayers.find((p) => p.id !== winner.id)! + const loserDirection = loser.direction + + // Calculate win type based on loser's checker positions + const loserCheckersOff = board.off[loserDirection]?.checkers?.length ?? 0 + const loserCheckersOnBar = board.bar[loserDirection]?.checkers?.length ?? 0 + + // Check if loser has checkers in winner's home board (positions 1-6 from winner's perspective) + const winnerDirection = winner.direction + const loserCheckersInWinnerHome = board.points.some((point) => { + const positionFromWinnerPerspective = point.position[winnerDirection] + return ( + positionFromWinnerPerspective >= 1 && + positionFromWinnerPerspective <= 6 && + point.checkers.some((c) => c.color === loser.color) + ) + }) + + // Determine base win type + let winType: 'simple' | 'gammon' | 'backgammon' = 'simple' + let baseMultiplier = 1 + + if (loserCheckersOff === 0) { + // Loser has no checkers off - at minimum a gammon + if (loserCheckersOnBar > 0 || loserCheckersInWinnerHome) { + // Backgammon: loser has checkers on bar OR in winner's home board + winType = 'backgammon' + baseMultiplier = 3 + } else { + // Gammon: loser just has no checkers off + winType = 'gammon' + baseMultiplier = 2 + } + } + + // Jacoby rule: In money games, gammons/backgammons only count if cube was turned + // Cube is considered "never turned" if value is undefined (centered) + const cubeValue = game.cube?.value + const cubeWasTurned = cubeValue !== undefined + if (game.rules?.useJacobyRule && !cubeWasTurned && winType !== 'simple') { + logger.info( + `Jacoby rule applied: ${winType} reduced to simple (cube never turned)` + ) + winType = 'simple' + baseMultiplier = 1 + } + + // Calculate total points: base multiplier * cube value (cube defaults to 1 if not turned) + const cubeMultiplier = cubeValue ?? 1 + const pointsWon = baseMultiplier * cubeMultiplier + + logger.info( + `🏆 [Game] Win type: ${winType}, Points won: ${pointsWon} (${baseMultiplier}x base * ${cubeMultiplier} cube)` + ) + + // Update players array to include the winner with correct state + const finalPlayers = updatedPlayers.map((p) => + p.id === winner.id ? winner : p + ) as BackgammonPlayers + + logger.info(`🏁 [Game] Game ${game.id} completed - Winner: ${winner.id}`) + + return incrementStateVersion({ + ...game, + stateKind: 'completed', + winner: winner.id, + winType, + pointsWon, + board, + activePlayer: winner, + activePlay: updatedActivePlay, + players: finalPlayers, + endTime: new Date(), // Add end time for completed games + } as BackgammonGameCompleted) + } + // --- END WIN CONDITION CHECK --- + + // DICE SWITCHING DEBUG: Check what's happening to dice and moves state + const finalActivePlayer = updatedPlayers.find( + (p) => p.id === movedPlayer.id + ) as any + const finalMoves = Array.from(updatedActivePlay.moves || []) + // Guarded for browser bundles — `process` is Node-only. + if ( + typeof process !== 'undefined' && + process.env?.NODOTS_DEBUG_DICE === '1' + ) { + // Optional dice/move state debug + debug('🎲 [DICE DEBUG] Game.move result:') + debug( + ' game.activePlayer.dice:', + game.activePlayer.dice?.currentRoll + ) + debug( + ' finalActivePlayer.dice:', + finalActivePlayer?.dice?.currentRoll + ) + debug( + ' finalMoves.dieValues:', + finalMoves.map((m: any) => m.dieValue) + ) + debug( + ' finalMoves.states:', + finalMoves.map((m: any) => m.stateKind) + ) + } + + // Set game stateKind based on activePlay stateKind + const gameStateKind = + updatedActivePlay.stateKind === 'moved' ? 'moved' : 'moving' + + // Set activePlayer stateKind based on activePlay stateKind + const finalActivePlayerWithState = { + ...finalActivePlayer, + stateKind: updatedActivePlay.stateKind === 'moved' ? 'moved' : 'moving', + } + + return incrementStateVersion({ + ...game, + stateKind: gameStateKind, + board, + players: updatedPlayers.map((p) => + p.id === finalActivePlayerWithState.id ? finalActivePlayerWithState : p + ), + activePlayer: finalActivePlayerWithState, + activePlay: updatedActivePlay, + } as BackgammonGameMoving | BackgammonGameMoved) +} + +/** + * Execute a human move and finalize the turn if all moves are completed. + * This keeps turn-completion logic inside CORE and provides a single + * entrypoint for API/clients. + */ +export function moveAndFinalize( + game: BackgammonGameMoving, + checkerId: string +): BackgammonGameMoving | BackgammonGameMoved | BackgammonGameCompleted { + const moved = move(game, checkerId) + if (moved.stateKind === 'moving') { + // Let CORE decide if the turn should complete now + return checkAndCompleteTurn(moved as BackgammonGameMoving) as + | BackgammonGameMoving + | BackgammonGameMoved + | BackgammonGameCompleted + } + return moved +} + +/** + * Transition from 'moving' to 'moved' state + * This represents that all moves are completed and the player must confirm their turn + */ +export function toMoved( + game: BackgammonGameMoving +): BackgammonGameMoved { + if (game.stateKind !== 'moving') { + throw new Error( + `Cannot transition to moved from ${ + (game as any).stateKind + } state. Must be in 'moving' state.` + ) + } + + // Ensure all moves are completed before transitioning + const activePlay = game.activePlay + if (!activePlay || !activePlay.moves) { + throw new Error('No active play found') + } + + const movesArray = activePlay.moves + const allMovesCompleted = movesArray.every( + (move) => move.stateKind === 'completed' + ) + + if (!allMovesCompleted) { + throw new Error( + 'Cannot transition to moved state - not all moves are completed' + ) + } + + // Create moved state - human player's turn is complete, waiting for dice click confirmation + return incrementStateVersion({ + ...game, + stateKind: 'moved', + } as BackgammonGameMoved) +} + +/** + * Execute a single move and recalculate fresh moves (just-in-time approach) + * This method prevents stale move references by always calculating moves based on current board state + * @param game - Current game state in 'moving' state + * @param originId - ID of the origin point/bar to move from + * @returns Updated game state with fresh moves calculated + */ +export function executeAndRecalculate( + game: BackgammonGameMoving, + originId: string, + options?: MoveExecutionOptions +): BackgammonGameMoving | BackgammonGame { + debug( + 'Game.executeAndRecalculate: About to execute move from origin:', + originId + ) + + if (!game) { + console.error('[DEBUG] CRITICAL: game parameter is undefined/null!') + throw new Error('Game parameter is undefined - cannot execute move') + } + + if (!game.board) { + console.error('[DEBUG] CRITICAL: game.board is undefined!', { + gameStateKind: game.stateKind, + gameKeys: Object.keys(game), + hasActivePlay: !!game.activePlay, + hasActivePlayer: !!game.activePlayer, + }) + throw new Error('Game.board is undefined - cannot execute move') + } + + // When expectedDieValue is specified, reorder ready moves so the + // matching die comes first. planMoveExecution picks firstDieValue + // from readyMoves[0], so placing the desired die first ensures it + // gets consumed instead of the other die. + if (options?.expectedDieValue != null && game.activePlay) { + const ap = game.activePlay as any + if (Array.isArray(ap.moves)) { + const expectedDie = options.expectedDieValue + const reordered = [...ap.moves].sort((a: any, b: any) => { + const aReady = a.stateKind === 'ready' && a.dieValue === expectedDie ? 0 : 1 + const bReady = b.stateKind === 'ready' && b.dieValue === expectedDie ? 0 : 1 + return aReady - bReady + }) + ap.moves = reordered + } + } + + // Find a checker in the specified origin container to execute the move + const checkers = Board.getCheckers(game.board) + const checkerInOrigin = checkers.find( + (c) => + c.checkercontainerId === originId && c.color === game.activePlayer.color + ) + + if (!checkerInOrigin) { + throw new Error( + `No ${game.activePlayer.color} checker found in container ${originId}` + ) + } + + // Push a pre-move snapshot + try { + const ap = game.activePlay + if (ap) { + const undo = ap.undo ?? (ap.undo = { frames: [] }) + const snapshot = + typeof structuredClone === 'function' + ? structuredClone(game) + : (JSON.parse(JSON.stringify(game)) as BackgammonGameMoving) + undo.frames.push(snapshot) + } + } catch (e) { + logger?.warn?.('Failed to push undo snapshot before move', e) + } + + const gameAfterMove = move(game, checkerInOrigin.id, undefined, options) + + debug( + 'Game.executeAndRecalculate: Move executed, game state:', + { + stateKind: gameAfterMove.stateKind, + hasActivePlay: !!(gameAfterMove as any).activePlay, + activePlayMoves: (gameAfterMove as any).activePlay?.moves + ? Array.from((gameAfterMove as any).activePlay.moves).length + : 0, + } + ) + + // Check if the game ended (win condition) + if (gameAfterMove.stateKind === 'completed') { + return gameAfterMove + } + + // Check if the game is already in 'moved' state after the move + if (gameAfterMove.stateKind === 'moved') { + debug('Game is already in moved state, returning as-is') + return gameAfterMove + } + + // Game continues in moving state + const movingGame = gameAfterMove as BackgammonGameMoving + + // Check if turn should be completed (for both human and robot players) + const gameAfterTurnCheck = checkAndCompleteTurn(movingGame) + + // For robot players, auto-confirm the turn if it transitioned to 'moved' + if ( + movingGame.activePlayer.isRobot && + gameAfterTurnCheck.stateKind === 'moved' + ) { + debug('Robot turn completed, auto-confirming turn') + return confirmTurn(gameAfterTurnCheck as BackgammonGameMoved) + } + + // Return the game (either still 'moving' or transitioned to 'moved') + if (gameAfterTurnCheck.stateKind === 'moved') { + debug('Turn completed, transitioned to moved state') + return gameAfterTurnCheck + } + + // CRITICAL FIX: After executing a move, the activePlay.moves now contains fresh possibleMoves + // for all remaining ready moves thanks to the fix in Play.move() + // The movingGame already has the updated board state and refreshed activePlay + + debug( + 'Game.executeAndRecalculate: Move executed successfully, returning updated game with fresh activePlay' + ) + + // Turn continues, return the game with fresh board state and updated activePlay + return gameAfterTurnCheck +} + +/** + * Check if the current turn is complete and transition to 'moved' state + * This method now follows the same state machine as human players for consistency + * @param game - Current game state + * @returns Updated game state in 'moved' state or current game if turn not complete + */ +export function checkAndCompleteTurn( + game: BackgammonGameMoving +): BackgammonGame { + // Use discriminated union pattern for turn completion states + type TurnCompletionState = + | { type: 'invalid-game' } + | { type: 'no-active-play' } + | { type: 'moves-incomplete'; completedCount: number; totalCount: number } + | { + type: 'all-moves-completed' + moves: Array<{ + id: string + dieValue: number + stateKind: string + moveKind: string + }> + } + + // Determine current turn completion state + const getTurnCompletionState = (): TurnCompletionState => { + // Validate game structure first + if (!game?.activePlayer?.color) { + return { type: 'invalid-game' } + } + + const activePlay = game.activePlay + if (!activePlay?.moves) { + return { type: 'no-active-play' } + } + + const movesArray = activePlay.moves + + // Auto-complete ready moves that have no possible moves on + // the current board. This handles the case where a player's + // remaining moves are blocked after executing earlier moves. + // Always verify against current board state since possibleMoves + // may be stale from before earlier moves in the turn. + for (const move of movesArray) { + if (move.stateKind === 'ready') { + const fresh = Board.getPossibleMoves( + game.board, + game.activePlayer, + move.dieValue + ) as BackgammonMoveSkeleton[] + if (!fresh || fresh.length === 0) { + ;(move as any).stateKind = 'completed' + ;(move as any).moveKind = 'no-move' + ;(move as any).possibleMoves = [] + ;(move as any).origin = undefined + ;(move as any).destination = undefined + ;(move as any).isHit = false + logger.info( + `Auto-completed blocked move (die ${move.dieValue}) as no-move` + ) + } + } + } + + const completedMoves = movesArray.filter( + (move) => move.stateKind === 'completed' + ) + + if (completedMoves.length === movesArray.length) { + return { + type: 'all-moves-completed', + moves: movesArray.map((m) => ({ + id: m.id, + dieValue: m.dieValue, + stateKind: m.stateKind, + moveKind: m.moveKind, + })), + } + } + + return { + type: 'moves-incomplete', + completedCount: completedMoves.length, + totalCount: movesArray.length, + } + } + + const turnState = getTurnCompletionState() + + // Log debug info only after validation + if (turnState.type !== 'invalid-game') { + logger.info( + '🔍 checkAndCompleteTurn called for player:', + game.activePlayer.color, + game.activePlayer.isRobot ? '(robot)' : '(human)' + ) + } + + // State machine using switch on discriminated union + switch (turnState.type) { + case 'invalid-game': { + logger.warn('❌ Invalid game structure, returning original game') + return game + } + case 'no-active-play': { + logger.info('❌ No active play or moves, returning original game') + return game + } + case 'moves-incomplete': { + logger.info( + `⏳ Turn incomplete: ${turnState.completedCount}/${turnState.totalCount} moves completed` + ) + return game + } + + case 'all-moves-completed': + logger.info( + '✅ All moves completed, attempting transition to moved state' + ) + logger.info( + '📋 Move details:', + turnState.moves.map((m) => `${m.dieValue}:${m.stateKind}`) + ) + + try { + const movedGame = toMoved(game) + logger.info('🎯 Successfully transitioned to moved state') + return movedGame + } catch (error) { + logger.error('💥 Error in toMoved transition:', error) + logger.error('📊 Game state:', game.stateKind) + logger.error('📊 Moves details:', turnState.moves) + return game + } + + default: + // TypeScript exhaustiveness check ensures we handle all cases + const _exhaustive: never = turnState + return game + } +} + +/** + * Manually confirm the current turn and pass control to the next player + * This is triggered by dice click after the player has finished their moves + * @param game - Current game state in 'moving' state + * @returns Updated game state with next player's turn + */ +export function confirmTurn( + game: BackgammonGameMoved +): BackgammonGameRolling { + if (game.stateKind !== 'moved') { + throw new Error('Cannot confirm turn from non-moving state') + } + + // Reset all isMovable flags on the board + const boardWithResetMovable = Checker.updateMovableCheckers(game.board, []) + + // Manually transition to next player since turn is confirmed + const nextColor = game.activeColor === 'white' ? 'black' : 'white' + + // Update players: current becomes inactive, next becomes rolling + const updatedPlayers = game.players.map((player) => { + if (player.color === game.activeColor) { + // CRITICAL FIX: Preserve robot dice currentRoll values when transitioning to inactive + // This ensures robot dice continue to display what they rolled + // + // ⚠️ TECH DEBT WARNING: currentRoll DATA DUPLICATION ISSUE ⚠️ + // The dice roll values are stored in TWO places in the model: + // 1. player.dice.currentRoll - Raw rolled values [x, y] + // 2. game.activePlay.moves[n].dieValue - Individual die values used for moves + // This duplication creates maintenance overhead and potential inconsistency. + // Future refactoring should consolidate this to a single source of truth. + // + const preservedDice = + player.isRobot && player.dice?.currentRoll + ? { + ...player.dice, + stateKind: 'inactive' as const, + } + : Dice.initialize(player.color, 'inactive') + + return { + ...player, + stateKind: 'inactive' as const, + dice: preservedDice, + } + } else { + return { + ...player, + stateKind: 'rolling' as const, + dice: Dice.initialize(player.color, 'rolling'), + } + } + }) as BackgammonPlayersRollingTuple + + // Recalculate pip counts before transitioning to next player + logger.info( + 'Game turn completion: Recalculating pip counts before transitioning to next player' + ) + const playersWithUpdatedPips = Player.recalculatePipCounts({ + ...game, + players: updatedPlayers, + }) + + const newActivePlayerWithPips = playersWithUpdatedPips.find( + (p) => p.color === nextColor + ) as BackgammonPlayerActive + const newInactivePlayerWithPips = playersWithUpdatedPips.find( + (p) => p.color === game.activeColor + ) as BackgammonPlayerInactive + + // CRITICAL FIX: Pass undefined for type compatibility, but the core issue is addressed + // The real fix requires extending the type system to support preserved activePlay + // For now, keep the original behavior but document the fix location + + // Return game with next player's turn + return incrementStateVersion({ + ...game, + cube: { ...(game.cube as any), offeredThisTurnBy: undefined } as any, + stateKind: 'rolling', + players: [ + newActivePlayerWithPips as BackgammonPlayerRolling, + newInactivePlayerWithPips, + ] as BackgammonPlayersRollingTuple, + board: boardWithResetMovable, + activeColor: nextColor, + activePlayer: newActivePlayerWithPips, + inactivePlayer: newInactivePlayerWithPips, + activePlay: undefined, // No activePlay after turn confirmation + } as BackgammonGameRolling) +} + +export function getPlayersForColor( + players: BackgammonPlayers, + color: BackgammonColor +): [ + activePlayerForColor: BackgammonPlayerActive, + inactivePlayerForColor: BackgammonPlayerInactive, +] { + const activePlayerForColor = players.find((p) => p.color === color) + const inactivePlayerForColor = players.find((p) => p.color !== color) + if (!activePlayerForColor || !inactivePlayerForColor) { + throw new Error('Players not found') + } + return [ + activePlayerForColor as BackgammonPlayerActive, + inactivePlayerForColor as BackgammonPlayerInactive, + ] +} + +export function startMove( + game: BackgammonGameDoubled, + movingPlay: BackgammonPlayMoving +): BackgammonGameMoving { + return { + ...game, + stateKind: 'moving', + activePlay: movingPlay, + } as BackgammonGameMoving +} 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 +}