Add chess

The full game: castling with every condition, en passant including the
case where taking would expose your own king, promotion to any of the
four, pins, checkmate, stalemate, insufficient material, the fifty-move
rule and threefold repetition.

Move generation is checked against the published perft counts rather
than against hand-written cases — the opening array to depth four
(197,281 positions) plus four standard test positions chosen precisely
because they break generators. Those counts are exact, so matching them
leaves very little room for a rule to be subtly wrong.

The opponent is a negamax search with alpha-beta, iterative deepening
and a capture-only quiescence tail, at three strengths. The quiescence
tail is the part that stops it hanging pieces: a fixed-depth search that
stops mid-exchange scores the position as though the recapture never
comes.

Two supporting changes:

The AI now thinks off the caller's thread. A card AI answers instantly,
but a chess search runs for a second or more, and the match controller's
scope belongs to the screen — searching there would freeze the board it
is drawn on.

Offline setup can seat the local player somewhere other than seat 0,
which is what lets you play Black. Seat 0 is White by definition, so
choosing a colour has to choose a seat.

FEN is read and written, so a table can start from a set position.

203 engine tests pass locally.
This commit is contained in:
Claude
2026-08-06 13:34:31 +00:00
parent fb70396d09
commit d6e237ce4a
14 changed files with 2243 additions and 17 deletions
@@ -58,6 +58,7 @@ class CatalogTest {
GameCatalog.KAISER,
GameCatalog.CRAZY_EIGHTS,
GameCatalog.WIZARD,
GameCatalog.CHESS,
),
GameCatalog.playable.map { it.id }.toSet(),
)
@@ -0,0 +1,131 @@
package org.prolibertate.games.game.chess
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ChessAiTest {
private fun position(fen: String, level: ChessLevel = ChessLevel.CLUB) =
ChessRules.fromPosition(ChessOptions(level = level), fen)
private fun ChessAi.moveIn(state: ChessState): ChessMove =
chooseMove(state, state.turnSeat, ChessRules.legalMoves(state))
@Test
fun `it only ever returns a legal move`() {
val ai = ChessAi()
var state = ChessRules.fromPosition(ChessOptions(), START_FEN)
repeat(30) {
if (ChessRules.isFinished(state)) return
val legal = ChessRules.legalMoves(state)
val move = ai.moveIn(state)
assertTrue("$move is not legal in ${state.toFen()}", legal.contains(move))
state = ChessRules.applyMove(state, state.turnSeat, move)
}
}
@Test
fun `it takes a piece that is hanging`() {
// The black queen on d5 is undefended and the rook on d1 sees it.
val state = position("4k3/8/8/3q4/8/8/8/3RK3 w - - 0 1")
assertEquals("d1d5", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
}
@Test
fun `it does not give a piece away for nothing`() {
// Rb1 and Rb7 are both available; Rb7 hangs the rook to the king.
val state = position("8/1k6/8/8/8/8/8/1R2K3 w - - 0 1")
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
assertTrue("played $move into the king", move.toString() != "b1b7")
}
@Test
fun `it finds mate in one`() {
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1")
assertEquals("a1a8", ChessAi(ChessLevel.STRONG).moveIn(state).toString())
}
@Test
fun `it gets out of check rather than ignoring it`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
val move = ChessAi().moveIn(state)
val after = ChessRules.applyMove(state, state.turnSeat, move)
assertTrue("still in check after $move", !ChessRules.inCheck(after.board, white = true))
}
@Test
fun `it promotes to a queen when there is nothing to fear`() {
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val move = ChessAi(ChessLevel.STRONG).moveIn(state)
assertEquals(PieceKind.QUEEN, move.promotion)
}
/** The same position with the colours swapped and the board turned round. */
private fun mirrored(state: ChessState): ChessState {
val board = arrayOfNulls<Piece>(64)
for (square in 0 until 64) {
state.board[square]?.let { board[square xor 56] = it.copy(white = !it.white) }
}
return state.copy(
board = board.toList(),
whiteToMove = !state.whiteToMove,
castling = CastlingRights(
whiteKingSide = state.castling.blackKingSide,
whiteQueenSide = state.castling.blackQueenSide,
blackKingSide = state.castling.whiteKingSide,
blackQueenSide = state.castling.whiteQueenSide,
),
enPassant = state.enPassant?.let { it xor 56 },
)
}
@Test
fun `the evaluation is symmetric`() {
// A position and its mirror image must score identically from the side
// to move, or the search believes one colour is better simply for being
// that colour — and it will then play for it.
listOf(
"r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4",
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1",
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1",
"4k3/8/8/8/8/8/8/3RK3 w - - 0 1",
).forEach { fen ->
val state = position(fen)
assertEquals(fen, ChessAi().evaluate(state), ChessAi().evaluate(mirrored(state)))
}
}
@Test
fun `two engines play a whole game without an illegal move`() {
val ai = ChessAi(ChessLevel.CASUAL)
var state = ChessRules.fromPosition(ChessOptions(level = ChessLevel.CASUAL), START_FEN)
var plies = 0
while (!ChessRules.isFinished(state) && plies++ < 220) {
val legal = ChessRules.legalMoves(state)
val move = ai.moveIn(state)
assertTrue("illegal $move", legal.contains(move))
state = ChessRules.applyMove(state, state.turnSeat, move)
}
// Either somebody was mated or the position ran into one of the draw
// rules; both are results. What matters is that it never got stuck.
assertTrue(
"no result and no legal continuation",
ChessRules.isFinished(state) || ChessRules.legalMoves(state).isNotEmpty(),
)
}
@Test
fun `a move takes a reasonable amount of time at every level`() {
// Rough guard rather than a benchmark: a phone should not sit thinking
// for a quarter of a minute, and the node budget is what enforces it.
val opening = "r1bqkb1r/pppp1ppp/2n2n2/4p3/2B1P3/5N2/PPPP1PPP/RNBQK2R w KQkq - 4 4"
ChessLevel.entries.forEach { level ->
val state = position(opening, level)
val started = System.nanoTime()
ChessAi(level).moveIn(state)
val millis = (System.nanoTime() - started) / 1_000_000
assertTrue("$level took ${millis}ms", millis < 15_000)
}
}
}
@@ -0,0 +1,417 @@
package org.prolibertate.games.game.chess
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
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
import org.prolibertate.games.game.engine.PlayerKind
import org.prolibertate.games.game.engine.PlayerSlot
import org.prolibertate.games.game.engine.TableConfig
import kotlin.random.Random
class ChessRulesTest {
private val json = Json { encodeDefaults = true }
private fun config(options: ChessOptions = ChessOptions()) = TableConfig(
gameId = "chess",
seats = (0 until 2).map {
PlayerSlot(seat = it, name = "P$it", kind = PlayerKind.AI, team = it)
},
optionsJson = json.encodeToString(options),
seed = 1L,
)
private fun position(fen: String, options: ChessOptions = ChessOptions()) =
ChessRules.fromPosition(options, fen)
private fun ChessState.moveTo(notation: String): ChessState {
val move = ChessRules.legalMoves(this).firstOrNull { it.toString() == notation }
assertNotNull("$notation is not legal in ${toFen()}", move)
return ChessRules.applyMove(this, turnSeat, move!!)
}
// -- Squares and FEN ----------------------------------------------------
@Test
fun `squares are numbered the way a FEN reads`() {
assertEquals(0, squareFromName("a8"))
assertEquals(7, squareFromName("h8"))
assertEquals(56, squareFromName("a1"))
assertEquals(63, squareFromName("h1"))
(0 until 64).forEach { assertEquals(it, squareFromName(squareName(it))) }
}
@Test
fun `a1 is dark and h1 is light`() {
assertFalse(isLightSquare(squareFromName("a1")))
assertTrue(isLightSquare(squareFromName("h1")))
assertTrue(isLightSquare(squareFromName("e4")))
}
@Test
fun `the opening array round trips through FEN`() {
val state = ChessRules.initialState(config())
assertEquals(START_FEN, state.toFen())
assertEquals(32, state.board.count { it != null })
assertEquals("white starts", 0, state.turnSeat)
}
@Test
fun `every published test position parses back to itself`() {
PERFT_POSITIONS.forEach { (fen, _) ->
assertEquals(fen, position(fen).toFen())
}
}
// -- Perft --------------------------------------------------------------
//
// These counts are published and exact. A generator that matches them has
// essentially nowhere left to hide a rule it gets wrong: the positions are
// chosen to exercise castling through check, en passant that would expose
// a king, promotion with capture, and pins.
@Test
fun `perft matches the published counts from the opening array`() {
val state = ChessRules.initialState(config())
assertEquals(20L, ChessRules.perft(state, 1))
assertEquals(400L, ChessRules.perft(state, 2))
assertEquals(8_902L, ChessRules.perft(state, 3))
assertEquals(197_281L, ChessRules.perft(state, 4))
}
@Test
fun `perft matches the published counts for the standard test positions`() {
PERFT_POSITIONS.forEach { (fen, counts) ->
val state = position(fen)
counts.forEachIndexed { index, expected ->
assertEquals(
"perft(${index + 1}) for $fen",
expected,
ChessRules.perft(state, index + 1),
)
}
}
}
// -- Individual rules ---------------------------------------------------
@Test
fun `a pawn on its home rank may step one or two`() {
val state = ChessRules.initialState(config())
val fromE2 = ChessRules.legalMoves(state).filter { squareName(it.from) == "e2" }
assertEquals(setOf("e2e3", "e2e4"), fromE2.map { it.toString() }.toSet())
}
@Test
fun `a double step can be answered en passant, and only immediately`() {
var state = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1")
state = state.moveTo("d2d4")
assertEquals("the skipped square is the target", "d3", squareName(state.enPassant!!))
val enPassant = ChessRules.legalMoves(state).first { it.toString() == "e4d3" }
val after = ChessRules.applyMove(state, state.turnSeat, enPassant)
assertNull("the pawn that stepped two is gone", after.board[squareFromName("d4")])
assertNotNull("and the capturer stands behind it", after.board[squareFromName("d3")])
// Waiting a move loses the right.
var later = position("4k3/8/8/8/4p3/8/3P4/4K3 w - - 0 1").moveTo("d2d4")
later = later.moveTo("e8d8")
later = later.moveTo("e1d1")
assertTrue(ChessRules.legalMoves(later).none { it.toString() == "e4d3" })
}
@Test
fun `en passant is refused when it would expose the king`() {
// White king on e5, black rook on h5: taking en passant clears two
// pawns off the fifth rank at once and hangs the king.
val state = position("8/8/8/K1Pp3r/8/8/8/4k3 w - d6 0 1")
assertTrue(
"c5d6 must not be legal",
ChessRules.legalMoves(state).none { it.toString() == "c5d6" },
)
}
@Test
fun `a pawn reaching the last rank offers all four promotions`() {
val state = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1")
val promotions = ChessRules.legalMoves(state)
.filter { squareName(it.from) == "a7" }
.mapNotNull { it.promotion }
assertEquals(
setOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT),
promotions.toSet(),
)
val queened = state.moveTo("a7a8q")
assertEquals(Piece(PieceKind.QUEEN, white = true), queened.board[squareFromName("a8")])
}
@Test
fun `castling moves the rook and gives up both rights`() {
var state = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
state = state.moveTo("e1g1")
assertEquals(Piece(PieceKind.KING, true), state.board[squareFromName("g1")])
assertEquals("the rook jumps over", Piece(PieceKind.ROOK, true), state.board[squareFromName("f1")])
assertNull(state.board[squareFromName("h1")])
assertFalse(state.castling.whiteKingSide)
assertFalse(state.castling.whiteQueenSide)
assertTrue("black keeps its own", state.castling.blackKingSide)
val queenSide = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
assertEquals(Piece(PieceKind.ROOK, true), queenSide.board[squareFromName("d1")])
}
@Test
fun `you may not castle out of, through, or into check`() {
// Rook on e8 attacks the king's own square.
assertTrue(
"out of check",
ChessRules.legalMoves(position("4r3/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" || it.toString() == "e1c1" },
)
// Rook on f8 attacks the square the king crosses.
assertTrue(
"through check",
ChessRules.legalMoves(position("5r2/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" },
)
// Rook on g8 attacks the square the king lands on.
assertTrue(
"into check",
ChessRules.legalMoves(position("6r1/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.none { it.toString() == "e1g1" },
)
// The b-file square may be attacked; the king does not cross it.
assertTrue(
"b1 attacked is still fine",
ChessRules.legalMoves(position("1r6/8/8/8/8/8/8/R3K2R w KQ - 0 1"))
.any { it.toString() == "e1c1" },
)
}
@Test
fun `castling rights go when a rook moves or is taken on its corner`() {
val moved = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("a1b1")
assertFalse(moved.castling.whiteQueenSide)
assertTrue(moved.castling.whiteKingSide)
// White's rook takes on a8, so Black loses the queen's side.
val taken = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1")
.moveTo("a1a8")
assertFalse("captured rook loses its right", taken.castling.blackQueenSide)
assertTrue(taken.castling.blackKingSide)
}
@Test
fun `a pinned piece may not step off the line`() {
// The knight on e2 is pinned against the king by the rook on e8.
val state = position("4r3/8/8/8/8/8/4N3/4K3 w - - 0 1")
assertTrue(
ChessRules.legalMoves(state).none { squareName(it.from) == "e2" },
)
}
@Test
fun `in check you must answer the check`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1")
assertTrue(ChessRules.inCheck(state))
val moves = ChessRules.legalMoves(state).map { it.toString() }.toSet()
// Take the rook, or step off the file. Nothing else.
assertEquals(setOf("e1e2", "e1d1", "e1f1"), moves)
}
// -- Endings ------------------------------------------------------------
@Test
fun `the back rank mate ends the game`() {
val state = position("6k1/5ppp/8/8/8/8/8/R5K1 w - - 0 1").moveTo("a1a8")
assertTrue(ChessRules.isFinished(state))
assertEquals(ChessOutcome.WHITE_WINS, state.outcome)
assertEquals("recorded as mate", "Ra8#", state.moveLog.last())
assertNull("nobody is on the clock", ChessRules.currentSeat(state))
}
@Test
fun `fools mate is mate`() {
var state = ChessRules.initialState(config())
state = state.moveTo("f2f3")
state = state.moveTo("e7e5")
state = state.moveTo("g2g4")
state = state.moveTo("d8h4")
assertEquals(ChessOutcome.BLACK_WINS, state.outcome)
assertEquals("Qh4#", state.moveLog.last())
}
@Test
fun `a king with no move and no check is stalemate`() {
// Qf7 takes g7, g8 and h7 away without touching h8.
val state = position("7k/8/6Q1/8/8/8/8/K7 w - - 0 1").moveTo("g6f7")
assertEquals(ChessOutcome.STALEMATE, state.outcome)
assertTrue(ChessRules.isFinished(state))
}
@Test
fun `bare kings cannot mate and the game is drawn`() {
val state = position("4k3/8/8/8/8/8/4r3/4K3 w - - 0 1").moveTo("e1e2")
assertEquals(ChessOutcome.INSUFFICIENT_MATERIAL, state.outcome)
}
@Test
fun `what counts as too little material`() {
fun material(fen: String) = ChessRules.insufficientMaterial(parseFen(fen).board)
assertTrue("bare kings", material("4k3/8/8/8/8/8/8/4K3 w - - 0 1"))
assertTrue("one knight", material("4k3/8/8/8/8/8/8/3NK3 w - - 0 1"))
assertTrue("one bishop", material("4k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
// Both bishops on dark squares: nothing can ever be forced.
assertTrue("same-colour bishops", material("2b1k3/8/8/8/8/8/8/3BK3 w - - 0 1"))
assertFalse("opposite-colour bishops", material("3bk3/8/8/8/8/8/8/3BK3 w - - 0 1"))
assertFalse("a rook is enough", material("4k3/8/8/8/8/8/8/3RK3 w - - 0 1"))
assertFalse("a pawn can promote", material("4k3/8/8/8/8/8/3P4/4K3 w - - 0 1"))
assertFalse("two knights are not automatic", material("4k3/8/8/8/8/8/8/2NNK3 w - - 0 1"))
}
@Test
fun `the fifty-move rule ends it, and can be switched off`() {
val fen = "4k3/8/8/8/8/8/8/R3K3 w - - 99 60"
val drawn = position(fen).moveTo("a1a2")
assertEquals(ChessOutcome.FIFTY_MOVE, drawn.outcome)
val played = position(fen, ChessOptions(fiftyMoveRule = false)).moveTo("a1a2")
assertNull("the toggle keeps the game alive", played.outcome)
}
@Test
fun `a capture or a pawn move resets the clock`() {
val state = position("4k3/8/8/8/8/8/3P4/4K3 w - - 40 60").moveTo("d2d4")
assertEquals(0, state.halfmoveClock)
}
@Test
fun `the same position three times is a draw`() {
// Both kings walk back and forth; the third repetition ends it.
var state = position("4k3/8/8/8/8/8/8/R3K3 w - - 0 1")
var guard = 0
while (!ChessRules.isFinished(state) && guard++ < 40) {
val square = if (guard % 4 == 1 || guard % 4 == 2) {
if (state.whiteToMove) "a1b1" else "e8d8"
} else {
if (state.whiteToMove) "b1a1" else "d8e8"
}
state = state.moveTo(square)
}
assertEquals(ChessOutcome.REPETITION, state.outcome)
}
// -- Notation -----------------------------------------------------------
@Test
fun `the move log reads as algebraic notation`() {
var state = ChessRules.initialState(config())
state = state.moveTo("e2e4")
state = state.moveTo("e7e5")
state = state.moveTo("g1f3")
state = state.moveTo("b8c6")
state = state.moveTo("f1b5")
assertEquals(listOf("e4", "e5", "Nf3", "Nc6", "Bb5"), state.moveLog)
}
@Test
fun `captures, castling and promotion are all written out`() {
val taken = position("4k3/8/8/3p4/4P3/8/8/4K3 w - - 0 1").moveTo("e4d5")
assertEquals("exd5", taken.moveLog.last())
val castled = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1g1")
assertEquals("O-O", castled.moveLog.last())
val long = position("r3k2r/8/8/8/8/8/8/R3K2R w KQkq - 0 1").moveTo("e1c1")
assertEquals("O-O-O", long.moveLog.last())
val promoted = position("4k3/P7/8/8/8/8/8/4K3 w - - 0 1").moveTo("a7a8r")
assertEquals("a8=R+", promoted.moveLog.last())
}
@Test
fun `two pieces reaching the same square are told apart`() {
// Knights on c3 and g1 both reach e2: the file separates them.
val byFile = position("4k3/8/8/8/8/2N5/8/4K1N1 w - - 0 1").moveTo("c3e2")
assertEquals("Nce2", byFile.moveLog.last())
// Rooks on a1 and a5 both reach a3, sharing a file: the rank separates them.
val byRank = position("4k3/8/8/R7/8/8/8/R3K3 w - - 0 1").moveTo("a1a3")
assertEquals("R1a3", byRank.moveLog.last())
}
// -- Playing it out -----------------------------------------------------
@Test
fun `random legal play always reaches a result`() {
repeat(12) { iteration ->
val random = Random(iteration.toLong())
var state = ChessRules.initialState(config())
var guard = 0
while (!ChessRules.isFinished(state) && guard++ < 1_000) {
val seat = ChessRules.currentSeat(state)!!
val legal = ChessRules.legalMoves(state, seat)
assertTrue("no legal move but the game is not over", legal.isNotEmpty())
state = ChessRules.applyMove(state, seat, legal[random.nextInt(legal.size)])
}
assertTrue("game $iteration never ended", ChessRules.isFinished(state))
assertNotNull(state.outcome)
}
}
@Test
fun `the wrong seat cannot move`() {
val state = ChessRules.initialState(config())
assertTrue(ChessRules.legalMoves(state, BLACK_SEAT).isEmpty())
val move = ChessRules.legalMoves(state, WHITE_SEAT).first()
runCatching { ChessRules.applyMove(state, BLACK_SEAT, move) }
.onSuccess { throw AssertionError("Black was allowed to play White's move") }
}
// -- Wire format --------------------------------------------------------
@Test
fun `both sides see the same board`() {
val state = ChessRules.initialState(config())
assertEquals(state, ChessRules.viewFor(state, WHITE_SEAT))
assertEquals(state, ChessRules.viewFor(state, BLACK_SEAT))
}
@Test
fun `state and moves survive a round trip`() {
val state = ChessRules.initialState(config()).moveTo("e2e4")
assertEquals(state, ChessRules.decodeState(ChessRules.encodeState(state)))
listOf(
ChessMove(squareFromName("e2"), squareFromName("e4")),
ChessMove(squareFromName("a7"), squareFromName("a8"), PieceKind.QUEEN),
).forEach { assertEquals(it, ChessRules.decodeMove(ChessRules.encodeMove(it))) }
}
private companion object {
/**
* The standard perft suite. Depths are kept where they run in a second
* or two apiece — deep enough that castling, en passant, promotion and
* pin handling are all exercised many thousands of times over.
*/
val PERFT_POSITIONS: List<Pair<String, List<Long>>> = listOf(
// Kiwipete: castling both ways, pins, and a board full of tactics.
"r3k2r/p1ppqpb1/bn2pnp1/3PN3/1p2P3/2N2Q1p/PPPBBPPP/R3K2R w KQkq - 0 1" to
listOf(48L, 2_039L, 97_862L),
// A rook-and-pawn endgame that catches en passant errors.
"8/2p5/3p4/KP5r/1R3p1k/8/4P1P1/8 w - - 0 1" to
listOf(14L, 191L, 2_812L, 43_238L),
// Promotion with capture, from both sides.
"r3k2r/Pppp1ppp/1b3nbN/nP6/BBP1P3/q4N2/Pp1P2PP/R2Q1RK1 w kq - 0 1" to
listOf(6L, 264L, 9_467L),
// A cramped position where most moves are illegal.
"rnbq1k1r/pp1Pbppp/2p5/8/2B5/8/PPP1NnPP/RNBQK2R w KQ - 1 8" to
listOf(44L, 1_486L, 62_379L),
)
}
}