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
15 changes: 15 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
156 changes: 156 additions & 0 deletions src/Game/__tests__/cube-resign-characterization.test.ts
Original file line number Diff line number Diff line change
@@ -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'
)
})
})
85 changes: 85 additions & 0 deletions src/Game/__tests__/game-accessors.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
69 changes: 69 additions & 0 deletions src/Game/__tests__/guards.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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)
})
})
Loading
Loading