diff --git a/src/AI/RobotAIProvider.ts b/src/AI/RobotAIProvider.ts index 565a3b7..fd77e84 100644 --- a/src/AI/RobotAIProvider.ts +++ b/src/AI/RobotAIProvider.ts @@ -1,4 +1,5 @@ import type { + BackgammonGame, BackgammonGameMoving, BackgammonGameRolling, BackgammonPlayMoving, @@ -54,4 +55,15 @@ export interface RobotAIProvider { play: BackgammonPlayMoving, playerUserId?: string ): Promise + + /** + * Respond to a pending resignation offer (game.resignationOffer) made by + * the robot's opponent: 'accept' completes the game at the offered points, + * 'decline' resumes play. Optional -- callers treat a provider without this + * method as accepting (the pre-offer-flow behavior, where any resignation + * ended the game immediately). + */ + decideResignationResponse?( + game: BackgammonGame + ): Promise<'accept' | 'decline'> } diff --git a/src/Game/__tests__/resignation-offer.test.ts b/src/Game/__tests__/resignation-offer.test.ts new file mode 100644 index 0000000..9e1eec4 --- /dev/null +++ b/src/Game/__tests__/resignation-offer.test.ts @@ -0,0 +1,189 @@ +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 +// cube-resign-characterization.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.offerResign()', () => { + it('records a pending offer without completing the game', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + + const offered = Game.offerResign(rolling, offering as any, 1) + + expect(offered.stateKind).toBe('rolling') + expect(offered.resignationOffer?.offeredById).toBe(offering.id) + expect(offered.resignationOffer?.points).toBe(1) + expect(offered.winner).toBeUndefined() + }) + + it('increments stateVersion', () => { + const rolling = buildRollingGame() + const before = rolling.stateVersion ?? 0 + const offered = Game.offerResign(rolling, rolling.activePlayer! as any, 2) + expect(offered.stateVersion).toBe(before + 1) + }) + + it('throws when resignation is disabled by settings', () => { + const base = buildRollingGame() + const rolling = { ...base, settings: { allowResign: false } } as any + expect(() => + Game.offerResign(rolling, rolling.activePlayer, 1) + ).toThrow('Resignation is not allowed') + }) + + it('throws when the game is already completed', () => { + const base = buildRollingGame() + const completed = { ...base, stateKind: 'completed' } as any + expect(() => + Game.offerResign(completed, base.activePlayer! as any, 1) + ).toThrow('Cannot resign a completed game') + }) + + it('throws when an offer is already pending', () => { + const rolling = buildRollingGame() + const offered = Game.offerResign(rolling, rolling.activePlayer! as any, 1) + expect(() => + Game.offerResign(offered, offered.activePlayer! as any, 1) + ).toThrow('already pending') + }) +}) + +describe('Game.canRespondToResign()', () => { + it('is true only for the opponent while an offer is pending', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const opponent = rolling.players.find((p) => p.id !== offering.id)! + + expect(Game.canRespondToResign(rolling, opponent as any)).toBe(false) + + const offered = Game.offerResign(rolling, offering as any, 1) + expect(Game.canRespondToResign(offered, opponent as any)).toBe(true) + expect(Game.canRespondToResign(offered, offering as any)).toBe(false) + }) +}) + +describe('Game.acceptResign()', () => { + it('completes the game with the accepter as winner at the offered points', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const opponent = rolling.players.find((p) => p.id !== offering.id)! + + const offered = Game.offerResign(rolling, offering as any, 1) + const completed = Game.acceptResign(offered, opponent as any) + + 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') + expect(completed.resignationOffer).toBeUndefined() + }) + + it('scores a gammon offer as 2x the cube value', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const opponent = rolling.players.find((p) => p.id !== offering.id)! + + const offered = Game.offerResign(rolling, offering as any, 2) + const completed = Game.acceptResign(offered, opponent as any) + + 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('multiplies by the live cube value', () => { + const base = buildRollingGame() + const offering = base.activePlayer! + const opponent = base.players.find((p) => p.id !== offering.id)! + const at4 = { ...base, cube: { ...base.cube, value: 4 } } as any + + const offered = Game.offerResign(at4, offering as any, 1) + const completed = Game.acceptResign(offered, opponent as any) + + expect((completed as any).pointsWon).toBe(4) + }) + + 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 offering = rolling.activePlayer + const opponent = rolling.players.find((p: any) => p.id !== offering.id) + + const offered = Game.offerResign(rolling, offering, 2) + const completed = Game.acceptResign(offered, opponent) + + expect((completed as any).winType).toBe('simple') + expect((completed as any).pointsWon).toBe(1) + }) + + it('throws when the offering player tries to accept their own offer', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const offered = Game.offerResign(rolling, offering as any, 1) + expect(() => Game.acceptResign(offered, offering as any)).toThrow( + 'Cannot respond to resignation' + ) + }) + + it('throws when no offer is pending', () => { + const rolling = buildRollingGame() + const opponent = rolling.players.find( + (p) => p.id !== rolling.activePlayer!.id + )! + expect(() => Game.acceptResign(rolling, opponent as any)).toThrow( + 'Cannot respond to resignation' + ) + }) +}) + +describe('Game.declineResign()', () => { + it('clears the offer and play resumes in the same state', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const opponent = rolling.players.find((p) => p.id !== offering.id)! + + const offered = Game.offerResign(rolling, offering as any, 1) + const declined = Game.declineResign(offered, opponent as any) + + expect(declined.stateKind).toBe('rolling') + expect(declined.resignationOffer).toBeUndefined() + expect(declined.winner).toBeUndefined() + expect(declined.activePlayer?.id).toBe(offering.id) + }) + + it('throws when the offering player tries to decline their own offer', () => { + const rolling = buildRollingGame() + const offering = rolling.activePlayer! + const offered = Game.offerResign(rolling, offering as any, 1) + expect(() => Game.declineResign(offered, offering as any)).toThrow( + 'Cannot respond to resignation' + ) + }) + + it('throws when no offer is pending', () => { + const rolling = buildRollingGame() + const opponent = rolling.players.find( + (p) => p.id !== rolling.activePlayer!.id + )! + expect(() => Game.declineResign(rolling, opponent as any)).toThrow( + 'Cannot respond to resignation' + ) + }) +}) diff --git a/src/Game/cube.ts b/src/Game/cube.ts index 58da8db..0fa09e5 100644 --- a/src/Game/cube.ts +++ b/src/Game/cube.ts @@ -11,6 +11,7 @@ import { BackgammonPlayerRolling, BackgammonPlayers, BackgammonPlayerWinner, + BackgammonResignationOffer, } from '@nodots/backgammon-types' import { Dice } from '../Dice' import { logger } from '../utils/logger' @@ -292,6 +293,105 @@ export function resign( } as BackgammonGameCompleted) // as BackgammonGameCompleted: narrowing spread object to completed game type } +/** + * Offer to resign for the given points (1=simple, 2=gammon, 3=backgammon). + * The game's stateKind is unchanged while the offer is pending; the opponent + * must accept (game completes at the offered value) or decline (play resumes). + * Mirrors the double/take flow: resign() below is the unilateral legacy path + * and remains only as the completion step used by acceptResign(). + */ +export function offerResign( + game: BackgammonGame, + resigningPlayer: BackgammonPlayer, + points: 1 | 2 | 3 = 1 +): BackgammonGame { + 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') + } + if (game.resignationOffer) { + throw new Error('A resignation offer is already pending') + } + if (!game.players.some((p) => p.id === resigningPlayer.id)) { + throw new Error('Resigning player is not in this game') + } + + const offer: BackgammonResignationOffer = { + offeredById: resigningPlayer.id, + points, + offeredAt: new Date(), + } + + logger.info( + `[Game] Resignation offered by ${resigningPlayer.id} for ${points} point(s)` + ) + + return incrementStateVersion({ + ...game, + resignationOffer: offer, + // as BackgammonGame: spreading the union widens players/activePlayer to + // their base types; the runtime shape is the same member we started from. + } as BackgammonGame) +} + +export function canRespondToResign( + game: BackgammonGame, + player: BackgammonPlayer +): boolean { + return ( + !!game.resignationOffer && + game.stateKind !== 'completed' && + game.resignationOffer.offeredById !== player.id && + game.players.some((p) => p.id === player.id) + ) +} + +/** + * Accept a pending resignation offer: the game completes at the offered + * points, scored exactly like a direct resignation (cube multiplier, Jacoby). + */ +export function acceptResign( + game: BackgammonGame, + acceptingPlayer: BackgammonPlayer +): BackgammonGameCompleted { + if (!canRespondToResign(game, acceptingPlayer)) { + throw new Error('Cannot respond to resignation') + } + const offer = game.resignationOffer! + const resigningPlayer = game.players.find((p) => p.id === offer.offeredById)! + const cleared = { + ...game, + resignationOffer: undefined, + // as BackgammonGame: see offerResign — spread widens the union member. + } as BackgammonGame + return resign(cleared, resigningPlayer, offer.points) +} + +/** + * Decline a pending resignation offer: the offer is cleared and play + * resumes in the state it was in when the offer was made. + */ +export function declineResign( + game: BackgammonGame, + decliningPlayer: BackgammonPlayer +): BackgammonGame { + if (!canRespondToResign(game, decliningPlayer)) { + throw new Error('Cannot respond to resignation') + } + + logger.info( + `[Game] Resignation declined by ${decliningPlayer.id} - play resumes` + ) + + return incrementStateVersion({ + ...game, + resignationOffer: undefined, + // as BackgammonGame: see offerResign — spread widens the union member. + } as BackgammonGame) +} + /** * Execute doubling action from rolling state (before rolling dice) * Transitions from 'rolling' to 'doubled' state and offers double to opponent diff --git a/src/Game/index.ts b/src/Game/index.ts index d260306..9ac64a2 100644 --- a/src/Game/index.ts +++ b/src/Game/index.ts @@ -29,10 +29,14 @@ import { BackgammonMoveDirection } from '../Play' import { logger } from '../utils/logger' import { acceptDouble, + acceptResign, canAcceptDouble, canOfferDouble, canRefuseDouble, + canRespondToResign, + declineResign, double, + offerResign, refuseDouble, resign, } from './cube' @@ -497,6 +501,35 @@ export class Game { return resign(game, resigningPlayer, points) } + public static offerResign( + game: BackgammonGame, + resigningPlayer: BackgammonPlayer, + points: 1 | 2 | 3 = 1 + ): BackgammonGame { + return offerResign(game, resigningPlayer, points) + } + + public static canRespondToResign( + game: BackgammonGame, + player: BackgammonPlayer + ): boolean { + return canRespondToResign(game, player) + } + + public static acceptResign( + game: BackgammonGame, + acceptingPlayer: BackgammonPlayer + ): BackgammonGameCompleted { + return acceptResign(game, acceptingPlayer) + } + + public static declineResign( + game: BackgammonGame, + decliningPlayer: BackgammonPlayer + ): BackgammonGame { + return declineResign(game, decliningPlayer) + } + public static confirmTurnWithRobotAutomation = confirmTurnWithRobotAutomation