diff --git a/README.md b/README.md index 5cf4362..db4866a 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,12 @@ or against other people over Wi-Fi (LAN) or Bluetooth. ## What works today -The app shell is complete and seven games are playable end to end: +The app shell is complete and eight games are playable end to end: | | | | --- | --- | -| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence | -| **Listed, not yet implemented** | Mastermind, Backgammon, Chess, Checkers | +| **Playable** | Euchre, Kaiser, President, Golf, Wizard, Crazy 8s, Sequence, Chess | +| **Listed, not yet implemented** | Mastermind, Backgammon, Checkers | Unimplemented games appear in the menu marked *Coming soon* **on dev builds only** — a production release lists just the games that can actually be played, @@ -153,6 +153,7 @@ game: - [Golf](docs/RULES-golf.md) - [Wizard](docs/RULES-wizard.md) - [Crazy 8s](docs/RULES-crazy8s.md) +- [Chess](docs/RULES-chess.md) ## Layout @@ -167,6 +168,7 @@ game/ rules engines and AI — pure Kotlin, no Android, unit-tested golf/ Golf model, scoring, rules, AI wizard/ Wizard model, rules, AI crazyeights/ Crazy 8s model, rules, AI + chess/ Chess model, FEN, move generation, search net/ wire protocol, LAN and Bluetooth transports, lobby, match driver settings/ DataStore-backed preferences update/ GitHub Releases OTA updater diff --git a/app/src/main/java/org/prolibertate/games/game/Catalog.kt b/app/src/main/java/org/prolibertate/games/game/Catalog.kt index f942acf..5a09f32 100644 --- a/app/src/main/java/org/prolibertate/games/game/Catalog.kt +++ b/app/src/main/java/org/prolibertate/games/game/Catalog.kt @@ -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, diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt new file mode 100644 index 0000000..3b4cb04 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessAi.kt @@ -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 { + + /** + * 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 { + 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>() + // 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, + preferred: ChessMove?, + ): List = 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, + ) + } +} diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt new file mode 100644 index 0000000..425ce81 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessModel.kt @@ -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, + 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, + /** The game score in algebraic notation, one entry per half-move. */ + val moveLog: List, +) { + /** 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, + 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(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, + 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" diff --git a/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt b/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt new file mode 100644 index 0000000..35d3b88 --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/game/chess/ChessRules.kt @@ -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 { + + 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(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, 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, 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, + file: Int, + rank: Int, + byWhite: Boolean, + directions: List>, + 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, 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 { + if (currentSeat(state) != seat) return emptyList() + return legalMoves(state) + } + + /** Every move the side to move may actually play. */ + fun legalMoves(state: ChessState): List { + 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 { + val moves = mutableListOf() + 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, + ) { + 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>, + moves: MutableList, + ) { + 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>, + moves: MutableList, + ) { + 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, + ) { + 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, + 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): 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) +} diff --git a/app/src/main/java/org/prolibertate/games/net/MatchController.kt b/app/src/main/java/org/prolibertate/games/net/MatchController.kt index 0bd5fe9..7c4c045 100644 --- a/app/src/main/java/org/prolibertate/games/net/MatchController.kt +++ b/app/src/main/java/org/prolibertate/games/net/MatchController.kt @@ -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( 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 } } diff --git a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt index 8902894..b706ec3 100644 --- a/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt +++ b/app/src/main/java/org/prolibertate/games/ui/AppRoot.kt @@ -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)) }, diff --git a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt index 0443203..c2552de 100644 --- a/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/GameSetupScreen.kt @@ -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(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 ChipRow( diff --git a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt index c8771c0..9ab7e12 100644 --- a/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt +++ b/app/src/main/java/org/prolibertate/games/ui/PlayScreen.kt @@ -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( + 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.") } } diff --git a/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt new file mode 100644 index 0000000..72a302a --- /dev/null +++ b/app/src/main/java/org/prolibertate/games/ui/game/ChessScreen.kt @@ -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, + localSeat: Int, + onExit: () -> Unit, +) { + val state by controller.state.collectAsState() + val legal by controller.legalMoves.collectAsState() + + var selected by remember { mutableStateOf(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(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, + 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, + 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") } }, + ) +} diff --git a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt index b4c2ae7..e548ef0 100644 --- a/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt +++ b/app/src/test/java/org/prolibertate/games/game/CatalogTest.kt @@ -58,6 +58,7 @@ class CatalogTest { GameCatalog.KAISER, GameCatalog.CRAZY_EIGHTS, GameCatalog.WIZARD, + GameCatalog.CHESS, ), GameCatalog.playable.map { it.id }.toSet(), ) diff --git a/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt new file mode 100644 index 0000000..be86777 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/chess/ChessAiTest.kt @@ -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(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) + } + } +} diff --git a/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt b/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt new file mode 100644 index 0000000..42135c3 --- /dev/null +++ b/app/src/test/java/org/prolibertate/games/game/chess/ChessRulesTest.kt @@ -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>> = 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), + ) + } +} diff --git a/docs/RULES-chess.md b/docs/RULES-chess.md new file mode 100644 index 0000000..1e7abc6 --- /dev/null +++ b/docs/RULES-chess.md @@ -0,0 +1,84 @@ +# Chess — implemented rules + +The full game, not a subset. This is the ruleset the engine enforces. Anything +marked **toggle** is in the game setup screen. + +## Table + +Two players. Seat 0 is always White; choosing to play Black in setup puts you in +seat 1 and turns the board round so your pieces are still at the near edge. + +## Movement + +All of it, including the parts that are usually left out: + +- **Castling**, both sides, with every condition: the king and the rook must not + have moved, the squares between them must be empty, and the king may not + castle out of check, through an attacked square, or into check. The b-file + square may be attacked on the queen's side — the king does not cross it. +- **En passant**, on the move immediately after a pawn steps two squares and + only then. It is refused when taking would expose your own king, which is the + one case a naive implementation gets wrong. +- **Promotion** to queen, rook, bishop or knight, chosen when the pawn arrives. +- **Pins** — a piece that shields its own king may not step off the line. + +Rights are given up correctly: a rook that moves loses its castling right, and +so does a rook captured on its own corner. + +## How a game ends + +| Ending | When | +| --- | --- | +| Checkmate | No legal move, and in check | +| Stalemate | No legal move, and not in check | +| Insufficient material | Neither side has enough to mate, whatever either plays | +| Fifty-move rule | A hundred half-moves with no capture and no pawn move — **toggle** | +| Threefold repetition | The same position, side to move and rights, three times — **toggle** | + +Insufficient material covers bare kings, a king and one minor piece, and two +bishops on the same colour square. King and two knights is *not* included: it +cannot be forced, but it can happen with cooperation, so it is not a dead +position. + +Both draw rules apply automatically rather than needing a claim. Switch either +off in setup for a game that runs until somebody is mated. + +## Notation + +The move list is ordinary algebraic: `e4`, `Nf3`, `exd5`, `O-O`, `a8=Q+`, +`Ra8#`. Two pieces that can reach the same square are told apart by file, or by +rank when they share a file, or by the whole square when they share both. + +## Positions + +The engine reads and writes FEN, so a table can start from a set position rather +than the opening array — a puzzle, or an adjourned game. + +## What the computer opponent does + +A negamax search with alpha-beta pruning, iterative deepening, and a +capture-only quiescence tail. The quiescence tail is what stops it giving pieces +away: a search that simply stops after a fixed number of moves scores a position +in the middle of an exchange as though the recapture never comes. + +Evaluation is material plus piece placement, with a term in bare endgames that +drives the losing king towards the edge — without it a won endgame just shuffles. + +**Toggle** for three strengths: + +| Level | Search | Plays | +| --- | --- | --- | +| Casual | 2 ply | Takes any move within about two thirds of a pawn of the best one | +| Club | 3 ply | Nearly always the best move it can see | +| Strong | 4 ply | The best move it can see | + +Every level is capped by a node budget, so no single move can hang the app on a +sharp position — it plays the best move from the last depth it finished. + +## Testing + +Move generation is checked against the published **perft** counts: the exact +number of positions reachable at a given depth. The opening array is verified to +depth four (197,281 positions), along with four standard test positions chosen +because they break generators — castling through check, en passant that would +expose a king, promotion with capture, and heavily pinned pieces.