diff --git a/package-lock.json b/package-lock.json index 38d22f1..2fd7915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nodots/backgammon-core", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nodots/backgammon-core", - "version": "1.0.1", + "version": "1.0.2", "license": "GPL-3.0", "dependencies": { "@nodots/backgammon-types": "^1.0.3", diff --git a/package.json b/package.json index 40e5595..6692080 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/Play/__tests__/must-use-both-dice-rule.test.ts b/src/Play/__tests__/must-use-both-dice-rule.test.ts index cd095c1..9534f3c 100644 --- a/src/Play/__tests__/must-use-both-dice-rule.test.ts +++ b/src/Play/__tests__/must-use-both-dice-rule.test.ts @@ -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', () => { @@ -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. diff --git a/src/Play/index.ts b/src/Play/index.ts index 1bc4cd0..98d9fd7 100644 --- a/src/Play/index.ts +++ b/src/Play/index.ts @@ -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 @@ -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'