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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/AI/RobotAIProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
BackgammonGame,
BackgammonGameMoving,
BackgammonGameRolling,
BackgammonPlayMoving,
Expand Down Expand Up @@ -54,4 +55,15 @@ export interface RobotAIProvider {
play: BackgammonPlayMoving,
playerUserId?: string
): Promise<BackgammonMoveReady | undefined>

/**
* 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'>
}
189 changes: 189 additions & 0 deletions src/Game/__tests__/resignation-offer.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
100 changes: 100 additions & 0 deletions src/Game/cube.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
BackgammonPlayerRolling,
BackgammonPlayers,
BackgammonPlayerWinner,
BackgammonResignationOffer,

Check failure on line 14 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Module '"@nodots/backgammon-types"' has no exported member 'BackgammonResignationOffer'.
} from '@nodots/backgammon-types'
import { Dice } from '../Dice'
import { logger } from '../utils/logger'
Expand Down Expand Up @@ -292,6 +293,105 @@
} 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) {

Check failure on line 314 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Property 'resignationOffer' does not exist on type 'BackgammonGameRollingForStart | BackgammonGameRolledForStart | BackgammonGameRolling | BackgammonGameDoubled | BackgammonGameMoving | BackgammonGameMoved'.
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,

Check failure on line 333 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Conversion of type '{ resignationOffer: BackgammonResignationOffer; stateKind: "rolling-for-start"; id: string; players: BackgammonPlayers; board: BackgammonBoard; cube: BackgammonCube; ... 19 more ...; settings: { ...; }; } | ... 4 more ... | { ...; }' to type 'BackgammonGame' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// 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 &&

Check failure on line 344 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Property 'resignationOffer' does not exist on type 'BackgammonGame'.
game.stateKind !== 'completed' &&
game.resignationOffer.offeredById !== player.id &&

Check failure on line 346 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Property 'resignationOffer' does not exist on type 'BackgammonGameRollingForStart | BackgammonGameRolledForStart | BackgammonGameRolling | BackgammonGameDoubled | BackgammonGameMoving | BackgammonGameMoved'.
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!

Check failure on line 362 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Property 'resignationOffer' does not exist on type 'BackgammonGame'.
const resigningPlayer = game.players.find((p) => p.id === offer.offeredById)!
const cleared = {
...game,
resignationOffer: undefined,

Check failure on line 366 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Conversion of type '{ resignationOffer: undefined; stateKind: "rolling-for-start"; id: string; players: BackgammonPlayers; board: BackgammonBoard; cube: BackgammonCube; ... 19 more ...; settings: { ...; }; } | ... 5 more ... | { ...; }' to type 'BackgammonGame' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// 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,

Check failure on line 390 in src/Game/cube.ts

View workflow job for this annotation

GitHub Actions / smoke-core-sim

Conversion of type '{ resignationOffer: undefined; stateKind: "rolling-for-start"; id: string; players: BackgammonPlayers; board: BackgammonBoard; cube: BackgammonCube; ... 19 more ...; settings: { ...; }; } | ... 5 more ... | { ...; }' to type 'BackgammonGame' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.
// 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
Expand Down
33 changes: 33 additions & 0 deletions src/Game/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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

Expand Down
Loading