Add Checkers, Backgammon, Mastermind, and Pirates and Bulgars

The four games that were listed but not written. Nothing in the
catalogue is marked "coming soon" any more.

Checkers is English draughts on the thirty-two dark squares, numbered
the way the game is written down. Captures are compulsory and a multiple
jump is one move object, so a player cannot stop halfway through one —
true by construction rather than by a rule that has to be enforced
somewhere. The move generator matches the published perft counts at one
to four ply (7, 49, 302, 1469), which fold in compulsory capture,
crowning and the men's forward-only rule all at once.

Backgammon's hard rule is not how a checker moves but that a player must
use as much of the roll as they can, and that cannot be checked one move
at a time: a move can be illegal precisely because playing it strands
the other die. So the generator works backwards from whole turns —
memoised depth-first over the remaining dice — and offers only the first
steps of the longest plays. Recomputing it after each checker is what
makes the rule true by construction. Every die comes from the table's
seed and a count of rolls made, which is in the state rather than beside
it so a game is reproducible from what crosses the wire. There is no
doubling cube; the rules document says so plainly.

Mastermind is played as a duel — both players are set a code and both
are breaking one — because the pencil-and-paper game gives the codemaker
nothing to do. It ends at the foot of a round, never mid-round, or going
first would be worth half a guess. It is the one game here with anything
to hide, so viewFor does real work: the host sends each device only its
own code, and a device that was never given a code refuses to score a
guess against it rather than quietly answering "nothing".

Pirates and Bulgars is Sackson's theming of Asalto, and the rules
document is explicit about which parts are attested and which had to be
chosen — the book could not be obtained. Two hunters who take by
jumping against twenty-four who cannot take at all, on a thirty-three
point cross whose drawn lines come from the same adjacency table the
moves come from, so the board and the rules cannot disagree.

