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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@nodots/backgammon-core",
"version": "1.0.1",
"version": "1.0.2",
"description": "Core game logic for Nodots Backgammon",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
133 changes: 129 additions & 4 deletions src/Play/__tests__/must-use-both-dice-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,25 @@ describe('Play - must use both dice (stranded-die bug)', () => {
expect([...(play.dieValuesPlayableAtStart ?? [])].sort()).toEqual([3, 6])
})

test('playing the smaller die (3) is rejected', () => {
test('initialize converts the smaller-die move to a completed no-move', () => {
const board = makeBoard()
const play = Play.initialize(board, movingPlayer('white', 'clockwise', [6, 3]))
const thrown = captureThrow(() =>
Play.move(board, play, originAt(board, 'clockwise', 9), 3)
const smaller = play.moves.find((m) => m.dieValue === 3)
const larger = play.moves.find((m) => m.dieValue === 6)
expect(smaller?.stateKind).toBe('completed')
expect(smaller?.moveKind).toBe('no-move')
expect(larger?.stateKind).toBe('ready')
})

test('attempting the smaller die (3) plays the larger (6) instead', () => {
const board = makeBoard()
const play = Play.initialize(board, movingPlayer('white', 'clockwise', [6, 3]))
const result = Play.move(board, play, originAt(board, 'clockwise', 9), 3)
const executed = result.play.moves.find(
(m) => m.stateKind === 'completed' && m.moveKind !== 'no-move'
)
expect(thrown?.name).toBe('MustUseLargerDieError')
expect(executed?.dieValue).toBe(6)
expect(diceUsed(result.play)).toBe(1)
})

test('playing the larger die (6) is accepted', () => {
Expand All @@ -178,6 +190,119 @@ describe('Play - must use both dice (stranded-die bug)', () => {
})
})

describe('last checker bears off with either die (production game 2d98cf14)', () => {
// One checker on the player's 2-point, 14 already off. Roll [3,5]: either
// die bears off and ends the game, so maxDiceUsable is 1 and the rules
// require the larger die (5). The robot picked the 3 and every turn
// attempt was rejected, freezing the game in 'moving'.
const makeBoardClockwise = (): BackgammonBoard =>
Board.buildBoard([
{
position: { clockwise: 2, counterclockwise: 23 },
checkers: { color: 'white', qty: 1 },
},
{
position: 'off',
direction: 'clockwise',
checkers: { color: 'white', qty: 14 },
},
{
position: { clockwise: 13, counterclockwise: 12 },
checkers: { color: 'black', qty: 5 },
},
{
position: { clockwise: 19, counterclockwise: 6 },
checkers: { color: 'black', qty: 5 },
},
{
position: { clockwise: 24, counterclockwise: 1 },
checkers: { color: 'black', qty: 5 },
},
])

test('initialize prunes the smaller die: 3 is a no-move, 5 is a ready bear-off', () => {
const board = makeBoardClockwise()
const play = Play.initialize(board, movingPlayer('white', 'clockwise', [3, 5]))
expect(play.maxDiceUsable).toBe(1)
expect([...(play.dieValuesPlayableAtStart ?? [])].sort()).toEqual([3, 5])
const smaller = play.moves.find((m) => m.dieValue === 3)
const larger = play.moves.find((m) => m.dieValue === 5)
expect(smaller?.stateKind).toBe('completed')
expect(smaller?.moveKind).toBe('no-move')
expect(larger?.stateKind).toBe('ready')
expect(larger?.moveKind).toBe('bear-off')
})

test('moving the last checker bears off with the 5, no throw', () => {
const board = makeBoardClockwise()
const play = Play.initialize(board, movingPlayer('white', 'clockwise', [3, 5]))
const result = Play.move(board, play, originAt(board, 'clockwise', 2))
const executed = result.play.moves.find(
(m) => m.stateKind === 'completed' && m.moveKind !== 'no-move'
)
expect(executed?.dieValue).toBe(5)
expect(diceUsed(result.play)).toBe(1)
expect(result.play.moves.some((m) => m.stateKind === 'ready')).toBe(false)
expect(result.board.off.clockwise.checkers.length).toBe(15)
})

test('roll order [5,3] behaves the same', () => {
const board = makeBoardClockwise()
const play = Play.initialize(board, movingPlayer('white', 'clockwise', [5, 3]))
const smaller = play.moves.find((m) => m.dieValue === 3)
expect(smaller?.moveKind).toBe('no-move')
const result = Play.move(board, play, originAt(board, 'clockwise', 2))
const executed = result.play.moves.find(
(m) => m.stateKind === 'completed' && m.moveKind !== 'no-move'
)
expect(executed?.dieValue).toBe(5)
expect(result.board.off.clockwise.checkers.length).toBe(15)
})

test('counterclockwise mirror behaves the same', () => {
const board = Board.buildBoard([
{
position: { clockwise: 23, counterclockwise: 2 },
checkers: { color: 'white', qty: 1 },
},
{
position: 'off',
direction: 'counterclockwise',
checkers: { color: 'white', qty: 14 },
},
{
position: { clockwise: 12, counterclockwise: 13 },
checkers: { color: 'black', qty: 5 },
},
{
position: { clockwise: 6, counterclockwise: 19 },
checkers: { color: 'black', qty: 5 },
},
{
position: { clockwise: 1, counterclockwise: 24 },
checkers: { color: 'black', qty: 5 },
},
])
const play = Play.initialize(
board,
movingPlayer('white', 'counterclockwise', [3, 5])
)
expect(play.maxDiceUsable).toBe(1)
const smaller = play.moves.find((m) => m.dieValue === 3)
expect(smaller?.moveKind).toBe('no-move')
const result = Play.move(
board,
play,
originAt(board, 'counterclockwise', 2)
)
const executed = result.play.moves.find(
(m) => m.stateKind === 'completed' && m.moveKind !== 'no-move'
)
expect(executed?.dieValue).toBe(5)
expect(result.board.off.counterclockwise.checkers.length).toBe(15)
})
})

describe('doubles', () => {
// One mobile checker on 13 can play two 6s (13->7->1); a blocked back
// checker on 24 caps usage at 2 of the four 6s.
Expand Down
55 changes: 54 additions & 1 deletion src/Play/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,21 @@ export class Play {
const expectedDieValue = options?.expectedDieValue

// Get initial die values (no mutation)
let firstDieValue = expectedDieValue ?? readyMoves[0].dieValue
// Must-use-larger-die: when one die can be played this turn and more
// than one die value is still ready, the larger die is the only choice
// the rules allow. Plays persisted before initialize pruned the
// smaller-die move (stale plays from the database) still carry both
// moves as ready — prefer the larger die for them.
const distinctReadyDice = [...new Set(readyMoves.map((m) => m.dieValue))]
const largerDieMandatory =
typeof expectedDieValue !== 'number' &&
play.maxDiceUsable === 1 &&
distinctReadyDice.length >= 2
let firstDieValue =
expectedDieValue ??
(largerDieMandatory
? distinctReadyDice.reduce((a, b) => (b > a ? b : a))
: readyMoves[0].dieValue)
const otherMoves = readyMoves.filter((m) => m.dieValue !== firstDieValue)
let secondDieValue =
otherMoves.length > 0 ? otherMoves[0].dieValue : firstDieValue
Expand Down Expand Up @@ -922,6 +936,45 @@ export class Play {
),
] as BackgammonDieValue[]

// Must-use-larger-die rule, applied at turn start: when one die can be
// played this turn and both dice had moves from the turn-start board,
// the rules require the larger die. pureMove rejects the smaller die
// after execution; converting its move to a completed no-move here means
// the option is never offered to humans or robots. Production game
// 2d98cf14: one checker on the 2-point, roll [3,5] — either die bears
// off, the robot picked 3 and every turn attempt was rejected.
if (
maxDiceUsable === 1 &&
dieValuesPlayableAtStart.length >= 2 &&
playableReentries === 0
) {
const largerDie = dieValuesPlayableAtStart.reduce((a, b) =>
b > a ? b : a
)
for (let i = 0; i < allMoves.length; i++) {
const move = allMoves[i]
if (
move.stateKind === 'ready' &&
move.dieValue !== largerDie &&
// cast: _sequenceDependent is a private marker set above, not on the move type
!(move as any)._sequenceDependent
) {
allMoves[i] = {
id: move.id,
player,
dieValue: move.dieValue,
stateKind: 'completed',
moveKind: 'no-move',
possibleMoves: [],
origin: undefined,
destination: undefined,
isHit: false,
// cast: literal matches the no-move constructions elsewhere in this function
} as BackgammonMoveCompletedNoMove
}
}
}

// Check if all moves are no-moves and auto-complete the play
const allMovesAreNoMoves = allMoves.every(
(move) => move.moveKind === 'no-move'
Expand Down
Loading