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") } },
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user