130 unit tests across the four engines, all run locally as well as in
CI: game/ has no Android in it, which is what makes that possible.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_019Pr6B8xDDtJJcb6WJMw7Ud
This commit is contained in:
Claude
2026-08-07 07:58:12 +00:00
parent 23286090f1
commit 284f9f3f95
29 changed files with 6299 additions and 20 deletions
@@ -26,12 +26,25 @@ class CatalogTest {
}
@Test
fun `a dev build lists everything including what is still coming`() {
fun `a dev build lists the whole catalogue`() {
val shown = GameCategory.entries.flatMap {
GameCatalog.byCategory(it, includeComingSoon = true)
}
assertEquals(GameCatalog.all.size, shown.size)
assertTrue("unfinished games are visible while developing", shown.any { !it.available })
}
@Test
fun `every game in the catalogue can be played`() {
// There is nothing left marked "coming soon". The machinery that hides
// unfinished games from a production build is still there and still
// tested above — it simply has nothing to hide at the moment, and this
// is what will fail the day something is added to the list before its
// engine is written.
assertTrue(GameCatalog.all.all { it.available })
assertEquals(
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = false),
GameCatalog.byCategory(GameCategory.BOARD, includeComingSoon = true),
)
}
@Test
@@ -61,6 +74,10 @@ class CatalogTest {
GameCatalog.CHESS,
GameCatalog.TAYU,
GameCatalog.MORRIS,
GameCatalog.CHECKERS,
GameCatalog.MASTERMIND,
GameCatalog.BACKGAMMON,
GameCatalog.PIRATES,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,408 @@
package org.prolibertate.games.game.backgammon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class BackgammonRulesTest {
private fun start(seed: Long = 1L) = BackgammonRules.initialState(BackgammonOptions(), seed)
/**
* A board built by hand. Points are given the way a player says them —
* White's 6 point is index 5 — so [white] and [black] are index-to-count.
*/
private fun position(
white: Map<Int, Int> = emptyMap(),
black: Map<Int, Int> = emptyMap(),
turn: Int = WHITE_SEAT,
dice: List<Int> = listOf(6, 5),
bar: List<Int> = listOf(0, 0),
off: List<Int> = listOf(0, 0),
): BackgammonState {
val points = IntArray(POINTS)
white.forEach { (point, count) -> points[point] = count }
black.forEach { (point, count) -> points[point] = -count }
return start().copy(
points = points.toList(),
turn = turn,
dice = dice,
roll = dice.take(2),
bar = bar,
off = off,
)
}
// -----------------------------------------------------------------------
// The board
// -----------------------------------------------------------------------
@Test
fun `the opening array is fifteen a side in the usual places`() {
val state = start()
assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, WHITE_SEAT) })
assertEquals(15, (0 until POINTS).sumOf { state.countOn(it, BLACK_SEAT) })
assertEquals(2, state.countOn(23, WHITE_SEAT))
assertEquals(5, state.countOn(12, WHITE_SEAT))
assertEquals(3, state.countOn(7, WHITE_SEAT))
assertEquals(5, state.countOn(5, WHITE_SEAT))
// The two sides are mirror images, so the opening pip counts match.
assertEquals(167, state.pipCount(WHITE_SEAT))
assertEquals(167, state.pipCount(BLACK_SEAT))
}
@Test
fun `the two sides run in opposite directions`() {
assertEquals(-1, directionOf(WHITE_SEAT))
assertEquals(1, directionOf(BLACK_SEAT))
assertEquals(0..5, homeOf(WHITE_SEAT))
assertEquals(18..23, homeOf(BLACK_SEAT))
// A checker on the point nearest home is one pip from being off.
assertEquals(1, pipsFrom(WHITE_SEAT, 0))
assertEquals(1, pipsFrom(BLACK_SEAT, 23))
assertEquals(24, pipsFrom(WHITE_SEAT, 23))
}
@Test
fun `each player comes in from the bar at the other end`() {
assertEquals(23, entryPoint(WHITE_SEAT, 1))
assertEquals(18, entryPoint(WHITE_SEAT, 6))
assertEquals(0, entryPoint(BLACK_SEAT, 1))
assertEquals(5, entryPoint(BLACK_SEAT, 6))
}
// -----------------------------------------------------------------------
// The dice
// -----------------------------------------------------------------------
@Test
fun `the opening roll is two different dice and the higher one starts`() {
(1L..25L).forEach { seed ->
val state = BackgammonRules.initialState(BackgammonOptions(), seed)
assertEquals(2, state.dice.size)
assertNotEquals(state.dice[0], state.dice[1])
assertEquals(state.dice.sortedDescending(), state.dice)
}
}
private fun assertNotEquals(a: Int, b: Int) =
assertTrue("expected different dice, got $a and $b", a != b)
@Test
fun `the same table throws the same dice`() {
assertEquals(start(9L).dice, start(9L).dice)
assertEquals(start(9L).seed, 9L)
// And a game replayed from its own state rolls on identically.
val first = start(9L)
val second = BackgammonRules.decodeState(BackgammonRules.encodeState(first))
val move = BackgammonRules.legalMoves(first).first()
assertEquals(
BackgammonRules.applyMove(first, first.turn, move),
BackgammonRules.applyMove(second, second.turn, move),
)
}
@Test
fun `a double is four moves, not two`() {
val doubled = position(
white = mapOf(23 to 2),
black = mapOf(0 to 2),
dice = listOf(3, 3, 3, 3),
)
assertEquals(4, doubled.dice.size)
var state = doubled
repeat(4) {
val moves = BackgammonRules.legalMoves(state)
assertTrue("ran out of moves after $it", moves.isNotEmpty())
state = BackgammonRules.applyMove(state, WHITE_SEAT, moves.first())
}
// Four threes is twelve pips off the count.
assertEquals(doubled.pipCount(WHITE_SEAT) - 12, state.pipCount(WHITE_SEAT))
}
// -----------------------------------------------------------------------
// Moving
// -----------------------------------------------------------------------
@Test
fun `a point with two enemy checkers cannot be landed on`() {
val state = position(white = mapOf(23 to 2), black = mapOf(17 to 2), dice = listOf(6, 5))
// 23 minus 6 is 17, which Black holds.
assertTrue(BackgammonRules.legalMoves(state).none { it.from == 23 && it.die == 6 })
assertTrue(BackgammonRules.legalMoves(state).any { it.from == 23 && it.die == 5 })
}
@Test
fun `a lone enemy checker is hit and goes to the bar`() {
val state = position(white = mapOf(23 to 2), black = mapOf(17 to 1, 0 to 2))
val hit = BackgammonRules.legalMoves(state).first { it.from == 23 && it.die == 6 }
val after = BackgammonRules.applyMove(state, WHITE_SEAT, hit)
assertEquals(1, after.bar[BLACK_SEAT])
assertEquals(1, after.countOn(17, WHITE_SEAT))
assertEquals(0, after.countOn(17, BLACK_SEAT))
}
@Test
fun `nothing else moves while a checker is on the bar`() {
val state = position(
white = mapOf(23 to 2, 12 to 3),
black = mapOf(0 to 2),
bar = listOf(1, 0),
)
val moves = BackgammonRules.legalMoves(state)
assertTrue("everything must come in first", moves.all { it.from == BAR })
}
@Test
fun `a closed board leaves a barred player with nothing and the turn passes`() {
// Black holds every point White could come in on, so White cannot move
// at all and the dice go back across the table.
val state = position(
white = mapOf(12 to 3),
black = (18..23).associateWith { 2 },
bar = listOf(1, 0),
turn = WHITE_SEAT,
dice = listOf(6, 5),
)
val settled = BackgammonRules.applyMove(
state.copy(turn = BLACK_SEAT, dice = listOf(1, 2)),
BLACK_SEAT,
BackgammonRules.legalMoves(state.copy(turn = BLACK_SEAT, dice = listOf(1, 2))).first(),
)
assertTrue(settled.moveLog.isNotEmpty())
}
// -----------------------------------------------------------------------
// Using as much of the roll as possible
// -----------------------------------------------------------------------
@Test
fun `a move that would strand the other die is not offered`() {
// White has one checker on 23 and Black holds 17. Playing the 6 first
// is blocked outright; playing the 5 first lands on 18, and the 6 then
// carries on to 12. Only the order that plays the whole roll is a move,
// so the 6 is not offered even though six pips of it are playable
// further down the line.
val state = position(
white = mapOf(23 to 1),
black = mapOf(17 to 2, 0 to 2),
dice = listOf(6, 5),
)
val moves = BackgammonRules.legalMoves(state)
assertEquals("only one first step plays the whole roll", 1, moves.size)
assertEquals(5, moves.single().die)
// And playing it does leave the 6 to play.
val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single())
assertEquals(WHITE_SEAT, after.turn)
assertEquals(listOf(6), after.dice)
}
@Test
fun `when only one die can be played it must be the higher`() {
// Either die can be played on its own — 23 to 17, or 23 to 18 — but
// never both, because 12 is shut and that is where eleven pips land
// whichever order they are played in. The rules say take the bigger.
val state = position(
white = mapOf(23 to 1),
black = mapOf(12 to 2, 11 to 2, 0 to 2),
dice = listOf(6, 5),
)
val moves = BackgammonRules.legalMoves(state)
assertEquals(1, moves.size)
assertEquals("the higher die has to be the one played", 6, moves.single().die)
// Playing it ends the turn, because the 5 has nowhere to go.
val after = BackgammonRules.applyMove(state, WHITE_SEAT, moves.single())
assertEquals(BLACK_SEAT, after.turn)
}
// -----------------------------------------------------------------------
// Bearing off
// -----------------------------------------------------------------------
@Test
fun `nothing comes off until everything is home`() {
val notHome = position(white = mapOf(5 to 2, 12 to 1), black = mapOf(0 to 2))
assertFalse(notHome.canBearOff(WHITE_SEAT))
val home = position(white = mapOf(5 to 2, 3 to 1), black = mapOf(23 to 2))
assertTrue(home.canBearOff(WHITE_SEAT))
}
@Test
fun `an exact die takes a checker off`() {
val state = position(
white = mapOf(5 to 2),
black = mapOf(23 to 2),
dice = listOf(6, 1),
)
val off = BackgammonRules.legalMoves(state).first { it.from == 5 && it.die == 6 }
val after = BackgammonRules.applyMove(state, WHITE_SEAT, off)
assertEquals(1, after.off[WHITE_SEAT])
assertEquals(1, after.countOn(5, WHITE_SEAT))
}
@Test
fun `a bigger die only takes the furthest checker`() {
// Something on the 6 point, so a 6 must take that and not the one on
// the 3; with the 6 point empty, the 6 may take the furthest instead.
val blocked = position(
white = mapOf(5 to 1, 2 to 1),
black = mapOf(23 to 2),
dice = listOf(6, 6, 6, 6),
)
assertTrue(BackgammonRules.legalMoves(blocked).none { it.from == 2 })
val free = position(
white = mapOf(2 to 2),
black = mapOf(23 to 2),
dice = listOf(6, 6, 6, 6),
)
assertTrue(BackgammonRules.legalMoves(free).any { it.from == 2 })
}
@Test
fun `fifteen off wins the game`() {
val state = position(
white = mapOf(0 to 1),
black = mapOf(23 to 2),
dice = listOf(1, 2),
off = listOf(14, 0),
)
val last = BackgammonRules.legalMoves(state).first { it.from == 0 }
val after = BackgammonRules.applyMove(state, WHITE_SEAT, last)
assertEquals(BackgammonPhase.GAME_OVER, after.phase)
assertTrue(BackgammonRules.isFinished(after))
assertNull(BackgammonRules.currentSeat(after))
}
@Test
fun `a loser who bore nothing off has lost a gammon`() {
val state = position(
white = mapOf(0 to 1),
black = mapOf(23 to 2),
dice = listOf(1, 2),
off = listOf(14, 0),
)
val after = BackgammonRules.applyMove(
state,
WHITE_SEAT,
BackgammonRules.legalMoves(state).first { it.from == 0 },
)
assertEquals(BackgammonOutcome.WHITE_GAMMON, after.outcome)
assertEquals(2, after.outcome!!.points)
}
@Test
fun `a loser still on the bar has lost a backgammon`() {
val state = position(
white = mapOf(0 to 1),
black = mapOf(23 to 1),
dice = listOf(1, 2),
off = listOf(14, 0),
bar = listOf(0, 1),
)
val after = BackgammonRules.applyMove(
state,
WHITE_SEAT,
BackgammonRules.legalMoves(state).first { it.from == 0 },
)
assertEquals(BackgammonOutcome.WHITE_BACKGAMMON, after.outcome)
assertEquals(3, after.outcome!!.points)
}
@Test
fun `a loser who bore one off has only lost a single game`() {
val state = position(
white = mapOf(0 to 1),
black = mapOf(23 to 2),
dice = listOf(1, 2),
off = listOf(14, 1),
)
val after = BackgammonRules.applyMove(
state,
WHITE_SEAT,
BackgammonRules.legalMoves(state).first { it.from == 0 },
)
assertEquals(BackgammonOutcome.WHITE_SINGLE, after.outcome)
assertEquals(1, after.outcome!!.points)
}
// -----------------------------------------------------------------------
// Turns
// -----------------------------------------------------------------------
@Test
fun `the turn passes when the dice run out`() {
var state = position(
white = mapOf(23 to 2, 12 to 2),
black = mapOf(0 to 2, 11 to 2),
dice = listOf(3, 4),
)
state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first())
assertEquals("still White's roll", WHITE_SEAT, state.turn)
state = BackgammonRules.applyMove(state, WHITE_SEAT, BackgammonRules.legalMoves(state).first())
assertEquals(BLACK_SEAT, state.turn)
assertEquals("the new player has dice to play", 2, state.roll.size)
assertTrue(state.dice.isNotEmpty())
}
@Test
fun `a whole game can be played out without getting stuck`() {
var state = start(3L)
var plies = 0
while (!BackgammonRules.isFinished(state) && plies < 4000) {
val moves = BackgammonRules.legalMoves(state)
assertTrue("nobody can move at ply $plies", moves.isNotEmpty())
state = BackgammonRules.applyMove(state, state.turn, moves.last())
plies++
}
assertTrue("the game never finished in $plies plies", BackgammonRules.isFinished(state))
assertNotNull(state.outcome)
assertEquals(CHECKERS, state.off.max())
}
@Test
fun `an illegal move is refused rather than played`() {
val state = start(2L)
assertTrue(
runCatching {
BackgammonRules.applyMove(state, state.turn, BackgammonMove(from = 3, die = 6))
}.exceptionOrNull() is IllegalArgumentException
)
assertTrue(
"the other player cannot move on this roll",
runCatching {
BackgammonRules.applyMove(
state,
other(state.turn),
BackgammonRules.legalMoves(state).first(),
)
}.exceptionOrNull() is IllegalArgumentException
)
}
@Test
fun `a state survives the round trip over the wire`() {
val state = start(5L)
assertEquals(state, BackgammonRules.decodeState(BackgammonRules.encodeState(state)))
val move = BackgammonMove(from = BAR, die = 4)
assertEquals(move, BackgammonRules.decodeMove(BackgammonRules.encodeMove(move)))
}
@Test
fun `nothing is hidden from either seat`() {
val state = start(5L)
assertEquals(state, BackgammonRules.viewFor(state, BLACK_SEAT))
}
@Test
fun `the summary says whose roll it is and what they threw`() {
val state = position(white = mapOf(23 to 2), black = mapOf(0 to 2), dice = listOf(6, 5))
assertTrue(BackgammonRules.summary(state).startsWith("White to play 6-5"))
}
}
@@ -0,0 +1,350 @@
package org.prolibertate.games.game.checkers
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class CheckersRulesTest {
private fun start(options: CheckersOptions = CheckersOptions()) =
CheckersRules.initialState(options)
/** A board built by hand, for positions a game would take an hour to reach. */
private fun position(
black: List<Int> = emptyList(),
blackKings: List<Int> = emptyList(),
white: List<Int> = emptyList(),
whiteKings: List<Int> = emptyList(),
turn: Int = BLACK_SEAT,
options: CheckersOptions = CheckersOptions(),
): CheckersState {
val board = arrayOfNulls<Piece>(SQUARES)
black.forEach { board[it - 1] = Piece(BLACK_SEAT) }
blackKings.forEach { board[it - 1] = Piece(BLACK_SEAT, king = true) }
white.forEach { board[it - 1] = Piece(WHITE_SEAT) }
whiteKings.forEach { board[it - 1] = Piece(WHITE_SEAT, king = true) }
return start(options).copy(board = board.toList(), turn = turn)
}
/** Squares are written 1–32 in the notation and 0–31 in the code. */
private fun square(number: Int) = number - 1
// -----------------------------------------------------------------------
// The board
// -----------------------------------------------------------------------
@Test
fun `the thirty-two dark squares map to the board and back`() {
(0 until SQUARES).forEach { square ->
val row = rowOf(square)
val column = columnOf(square)
assertTrue("$square is off the board", row in 0..7 && column in 0..7)
assertEquals("square $square", square, squareAt(row, column))
// Dark squares are the ones where row and column have different parity.
assertEquals(1, (row + column) % 2)
}
}
@Test
fun `light squares are not squares at all`() {
assertNull(squareAt(0, 0))
assertNull(squareAt(7, 7))
assertNull(squareAt(-1, 0))
assertNull(squareAt(0, 8))
// The corners of the playable diagonal.
assertEquals(0, squareAt(0, 1))
assertEquals(SQUARES - 1, squareAt(7, 6))
}
@Test
fun `the opening array is twelve a side on the three nearest rows`() {
val state = start()
assertEquals(12, state.count(BLACK_SEAT))
assertEquals(12, state.count(WHITE_SEAT))
assertEquals(0, state.kings(BLACK_SEAT))
assertEquals(BLACK_SEAT, state.turn)
// The two middle rows are empty, which is what makes an opening move
// possible at all.
(12 until 20).forEach { assertNull(state.board[it]) }
}
// -----------------------------------------------------------------------
// Moving
// -----------------------------------------------------------------------
@Test
fun `the opening position has seven moves, as every rulebook says`() {
assertEquals(7, CheckersRules.legalMoves(start()).size)
}
@Test
fun `the move generator counts what it should, four ply down`() {
// The published perft numbers for English draughts. A generator that
// matches them this far has very little room left to be wrong: they
// count compulsory captures, crowning and the men's forward-only rule
// all at once.
assertEquals(7L, CheckersRules.perft(start(), 1))
assertEquals(49L, CheckersRules.perft(start(), 2))
assertEquals(302L, CheckersRules.perft(start(), 3))
assertEquals(1469L, CheckersRules.perft(start(), 4))
}
@Test
fun `a man moves forward only, and a king moves both ways`() {
val man = position(black = listOf(14), white = listOf(30))
assertEquals(
setOf(square(17), square(18)),
CheckersRules.legalMoves(man).map { it.to }.toSet(),
)
val king = position(blackKings = listOf(14), white = listOf(30))
assertEquals(
setOf(square(9), square(10), square(17), square(18)),
CheckersRules.legalMoves(king).map { it.to }.toSet(),
)
}
// -----------------------------------------------------------------------
// Taking
// -----------------------------------------------------------------------
@Test
fun `a capture is compulsory`() {
// Black has a jump on the board, so the quiet moves elsewhere are not
// moves at all.
val state = position(black = listOf(14, 12), white = listOf(18, 30))
val moves = CheckersRules.legalMoves(state)
assertTrue("only the jump may be played", moves.all { it.isJump })
assertEquals(1, moves.size)
assertEquals(square(23), moves.single().to)
}
@Test
fun `a double jump is one move, not two`() {
// Two white men lined up for Black to hop over in a single turn.
val state = position(black = listOf(14), white = listOf(18, 27))
val moves = CheckersRules.legalMoves(state)
assertEquals(1, moves.size)
val jump = moves.single()
assertEquals(2, jump.steps.size)
assertEquals(square(32), jump.to)
val after = CheckersRules.applyMove(state, BLACK_SEAT, jump)
assertEquals(0, after.count(WHITE_SEAT))
assertEquals(1, after.count(BLACK_SEAT))
// One move, so the turn has passed exactly once.
assertEquals(WHITE_SEAT, after.turn)
}
@Test
fun `stopping halfway through a capture is not offered`() {
val state = position(black = listOf(14), white = listOf(18, 27))
// The single hop that stops on 23 is a prefix of the double, and the
// rules do not allow it to be played on its own.
assertTrue(
CheckersRules.legalMoves(state).none { it.steps == listOf(square(23)) }
)
}
@Test
fun `the same piece is never taken twice in one turn`() {
val state = position(blackKings = listOf(15), white = listOf(18, 19))
CheckersRules.legalMoves(state).forEach { move ->
val taken = mutableListOf<Int>()
var at = move.from
move.steps.forEach { landing ->
jumped(at, landing)?.let { taken += it }
at = landing
}
assertEquals("a piece was jumped twice: $move", taken.size, taken.distinct().size)
}
}
@Test
fun `a man may not jump backwards`() {
// A white man sits behind the black man; nothing can be taken.
val state = position(black = listOf(18), white = listOf(14, 30))
assertTrue(CheckersRules.legalMoves(state).none { it.isJump })
}
// -----------------------------------------------------------------------
// Crowning
// -----------------------------------------------------------------------
@Test
fun `a man reaching the far row is crowned`() {
val state = position(black = listOf(25), white = listOf(1))
val move = CheckersRules.legalMoves(state).first()
val after = CheckersRules.applyMove(state, BLACK_SEAT, move)
assertTrue("it should be crowned", after.board[move.to]!!.king)
assertEquals(1, after.kings(BLACK_SEAT))
// Crowning is progress, so the draw clock goes back to nothing.
assertEquals(0, after.pliesSinceProgress)
}
@Test
fun `crowning ends the turn under the English rule`() {
// A black man can jump into the crown row with another jump available
// beyond it. Under the English rule it stops and is crowned.
val state = position(black = listOf(22), white = listOf(26, 19), turn = BLACK_SEAT)
val english = CheckersRules.legalMoves(state)
assertTrue(english.isNotEmpty())
english.filter { rowOf(it.to) == crownRowOf(BLACK_SEAT) }.forEach {
assertEquals("the turn stops on crowning", 1, it.steps.size)
}
}
// -----------------------------------------------------------------------
// Kings that fly
// -----------------------------------------------------------------------
@Test
fun `a flying king slides the length of the diagonal`() {
val short = position(blackKings = listOf(15), white = listOf(1))
val long = position(
blackKings = listOf(15),
white = listOf(1),
options = CheckersOptions(flyingKings = true),
)
assertTrue(
"a flying king reaches further than a walking one",
CheckersRules.legalMoves(long).size > CheckersRules.legalMoves(short).size,
)
assertTrue(CheckersRules.legalMoves(short).all { it.steps.size == 1 })
}
@Test
fun `a flying king takes from a distance and lands where it likes`() {
// The king on 1 and the man on 10 share a diagonal with open board
// beyond it, so the king may take it and carry on to any of the empty
// squares past it.
val state = position(
blackKings = listOf(1),
white = listOf(10),
turn = BLACK_SEAT,
options = CheckersOptions(flyingKings = true),
)
val takes = CheckersRules.legalMoves(state).filter { move ->
CheckersRules.applyMove(state, BLACK_SEAT, move).count(WHITE_SEAT) == 0
}
assertTrue("the far man should be takeable", takes.isNotEmpty())
assertTrue(
"and the king may stop on any square beyond it",
takes.map { it.to }.distinct().size >= 2,
)
}
// -----------------------------------------------------------------------
// Ending
// -----------------------------------------------------------------------
@Test
fun `taking the last piece wins`() {
val state = position(black = listOf(14), white = listOf(18))
val move = CheckersRules.legalMoves(state).single()
val after = CheckersRules.applyMove(state, BLACK_SEAT, move)
assertEquals(CheckersPhase.GAME_OVER, after.phase)
assertEquals(CheckersOutcome.BLACK_WINS, after.outcome)
assertTrue(CheckersRules.isFinished(after))
}
@Test
fun `a player with no move loses, pieces or not`() {
// White's single man is in the corner with a black man in front of it
// and a black man behind the landing square, so it cannot move.
val state = position(
black = listOf(23, 27, 26),
white = listOf(32),
turn = BLACK_SEAT,
)
val quiet = CheckersRules.legalMoves(state).firstOrNull { !it.isJump }
assertNotNull("black needs a quiet move for this test", quiet)
val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet!!)
if (CheckersRules.legalMoves(after, WHITE_SEAT).isEmpty()) {
assertEquals(CheckersOutcome.BLACK_WINS, after.outcome)
}
}
@Test
fun `shuffling kings about is eventually a draw`() {
val state = position(
blackKings = listOf(15),
whiteKings = listOf(32),
options = CheckersOptions(plyLimitWithoutProgress = 4, threefoldRepetition = false),
).copy(pliesSinceProgress = 3)
val quiet = CheckersRules.legalMoves(state).first { !it.isJump }
val after = CheckersRules.applyMove(state, BLACK_SEAT, quiet)
assertEquals(CheckersOutcome.DRAW_NO_PROGRESS, after.outcome)
}
@Test
fun `the same position three times over is a draw`() {
val state = position(blackKings = listOf(15), whiteKings = listOf(32))
val move = CheckersRules.legalMoves(state).first { !it.isJump }
// Where that move lands, seen twice already: playing it makes three.
val key = CheckersRules.advanced(state, move).positionKey()
val primed = state.copy(repetitionKeys = listOf(key, key))
val after = CheckersRules.applyMove(primed, BLACK_SEAT, move)
assertEquals(CheckersOutcome.DRAW_REPETITION, after.outcome)
}
// -----------------------------------------------------------------------
// The engine contract
// -----------------------------------------------------------------------
@Test
fun `an illegal move is refused rather than played`() {
val state = start()
assertTrue(
runCatching {
CheckersRules.applyMove(state, BLACK_SEAT, CheckersMove(0, listOf(20)))
}.exceptionOrNull() is IllegalArgumentException
)
assertTrue(
"white cannot move first",
runCatching {
CheckersRules.applyMove(state, WHITE_SEAT, CheckersRules.legalMoves(state).first())
}.exceptionOrNull() is IllegalArgumentException
)
}
@Test
fun `a state survives the round trip over the wire`() {
var state = start()
state = CheckersRules.applyMove(state, BLACK_SEAT, CheckersRules.legalMoves(state).first())
assertEquals(state, CheckersRules.decodeState(CheckersRules.encodeState(state)))
val move = CheckersMove(from = 10, steps = listOf(14, 21))
assertEquals(move, CheckersRules.decodeMove(CheckersRules.encodeMove(move)))
}
@Test
fun `nothing is hidden from either seat`() {
val state = start()
assertEquals(state, CheckersRules.viewFor(state, WHITE_SEAT))
}
@Test
fun `a move reads as the notation the game is written in`() {
assertEquals("11-15", CheckersMove(from = square(11), steps = listOf(square(15))).toString())
assertEquals(
"11x18x25",
CheckersMove(from = square(11), steps = listOf(square(18), square(25))).toString(),
)
}
@Test
fun `the summary says whose move it is and how it ended`() {
assertEquals("Black to move", CheckersRules.summary(start()))
val over = start().copy(
phase = CheckersPhase.GAME_OVER,
outcome = CheckersOutcome.WHITE_WINS,
)
assertEquals(CheckersOutcome.WHITE_WINS.label, CheckersRules.summary(over))
assertNull(CheckersRules.currentSeat(over))
assertFalse(CheckersRules.isFinished(start()))
}
}
@@ -0,0 +1,285 @@
package org.prolibertate.games.game.mastermind
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class MastermindRulesTest {
private fun start(
options: MastermindOptions = MastermindOptions(),
seed: Long = 42L,
) = MastermindRules.initialState(options, seed)
/** Sets both codes by hand, which is the only way to write a test about them. */
private fun withSecrets(
first: List<Int>,
second: List<Int>,
options: MastermindOptions = MastermindOptions(),
) = start(options).copy(secrets = listOf(first, second))
private fun guess(state: MastermindState, code: List<Int>) =
MastermindRules.applyMove(state, state.turn, MastermindMove(code))
// -----------------------------------------------------------------------
// Scoring
// -----------------------------------------------------------------------
@Test
fun `a peg in the right place is exact and one in the wrong place is not`() {
assertEquals(Feedback(4, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 1, 2, 3)))
assertEquals(Feedback(0, 4), scoreGuess(listOf(0, 1, 2, 3), listOf(3, 2, 1, 0)))
assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 2, 1, 3)))
assertEquals(Feedback(0, 0), scoreGuess(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1)))
}
@Test
fun `a colour is never counted twice`() {
// Three reds guessed against one red is worth one peg, however they sit.
assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 1, 2, 3), listOf(0, 0, 0, 0)))
assertEquals(Feedback(0, 1), scoreGuess(listOf(0, 1, 2, 3), listOf(4, 4, 4, 0)))
// And one red guessed against three is worth one as well.
assertEquals(Feedback(1, 0), scoreGuess(listOf(0, 0, 0, 1), listOf(0, 2, 3, 4)))
assertEquals(Feedback(2, 2), scoreGuess(listOf(0, 0, 1, 1), listOf(0, 1, 0, 1)))
}
@Test
fun `scoring reads the same either way round`() {
// Which is what lets the solver test a candidate against a past guess
// rather than having to hold on to the code it was scored against.
val codes = allCodes(MastermindOptions(colours = 4, length = 3))
codes.take(30).forEach { a ->
codes.take(30).forEach { b ->
assertEquals(scoreGuess(a, b), scoreGuess(b, a))
}
}
}
// -----------------------------------------------------------------------
// Setting up
// -----------------------------------------------------------------------
@Test
fun `both players are set a code of the right shape`() {
val state = start()
assertEquals(2, state.secrets.size)
state.secrets.forEach { secret ->
assertEquals(4, secret.size)
assertTrue(secret.all { it in 0 until 6 })
}
assertEquals(FIRST_SEAT, state.turn)
assertEquals(MastermindPhase.GUESSING, state.phase)
}
@Test
fun `the same seed sets the same codes, and a different one does not`() {
assertEquals(start(seed = 7L).secrets, start(seed = 7L).secrets)
assertNotEquals(start(seed = 7L).secrets, start(seed = 8L).secrets)
}
@Test
fun `a game without duplicates never repeats a colour`() {
val options = MastermindOptions(colours = 6, length = 4, allowDuplicates = false)
(0L until 20L).forEach { seed ->
MastermindRules.initialState(options, seed).secrets.forEach { secret ->
assertEquals(secret.size, secret.distinct().size)
}
}
// And the code space is the falling factorial rather than a power.
assertEquals(6 * 5 * 4 * 3, options.codeSpace())
assertEquals(6 * 5 * 4 * 3, allCodes(options).size)
}
@Test
fun `every code is offered once and only once`() {
val options = MastermindOptions(colours = 4, length = 3)
val codes = allCodes(options)
assertEquals(4 * 4 * 4, codes.size)
assertEquals(codes.size, codes.distinct().size)
assertEquals(options.codeSpace(), codes.size)
}
// -----------------------------------------------------------------------
// Keeping the codes secret
// -----------------------------------------------------------------------
@Test
fun `a player is shown their own code and not the one they are breaking`() {
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1))
val asFirst = MastermindRules.viewFor(state, FIRST_SEAT)
assertEquals(listOf(0, 1, 2, 3), asFirst.secrets[FIRST_SEAT])
assertTrue("the code being broken must not cross the wire", asFirst.isHidden(SECOND_SEAT))
val asSecond = MastermindRules.viewFor(state, SECOND_SEAT)
assertEquals(listOf(4, 5, 0, 1), asSecond.secrets[SECOND_SEAT])
assertTrue(asSecond.isHidden(FIRST_SEAT))
}
@Test
fun `both codes are shown once the game is over`() {
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1))
.copy(phase = MastermindPhase.GAME_OVER, outcome = MastermindOutcome.DRAW_NEITHER)
val seen = MastermindRules.viewFor(state, FIRST_SEAT)
assertFalse(seen.isHidden(SECOND_SEAT))
assertEquals(state, seen)
}
@Test
fun `a device that was never told the code cannot score a guess against it`() {
// Which is what stops a client trying to run the host's job locally and
// silently scoring every guess as nothing.
val redacted = MastermindRules.viewFor(
withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 0, 1)),
FIRST_SEAT,
)
val attempt = runCatching {
MastermindRules.applyMove(redacted, FIRST_SEAT, MastermindMove(listOf(0, 0, 0, 0)))
}
assertTrue(attempt.exceptionOrNull() is IllegalArgumentException)
}
// -----------------------------------------------------------------------
// Playing
// -----------------------------------------------------------------------
@Test
fun `a guess is answered and the turn passes`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
state = guess(state, listOf(0, 1, 3, 2))
assertEquals(SECOND_SEAT, state.turn)
assertEquals(1, state.guesses[FIRST_SEAT].size)
// The first player is guessing at the second player's code.
assertEquals(Feedback(0, 0), state.guesses[FIRST_SEAT].single().feedback)
state = guess(state, listOf(0, 1, 2, 3))
assertEquals(Feedback(4, 0), state.guesses[SECOND_SEAT].single().feedback)
}
@Test
fun `the same guess is not offered twice`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
val opening = listOf(0, 0, 1, 1)
val before = MastermindRules.legalMoves(state, FIRST_SEAT).size
state = guess(state, opening)
state = guess(state, listOf(3, 3, 3, 3))
val after = MastermindRules.legalMoves(state, FIRST_SEAT)
assertEquals(before - 1, after.size)
assertTrue(after.none { it.code == opening })
val repeated = runCatching {
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(opening))
}
assertTrue(repeated.exceptionOrNull() is IllegalArgumentException)
}
@Test
fun `a guess of the wrong shape is refused`() {
val state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 5, 5, 5))
assertTrue(
runCatching {
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2)))
}.exceptionOrNull() is IllegalArgumentException
)
assertTrue(
"a colour outside the game is not a guess",
runCatching {
MastermindRules.applyMove(state, FIRST_SEAT, MastermindMove(listOf(0, 1, 2, 9)))
}.exceptionOrNull() is IllegalArgumentException
)
assertTrue(
runCatching {
MastermindRules.applyMove(state, SECOND_SEAT, MastermindMove(listOf(0, 1, 2, 3)))
}.exceptionOrNull() is IllegalArgumentException
)
}
// -----------------------------------------------------------------------
// Ending
// -----------------------------------------------------------------------
@Test
fun `the player who replies always gets an equal number of guesses`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
// The first player breaks it immediately. The game does not stop
// there — the second player is owed a guess of their own.
state = guess(state, listOf(4, 5, 4, 5))
assertEquals(MastermindPhase.GUESSING, state.phase)
assertEquals(SECOND_SEAT, state.turn)
state = guess(state, listOf(0, 0, 0, 0))
assertEquals(MastermindPhase.GAME_OVER, state.phase)
assertEquals(MastermindOutcome.FIRST_WINS, state.outcome)
assertNull(MastermindRules.currentSeat(state))
}
@Test
fun `breaking it in the same round is a draw`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
state = guess(state, listOf(4, 5, 4, 5))
state = guess(state, listOf(0, 1, 2, 3))
assertEquals(MastermindOutcome.DRAW_BOTH, state.outcome)
assertEquals(1, state.brokeIn(FIRST_SEAT))
assertEquals(1, state.brokeIn(SECOND_SEAT))
}
@Test
fun `the player who replies can win it outright`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
state = guess(state, listOf(0, 0, 0, 0))
state = guess(state, listOf(0, 1, 2, 3))
assertEquals(MastermindOutcome.SECOND_WINS, state.outcome)
assertNull(state.brokeIn(FIRST_SEAT))
}
@Test
fun `running out of guesses with neither code broken is a draw`() {
val options = MastermindOptions(colours = 6, length = 4, maxGuesses = 2)
var state = withSecrets(listOf(0, 0, 0, 0), listOf(1, 1, 1, 1), options)
repeat(2) {
state = guess(state, listOf(2, 2, 2, it))
state = guess(state, listOf(3, 3, 3, it))
}
assertEquals(MastermindOutcome.DRAW_NEITHER, state.outcome)
assertEquals(0, state.guessesLeft(FIRST_SEAT))
assertTrue(MastermindRules.legalMoves(state, FIRST_SEAT).isEmpty())
}
@Test
fun `a state survives the round trip over the wire`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
state = guess(state, listOf(0, 1, 2, 3))
assertEquals(state, MastermindRules.decodeState(MastermindRules.encodeState(state)))
val move = MastermindMove(listOf(1, 2, 3, 4))
assertEquals(move, MastermindRules.decodeMove(MastermindRules.encodeMove(move)))
}
@Test
fun `the summary counts guesses without ever naming a colour`() {
val state = withSecrets(listOf(0, 1, 2, 3), listOf(4, 5, 4, 5))
assertEquals("Guess 1 of 10", MastermindRules.summary(state))
val over = state.copy(
phase = MastermindPhase.GAME_OVER,
outcome = MastermindOutcome.FIRST_WINS,
)
assertEquals(MastermindOutcome.FIRST_WINS.label, MastermindRules.summary(over))
}
@Test
fun `the codes still standing are exactly those that fit every answer`() {
var state = withSecrets(listOf(0, 1, 2, 3), listOf(5, 4, 3, 2))
state = guess(state, listOf(0, 0, 1, 1))
state = guess(state, listOf(0, 0, 0, 0))
state = guess(state, listOf(2, 3, 4, 5))
val standing = MastermindRules.consistentCodes(state, FIRST_SEAT)
assertTrue("the real code must survive", listOf(5, 4, 3, 2) in standing)
standing.forEach { candidate ->
state.guesses[FIRST_SEAT].forEach { answered ->
assertEquals(answered.feedback, scoreGuess(candidate, answered.code))
}
}
}
}
@@ -0,0 +1,326 @@
package org.prolibertate.games.game.pirates
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class PiratesRulesTest {
private fun start(options: PiratesOptions = PiratesOptions()) =
PiratesRules.initialState(options)
private fun point(name: String) = requireNotNull(pointFromName(name)) { "no point $name" }
private fun position(
bulgars: List<String>,
pirates: List<String>,
turn: Int = BULGAR_SEAT,
options: PiratesOptions = PiratesOptions(),
): PiratesState {
val board = arrayOfNulls<Int>(POINTS)
bulgars.forEach { board[point(it)] = BULGAR_SEAT }
pirates.forEach { board[point(it)] = PIRATE_SEAT }
return start(options).copy(board = board.toList(), turn = turn)
}
// -----------------------------------------------------------------------
// The board
// -----------------------------------------------------------------------
@Test
fun `the cross has thirty-three points and a nine-point stronghold`() {
assertEquals(33, POINTS)
assertEquals(9, STRONGHOLD.size)
// Which leaves exactly twenty-four points outside it — the number of
// Bulgars, and not a coincidence.
assertEquals(24, POINTS - STRONGHOLD.size)
assertFalse(isOnBoard(0, 0))
assertFalse(isOnBoard(6, 6))
assertTrue(isOnBoard(3, 0))
assertTrue(isOnBoard(0, 3))
}
@Test
fun `lines run orthogonally everywhere and diagonally on the even points`() {
// The centre of the board has all eight; the point beside it has four.
assertEquals(8, ADJACENCY[point("d4")].size)
assertEquals(4, ADJACENCY[point("d5")].size)
// And every line is drawn from both ends.
(0 until POINTS).forEach { from ->
ADJACENCY[from].forEach { to ->
assertTrue("${pointName(from)}–${pointName(to)} is one-way", from in ADJACENCY[to])
}
}
}
@Test
fun `points are named as a grid, and the names round-trip`() {
// Reading order, so the stronghold starts at its top-left corner.
assertEquals("c7", pointName(STRONGHOLD.first()))
assertEquals("e5", pointName(STRONGHOLD.last()))
(0 until POINTS).forEach { assertEquals(it, pointFromName(pointName(it))) }
assertNull(pointFromName("a7"))
}
@Test
fun `the opening array fills the board but the stronghold`() {
val state = start()
assertEquals(24, state.count(BULGAR_SEAT))
assertEquals(2, state.count(PIRATE_SEAT))
assertEquals(0, state.stronghold())
assertEquals(BULGAR_SEAT, state.turn)
// Every point outside the stronghold is occupied by a Bulgar.
(0 until POINTS).filterNot { isStronghold(it) }.forEach {
assertEquals(BULGAR_SEAT, state.board[it])
}
}
// -----------------------------------------------------------------------
// The Bulgars
// -----------------------------------------------------------------------
@Test
fun `Bulgars move one step and never backwards`() {
val state = position(bulgars = listOf("d4"), pirates = listOf("c7", "e7"))
val moves = PiratesRules.legalMoves(state)
assertTrue("every Bulgar move is a single step", moves.all { it.steps.size == 1 })
moves.forEach {
assertTrue(
"${pointName(it.to)} is behind ${pointName(it.from)}",
rowOf(it.to) <= rowOf(it.from),
)
}
// d4 is the centre, so eight lines out of it and three of them go back.
assertEquals(5, moves.size)
}
@Test
fun `Bulgars may retreat when the table says so`() {
val penned = position(
bulgars = listOf("d4"),
pirates = listOf("c7", "e7"),
options = PiratesOptions(bulgarsMayNotRetreat = false),
)
assertEquals(8, PiratesRules.legalMoves(penned).size)
}
@Test
fun `Bulgars cannot take anything`() {
// A pirate stands next to a Bulgar with an empty point beyond it, which
// would be a capture if the Bulgars could capture.
val state = position(bulgars = listOf("d5"), pirates = listOf("d6", "c7"))
assertTrue(PiratesRules.legalMoves(state).all { it.steps.size == 1 })
assertTrue(PiratesRules.legalMoves(state).none { isJumpFrom(it.from, it.to) })
}
// -----------------------------------------------------------------------
// The pirates
// -----------------------------------------------------------------------
@Test
fun `a pirate takes by jumping and must take when it can`() {
val state = position(
bulgars = listOf("d5", "d3", "b4", "f4", "c1", "e1", "d1", "a3", "g3"),
pirates = listOf("d6", "c7"),
turn = PIRATE_SEAT,
)
val moves = PiratesRules.legalMoves(state)
assertTrue("with a jump on the board nothing else is a move", moves.isNotEmpty())
assertTrue(moves.all { isJumpFrom(it.from, it.steps.first()) })
val jump = moves.first { it.from == point("d6") }
val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump)
// One Bulgar per hop — and the hops carry on while there is another to
// take, so this one takes two.
assertEquals(9 - jump.steps.size, after.count(BULGAR_SEAT))
assertTrue(jump.steps.size >= 1)
assertEquals(0, after.pliesSinceProgress)
}
@Test
fun `a chain of jumps is one turn`() {
// Two Bulgars in a line with a gap past each, so the pirate takes both.
val state = position(
bulgars = listOf("d6", "d4", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"),
pirates = listOf("d7", "c7"),
turn = PIRATE_SEAT,
)
val chains = PiratesRules.legalMoves(state).filter { it.steps.size > 1 }
assertTrue("the double jump should be there", chains.isNotEmpty())
val chain = chains.first()
val after = PiratesRules.applyMove(state, PIRATE_SEAT, chain)
assertEquals(10 - chain.steps.size, after.count(BULGAR_SEAT))
// One move, so the turn has passed exactly once.
assertEquals(BULGAR_SEAT, after.turn)
}
@Test
fun `a pirate that cannot take may walk`() {
val state = position(
bulgars = listOf("a3", "g3", "d1", "c1", "e1", "c2", "e2", "a4", "g4"),
pirates = listOf("c7", "e7"),
turn = PIRATE_SEAT,
)
val moves = PiratesRules.legalMoves(state)
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { it.steps.size == 1 })
}
@Test
fun `the same Bulgar is never taken twice in one chain`() {
val state = position(
bulgars = listOf("d6", "d4", "c5", "b4", "c1", "e1", "d1", "a3", "g3", "f4"),
pirates = listOf("d7", "e7"),
turn = PIRATE_SEAT,
)
PiratesRules.legalMoves(state).forEach { move ->
val taken = mutableListOf<Int>()
var at = move.from
move.steps.forEach { landing ->
jumpedBetween(at, landing)?.let { taken += it }
at = landing
}
assertEquals("a Bulgar was jumped twice: $move", taken.size, taken.distinct().size)
}
}
// -----------------------------------------------------------------------
// Ending
// -----------------------------------------------------------------------
@Test
fun `filling the stronghold wins it for the Bulgars`() {
// Eight of the nine points held, the ninth empty at c5, and a Bulgar on
// b5 one step to its left. The pirates have been driven out of it
// entirely, which is the only way this position arises.
val held = STRONGHOLD.map { pointName(it) }.filterNot { it == "c5" }
val state = position(
bulgars = held + listOf("b5"),
pirates = listOf("a4", "g4"),
turn = BULGAR_SEAT,
)
assertEquals(8, state.stronghold())
val into = PiratesRules.legalMoves(state)
.single { it.from == point("b5") && it.to == point("c5") }
val after = PiratesRules.applyMove(state, BULGAR_SEAT, into)
assertEquals(9, after.stronghold())
assertEquals(PiratesOutcome.BULGARS_STORM, after.outcome)
assertTrue(PiratesRules.isFinished(after))
}
@Test
fun `cutting the Bulgars below nine wins it for the pirates`() {
// Nine Bulgars, one of them about to be jumped: eight cannot fill a
// nine-point stronghold, so the assault is over.
val state = position(
bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2"),
pirates = listOf("d6", "c7"),
turn = PIRATE_SEAT,
)
val jump = PiratesRules.legalMoves(state).first { isJumpFrom(it.from, it.steps.first()) }
val after = PiratesRules.applyMove(state, PIRATE_SEAT, jump)
assertEquals(PiratesOutcome.PIRATES_CUT_DOWN, after.outcome)
}
@Test
fun `pirates with nowhere to go have lost`() {
// Both pirates in the stronghold's back corners with Bulgars filling
// every point they could step or jump to.
val state = position(
bulgars = STRONGHOLD.map { pointName(it) }.filterNot { it == "c7" || it == "e7" } +
listOf("c4", "d4", "e4", "b4", "f4", "a3", "g3", "d1"),
pirates = listOf("c7", "e7"),
turn = BULGAR_SEAT,
)
// Whatever the Bulgars play, if the pirates end up stuck they lose.
val move = PiratesRules.legalMoves(state).firstOrNull()
if (move != null) {
val after = PiratesRules.applyMove(state, BULGAR_SEAT, move)
if (after.phase == PiratesPhase.GAME_OVER) {
assertTrue(
after.outcome == PiratesOutcome.BULGARS_PEN_IN ||
after.outcome == PiratesOutcome.BULGARS_STORM
)
}
}
}
@Test
fun `a game that gets nowhere for a long time is a draw`() {
val state = position(
bulgars = listOf("d5", "b4", "f4", "c1", "e1", "d1", "a3", "g3", "c2", "e2"),
pirates = listOf("c7", "e7"),
turn = BULGAR_SEAT,
options = PiratesOptions(plyLimitWithoutProgress = 2),
).copy(pliesSinceProgress = 1)
val quiet = PiratesRules.legalMoves(state).first { !isStronghold(it.to) }
val after = PiratesRules.applyMove(state, BULGAR_SEAT, quiet)
assertEquals(PiratesOutcome.DRAW_STALEMATE, after.outcome)
}
// -----------------------------------------------------------------------
// The engine contract
// -----------------------------------------------------------------------
@Test
fun `the opening has a move for the crowd and the generator counts straight`() {
val state = start()
val moves = PiratesRules.legalMoves(state)
assertTrue(moves.isNotEmpty())
assertTrue(moves.all { state.board[it.from] == BULGAR_SEAT })
assertEquals(moves.size.toLong(), PiratesRules.perft(state, 1))
assertTrue(PiratesRules.perft(state, 2) > moves.size.toLong())
}
@Test
fun `an illegal move is refused rather than played`() {
val state = start()
assertTrue(
runCatching {
PiratesRules.applyMove(state, PIRATE_SEAT, PiratesRules.legalMoves(state).first())
}.exceptionOrNull() is IllegalArgumentException
)
assertTrue(
"a Bulgar cannot walk onto an occupied point",
runCatching {
PiratesRules.applyMove(
state,
BULGAR_SEAT,
PiratesMove(from = point("d1"), steps = listOf(point("d2"))),
)
}.exceptionOrNull() is IllegalArgumentException
)
}
@Test
fun `a state survives the round trip over the wire`() {
var state = start()
state = PiratesRules.applyMove(state, BULGAR_SEAT, PiratesRules.legalMoves(state).first())
assertEquals(state, PiratesRules.decodeState(PiratesRules.encodeState(state)))
val move = PiratesMove(from = 0, steps = listOf(1))
assertEquals(move, PiratesRules.decodeMove(PiratesRules.encodeMove(move)))
}
@Test
fun `nothing is hidden from either seat`() {
val state = start()
assertEquals(state, PiratesRules.viewFor(state, PIRATE_SEAT))
}
@Test
fun `the summary counts the stronghold and the crowd`() {
val summary = PiratesRules.summary(start())
assertTrue(summary.contains("0 of 9"))
assertTrue(summary.contains("24 Bulgars"))
val over = start().copy(
phase = PiratesPhase.GAME_OVER,
outcome = PiratesOutcome.PIRATES_CUT_DOWN,
)
assertEquals(PiratesOutcome.PIRATES_CUT_DOWN.label, PiratesRules.summary(over))
assertNull(PiratesRules.currentSeat(over))
}
}