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:
@@ -138,7 +138,7 @@ object GameCatalog {
|
||||
maxPlayers = 2,
|
||||
teamBased = false,
|
||||
blurb = "The old game. Full legal moves, castling, en passant, promotion.",
|
||||
available = false,
|
||||
available = true,
|
||||
),
|
||||
GameDescriptor(
|
||||
id = CHECKERS,
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package org.prolibertate.games.game.chess
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.random.Random
|
||||
import org.prolibertate.games.game.engine.GameAi
|
||||
|
||||
/**
|
||||
* A negamax search with alpha-beta pruning, iterative deepening and a capture-
|
||||
* only quiescence tail.
|
||||
*
|
||||
* The quiescence tail is what stops it hanging pieces: a fixed-depth search that
|
||||
* stops in the middle of an exchange scores the position as though the recapture
|
||||
* never happens, and every such search plays like it is being robbed.
|
||||
*
|
||||
* Search depth comes from the table's own options unless a level is passed in,
|
||||
* so the setup screen sets the strength without the caller having to thread it
|
||||
* through.
|
||||
*/
|
||||
class ChessAi(private val level: ChessLevel? = null) : GameAi<ChessState, ChessMove> {
|
||||
|
||||
/**
|
||||
* Ceiling on how much thinking any one move gets. A phone should not stall
|
||||
* on a sharp middlegame, so the search stops where it is and plays the best
|
||||
* move from the last depth it finished.
|
||||
*/
|
||||
private var nodeBudget = 0
|
||||
private var nodesUsed = 0
|
||||
|
||||
override fun chooseMove(state: ChessState, seat: Int, legal: List<ChessMove>): ChessMove {
|
||||
require(legal.isNotEmpty()) { "No legal move for seat $seat" }
|
||||
val setting = level ?: state.options.level
|
||||
if (legal.size == 1) return legal.first()
|
||||
|
||||
nodeBudget = NODE_BUDGET
|
||||
nodesUsed = 0
|
||||
|
||||
// Deterministic per position, so a host and a client replaying the same
|
||||
// game reach the same move, but varied enough that two games from the
|
||||
// opening array do not run identically.
|
||||
val random = Random(state.repetitionKeys.size * 31L + state.toFen().hashCode())
|
||||
|
||||
var best = legal.first()
|
||||
var scored = legal.map { it to 0 }
|
||||
|
||||
for (depth in 1..setting.depth) {
|
||||
val results = mutableListOf<Pair<ChessMove, Int>>()
|
||||
// Search the previous iteration's best first, so the sub-searches
|
||||
// below it have a decent bound to prune against.
|
||||
val ordered = orderMoves(state, legal, preferred = best)
|
||||
for (move in ordered) {
|
||||
// Every root move gets a full window rather than being cut off
|
||||
// against the best so far. It costs some pruning, but it is
|
||||
// what makes the scores comparable — the weaker levels pick
|
||||
// from among the near-best moves, and a move that returned only
|
||||
// an upper bound cannot be compared with one that did not.
|
||||
val score = -negamax(
|
||||
state = ChessRules.advanced(state, move),
|
||||
depth = depth - 1,
|
||||
alpha = -INFINITY,
|
||||
beta = INFINITY,
|
||||
ply = 1,
|
||||
)
|
||||
results += move to score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
// A half-finished iteration has scored only some of the moves, so
|
||||
// it is thrown away rather than compared against a full one.
|
||||
if (nodesUsed >= nodeBudget && results.size < ordered.size) break
|
||||
scored = results
|
||||
best = results.maxBy { it.second }.first
|
||||
}
|
||||
|
||||
// Weaker levels take any move that is nearly as good, which is what
|
||||
// makes them beatable without making them play nonsense.
|
||||
val bestScore = scored.maxOf { it.second }
|
||||
val acceptable = scored.filter { it.second >= bestScore - setting.slack() }
|
||||
return acceptable[random.nextInt(acceptable.size)].first
|
||||
}
|
||||
|
||||
private fun ChessLevel.slack(): Int = when (this) {
|
||||
ChessLevel.CASUAL -> 70
|
||||
ChessLevel.CLUB -> 20
|
||||
ChessLevel.STRONG -> 0
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Search
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private fun negamax(state: ChessState, depth: Int, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
val moves = ChessRules.legalMoves(state)
|
||||
if (moves.isEmpty()) {
|
||||
// Mate is scored by distance, so the search prefers mating sooner
|
||||
// and, when it is losing, delays as long as it can.
|
||||
return if (ChessRules.inCheck(state)) -(MATE - ply) else 0
|
||||
}
|
||||
if (state.halfmoveClock >= 100) return 0
|
||||
if (depth <= 0) return quiescence(state, alpha, beta, ply)
|
||||
|
||||
var localAlpha = alpha
|
||||
for (move in orderMoves(state, moves, preferred = null)) {
|
||||
val score = -negamax(ChessRules.advanced(state, move), depth - 1, -beta, -localAlpha, ply + 1)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps searching while pieces are still being taken.
|
||||
*
|
||||
* Standing pat is allowed because the side to move is never obliged to
|
||||
* capture — the position as it stands is a lower bound on what it can get.
|
||||
*/
|
||||
private fun quiescence(state: ChessState, alpha: Int, beta: Int, ply: Int): Int {
|
||||
nodesUsed++
|
||||
val standPat = evaluate(state)
|
||||
if (standPat >= beta) return beta
|
||||
var localAlpha = maxOf(alpha, standPat)
|
||||
if (ply >= MAX_QUIESCENCE_PLY) return localAlpha
|
||||
|
||||
val captures = ChessRules.legalMoves(state).filter {
|
||||
ChessRules.isCapture(state, it) || it.promotion != null
|
||||
}
|
||||
for (move in orderMoves(state, captures, preferred = null)) {
|
||||
val score = -quiescence(ChessRules.advanced(state, move), -beta, -localAlpha, ply + 1)
|
||||
if (score >= beta) return beta
|
||||
if (score > localAlpha) localAlpha = score
|
||||
if (nodesUsed >= nodeBudget) break
|
||||
}
|
||||
return localAlpha
|
||||
}
|
||||
|
||||
/** Captures first, biggest victim by smallest attacker — cheap and effective. */
|
||||
private fun orderMoves(
|
||||
state: ChessState,
|
||||
moves: List<ChessMove>,
|
||||
preferred: ChessMove?,
|
||||
): List<ChessMove> = moves.sortedByDescending { move ->
|
||||
var score = 0
|
||||
if (move == preferred) score += 1_000_000
|
||||
val victim = state.board[move.to]
|
||||
if (victim != null) {
|
||||
score += 10_000 + victim.kind.value - (state.board[move.from]?.kind?.value ?: 0) / 10
|
||||
}
|
||||
move.promotion?.let { score += 9_000 + it.value }
|
||||
score
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Evaluation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Centipawns, from the point of view of whoever is to move. */
|
||||
fun evaluate(state: ChessState): Int {
|
||||
var white = 0
|
||||
var black = 0
|
||||
var whitePieces = 0
|
||||
var blackPieces = 0
|
||||
|
||||
for (square in 0 until 64) {
|
||||
val piece = state.board[square] ?: continue
|
||||
val table = tableFor(piece.kind)
|
||||
// Black reads the same table from the other end of the board.
|
||||
val placement = table[if (piece.white) square else square xor 56]
|
||||
if (piece.white) {
|
||||
white += piece.kind.value + placement
|
||||
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) whitePieces++
|
||||
} else {
|
||||
black += piece.kind.value + placement
|
||||
if (piece.kind != PieceKind.PAWN && piece.kind != PieceKind.KING) blackPieces++
|
||||
}
|
||||
}
|
||||
|
||||
// In a bare endgame the tables stop meaning much and driving the enemy
|
||||
// king to the edge starts to; without this a won endgame shuffles.
|
||||
if (whitePieces + blackPieces <= 2) {
|
||||
state.kingSquare(white = false)?.let { white += edgeDistance(it) * 8 }
|
||||
state.kingSquare(white = true)?.let { black += edgeDistance(it) * 8 }
|
||||
}
|
||||
|
||||
val score = white - black
|
||||
return if (state.whiteToMove) score else -score
|
||||
}
|
||||
|
||||
/** How far a king has been pushed towards the rim, 0 in the centre. */
|
||||
private fun edgeDistance(square: Int): Int {
|
||||
val file = fileOf(square)
|
||||
val rank = rankOf(square)
|
||||
return maxOf(abs(file * 2 - 7), abs(rank * 2 - 7))
|
||||
}
|
||||
|
||||
private fun tableFor(kind: PieceKind): IntArray = when (kind) {
|
||||
PieceKind.PAWN -> PAWN_TABLE
|
||||
PieceKind.KNIGHT -> KNIGHT_TABLE
|
||||
PieceKind.BISHOP -> BISHOP_TABLE
|
||||
PieceKind.ROOK -> ROOK_TABLE
|
||||
PieceKind.QUEEN -> QUEEN_TABLE
|
||||
PieceKind.KING -> KING_TABLE
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val INFINITY = 1_000_000
|
||||
const val MATE = 100_000
|
||||
const val NODE_BUDGET = 120_000
|
||||
const val MAX_QUIESCENCE_PLY = 24
|
||||
|
||||
/**
|
||||
* Placement bonuses in centipawns, written from White's side with rank
|
||||
* eight on the top row — the same reading order the board uses, so the
|
||||
* table can be laid out to look like a board.
|
||||
*/
|
||||
val PAWN_TABLE = intArrayOf(
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
50, 50, 50, 50, 50, 50, 50, 50,
|
||||
10, 10, 20, 30, 30, 20, 10, 10,
|
||||
5, 5, 10, 25, 25, 10, 5, 5,
|
||||
0, 0, 0, 20, 20, 0, 0, 0,
|
||||
5, -5, -10, 0, 0, -10, -5, 5,
|
||||
5, 10, 10, -20, -20, 10, 10, 5,
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
)
|
||||
|
||||
val KNIGHT_TABLE = intArrayOf(
|
||||
-50, -40, -30, -30, -30, -30, -40, -50,
|
||||
-40, -20, 0, 0, 0, 0, -20, -40,
|
||||
-30, 0, 10, 15, 15, 10, 0, -30,
|
||||
-30, 5, 15, 20, 20, 15, 5, -30,
|
||||
-30, 0, 15, 20, 20, 15, 0, -30,
|
||||
-30, 5, 10, 15, 15, 10, 5, -30,
|
||||
-40, -20, 0, 5, 5, 0, -20, -40,
|
||||
-50, -40, -30, -30, -30, -30, -40, -50,
|
||||
)
|
||||
|
||||
val BISHOP_TABLE = intArrayOf(
|
||||
-20, -10, -10, -10, -10, -10, -10, -20,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-10, 0, 5, 10, 10, 5, 0, -10,
|
||||
-10, 5, 5, 10, 10, 5, 5, -10,
|
||||
-10, 0, 10, 10, 10, 10, 0, -10,
|
||||
-10, 10, 10, 10, 10, 10, 10, -10,
|
||||
-10, 5, 0, 0, 0, 0, 5, -10,
|
||||
-20, -10, -10, -10, -10, -10, -10, -20,
|
||||
)
|
||||
|
||||
val ROOK_TABLE = intArrayOf(
|
||||
0, 0, 0, 0, 0, 0, 0, 0,
|
||||
5, 10, 10, 10, 10, 10, 10, 5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
-5, 0, 0, 0, 0, 0, 0, -5,
|
||||
0, 0, 0, 5, 5, 0, 0, 0,
|
||||
)
|
||||
|
||||
val QUEEN_TABLE = intArrayOf(
|
||||
-20, -10, -10, -5, -5, -10, -10, -20,
|
||||
-10, 0, 0, 0, 0, 0, 0, -10,
|
||||
-10, 0, 5, 5, 5, 5, 0, -10,
|
||||
-5, 0, 5, 5, 5, 5, 0, -5,
|
||||
0, 0, 5, 5, 5, 5, 0, -5,
|
||||
-10, 5, 5, 5, 5, 5, 0, -10,
|
||||
-10, 0, 5, 0, 0, 0, 0, -10,
|
||||
-20, -10, -10, -5, -5, -10, -10, -20,
|
||||
)
|
||||
|
||||
val KING_TABLE = intArrayOf(
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-30, -40, -40, -50, -50, -40, -40, -30,
|
||||
-20, -30, -30, -40, -40, -30, -30, -20,
|
||||
-10, -20, -20, -20, -20, -20, -20, -10,
|
||||
20, 20, 0, 0, 0, 20, 20, 20,
|
||||
20, 30, 10, 0, 0, 10, 30, 20,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
package org.prolibertate.games.game.chess
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Squares are numbered the way a FEN string reads them: 0 is a8, 7 is h8, and
|
||||
* 63 is h1. Keeping the board in reading order means parsing and printing a
|
||||
* position are both straight loops, and the screen only has to flip the index
|
||||
* when it draws from Black's side.
|
||||
*/
|
||||
fun squareOf(file: Int, rank: Int): Int = (7 - rank) * 8 + file
|
||||
|
||||
/** 0 is the a-file. */
|
||||
fun fileOf(square: Int): Int = square % 8
|
||||
|
||||
/** 0 is rank one. */
|
||||
fun rankOf(square: Int): Int = 7 - square / 8
|
||||
|
||||
fun squareName(square: Int): String =
|
||||
"${'a' + fileOf(square)}${rankOf(square) + 1}"
|
||||
|
||||
fun squareFromName(name: String): Int =
|
||||
squareOf(name[0] - 'a', name[1] - '1')
|
||||
|
||||
/** True for the light squares, which is all the screen needs to colour a board. */
|
||||
fun isLightSquare(square: Int): Boolean = (fileOf(square) + rankOf(square)) % 2 == 1
|
||||
|
||||
@Serializable
|
||||
enum class PieceKind(val letter: Char, val glyphIndex: Int, val value: Int) {
|
||||
PAWN('P', 5, 100),
|
||||
KNIGHT('N', 4, 320),
|
||||
BISHOP('B', 3, 330),
|
||||
ROOK('R', 2, 500),
|
||||
QUEEN('Q', 1, 900),
|
||||
|
||||
/**
|
||||
* The king has no material value: it is never captured, and a search that
|
||||
* could trade it for anything would happily do so.
|
||||
*/
|
||||
KING('K', 0, 0),
|
||||
;
|
||||
|
||||
companion object {
|
||||
fun fromLetter(letter: Char): PieceKind? =
|
||||
entries.firstOrNull { it.letter == letter.uppercaseChar() }
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class Piece(val kind: PieceKind, val white: Boolean) {
|
||||
/** FEN notation: white in upper case, black in lower. */
|
||||
val letter: Char get() = if (white) kind.letter else kind.letter.lowercaseChar()
|
||||
|
||||
/** The Unicode chess figurine, which is how the board is drawn. */
|
||||
val glyph: String get() = (if (white) WHITE_GLYPHS else BLACK_GLYPHS)[kind.glyphIndex].toString()
|
||||
}
|
||||
|
||||
private const val WHITE_GLYPHS = "♔♕♖♗♘♙"
|
||||
private const val BLACK_GLYPHS = "♚♛♜♝♞♟"
|
||||
|
||||
/** Which castles are still available, regardless of whether they are legal now. */
|
||||
@Serializable
|
||||
data class CastlingRights(
|
||||
val whiteKingSide: Boolean = true,
|
||||
val whiteQueenSide: Boolean = true,
|
||||
val blackKingSide: Boolean = true,
|
||||
val blackQueenSide: Boolean = true,
|
||||
) {
|
||||
fun kingSide(white: Boolean): Boolean = if (white) whiteKingSide else blackKingSide
|
||||
fun queenSide(white: Boolean): Boolean = if (white) whiteQueenSide else blackQueenSide
|
||||
|
||||
/** The FEN field, or "-" when nobody may castle. */
|
||||
fun toFen(): String = buildString {
|
||||
if (whiteKingSide) append('K')
|
||||
if (whiteQueenSide) append('Q')
|
||||
if (blackKingSide) append('k')
|
||||
if (blackQueenSide) append('q')
|
||||
if (isEmpty()) append('-')
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromFen(field: String): CastlingRights = CastlingRights(
|
||||
whiteKingSide = field.contains('K'),
|
||||
whiteQueenSide = field.contains('Q'),
|
||||
blackKingSide = field.contains('k'),
|
||||
blackQueenSide = field.contains('q'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A move is only ever from one square to another, plus what a pawn turns into.
|
||||
* Castling is recorded as the king's two-square step and en passant as the
|
||||
* pawn's diagonal — the rules work out the rest from the position.
|
||||
*/
|
||||
@Serializable
|
||||
data class ChessMove(
|
||||
val from: Int,
|
||||
val to: Int,
|
||||
val promotion: PieceKind? = null,
|
||||
) {
|
||||
/** Long algebraic, as used by every engine protocol: e2e4, e7e8q. */
|
||||
override fun toString(): String =
|
||||
squareName(from) + squareName(to) + (promotion?.letter?.lowercaseChar() ?: "")
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class ChessPhase { PLAYING, GAME_OVER }
|
||||
|
||||
@Serializable
|
||||
enum class ChessOutcome(val label: String) {
|
||||
// Named for who wins rather than who was mated: every caller wants the
|
||||
// result, and "White mated" reads either way round.
|
||||
WHITE_WINS("White wins by checkmate"),
|
||||
BLACK_WINS("Black wins by checkmate"),
|
||||
STALEMATE("Draw by stalemate"),
|
||||
FIFTY_MOVE("Draw by the fifty-move rule"),
|
||||
REPETITION("Draw by threefold repetition"),
|
||||
INSUFFICIENT_MATERIAL("Draw — neither side can mate"),
|
||||
}
|
||||
|
||||
/** How hard the computer thinks about it. */
|
||||
@Serializable
|
||||
enum class ChessLevel(val label: String, val depth: Int) {
|
||||
CASUAL("Casual", depth = 2),
|
||||
CLUB("Club", depth = 3),
|
||||
STRONG("Strong", depth = 4),
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class ChessOptions(
|
||||
val level: ChessLevel = ChessLevel.CLUB,
|
||||
/** A hundred half-moves with no capture and no pawn move is a draw. */
|
||||
val fiftyMoveRule: Boolean = true,
|
||||
/** The same position three times over is a draw. */
|
||||
val threefoldRepetition: Boolean = true,
|
||||
/**
|
||||
* Where the game starts, in FEN. Blank means the ordinary opening array;
|
||||
* anything else lets a table set up a puzzle or resume an adjourned game.
|
||||
*/
|
||||
val startingFen: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChessState(
|
||||
val options: ChessOptions,
|
||||
/** Sixty-four squares in FEN reading order; null is an empty square. */
|
||||
val board: List<Piece?>,
|
||||
val whiteToMove: Boolean,
|
||||
val castling: CastlingRights,
|
||||
/** The square a pawn just skipped over, which is where it may be taken. */
|
||||
val enPassant: Int?,
|
||||
val halfmoveClock: Int,
|
||||
val fullmoveNumber: Int,
|
||||
val phase: ChessPhase,
|
||||
val outcome: ChessOutcome?,
|
||||
val lastMove: ChessMove?,
|
||||
/**
|
||||
* One entry per position reached, in the four-field form that decides
|
||||
* repetition — the clocks are deliberately left out, since a position
|
||||
* repeats whether or not the same number of moves has been played.
|
||||
*/
|
||||
val repetitionKeys: List<String>,
|
||||
/** The game score in algebraic notation, one entry per half-move. */
|
||||
val moveLog: List<String>,
|
||||
) {
|
||||
/** Seat 0 is White, seat 1 is Black. */
|
||||
val turnSeat: Int get() = if (whiteToMove) WHITE_SEAT else BLACK_SEAT
|
||||
|
||||
fun kingSquare(white: Boolean): Int? =
|
||||
board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
|
||||
.takeIf { it >= 0 }
|
||||
}
|
||||
|
||||
const val WHITE_SEAT = 0
|
||||
const val BLACK_SEAT = 1
|
||||
|
||||
fun seatIsWhite(seat: Int): Boolean = seat == WHITE_SEAT
|
||||
|
||||
const val START_FEN = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
|
||||
/** A position with everything needed to resume it: board, side, rights, clocks. */
|
||||
data class FenPosition(
|
||||
val board: List<Piece?>,
|
||||
val whiteToMove: Boolean,
|
||||
val castling: CastlingRights,
|
||||
val enPassant: Int?,
|
||||
val halfmoveClock: Int,
|
||||
val fullmoveNumber: Int,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parses a FEN string.
|
||||
*
|
||||
* This is what the tests are built on — the standard perft positions are all
|
||||
* published as FEN — and it is also what lets a game start from something other
|
||||
* than the opening array.
|
||||
*/
|
||||
fun parseFen(fen: String): FenPosition {
|
||||
val fields = fen.trim().split(" ")
|
||||
require(fields.size >= 4) { "FEN needs at least four fields: $fen" }
|
||||
|
||||
val board = arrayOfNulls<Piece>(64)
|
||||
var index = 0
|
||||
for (character in fields[0]) {
|
||||
when {
|
||||
character == '/' -> Unit
|
||||
character.isDigit() -> index += character - '0'
|
||||
else -> {
|
||||
val kind = requireNotNull(PieceKind.fromLetter(character)) {
|
||||
"Unknown piece '$character' in $fen"
|
||||
}
|
||||
require(index < 64) { "Too many squares in $fen" }
|
||||
board[index] = Piece(kind, white = character.isUpperCase())
|
||||
index++
|
||||
}
|
||||
}
|
||||
}
|
||||
require(index == 64) { "FEN describes $index squares, not 64: $fen" }
|
||||
|
||||
return FenPosition(
|
||||
board = board.toList(),
|
||||
whiteToMove = fields[1] == "w",
|
||||
castling = CastlingRights.fromFen(fields[2]),
|
||||
enPassant = fields[3].takeIf { it != "-" }?.let { squareFromName(it) },
|
||||
halfmoveClock = fields.getOrNull(4)?.toIntOrNull() ?: 0,
|
||||
fullmoveNumber = fields.getOrNull(5)?.toIntOrNull() ?: 1,
|
||||
)
|
||||
}
|
||||
|
||||
/** The first four FEN fields — the part that decides whether a position repeats. */
|
||||
fun positionKey(
|
||||
board: List<Piece?>,
|
||||
whiteToMove: Boolean,
|
||||
castling: CastlingRights,
|
||||
enPassant: Int?,
|
||||
): String = buildString {
|
||||
for (rank in 0 until 8) {
|
||||
var empty = 0
|
||||
for (file in 0 until 8) {
|
||||
val piece = board[rank * 8 + file]
|
||||
if (piece == null) {
|
||||
empty++
|
||||
} else {
|
||||
if (empty > 0) { append(empty); empty = 0 }
|
||||
append(piece.letter)
|
||||
}
|
||||
}
|
||||
if (empty > 0) append(empty)
|
||||
if (rank < 7) append('/')
|
||||
}
|
||||
append(' ').append(if (whiteToMove) 'w' else 'b')
|
||||
append(' ').append(castling.toFen())
|
||||
append(' ').append(enPassant?.let { squareName(it) } ?: "-")
|
||||
}
|
||||
|
||||
fun ChessState.toFen(): String =
|
||||
positionKey(board, whiteToMove, castling, enPassant) + " $halfmoveClock $fullmoveNumber"
|
||||
@@ -0,0 +1,577 @@
|
||||
package org.prolibertate.games.game.chess
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.engine.GameRules
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
|
||||
/**
|
||||
* Chess as a pure state machine. See RULES-chess.md.
|
||||
*
|
||||
* Move generation is pseudo-legal followed by a king-safety filter: every
|
||||
* candidate is played onto a copy of the board and thrown away if it leaves the
|
||||
* mover in check. That is slower than tracking pins, and it is the reason the
|
||||
* perft counts in the tests match published figures exactly — there is no
|
||||
* special case to get subtly wrong.
|
||||
*/
|
||||
object ChessRules : GameRules<ChessState, ChessMove> {
|
||||
|
||||
override val gameId: String = GameCatalog.CHESS
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
override fun initialState(config: TableConfig): ChessState {
|
||||
require(config.seats.size == 2) { "Chess is a two-handed game" }
|
||||
val options = json.decodeFromString<ChessOptions>(config.optionsJson)
|
||||
return fromPosition(options, options.startingFen.ifBlank { START_FEN })
|
||||
}
|
||||
|
||||
/** Builds a state from a FEN string, which is also how a puzzle is loaded. */
|
||||
fun fromPosition(options: ChessOptions, fen: String): ChessState {
|
||||
val position = parseFen(fen)
|
||||
return ChessState(
|
||||
options = options,
|
||||
board = position.board,
|
||||
whiteToMove = position.whiteToMove,
|
||||
castling = position.castling,
|
||||
enPassant = position.enPassant,
|
||||
halfmoveClock = position.halfmoveClock,
|
||||
fullmoveNumber = position.fullmoveNumber,
|
||||
phase = ChessPhase.PLAYING,
|
||||
outcome = null,
|
||||
lastMove = null,
|
||||
repetitionKeys = listOf(
|
||||
positionKey(
|
||||
position.board,
|
||||
position.whiteToMove,
|
||||
position.castling,
|
||||
position.enPassant,
|
||||
)
|
||||
),
|
||||
moveLog = emptyList(),
|
||||
).withTerminalCheck()
|
||||
}
|
||||
|
||||
override fun currentSeat(state: ChessState): Int? =
|
||||
if (state.phase == ChessPhase.PLAYING) state.turnSeat else null
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Attacks
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private val KNIGHT_STEPS =
|
||||
listOf(1 to 2, 2 to 1, 2 to -1, 1 to -2, -1 to -2, -2 to -1, -2 to 1, -1 to 2)
|
||||
private val KING_STEPS =
|
||||
listOf(1 to 0, 1 to 1, 0 to 1, -1 to 1, -1 to 0, -1 to -1, 0 to -1, 1 to -1)
|
||||
private val ROOK_DIRS = listOf(1 to 0, -1 to 0, 0 to 1, 0 to -1)
|
||||
private val BISHOP_DIRS = listOf(1 to 1, 1 to -1, -1 to 1, -1 to -1)
|
||||
|
||||
private fun pieceAt(board: List<Piece?>, file: Int, rank: Int): Piece? =
|
||||
if (file in 0..7 && rank in 0..7) board[squareOf(file, rank)] else null
|
||||
|
||||
/** Whether [square] is attacked by the side [byWhite], king included. */
|
||||
fun isAttacked(board: List<Piece?>, square: Int, byWhite: Boolean): Boolean {
|
||||
val file = fileOf(square)
|
||||
val rank = rankOf(square)
|
||||
|
||||
// A pawn attacks forwards, so an attacker stands one rank behind.
|
||||
val pawnRank = rank - if (byWhite) 1 else -1
|
||||
for (side in intArrayOf(-1, 1)) {
|
||||
val found = pieceAt(board, file + side, pawnRank)
|
||||
if (found != null && found.white == byWhite && found.kind == PieceKind.PAWN) return true
|
||||
}
|
||||
|
||||
for ((df, dr) in KNIGHT_STEPS) {
|
||||
val found = pieceAt(board, file + df, rank + dr)
|
||||
if (found != null && found.white == byWhite && found.kind == PieceKind.KNIGHT) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
for ((df, dr) in KING_STEPS) {
|
||||
val found = pieceAt(board, file + df, rank + dr)
|
||||
if (found != null && found.white == byWhite && found.kind == PieceKind.KING) return true
|
||||
}
|
||||
|
||||
if (raysHit(board, file, rank, byWhite, ROOK_DIRS, PieceKind.ROOK)) return true
|
||||
if (raysHit(board, file, rank, byWhite, BISHOP_DIRS, PieceKind.BISHOP)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Walks each direction until something blocks, looking for [kind] or a queen. */
|
||||
private fun raysHit(
|
||||
board: List<Piece?>,
|
||||
file: Int,
|
||||
rank: Int,
|
||||
byWhite: Boolean,
|
||||
directions: List<Pair<Int, Int>>,
|
||||
kind: PieceKind,
|
||||
): Boolean {
|
||||
for ((df, dr) in directions) {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while (f in 0..7 && r in 0..7) {
|
||||
val found = board[squareOf(f, r)]
|
||||
if (found != null) {
|
||||
if (found.white == byWhite &&
|
||||
(found.kind == kind || found.kind == PieceKind.QUEEN)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
}
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Whether the side to move is in check. */
|
||||
fun inCheck(state: ChessState): Boolean = inCheck(state.board, state.whiteToMove)
|
||||
|
||||
fun inCheck(board: List<Piece?>, white: Boolean): Boolean {
|
||||
val king = board.indexOfFirst { it != null && it.white == white && it.kind == PieceKind.KING }
|
||||
// A position with no king cannot be in check. Only reachable from a
|
||||
// hand-written FEN, but it must not throw.
|
||||
return king >= 0 && isAttacked(board, king, byWhite = !white)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Move generation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun legalMoves(state: ChessState, seat: Int): List<ChessMove> {
|
||||
if (currentSeat(state) != seat) return emptyList()
|
||||
return legalMoves(state)
|
||||
}
|
||||
|
||||
/** Every move the side to move may actually play. */
|
||||
fun legalMoves(state: ChessState): List<ChessMove> {
|
||||
if (state.phase != ChessPhase.PLAYING) return emptyList()
|
||||
val white = state.whiteToMove
|
||||
return pseudoMoves(state).filter { move ->
|
||||
!inCheck(make(state, move).board, white)
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves that obey the way pieces travel, but may leave the king in check. */
|
||||
private fun pseudoMoves(state: ChessState): List<ChessMove> {
|
||||
val moves = mutableListOf<ChessMove>()
|
||||
val white = state.whiteToMove
|
||||
for (square in 0 until 64) {
|
||||
val piece = state.board[square] ?: continue
|
||||
if (piece.white != white) continue
|
||||
when (piece.kind) {
|
||||
PieceKind.PAWN -> pawnMoves(state, square, white, moves)
|
||||
PieceKind.KNIGHT -> stepMoves(state, square, white, KNIGHT_STEPS, moves)
|
||||
PieceKind.KING -> {
|
||||
stepMoves(state, square, white, KING_STEPS, moves)
|
||||
castlingMoves(state, square, white, moves)
|
||||
}
|
||||
|
||||
PieceKind.BISHOP -> slideMoves(state, square, white, BISHOP_DIRS, moves)
|
||||
PieceKind.ROOK -> slideMoves(state, square, white, ROOK_DIRS, moves)
|
||||
PieceKind.QUEEN ->
|
||||
slideMoves(state, square, white, ROOK_DIRS + BISHOP_DIRS, moves)
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
private val PROMOTIONS =
|
||||
listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
|
||||
|
||||
private fun pawnMoves(
|
||||
state: ChessState,
|
||||
from: Int,
|
||||
white: Boolean,
|
||||
moves: MutableList<ChessMove>,
|
||||
) {
|
||||
val board = state.board
|
||||
val file = fileOf(from)
|
||||
val rank = rankOf(from)
|
||||
val forward = if (white) 1 else -1
|
||||
val startRank = if (white) 1 else 6
|
||||
val lastRank = if (white) 7 else 0
|
||||
|
||||
fun addPawnMove(to: Int) {
|
||||
if (rankOf(to) == lastRank) {
|
||||
PROMOTIONS.forEach { moves += ChessMove(from, to, it) }
|
||||
} else {
|
||||
moves += ChessMove(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
val ahead = rank + forward
|
||||
if (ahead in 0..7 && board[squareOf(file, ahead)] == null) {
|
||||
addPawnMove(squareOf(file, ahead))
|
||||
val twoAhead = rank + 2 * forward
|
||||
if (rank == startRank && board[squareOf(file, twoAhead)] == null) {
|
||||
moves += ChessMove(from, squareOf(file, twoAhead))
|
||||
}
|
||||
}
|
||||
|
||||
if (ahead !in 0..7) return
|
||||
for (side in intArrayOf(-1, 1)) {
|
||||
val captureFile = file + side
|
||||
if (captureFile !in 0..7) continue
|
||||
val to = squareOf(captureFile, ahead)
|
||||
val occupant = board[to]
|
||||
when {
|
||||
occupant != null && occupant.white != white -> addPawnMove(to)
|
||||
// The square behind a pawn that has just stepped two.
|
||||
occupant == null && to == state.enPassant -> moves += ChessMove(from, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stepMoves(
|
||||
state: ChessState,
|
||||
from: Int,
|
||||
white: Boolean,
|
||||
steps: List<Pair<Int, Int>>,
|
||||
moves: MutableList<ChessMove>,
|
||||
) {
|
||||
val file = fileOf(from)
|
||||
val rank = rankOf(from)
|
||||
for ((df, dr) in steps) {
|
||||
val f = file + df
|
||||
val r = rank + dr
|
||||
if (f !in 0..7 || r !in 0..7) continue
|
||||
val to = squareOf(f, r)
|
||||
val occupant = state.board[to]
|
||||
if (occupant == null || occupant.white != white) moves += ChessMove(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
private fun slideMoves(
|
||||
state: ChessState,
|
||||
from: Int,
|
||||
white: Boolean,
|
||||
directions: List<Pair<Int, Int>>,
|
||||
moves: MutableList<ChessMove>,
|
||||
) {
|
||||
val file = fileOf(from)
|
||||
val rank = rankOf(from)
|
||||
for ((df, dr) in directions) {
|
||||
var f = file + df
|
||||
var r = rank + dr
|
||||
while (f in 0..7 && r in 0..7) {
|
||||
val to = squareOf(f, r)
|
||||
val occupant = state.board[to]
|
||||
if (occupant == null) {
|
||||
moves += ChessMove(from, to)
|
||||
} else {
|
||||
if (occupant.white != white) moves += ChessMove(from, to)
|
||||
break
|
||||
}
|
||||
f += df
|
||||
r += dr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun castlingMoves(
|
||||
state: ChessState,
|
||||
from: Int,
|
||||
white: Boolean,
|
||||
moves: MutableList<ChessMove>,
|
||||
) {
|
||||
val homeRank = if (white) 0 else 7
|
||||
// A hand-written FEN can claim rights for a king that has moved, so the
|
||||
// king's own square is checked rather than trusted.
|
||||
if (from != squareOf(4, homeRank)) return
|
||||
// Castling out of check is never allowed.
|
||||
if (isAttacked(state.board, from, byWhite = !white)) return
|
||||
|
||||
fun clear(vararg files: Int) = files.all { state.board[squareOf(it, homeRank)] == null }
|
||||
fun safe(vararg files: Int) = files.all {
|
||||
!isAttacked(state.board, squareOf(it, homeRank), byWhite = !white)
|
||||
}
|
||||
|
||||
fun rookAt(file: Int): Boolean {
|
||||
val piece = state.board[squareOf(file, homeRank)]
|
||||
return piece != null && piece.white == white && piece.kind == PieceKind.ROOK
|
||||
}
|
||||
|
||||
if (state.castling.kingSide(white) && rookAt(7) && clear(5, 6) && safe(5, 6)) {
|
||||
moves += ChessMove(from, squareOf(6, homeRank))
|
||||
}
|
||||
// The b-file square must be empty but may be attacked — the king does
|
||||
// not pass over it.
|
||||
if (state.castling.queenSide(white) && rookAt(0) && clear(1, 2, 3) && safe(2, 3)) {
|
||||
moves += ChessMove(from, squareOf(2, homeRank))
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Making a move
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The position a move produces, before any terminal test. */
|
||||
private data class Made(
|
||||
val board: List<Piece?>,
|
||||
val castling: CastlingRights,
|
||||
val enPassant: Int?,
|
||||
val halfmoveClock: Int,
|
||||
val captured: Piece?,
|
||||
val castled: Boolean,
|
||||
)
|
||||
|
||||
private fun make(state: ChessState, move: ChessMove): Made {
|
||||
val board = state.board.toMutableList()
|
||||
val piece = requireNotNull(board[move.from]) { "No piece on ${squareName(move.from)}" }
|
||||
var captured = board[move.to]
|
||||
|
||||
// Taking en passant removes a pawn that is not on the target square.
|
||||
if (piece.kind == PieceKind.PAWN && move.to == state.enPassant && captured == null) {
|
||||
val victim = squareOf(fileOf(move.to), rankOf(move.from))
|
||||
captured = board[victim]
|
||||
board[victim] = null
|
||||
}
|
||||
|
||||
board[move.from] = null
|
||||
board[move.to] = move.promotion?.let { Piece(it, piece.white) } ?: piece
|
||||
|
||||
val castled = piece.kind == PieceKind.KING &&
|
||||
abs(fileOf(move.to) - fileOf(move.from)) == 2
|
||||
if (castled) {
|
||||
val homeRank = rankOf(move.from)
|
||||
val rookFrom = squareOf(if (fileOf(move.to) == 6) 7 else 0, homeRank)
|
||||
val rookTo = squareOf(if (fileOf(move.to) == 6) 5 else 3, homeRank)
|
||||
board[rookTo] = board[rookFrom]
|
||||
board[rookFrom] = null
|
||||
}
|
||||
|
||||
val doubleStep = piece.kind == PieceKind.PAWN &&
|
||||
abs(rankOf(move.to) - rankOf(move.from)) == 2
|
||||
val enPassant = if (doubleStep) {
|
||||
squareOf(fileOf(move.from), (rankOf(move.from) + rankOf(move.to)) / 2)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
var castling = state.castling
|
||||
if (piece.kind == PieceKind.KING) {
|
||||
castling = if (piece.white) {
|
||||
castling.copy(whiteKingSide = false, whiteQueenSide = false)
|
||||
} else {
|
||||
castling.copy(blackKingSide = false, blackQueenSide = false)
|
||||
}
|
||||
}
|
||||
// A rook that moves loses its right; so does one that is captured on
|
||||
// its own corner, which is the case that is easy to forget.
|
||||
castling = clearCornerRight(castling, move.from)
|
||||
castling = clearCornerRight(castling, move.to)
|
||||
|
||||
val resetsClock = piece.kind == PieceKind.PAWN || captured != null
|
||||
return Made(
|
||||
board = board,
|
||||
castling = castling,
|
||||
enPassant = enPassant,
|
||||
halfmoveClock = if (resetsClock) 0 else state.halfmoveClock + 1,
|
||||
captured = captured,
|
||||
castled = castled,
|
||||
)
|
||||
}
|
||||
|
||||
private fun clearCornerRight(rights: CastlingRights, square: Int): CastlingRights =
|
||||
when (square) {
|
||||
squareOf(0, 0) -> rights.copy(whiteQueenSide = false)
|
||||
squareOf(7, 0) -> rights.copy(whiteKingSide = false)
|
||||
squareOf(0, 7) -> rights.copy(blackQueenSide = false)
|
||||
squareOf(7, 7) -> rights.copy(blackKingSide = false)
|
||||
else -> rights
|
||||
}
|
||||
|
||||
override fun applyMove(state: ChessState, seat: Int, move: ChessMove): ChessState {
|
||||
require(currentSeat(state) == seat) { "Not seat $seat's turn" }
|
||||
require(move in legalMoves(state, seat)) { "Illegal move $move for seat $seat" }
|
||||
|
||||
val notation = baseNotation(state, move)
|
||||
val made = make(state, move)
|
||||
val key = positionKey(made.board, !state.whiteToMove, made.castling, made.enPassant)
|
||||
|
||||
val next = state.copy(
|
||||
board = made.board,
|
||||
whiteToMove = !state.whiteToMove,
|
||||
castling = made.castling,
|
||||
enPassant = made.enPassant,
|
||||
halfmoveClock = made.halfmoveClock,
|
||||
fullmoveNumber = state.fullmoveNumber + if (state.whiteToMove) 0 else 1,
|
||||
lastMove = move,
|
||||
repetitionKeys = state.repetitionKeys + key,
|
||||
).withTerminalCheck()
|
||||
|
||||
val suffix = when {
|
||||
next.outcome == ChessOutcome.WHITE_WINS || next.outcome == ChessOutcome.BLACK_WINS ->
|
||||
"#"
|
||||
|
||||
inCheck(next) -> "+"
|
||||
else -> ""
|
||||
}
|
||||
return next.copy(moveLog = state.moveLog + (notation + suffix))
|
||||
}
|
||||
|
||||
/** Applies the terminal tests to a position whose side to move is set. */
|
||||
private fun ChessState.withTerminalCheck(): ChessState {
|
||||
if (legalMoves(this).isEmpty()) {
|
||||
val outcome = when {
|
||||
// Whoever is to move has been mated, so the other side wins.
|
||||
inCheck(this) -> if (whiteToMove) {
|
||||
ChessOutcome.BLACK_WINS
|
||||
} else {
|
||||
ChessOutcome.WHITE_WINS
|
||||
}
|
||||
|
||||
else -> ChessOutcome.STALEMATE
|
||||
}
|
||||
return copy(phase = ChessPhase.GAME_OVER, outcome = outcome)
|
||||
}
|
||||
if (insufficientMaterial(board)) {
|
||||
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.INSUFFICIENT_MATERIAL)
|
||||
}
|
||||
if (options.fiftyMoveRule && halfmoveClock >= 100) {
|
||||
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.FIFTY_MOVE)
|
||||
}
|
||||
if (options.threefoldRepetition &&
|
||||
repetitionKeys.count { it == repetitionKeys.last() } >= 3
|
||||
) {
|
||||
return copy(phase = ChessPhase.GAME_OVER, outcome = ChessOutcome.REPETITION)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/** Positions from which no sequence of legal moves can produce mate. */
|
||||
fun insufficientMaterial(board: List<Piece?>): Boolean {
|
||||
val pieces = board.withIndex().mapNotNull { (square, piece) ->
|
||||
piece?.takeIf { it.kind != PieceKind.KING }?.let { square to it }
|
||||
}
|
||||
if (pieces.any {
|
||||
it.second.kind == PieceKind.PAWN ||
|
||||
it.second.kind == PieceKind.ROOK ||
|
||||
it.second.kind == PieceKind.QUEEN
|
||||
}
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return when (pieces.size) {
|
||||
// Bare kings, or a king and one minor piece.
|
||||
0, 1 -> true
|
||||
// Two bishops on the same colour square can never mate, whichever
|
||||
// sides they belong to.
|
||||
2 -> pieces.all { it.second.kind == PieceKind.BISHOP } &&
|
||||
isLightSquare(pieces[0].first) == isLightSquare(pieces[1].first)
|
||||
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Notation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Algebraic notation without the check or mate suffix, which needs the result. */
|
||||
private fun baseNotation(state: ChessState, move: ChessMove): String {
|
||||
val piece = state.board[move.from] ?: return move.toString()
|
||||
if (piece.kind == PieceKind.KING && abs(fileOf(move.to) - fileOf(move.from)) == 2) {
|
||||
return if (fileOf(move.to) == 6) "O-O" else "O-O-O"
|
||||
}
|
||||
|
||||
val captures = state.board[move.to] != null ||
|
||||
(piece.kind == PieceKind.PAWN && move.to == state.enPassant)
|
||||
|
||||
return buildString {
|
||||
if (piece.kind == PieceKind.PAWN) {
|
||||
if (captures) append('a' + fileOf(move.from))
|
||||
} else {
|
||||
append(piece.kind.letter)
|
||||
append(disambiguation(state, move, piece.kind))
|
||||
}
|
||||
if (captures) append('x')
|
||||
append(squareName(move.to))
|
||||
move.promotion?.let { append('=').append(it.letter) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The least that distinguishes this move from another of the same kind to
|
||||
* the same square — the file if that is enough, otherwise the rank, and
|
||||
* both when two pieces share a file and a rank with a third.
|
||||
*/
|
||||
private fun disambiguation(state: ChessState, move: ChessMove, kind: PieceKind): String {
|
||||
val rivals = legalMoves(state).filter { other ->
|
||||
other.to == move.to &&
|
||||
other.from != move.from &&
|
||||
state.board[other.from]?.kind == kind
|
||||
}
|
||||
if (rivals.isEmpty()) return ""
|
||||
val fileIsUnique = rivals.none { fileOf(it.from) == fileOf(move.from) }
|
||||
if (fileIsUnique) return ('a' + fileOf(move.from)).toString()
|
||||
val rankIsUnique = rivals.none { rankOf(it.from) == rankOf(move.from) }
|
||||
if (rankIsUnique) return (rankOf(move.from) + 1).toString()
|
||||
return squareName(move.from)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Engine contract
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
override fun isFinished(state: ChessState): Boolean = state.phase == ChessPhase.GAME_OVER
|
||||
|
||||
override fun summary(state: ChessState): String = state.outcome?.label
|
||||
?: if (state.whiteToMove) "White to move" else "Black to move"
|
||||
|
||||
/** Chess hides nothing: both players see the same board. */
|
||||
override fun viewFor(state: ChessState, seat: Int): ChessState = state
|
||||
|
||||
override fun encodeState(state: ChessState): String = json.encodeToString(state)
|
||||
override fun decodeState(json: String): ChessState = this.json.decodeFromString(json)
|
||||
override fun encodeMove(move: ChessMove): String = json.encodeToString(move)
|
||||
override fun decodeMove(json: String): ChessMove = this.json.decodeFromString(json)
|
||||
|
||||
/**
|
||||
* Counts the leaf nodes at [depth]. Only used by the tests, but it lives
|
||||
* here because it is the single most valuable thing that can be said about
|
||||
* a move generator: the published counts for a given position are exact, so
|
||||
* a generator that matches them at depth four has essentially no room left
|
||||
* for a rule it gets wrong.
|
||||
*/
|
||||
fun perft(state: ChessState, depth: Int): Long {
|
||||
if (depth == 0) return 1L
|
||||
val moves = legalMoves(state)
|
||||
if (depth == 1) return moves.size.toLong()
|
||||
return moves.sumOf { perft(advanced(state, it), depth - 1) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The position after [move] with none of the terminal tests applied.
|
||||
*
|
||||
* The search walks millions of positions it will never keep, and running
|
||||
* the repetition and fifty-move checks on every one of them would cost more
|
||||
* than the search itself. Anything that ends up on the board goes through
|
||||
* [applyMove] instead.
|
||||
*/
|
||||
fun advanced(state: ChessState, move: ChessMove): ChessState {
|
||||
val made = make(state, move)
|
||||
return state.copy(
|
||||
board = made.board,
|
||||
whiteToMove = !state.whiteToMove,
|
||||
castling = made.castling,
|
||||
enPassant = made.enPassant,
|
||||
halfmoveClock = made.halfmoveClock,
|
||||
lastMove = move,
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether [move] takes something — including en passant, which takes nothing on its square. */
|
||||
fun isCapture(state: ChessState, move: ChessMove): Boolean =
|
||||
state.board[move.to] != null ||
|
||||
(state.board[move.from]?.kind == PieceKind.PAWN && move.to == state.enPassant)
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
package org.prolibertate.games.net
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -231,17 +233,24 @@ class MatchController<S : Any, M : Any>(
|
||||
if (seatKinds[seat] != PlayerKind.AI) return // a person's turn
|
||||
|
||||
delay(holdBeforeNextMove(current) + aiThinkingMillis())
|
||||
val chosen = lock.withLock {
|
||||
|
||||
val legal = rules.legalMoves(current, seat)
|
||||
if (legal.isEmpty()) return
|
||||
// Off the caller's thread. A card AI answers instantly, but a chess
|
||||
// search runs for a second or more, and this scope belongs to the
|
||||
// screen — thinking here would freeze the board it is drawn on.
|
||||
val move = withContext(Dispatchers.Default) { ai.chooseMove(current, seat, legal) }
|
||||
|
||||
val applied = lock.withLock {
|
||||
val live = authoritative ?: return
|
||||
if (rules.currentSeat(live) != seat) return@withLock null
|
||||
val legal = rules.legalMoves(live, seat)
|
||||
if (legal.isEmpty()) return@withLock null
|
||||
val move = ai.chooseMove(live, seat, legal)
|
||||
// Nothing else can move while an AI seat is on the clock, but
|
||||
// the state is re-read rather than assumed.
|
||||
if (rules.currentSeat(live) != seat) return@withLock false
|
||||
authoritative = rules.applyMove(live, seat, move)
|
||||
publish()
|
||||
move
|
||||
true
|
||||
}
|
||||
if (chosen == null) return
|
||||
if (!applied) return
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ private fun AppContent(
|
||||
descriptor = GameCatalog.byId(route.gameId)!!,
|
||||
playerName = settings.displayName,
|
||||
onBack = { pop() },
|
||||
onPlayOffline = { config -> push(Route.Play(route.gameId, config, true, 0)) },
|
||||
onPlayOffline = { config, seat -> push(Route.Play(route.gameId, config, true, seat)) },
|
||||
onHostOnline = { optionsJson ->
|
||||
push(Route.Lobby(route.gameId, optionsJson, hosting = true))
|
||||
},
|
||||
|
||||
@@ -30,6 +30,8 @@ import org.prolibertate.games.game.GameDescriptor
|
||||
import org.prolibertate.games.game.engine.PlayerKind
|
||||
import org.prolibertate.games.game.engine.PlayerSlot
|
||||
import org.prolibertate.games.game.engine.TableConfig
|
||||
import org.prolibertate.games.game.chess.ChessLevel
|
||||
import org.prolibertate.games.game.chess.ChessOptions
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsOptions
|
||||
import org.prolibertate.games.game.euchre.EuchreOptions
|
||||
import org.prolibertate.games.game.golf.GolfOptions
|
||||
@@ -54,7 +56,7 @@ fun GameSetupScreen(
|
||||
descriptor: GameDescriptor,
|
||||
playerName: String,
|
||||
onBack: () -> Unit,
|
||||
onPlayOffline: (TableConfig) -> Unit,
|
||||
onPlayOffline: (TableConfig, Int) -> Unit,
|
||||
onHostOnline: (String) -> Unit,
|
||||
) {
|
||||
var euchre by remember { mutableStateOf(EuchreOptions()) }
|
||||
@@ -64,6 +66,10 @@ fun GameSetupScreen(
|
||||
var kaiser by remember { mutableStateOf(KaiserOptions()) }
|
||||
var crazyEights by remember { mutableStateOf(CrazyEightsOptions()) }
|
||||
var wizard by remember { mutableStateOf(WizardOptions()) }
|
||||
var chess by remember { mutableStateOf(ChessOptions()) }
|
||||
// Not an engine option: which seat the local player takes. Seat 0 is
|
||||
// always White, so choosing Black means sitting in seat 1.
|
||||
var playWhite by remember { mutableStateOf(true) }
|
||||
|
||||
val optionsJson = when (descriptor.id) {
|
||||
GameCatalog.EUCHRE -> setupJson.encodeToString(euchre)
|
||||
@@ -73,6 +79,7 @@ fun GameSetupScreen(
|
||||
GameCatalog.KAISER -> setupJson.encodeToString(kaiser)
|
||||
GameCatalog.CRAZY_EIGHTS -> setupJson.encodeToString(crazyEights)
|
||||
GameCatalog.WIZARD -> setupJson.encodeToString(wizard)
|
||||
GameCatalog.CHESS -> setupJson.encodeToString(chess)
|
||||
else -> "{}"
|
||||
}
|
||||
val seatCount = seatCountFor(descriptor.id, optionsJson)
|
||||
@@ -98,6 +105,13 @@ fun GameSetupScreen(
|
||||
}
|
||||
|
||||
GameCatalog.WIZARD -> WizardOptionsEditor(wizard) { wizard = it }
|
||||
GameCatalog.CHESS -> ChessOptionsEditor(
|
||||
options = chess,
|
||||
playWhite = playWhite,
|
||||
onChange = { chess = it },
|
||||
onColour = { playWhite = it },
|
||||
)
|
||||
|
||||
else -> Text("No options yet for this game.")
|
||||
}
|
||||
|
||||
@@ -110,9 +124,13 @@ fun GameSetupScreen(
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
|
||||
// Chess is the only game so far where the local player picks a
|
||||
// seat rather than always taking the first one.
|
||||
val localSeat = if (descriptor.id == GameCatalog.CHESS && !playWhite) 1 else 0
|
||||
PrimaryAction(text = "Play against the computer") {
|
||||
onPlayOffline(
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName)
|
||||
offlineConfig(descriptor.id, optionsJson, seatCount, playerName, localSeat),
|
||||
localSeat,
|
||||
)
|
||||
}
|
||||
PrimaryAction(text = "Host a game for others to join") {
|
||||
@@ -122,19 +140,20 @@ fun GameSetupScreen(
|
||||
}
|
||||
}
|
||||
|
||||
/** Seat 0 is the local player; the rest start as AI. */
|
||||
/** [localSeat] is the person at this device; every other seat starts as AI. */
|
||||
private fun offlineConfig(
|
||||
gameId: String,
|
||||
optionsJson: String,
|
||||
seatCount: Int,
|
||||
playerName: String,
|
||||
localSeat: Int = 0,
|
||||
): TableConfig = TableConfig(
|
||||
gameId = gameId,
|
||||
seats = (0 until seatCount).map { seat ->
|
||||
PlayerSlot(
|
||||
seat = seat,
|
||||
name = if (seat == 0) playerName else "Computer $seat",
|
||||
kind = if (seat == 0) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||
name = if (seat == localSeat) playerName else "Computer $seat",
|
||||
kind = if (seat == localSeat) PlayerKind.HUMAN_LOCAL else PlayerKind.AI,
|
||||
team = teamForSeat(gameId, seat),
|
||||
)
|
||||
},
|
||||
@@ -170,6 +189,7 @@ fun seatCountFor(gameId: String, optionsJson: String): Int {
|
||||
GameCatalog.WIZARD ->
|
||||
setupJson.decodeFromString<WizardOptions>(optionsJson).playerCount
|
||||
|
||||
GameCatalog.CHESS -> 2
|
||||
else -> fallback
|
||||
}
|
||||
}.getOrDefault(fallback)
|
||||
@@ -456,6 +476,48 @@ private fun WizardOptionsEditor(options: WizardOptions, onChange: (WizardOptions
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChessOptionsEditor(
|
||||
options: ChessOptions,
|
||||
playWhite: Boolean,
|
||||
onChange: (ChessOptions) -> Unit,
|
||||
onColour: (Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ChipRow(
|
||||
label = "You play",
|
||||
values = listOf(true, false),
|
||||
selected = playWhite,
|
||||
display = { if (it) "White" else "Black" },
|
||||
onSelect = onColour,
|
||||
)
|
||||
ChipRow(
|
||||
label = "Opponent",
|
||||
values = ChessLevel.entries.toList(),
|
||||
selected = options.level,
|
||||
display = { it.label },
|
||||
onSelect = { onChange(options.copy(level = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Fifty-move rule",
|
||||
subtitle = "Fifty moves each with no capture and no pawn move is a draw",
|
||||
checked = options.fiftyMoveRule,
|
||||
onChange = { onChange(options.copy(fiftyMoveRule = it)) },
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Threefold repetition",
|
||||
subtitle = "The same position three times over is a draw",
|
||||
checked = options.threefoldRepetition,
|
||||
onChange = { onChange(options.copy(threefoldRepetition = it)) },
|
||||
)
|
||||
Text(
|
||||
text = "Full rules: castling, en passant, promotion, stalemate, and a draw " +
|
||||
"when neither side has enough material to mate.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun <T> ChipRow(
|
||||
|
||||
@@ -7,6 +7,10 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import org.prolibertate.games.game.GameCatalog
|
||||
import org.prolibertate.games.game.chess.ChessAi
|
||||
import org.prolibertate.games.game.chess.ChessMove
|
||||
import org.prolibertate.games.game.chess.ChessRules
|
||||
import org.prolibertate.games.game.chess.ChessState
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsAi
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsMove
|
||||
import org.prolibertate.games.game.crazyeights.CrazyEightsPhase
|
||||
@@ -43,6 +47,7 @@ import org.prolibertate.games.game.wizard.WizardRules
|
||||
import org.prolibertate.games.game.wizard.WizardState
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.settings.Settings
|
||||
import org.prolibertate.games.ui.game.ChessScreen
|
||||
import org.prolibertate.games.ui.game.CrazyEightsScreen
|
||||
import org.prolibertate.games.ui.game.EuchreScreen
|
||||
import org.prolibertate.games.ui.game.GolfScreen
|
||||
@@ -273,6 +278,27 @@ fun PlayScreen(
|
||||
CrazyEightsScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
GameCatalog.CHESS -> {
|
||||
val controller = remember(route) {
|
||||
MatchController<ChessState, ChessMove>(
|
||||
rules = ChessRules,
|
||||
// Strength comes from the table's own options, so the setup
|
||||
// screen sets it without this having to decode anything.
|
||||
ai = ChessAi(),
|
||||
config = route.config,
|
||||
scope = scope,
|
||||
role = role,
|
||||
localSeats = setOf(route.localSeat),
|
||||
primarySeat = route.localSeat,
|
||||
// The search is the thinking time; adding more on top of it
|
||||
// would only make the computer look slower than it is.
|
||||
aiThinkingMillis = { settings.scaled(250L) },
|
||||
)
|
||||
}
|
||||
StartMatch(env, controller, route)
|
||||
ChessScreen(controller = controller, localSeat = route.localSeat, onExit = onExit)
|
||||
}
|
||||
|
||||
else -> Text("That game isn't playable yet.")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
package org.prolibertate.games.ui.game
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import org.prolibertate.games.game.chess.ChessMove
|
||||
import org.prolibertate.games.game.chess.ChessPhase
|
||||
import org.prolibertate.games.game.chess.ChessRules
|
||||
import org.prolibertate.games.game.chess.ChessState
|
||||
import org.prolibertate.games.game.chess.Piece
|
||||
import org.prolibertate.games.game.chess.PieceKind
|
||||
import org.prolibertate.games.game.chess.fileOf
|
||||
import org.prolibertate.games.game.chess.isLightSquare
|
||||
import org.prolibertate.games.game.chess.rankOf
|
||||
import org.prolibertate.games.game.chess.seatIsWhite
|
||||
import org.prolibertate.games.game.chess.squareName
|
||||
import org.prolibertate.games.net.MatchController
|
||||
import org.prolibertate.games.ui.ScreenScaffold
|
||||
|
||||
/** Board colours, kept off the theme so the board reads the same in either mode. */
|
||||
private val LightSquare = Color(0xFFEEDAB4)
|
||||
private val DarkSquare = Color(0xFF9A7248)
|
||||
private val SelectedSquare = Color(0x8830C030)
|
||||
private val LastMoveSquare = Color(0x55FFC107)
|
||||
private val CheckSquare = Color(0x99D32F2F)
|
||||
private val MoveDot = Color(0x66000000)
|
||||
|
||||
/**
|
||||
* The chess board.
|
||||
*
|
||||
* Tap a piece, then tap where it goes. The board is always drawn from the local
|
||||
* player's side, so a game against the computer as Black is played the way it
|
||||
* would be over a real board rather than upside down.
|
||||
*/
|
||||
@Composable
|
||||
fun ChessScreen(
|
||||
controller: MatchController<ChessState, ChessMove>,
|
||||
localSeat: Int,
|
||||
onExit: () -> Unit,
|
||||
) {
|
||||
val state by controller.state.collectAsState()
|
||||
val legal by controller.legalMoves.collectAsState()
|
||||
|
||||
var selected by remember { mutableStateOf<Int?>(null) }
|
||||
// A pawn reaching the last rank is one tap that stands for four moves, so
|
||||
// the destination is held while the piece is chosen.
|
||||
var promotionTo by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
val current = state
|
||||
if (current == null) {
|
||||
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
|
||||
Box(modifier, contentAlignment = Alignment.Center) { Text("Setting up…") }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val flipped = !seatIsWhite(localSeat)
|
||||
val fromSelected = selected?.let { from -> legal.filter { it.from == from } }.orEmpty()
|
||||
|
||||
val onSquareTapped: (Int) -> Unit = { square ->
|
||||
val destinations = fromSelected.filter { it.to == square }
|
||||
when {
|
||||
destinations.any { it.promotion != null } -> promotionTo = square
|
||||
destinations.isNotEmpty() -> {
|
||||
controller.submit(destinations.first())
|
||||
selected = null
|
||||
}
|
||||
// Tapping another of your own pieces switches the selection rather
|
||||
// than clearing it, which is what a player expects after a misclick.
|
||||
legal.any { it.from == square } -> selected = square
|
||||
else -> selected = null
|
||||
}
|
||||
}
|
||||
|
||||
ScreenScaffold(title = "Chess", onBack = onExit) { modifier ->
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val wide = maxWidth > maxHeight
|
||||
val boardSide = if (wide) {
|
||||
minOf(maxHeight, maxWidth * 0.62f)
|
||||
} else {
|
||||
minOf(maxWidth, maxHeight * 0.72f)
|
||||
}
|
||||
|
||||
val board: @Composable () -> Unit = {
|
||||
Board(
|
||||
state = current,
|
||||
legalFromSelected = fromSelected,
|
||||
selected = selected,
|
||||
flipped = flipped,
|
||||
side = boardSide,
|
||||
onTap = onSquareTapped,
|
||||
)
|
||||
}
|
||||
val panel: @Composable (Modifier) -> Unit = { panelModifier ->
|
||||
Column(
|
||||
modifier = panelModifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
StatusLine(current, localSeat)
|
||||
MoveList(current, modifier = Modifier.weight(1f, fill = false))
|
||||
}
|
||||
}
|
||||
|
||||
if (wide) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.weight(1f).fillMaxSize())
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
board()
|
||||
panel(Modifier.fillMaxWidth().weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
promotionTo?.let { destination ->
|
||||
PromotionDialog(
|
||||
white = current.whiteToMove,
|
||||
choices = fromSelected.filter { it.to == destination },
|
||||
onPick = {
|
||||
controller.submit(it)
|
||||
promotionTo = null
|
||||
selected = null
|
||||
},
|
||||
onDismiss = { promotionTo = null },
|
||||
)
|
||||
}
|
||||
|
||||
if (current.phase == ChessPhase.GAME_OVER) {
|
||||
ResultDialog(current, onExit)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Board(
|
||||
state: ChessState,
|
||||
legalFromSelected: List<ChessMove>,
|
||||
selected: Int?,
|
||||
flipped: Boolean,
|
||||
side: Dp,
|
||||
onTap: (Int) -> Unit,
|
||||
) {
|
||||
val squareSide = side / 8
|
||||
val destinations = legalFromSelected.map { it.to }.toSet()
|
||||
val checkedKing = if (ChessRules.inCheck(state)) state.kingSquare(state.whiteToMove) else null
|
||||
|
||||
Column(modifier = Modifier.size(side).border(2.dp, DarkSquare)) {
|
||||
for (row in 0 until 8) {
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
for (column in 0 until 8) {
|
||||
val display = row * 8 + column
|
||||
// Turning the board round is one subtraction, so the rest of
|
||||
// the screen never has to think about which side is which.
|
||||
val square = if (flipped) 63 - display else display
|
||||
Square(
|
||||
state = state,
|
||||
square = square,
|
||||
squareSide = squareSide,
|
||||
selected = square == selected,
|
||||
isDestination = square in destinations,
|
||||
inCheck = square == checkedKing,
|
||||
modifier = Modifier.weight(1f).fillMaxSize(),
|
||||
onTap = { onTap(square) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Square(
|
||||
state: ChessState,
|
||||
square: Int,
|
||||
squareSide: Dp,
|
||||
selected: Boolean,
|
||||
isDestination: Boolean,
|
||||
inCheck: Boolean,
|
||||
modifier: Modifier,
|
||||
onTap: () -> Unit,
|
||||
) {
|
||||
val piece = state.board[square]
|
||||
val touchedByLastMove =
|
||||
state.lastMove?.let { square == it.from || square == it.to } == true
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(if (isLightSquare(square)) LightSquare else DarkSquare)
|
||||
.then(if (touchedByLastMove) Modifier.background(LastMoveSquare) else Modifier)
|
||||
.then(if (inCheck) Modifier.background(CheckSquare) else Modifier)
|
||||
.then(if (selected) Modifier.background(SelectedSquare) else Modifier)
|
||||
.clickable { onTap() },
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (piece != null) {
|
||||
Text(
|
||||
text = piece.glyph,
|
||||
fontSize = (squareSide.value * 0.78f).sp,
|
||||
// The glyphs carry their own colour, but an outline colour keeps
|
||||
// white pieces visible on the light squares.
|
||||
color = if (piece.white) Color(0xFFFFFFFF) else Color(0xFF101010),
|
||||
modifier = Modifier.padding(bottom = squareSide * 0.08f),
|
||||
)
|
||||
}
|
||||
if (isDestination) {
|
||||
// A dot for an empty square, a ring for something to take.
|
||||
if (piece == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(squareSide * 0.28f)
|
||||
.clip(CircleShape)
|
||||
.background(MoveDot)
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(squareSide * 0.04f)
|
||||
.border(squareSide * 0.08f, MoveDot, CircleShape)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Coordinates on the outer files and ranks, as a printed board has.
|
||||
if (fileOf(square) == 0) {
|
||||
Text(
|
||||
text = "${rankOf(square) + 1}",
|
||||
fontSize = (squareSide.value * 0.2f).sp,
|
||||
color = if (isLightSquare(square)) DarkSquare else LightSquare,
|
||||
modifier = Modifier.align(Alignment.TopStart).padding(1.dp),
|
||||
)
|
||||
}
|
||||
if (rankOf(square) == 0) {
|
||||
Text(
|
||||
text = "${'a' + fileOf(square)}",
|
||||
fontSize = (squareSide.value * 0.2f).sp,
|
||||
color = if (isLightSquare(square)) DarkSquare else LightSquare,
|
||||
modifier = Modifier.align(Alignment.BottomEnd).padding(1.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusLine(state: ChessState, localSeat: Int) {
|
||||
val yourMove = state.turnSeat == localSeat && state.phase == ChessPhase.PLAYING
|
||||
Column {
|
||||
Text(
|
||||
text = state.outcome?.label ?: when {
|
||||
yourMove && ChessRules.inCheck(state) -> "You are in check"
|
||||
yourMove -> "Your move"
|
||||
ChessRules.inCheck(state) -> "Check"
|
||||
else -> "Thinking…"
|
||||
},
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "You play ${if (seatIsWhite(localSeat)) "White" else "Black"} · " +
|
||||
"move ${state.fullmoveNumber}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The game score, two half-moves to a line, most recent at the bottom. */
|
||||
@Composable
|
||||
private fun MoveList(state: ChessState, modifier: Modifier = Modifier) {
|
||||
if (state.moveLog.isEmpty()) {
|
||||
Text("No moves yet.", style = MaterialTheme.typography.bodySmall, modifier = modifier)
|
||||
return
|
||||
}
|
||||
Column(modifier = modifier.verticalScroll(rememberScrollState())) {
|
||||
state.moveLog.chunked(2).forEachIndexed { index, pair ->
|
||||
Text(
|
||||
text = "${index + 1}. ${pair.joinToString(" ")}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PromotionDialog(
|
||||
white: Boolean,
|
||||
choices: List<ChessMove>,
|
||||
onPick: (ChessMove) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val order = listOf(PieceKind.QUEEN, PieceKind.ROOK, PieceKind.BISHOP, PieceKind.KNIGHT)
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Promote to") },
|
||||
text = {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
order.forEach { kind ->
|
||||
val move = choices.firstOrNull { it.promotion == kind } ?: return@forEach
|
||||
Button(onClick = { onPick(move) }, modifier = Modifier.width(64.dp)) {
|
||||
Text(
|
||||
text = Piece(kind, white).glyph,
|
||||
fontSize = 24.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ResultDialog(state: ChessState, onExit: () -> Unit) {
|
||||
var dismissed by remember(state.outcome) { mutableStateOf(false) }
|
||||
if (dismissed) return
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { dismissed = true },
|
||||
title = { Text("Game over") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(state.outcome?.label.orEmpty(), fontWeight = FontWeight.Bold)
|
||||
state.lastMove?.let {
|
||||
Text(
|
||||
text = "Last move ${state.moveLog.lastOrNull() ?: squareName(it.to)}",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = "${state.moveLog.size} half-moves played.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = { TextButton(onClick = onExit) { Text("Back to the menu") } },
|
||||
dismissButton = { TextButton(onClick = { dismissed = true }) { Text("Look at the board") } },
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user