Add :games, the rules engines for games in chats
A plain JVM module set up like :engine: chess, checkers, reversi and tic-tac-toe as DESIGN.md section 2 describes them, with the same API as the iOS FlamenetGames package. Moves are strings in the wire notation and apply accepts exactly the strings legalMoves returns. Replay refuses a log over the game's cap before playing it and names the index of the first move it cannot play. The tests run games-v1.json, byte for byte the file the iOS package runs: the perft counts from DESIGN.md and 147 cases. Deeper published perft counts run with FLAMENET_GAMES_DEEP=1. Nothing in :app uses it yet. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
446323bc36
commit
597dd8042b
@@ -0,0 +1,40 @@
|
|||||||
|
plugins {
|
||||||
|
kotlin("jvm")
|
||||||
|
jacoco
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories { mavenCentral() }
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation(kotlin("test"))
|
||||||
|
// The vectors are JSON; the same parser `:engine` tests with.
|
||||||
|
testImplementation("org.json:json:20240303")
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
testLogging {
|
||||||
|
events("passed", "failed", "skipped")
|
||||||
|
showStandardStreams = true
|
||||||
|
}
|
||||||
|
// The deep perft counts run only when FLAMENET_GAMES_DEEP=1. Gradle does not track an
|
||||||
|
// environment variable by itself, so without this a cached pass would be replayed instead.
|
||||||
|
inputs.property("deepPerft", providers.environmentVariable("FLAMENET_GAMES_DEEP").orElse(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<Test>().configureEach { finalizedBy(tasks.named("jacocoTestReport")) }
|
||||||
|
tasks.named<JacocoReport>("jacocoTestReport") {
|
||||||
|
dependsOn(tasks.named("test"))
|
||||||
|
reports { xml.required.set(true); html.required.set(true) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An English draughts position on squares 1-32 in standard PDN numbering.
|
||||||
|
*
|
||||||
|
* Keeps the plies since the last capture or man move and the positions in that stretch, for the
|
||||||
|
* 80-ply draw and threefold repetition.
|
||||||
|
*/
|
||||||
|
class CheckersPosition internal constructor(
|
||||||
|
/** Index 0 is square 1. First's (dark) men are 1 and kings 2; second's are -1 and -2. */
|
||||||
|
internal val squares: IntArray,
|
||||||
|
override val sideToMove: Side,
|
||||||
|
internal val quietPlies: Int,
|
||||||
|
earlier: List<Key>,
|
||||||
|
) : GamePosition() {
|
||||||
|
internal class Key(
|
||||||
|
val squares: IntArray,
|
||||||
|
val side: Side,
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is Key && side == other.side && squares.contentEquals(other.squares)
|
||||||
|
|
||||||
|
override fun hashCode(): Int = squares.contentHashCode() * 31 + side.hashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [path] is the starting square, then every square the piece lands on (indexes, not numbers). */
|
||||||
|
internal data class Move(
|
||||||
|
val path: List<Int>,
|
||||||
|
val captures: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The positions since the last capture or man move, oldest first, ending with this one. */
|
||||||
|
internal val history: List<Key> = earlier + Key(squares, sideToMove)
|
||||||
|
|
||||||
|
override val type: GameType get() = GameType.CHECKERS
|
||||||
|
|
||||||
|
private val repetitions: Int
|
||||||
|
get() {
|
||||||
|
val current = history.last()
|
||||||
|
return history.count { it == current }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every move the rules allow, without the automatic draws: all capturing sequences when any
|
||||||
|
* capture exists (not only the longest), otherwise all steps.
|
||||||
|
*/
|
||||||
|
private fun movesByTheRules(): List<Move> {
|
||||||
|
val us = if (sideToMove == Side.FIRST) 1 else -1
|
||||||
|
val board = squares.copyOf()
|
||||||
|
val jumps = ArrayList<Move>()
|
||||||
|
for (from in 0 until 32) {
|
||||||
|
val piece = squares[from]
|
||||||
|
if (piece == 0 || (piece > 0) != (us > 0)) continue
|
||||||
|
collectJumps(board, from, piece, mutableListOf(from), jumps)
|
||||||
|
}
|
||||||
|
if (jumps.isNotEmpty()) return jumps
|
||||||
|
val steps = ArrayList<Move>()
|
||||||
|
for (from in 0 until 32) {
|
||||||
|
val piece = squares[from]
|
||||||
|
if (piece == 0 || (piece > 0) != (us > 0)) continue
|
||||||
|
for (heading in headings(piece)) {
|
||||||
|
val to = NEIGHBOUR[from][heading]
|
||||||
|
if (to >= 0 && squares[to] == 0) steps += Move(listOf(from, to), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return steps
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun outcome(): Outcome {
|
||||||
|
if (movesByTheRules().isEmpty()) return Outcome.Win(sideToMove.opponent, WinReason.NO_MOVES)
|
||||||
|
if (quietPlies >= 80) return Outcome.Draw(DrawReason.FORTY_MOVES)
|
||||||
|
if (repetitions >= 3) return Outcome.Draw(DrawReason.REPETITION)
|
||||||
|
return Outcome.Ongoing
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legalMoveList(): List<Move> =
|
||||||
|
if (quietPlies >= 80 || repetitions >= 3) emptyList() else movesByTheRules()
|
||||||
|
|
||||||
|
private fun notation(move: Move): String =
|
||||||
|
move.path.joinToString(if (move.captures) "x" else "-") { (it + 1).toString() }
|
||||||
|
|
||||||
|
private fun applying(move: Move): CheckersPosition {
|
||||||
|
val board = squares.copyOf()
|
||||||
|
val piece = board[move.path[0]]
|
||||||
|
board[move.path[0]] = 0
|
||||||
|
if (move.captures) {
|
||||||
|
for (i in 0 until move.path.size - 1) {
|
||||||
|
val from = move.path[i]
|
||||||
|
val to = move.path[i + 1]
|
||||||
|
val heading = (0 until 4).first { LANDING[from][it] == to }
|
||||||
|
board[NEIGHBOUR[from][heading]] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val end = move.path.last()
|
||||||
|
board[end] = if (crowns(piece, end)) piece * 2 else piece
|
||||||
|
val reset = move.captures || abs(piece) == 1
|
||||||
|
return CheckersPosition(
|
||||||
|
board,
|
||||||
|
sideToMove.opponent,
|
||||||
|
if (reset) 0 else quietPlies + 1,
|
||||||
|
if (reset) emptyList() else history,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalNotations(): List<String> = legalMoveList().map(::notation)
|
||||||
|
|
||||||
|
override fun applyNotation(text: String): MoveResult =
|
||||||
|
applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying)
|
||||||
|
|
||||||
|
override fun perft(depth: Int): Long =
|
||||||
|
perftOf(this, depth, CheckersPosition::legalMoveList, CheckersPosition::applying)
|
||||||
|
|
||||||
|
override val board: Board
|
||||||
|
get() {
|
||||||
|
val cells = ArrayList<Piece?>(64)
|
||||||
|
val names = ArrayList<String?>(64)
|
||||||
|
for (row in 0 until 8) {
|
||||||
|
for (column in 0 until 8) {
|
||||||
|
// The first player sees the standard diagram turned round, own men at the bottom.
|
||||||
|
val square = index(7 - row, 7 - column)
|
||||||
|
if (square == null) {
|
||||||
|
cells += null
|
||||||
|
names += null
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val piece = squares[square]
|
||||||
|
cells +=
|
||||||
|
if (piece == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Piece(
|
||||||
|
if (piece > 0) Side.FIRST else Side.SECOND,
|
||||||
|
if (abs(piece) == 2) PieceKind.KING else PieceKind.MAN,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
names += (square + 1).toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Board(8, 8, cells, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PDN FEN with the second (W) list first, as in `B:W21,...,32:B1,...,12`. */
|
||||||
|
override val setup: String
|
||||||
|
get() {
|
||||||
|
fun list(sign: Int): String =
|
||||||
|
(0 until 32)
|
||||||
|
.filter { squares[it] != 0 && (squares[it] > 0) == (sign > 0) }
|
||||||
|
.joinToString(",") { (if (abs(squares[it]) == 2) "K" else "") + (it + 1) }
|
||||||
|
return "${if (sideToMove == Side.FIRST) "B" else "W"}:W${list(-1)}:B${list(1)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is CheckersPosition && quietPlies == other.quietPlies && history == other.history
|
||||||
|
|
||||||
|
override fun hashCode(): Int = history.hashCode() * 31 + quietPlies
|
||||||
|
|
||||||
|
override fun toString(): String = "CheckersPosition($setup)"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** (row, column) steps. Headings 0 and 1 lead toward row 7, where first's men are crowned. */
|
||||||
|
private val STEPS =
|
||||||
|
arrayOf(intArrayOf(1, -1), intArrayOf(1, 1), intArrayOf(-1, -1), intArrayOf(-1, 1))
|
||||||
|
private val FIRST_MAN = intArrayOf(0, 1)
|
||||||
|
private val SECOND_MAN = intArrayOf(2, 3)
|
||||||
|
private val ANY_WAY = intArrayOf(0, 1, 2, 3)
|
||||||
|
|
||||||
|
/** Rows as in the standard diagram: row 0 holds squares 1-4, row 7 holds 29-32. */
|
||||||
|
private fun row(index: Int): Int = index / 4
|
||||||
|
|
||||||
|
private fun column(index: Int): Int =
|
||||||
|
if (row(index) % 2 == 0) 2 * (index % 4) + 1 else 2 * (index % 4)
|
||||||
|
|
||||||
|
private fun index(
|
||||||
|
row: Int,
|
||||||
|
column: Int,
|
||||||
|
): Int? =
|
||||||
|
if (row in 0..7 && column in 0..7 && (row + column) % 2 == 1) row * 4 + column / 2 else null
|
||||||
|
|
||||||
|
/** The square one step away in each heading, or -1 off the board. */
|
||||||
|
private val NEIGHBOUR: Array<IntArray> =
|
||||||
|
Array(32) { i ->
|
||||||
|
IntArray(4) { h -> index(row(i) + STEPS[h][0], column(i) + STEPS[h][1]) ?: -1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a jump in each heading lands, or -1 off the board. */
|
||||||
|
private val LANDING: Array<IntArray> =
|
||||||
|
Array(32) { i ->
|
||||||
|
IntArray(4) { h ->
|
||||||
|
val next = NEIGHBOUR[i][h]
|
||||||
|
if (next < 0) -1 else NEIGHBOUR[next][h]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun headings(piece: Int): IntArray =
|
||||||
|
when (piece) {
|
||||||
|
1 -> FIRST_MAN
|
||||||
|
-1 -> SECOND_MAN
|
||||||
|
else -> ANY_WAY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a man that ends on [index] is crowned there. */
|
||||||
|
private fun crowns(
|
||||||
|
piece: Int,
|
||||||
|
index: Int,
|
||||||
|
): Boolean = (piece == 1 && row(index) == 7) || (piece == -1 && row(index) == 0)
|
||||||
|
|
||||||
|
/** A square number 1-32 written without leading zeros. */
|
||||||
|
private fun number(text: String): Int? {
|
||||||
|
if (text.length !in 1..2 || text[0] == '0' || text.any { it !in '0'..'9' }) return null
|
||||||
|
return text.toInt().takeIf { it in 1..32 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `11-15` for a step, `15x24` or `15x24x31` for captures, squares 1-32 without leading zeros.
|
||||||
|
*/
|
||||||
|
internal fun isWellFormed(text: String): Boolean {
|
||||||
|
val step = '-' in text
|
||||||
|
if (step == ('x' in text)) return false
|
||||||
|
val parts = text.split(if (step) '-' else 'x')
|
||||||
|
if (if (step) parts.size != 2 else parts.size < 2) return false
|
||||||
|
return parts.all { number(it) != null }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds every complete capturing sequence for the piece on [at]. A sequence goes on while
|
||||||
|
* the piece can jump again, and stops where a man is crowned. Captured pieces come off as
|
||||||
|
* they are jumped; with one-square kings that is the same as lifting them at the end.
|
||||||
|
*/
|
||||||
|
private fun collectJumps(
|
||||||
|
board: IntArray,
|
||||||
|
at: Int,
|
||||||
|
piece: Int,
|
||||||
|
path: MutableList<Int>,
|
||||||
|
found: MutableList<Move>,
|
||||||
|
) {
|
||||||
|
var jumped = false
|
||||||
|
for (heading in headings(piece)) {
|
||||||
|
val over = NEIGHBOUR[at][heading]
|
||||||
|
val to = LANDING[at][heading]
|
||||||
|
if (to < 0 || board[to] != 0) continue
|
||||||
|
if (board[over] == 0 || (board[over] > 0) == (piece > 0)) continue
|
||||||
|
jumped = true
|
||||||
|
val taken = board[over]
|
||||||
|
board[over] = 0
|
||||||
|
board[at] = 0
|
||||||
|
board[to] = piece
|
||||||
|
path += to
|
||||||
|
if (crowns(piece, to)) {
|
||||||
|
found += Move(path.toList(), true)
|
||||||
|
} else {
|
||||||
|
collectJumps(board, to, piece, path, found)
|
||||||
|
}
|
||||||
|
path.removeAt(path.size - 1)
|
||||||
|
board[to] = 0
|
||||||
|
board[at] = piece
|
||||||
|
board[over] = taken
|
||||||
|
}
|
||||||
|
if (!jumped && path.size > 1) found += Move(path.toList(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads PDN FEN: the side to move (B first, W second), then both piece lists in either
|
||||||
|
* order, K marking a king. A man on the row where it would have been crowned is refused.
|
||||||
|
*/
|
||||||
|
internal fun fromSetup(setup: String): CheckersPosition? {
|
||||||
|
val fields = setup.split(':')
|
||||||
|
if (fields.size != 3) return null
|
||||||
|
val side =
|
||||||
|
when (fields[0]) {
|
||||||
|
"B" -> Side.FIRST
|
||||||
|
"W" -> Side.SECOND
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val board = IntArray(32)
|
||||||
|
val listed = HashSet<Char>()
|
||||||
|
for (field in fields.subList(1, 3)) {
|
||||||
|
val colour = field.firstOrNull() ?: return null
|
||||||
|
if ((colour != 'B' && colour != 'W') || !listed.add(colour)) return null
|
||||||
|
val sign = if (colour == 'B') 1 else -1
|
||||||
|
val items = field.substring(1)
|
||||||
|
if (items.isEmpty()) continue
|
||||||
|
for (item in items.split(',')) {
|
||||||
|
val crowned = item.startsWith("K")
|
||||||
|
val number = number(if (crowned) item.substring(1) else item) ?: return null
|
||||||
|
if (board[number - 1] != 0) return null
|
||||||
|
val piece = if (crowned) sign * 2 else sign
|
||||||
|
if (crowns(piece, number - 1)) return null
|
||||||
|
board[number - 1] = piece
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return CheckersPosition(board, side, 0, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val START: CheckersPosition =
|
||||||
|
fromSetup("B:W21,22,23,24,25,26,27,28,29,30,31,32:B1,2,3,4,5,6,7,8,9,10,11,12")!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,616 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A chess position.
|
||||||
|
*
|
||||||
|
* Besides the placement it keeps what the rules need: castling rights, the en passant square
|
||||||
|
* (only while an en passant capture is legal, which is also what repetition compares), the
|
||||||
|
* clocks, and the positions since the last capture or pawn move, for threefold repetition.
|
||||||
|
*/
|
||||||
|
class ChessPosition internal constructor(
|
||||||
|
/** Square 0 is a1, 7 is h1, 63 is h8. First's (white) pieces are positive, second's negative. */
|
||||||
|
internal val squares: IntArray,
|
||||||
|
override val sideToMove: Side,
|
||||||
|
internal val castling: Int,
|
||||||
|
/** -1 when no en passant capture is legal. */
|
||||||
|
internal val enPassant: Int,
|
||||||
|
internal val halfmoveClock: Int,
|
||||||
|
internal val fullmoveNumber: Int,
|
||||||
|
earlier: List<Key>,
|
||||||
|
) : GamePosition() {
|
||||||
|
/** What makes two positions the same for repetition. */
|
||||||
|
internal class Key(
|
||||||
|
val squares: IntArray,
|
||||||
|
val side: Side,
|
||||||
|
val castling: Int,
|
||||||
|
val enPassant: Int,
|
||||||
|
) {
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is Key && side == other.side && castling == other.castling &&
|
||||||
|
enPassant == other.enPassant && squares.contentEquals(other.squares)
|
||||||
|
|
||||||
|
override fun hashCode(): Int =
|
||||||
|
((squares.contentHashCode() * 31 + side.hashCode()) * 31 + castling) * 31 + enPassant
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [promotion] is 0, or the code of the piece a pawn becomes. */
|
||||||
|
internal data class Move(
|
||||||
|
val from: Int,
|
||||||
|
val to: Int,
|
||||||
|
val promotion: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The positions since the last capture or pawn move, oldest first, ending with this one. */
|
||||||
|
internal val history: List<Key> = earlier + Key(squares, sideToMove, castling, enPassant)
|
||||||
|
|
||||||
|
override val type: GameType get() = GameType.CHESS
|
||||||
|
|
||||||
|
private val sign: Int get() = if (sideToMove == Side.FIRST) 1 else -1
|
||||||
|
|
||||||
|
/** Whether the side to move is in check. */
|
||||||
|
val inCheck: Boolean get() = isAttacked(squares, kingSquare(sign), -sign)
|
||||||
|
|
||||||
|
// Every position has exactly one king a side: setup refuses anything else.
|
||||||
|
private fun kingSquare(sign: Int): Int = squares.indexOf(sign * KING)
|
||||||
|
|
||||||
|
private val repetitions: Int
|
||||||
|
get() {
|
||||||
|
val current = history.last()
|
||||||
|
return history.count { it == current }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** K v K, K+B v K, K+N v K, and K+B v K+B with both bishops on one colour. Nothing else. */
|
||||||
|
private val insufficientMaterial: Boolean
|
||||||
|
get() {
|
||||||
|
val pieces = ArrayList<Int>(2)
|
||||||
|
val places = ArrayList<Int>(2)
|
||||||
|
for (square in 0 until 64) {
|
||||||
|
val piece = squares[square]
|
||||||
|
if (piece == 0 || abs(piece) == KING) continue
|
||||||
|
pieces += piece
|
||||||
|
places += square
|
||||||
|
if (pieces.size > 2) return false
|
||||||
|
}
|
||||||
|
return when (pieces.size) {
|
||||||
|
0 -> true
|
||||||
|
1 -> abs(pieces[0]) == BISHOP || abs(pieces[0]) == KNIGHT
|
||||||
|
else ->
|
||||||
|
abs(pieces[0]) == BISHOP && abs(pieces[1]) == BISHOP &&
|
||||||
|
(pieces[0] > 0) != (pieces[1] > 0) && colour(places[0]) == colour(places[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val drawnAutomatically: Boolean
|
||||||
|
get() = insufficientMaterial || halfmoveClock >= 100 || repetitions >= 3
|
||||||
|
|
||||||
|
/** Every move the rules of movement allow, without the automatic draws. */
|
||||||
|
private fun movesByTheRules(): List<Move> {
|
||||||
|
val us = sign
|
||||||
|
val king = kingSquare(us)
|
||||||
|
val board = squares.copyOf()
|
||||||
|
val legal = ArrayList<Move>()
|
||||||
|
for (move in pseudoLegalMoves()) {
|
||||||
|
val moving = board[move.from]
|
||||||
|
val taken = board[move.to]
|
||||||
|
board[move.to] = if (move.promotion != 0) us * move.promotion else moving
|
||||||
|
board[move.from] = 0
|
||||||
|
var victim = -1
|
||||||
|
if (abs(moving) == PAWN && move.to == enPassant && taken == 0 &&
|
||||||
|
move.to % 8 != move.from % 8
|
||||||
|
) {
|
||||||
|
victim = move.to - 8 * us
|
||||||
|
board[victim] = 0
|
||||||
|
}
|
||||||
|
val guarded = if (abs(moving) == KING) move.to else king
|
||||||
|
if (!isAttacked(board, guarded, -us)) legal += move
|
||||||
|
board[move.from] = moving
|
||||||
|
board[move.to] = taken
|
||||||
|
if (victim >= 0) board[victim] = -us * PAWN
|
||||||
|
}
|
||||||
|
return legal
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Moves by the pieces' rules of movement, before checking the mover's king is safe. */
|
||||||
|
private fun pseudoLegalMoves(): List<Move> {
|
||||||
|
val moves = ArrayList<Move>(64)
|
||||||
|
for (from in 0 until 64) {
|
||||||
|
val piece = squares[from]
|
||||||
|
if (piece == 0 || (piece > 0) != (sign > 0)) continue
|
||||||
|
when (abs(piece)) {
|
||||||
|
PAWN -> addPawnMoves(moves, from)
|
||||||
|
KNIGHT -> addSteps(moves, from, KNIGHT_STEPS)
|
||||||
|
BISHOP -> addSlides(moves, from, DIAGONAL)
|
||||||
|
ROOK -> addSlides(moves, from, STRAIGHT)
|
||||||
|
QUEEN -> {
|
||||||
|
addSlides(moves, from, STRAIGHT)
|
||||||
|
addSlides(moves, from, DIAGONAL)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
addSteps(moves, from, KING_STEPS)
|
||||||
|
addCastling(moves, from)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return moves
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addPawnMoves(
|
||||||
|
moves: MutableList<Move>,
|
||||||
|
from: Int,
|
||||||
|
) {
|
||||||
|
val us = sign
|
||||||
|
val file = from % 8
|
||||||
|
val rank = from / 8
|
||||||
|
// A pawn never stands on its last rank, so the rank ahead is always on the board.
|
||||||
|
val ahead = rank + us
|
||||||
|
val promotes = ahead == (if (us > 0) 7 else 0)
|
||||||
|
|
||||||
|
fun add(to: Int) {
|
||||||
|
if (promotes) {
|
||||||
|
for (piece in PROMOTIONS) moves += Move(from, to, piece)
|
||||||
|
} else {
|
||||||
|
moves += Move(from, to, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val one = ahead * 8 + file
|
||||||
|
if (squares[one] == 0) {
|
||||||
|
add(one)
|
||||||
|
val two = one + 8 * us
|
||||||
|
if (rank == (if (us > 0) 1 else 6) && squares[two] == 0) moves += Move(from, two, 0)
|
||||||
|
}
|
||||||
|
for (step in intArrayOf(-1, 1)) {
|
||||||
|
val target = file + step
|
||||||
|
if (target !in 0..7) continue
|
||||||
|
val to = ahead * 8 + target
|
||||||
|
val there = squares[to]
|
||||||
|
if (there != 0 && (there > 0) != (us > 0)) {
|
||||||
|
add(to)
|
||||||
|
} else if (there == 0 && to == enPassant) {
|
||||||
|
moves += Move(from, to, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSteps(
|
||||||
|
moves: MutableList<Move>,
|
||||||
|
from: Int,
|
||||||
|
steps: Array<IntArray>,
|
||||||
|
) {
|
||||||
|
val file = from % 8
|
||||||
|
val rank = from / 8
|
||||||
|
for (step in steps) {
|
||||||
|
val f = file + step[0]
|
||||||
|
val r = rank + step[1]
|
||||||
|
if (f !in 0..7 || r !in 0..7) continue
|
||||||
|
val there = squares[r * 8 + f]
|
||||||
|
if (there == 0 || (there > 0) != (sign > 0)) moves += Move(from, r * 8 + f, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSlides(
|
||||||
|
moves: MutableList<Move>,
|
||||||
|
from: Int,
|
||||||
|
directions: Array<IntArray>,
|
||||||
|
) {
|
||||||
|
val file = from % 8
|
||||||
|
val rank = from / 8
|
||||||
|
for (direction in directions) {
|
||||||
|
var f = file + direction[0]
|
||||||
|
var r = rank + direction[1]
|
||||||
|
while (f in 0..7 && r in 0..7) {
|
||||||
|
val there = squares[r * 8 + f]
|
||||||
|
if (there == 0 || (there > 0) != (sign > 0)) moves += Move(from, r * 8 + f, 0)
|
||||||
|
if (there != 0) break
|
||||||
|
f += direction[0]
|
||||||
|
r += direction[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addCastling(
|
||||||
|
moves: MutableList<Move>,
|
||||||
|
home: Int,
|
||||||
|
) {
|
||||||
|
val us = sign
|
||||||
|
if (home != (if (us > 0) 4 else 60)) return
|
||||||
|
val kingside = if (us > 0) WHITE_KINGSIDE else BLACK_KINGSIDE
|
||||||
|
val queenside = if (us > 0) WHITE_QUEENSIDE else BLACK_QUEENSIDE
|
||||||
|
val rook = us * ROOK
|
||||||
|
|
||||||
|
fun attacked(square: Int) = isAttacked(squares, square, -us)
|
||||||
|
// Not out of, through or into check. On the queen's side b1 (b8) only has to be empty.
|
||||||
|
if ((castling and kingside) != 0 && squares[home + 1] == 0 && squares[home + 2] == 0 &&
|
||||||
|
squares[home + 3] == rook && !attacked(home) && !attacked(home + 1) &&
|
||||||
|
!attacked(home + 2)
|
||||||
|
) {
|
||||||
|
moves += Move(home, home + 2, 0)
|
||||||
|
}
|
||||||
|
if ((castling and queenside) != 0 && squares[home - 1] == 0 && squares[home - 2] == 0 &&
|
||||||
|
squares[home - 3] == 0 && squares[home - 4] == rook && !attacked(home) &&
|
||||||
|
!attacked(home - 1) && !attacked(home - 2)
|
||||||
|
) {
|
||||||
|
moves += Move(home, home - 2, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun outcome(): Outcome {
|
||||||
|
if (movesByTheRules().isEmpty()) {
|
||||||
|
return if (inCheck) {
|
||||||
|
Outcome.Win(sideToMove.opponent, WinReason.CHECKMATE)
|
||||||
|
} else {
|
||||||
|
Outcome.Draw(DrawReason.STALEMATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (insufficientMaterial) return Outcome.Draw(DrawReason.INSUFFICIENT_MATERIAL)
|
||||||
|
if (halfmoveClock >= 100) return Outcome.Draw(DrawReason.FIFTY_MOVES)
|
||||||
|
if (repetitions >= 3) return Outcome.Draw(DrawReason.REPETITION)
|
||||||
|
return Outcome.Ongoing
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legalMoveList(): List<Move> = if (drawnAutomatically) emptyList() else movesByTheRules()
|
||||||
|
|
||||||
|
private fun notation(move: Move): String {
|
||||||
|
val promotion = if (move.promotion != 0) LETTERS[move.promotion - 1].toString() else ""
|
||||||
|
return name(move.from) + name(move.to) + promotion
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applying(move: Move): ChessPosition {
|
||||||
|
val us = sign
|
||||||
|
val board = squares.copyOf()
|
||||||
|
val moving = board[move.from]
|
||||||
|
val kind = abs(moving)
|
||||||
|
var captured = board[move.to] != 0
|
||||||
|
board[move.to] = if (move.promotion != 0) us * move.promotion else moving
|
||||||
|
board[move.from] = 0
|
||||||
|
if (kind == PAWN && move.to == enPassant && !captured && move.to % 8 != move.from % 8) {
|
||||||
|
board[move.to - 8 * us] = 0
|
||||||
|
captured = true
|
||||||
|
}
|
||||||
|
if (kind == KING && abs(move.to - move.from) == 2) {
|
||||||
|
// Castling: the rook lands on the square the king crossed.
|
||||||
|
val rookFrom = if (move.to > move.from) move.from + 3 else move.from - 4
|
||||||
|
val rookTo = if (move.to > move.from) move.from + 1 else move.from - 1
|
||||||
|
board[rookTo] = board[rookFrom]
|
||||||
|
board[rookFrom] = 0
|
||||||
|
}
|
||||||
|
var rights = castling
|
||||||
|
if (kind == KING) {
|
||||||
|
val both =
|
||||||
|
if (us > 0) WHITE_KINGSIDE or WHITE_QUEENSIDE else BLACK_KINGSIDE or BLACK_QUEENSIDE
|
||||||
|
rights = rights and both.inv()
|
||||||
|
}
|
||||||
|
rights = rights and rightTiedTo(move.from).inv() and rightTiedTo(move.to).inv()
|
||||||
|
var target = -1
|
||||||
|
if (kind == PAWN && abs(move.to - move.from) == 16) {
|
||||||
|
val skipped = (move.from + move.to) / 2
|
||||||
|
if (enPassantIsLegal(board, skipped, -us)) target = skipped
|
||||||
|
}
|
||||||
|
val reset = captured || kind == PAWN
|
||||||
|
return ChessPosition(
|
||||||
|
board,
|
||||||
|
sideToMove.opponent,
|
||||||
|
rights,
|
||||||
|
target,
|
||||||
|
if (reset) 0 else halfmoveClock + 1,
|
||||||
|
fullmoveNumber + if (sideToMove == Side.SECOND) 1 else 0,
|
||||||
|
if (reset) emptyList() else history,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalNotations(): List<String> = legalMoveList().map(::notation)
|
||||||
|
|
||||||
|
override fun applyNotation(text: String): MoveResult =
|
||||||
|
applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying)
|
||||||
|
|
||||||
|
override fun perft(depth: Int): Long =
|
||||||
|
perftOf(this, depth, ChessPosition::legalMoveList, ChessPosition::applying)
|
||||||
|
|
||||||
|
override val board: Board
|
||||||
|
get() {
|
||||||
|
val cells = ArrayList<Piece?>(64)
|
||||||
|
val names = ArrayList<String?>(64)
|
||||||
|
for (row in 0 until 8) {
|
||||||
|
for (column in 0 until 8) {
|
||||||
|
val square = (7 - row) * 8 + column
|
||||||
|
val piece = squares[square]
|
||||||
|
cells +=
|
||||||
|
if (piece == 0) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
Piece(if (piece > 0) Side.FIRST else Side.SECOND, KINDS[abs(piece) - 1])
|
||||||
|
}
|
||||||
|
names += name(square)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Board(8, 8, cells, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FEN, with the en passant square shown only while the capture is legal. */
|
||||||
|
override val setup: String
|
||||||
|
get() {
|
||||||
|
val text = StringBuilder()
|
||||||
|
for (rank in 7 downTo 0) {
|
||||||
|
var empty = 0
|
||||||
|
for (file in 0 until 8) {
|
||||||
|
val piece = squares[rank * 8 + file]
|
||||||
|
if (piece == 0) {
|
||||||
|
empty++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (empty > 0) text.append(empty)
|
||||||
|
empty = 0
|
||||||
|
val letter = LETTERS[abs(piece) - 1]
|
||||||
|
text.append(if (piece > 0) letter.uppercaseChar() else letter)
|
||||||
|
}
|
||||||
|
if (empty > 0) text.append(empty)
|
||||||
|
if (rank > 0) text.append('/')
|
||||||
|
}
|
||||||
|
val rights =
|
||||||
|
buildString {
|
||||||
|
if ((castling and WHITE_KINGSIDE) != 0) append('K')
|
||||||
|
if ((castling and WHITE_QUEENSIDE) != 0) append('Q')
|
||||||
|
if ((castling and BLACK_KINGSIDE) != 0) append('k')
|
||||||
|
if ((castling and BLACK_QUEENSIDE) != 0) append('q')
|
||||||
|
}
|
||||||
|
val side = if (sideToMove == Side.FIRST) "w" else "b"
|
||||||
|
val target = if (enPassant >= 0) name(enPassant) else "-"
|
||||||
|
return "$text $side ${rights.ifEmpty { "-" }} $target $halfmoveClock $fullmoveNumber"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is ChessPosition && halfmoveClock == other.halfmoveClock &&
|
||||||
|
fullmoveNumber == other.fullmoveNumber && history == other.history
|
||||||
|
|
||||||
|
override fun hashCode(): Int = (history.hashCode() * 31 + halfmoveClock) * 31 + fullmoveNumber
|
||||||
|
|
||||||
|
override fun toString(): String = "ChessPosition($setup)"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PAWN = 1
|
||||||
|
private const val KNIGHT = 2
|
||||||
|
private const val BISHOP = 3
|
||||||
|
private const val ROOK = 4
|
||||||
|
private const val QUEEN = 5
|
||||||
|
private const val KING = 6
|
||||||
|
|
||||||
|
private const val WHITE_KINGSIDE = 1
|
||||||
|
private const val WHITE_QUEENSIDE = 2
|
||||||
|
private const val BLACK_KINGSIDE = 4
|
||||||
|
private const val BLACK_QUEENSIDE = 8
|
||||||
|
|
||||||
|
/** Indexed by piece code minus one. */
|
||||||
|
private val LETTERS = charArrayOf('p', 'n', 'b', 'r', 'q', 'k')
|
||||||
|
private val KINDS =
|
||||||
|
arrayOf(
|
||||||
|
PieceKind.PAWN,
|
||||||
|
PieceKind.KNIGHT,
|
||||||
|
PieceKind.BISHOP,
|
||||||
|
PieceKind.ROOK,
|
||||||
|
PieceKind.QUEEN,
|
||||||
|
PieceKind.KING,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The order promotions are listed in. */
|
||||||
|
private val PROMOTIONS = intArrayOf(QUEEN, ROOK, BISHOP, KNIGHT)
|
||||||
|
|
||||||
|
/** (file, rank) steps. */
|
||||||
|
private val KNIGHT_STEPS = steps(1, 2, 2, 1, 2, -1, 1, -2, -1, -2, -2, -1, -2, 1, -1, 2)
|
||||||
|
private val KING_STEPS = steps(0, 1, 1, 1, 1, 0, 1, -1, 0, -1, -1, -1, -1, 0, -1, 1)
|
||||||
|
private val STRAIGHT = steps(0, 1, 0, -1, 1, 0, -1, 0)
|
||||||
|
private val DIAGONAL = steps(1, 1, -1, 1, 1, -1, -1, -1)
|
||||||
|
|
||||||
|
private fun steps(vararg pairs: Int): Array<IntArray> =
|
||||||
|
Array(pairs.size / 2) { intArrayOf(pairs[2 * it], pairs[2 * it + 1]) }
|
||||||
|
|
||||||
|
private fun name(square: Int): String = "${'a' + square % 8}${'1' + square / 8}"
|
||||||
|
|
||||||
|
private fun squareNamed(text: String): Int? {
|
||||||
|
if (text.length != 2 || text[0] !in 'a'..'h' || text[1] !in '1'..'8') return null
|
||||||
|
return (text[1] - '1') * 8 + (text[0] - 'a')
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun code(character: Char): Int? {
|
||||||
|
if (character.code > 127) return null
|
||||||
|
val index = LETTERS.indexOf(character.lowercaseChar())
|
||||||
|
if (index < 0) return null
|
||||||
|
return if (character.isUpperCase()) index + 1 else -(index + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 for a dark square, 1 for a light one. */
|
||||||
|
private fun colour(square: Int): Int = (square % 8 + square / 8) % 2
|
||||||
|
|
||||||
|
/** The castling right lost when a piece leaves or is taken on this corner. */
|
||||||
|
private fun rightTiedTo(square: Int): Int =
|
||||||
|
when (square) {
|
||||||
|
0 -> WHITE_QUEENSIDE
|
||||||
|
7 -> WHITE_KINGSIDE
|
||||||
|
56 -> BLACK_QUEENSIDE
|
||||||
|
63 -> BLACK_KINGSIDE
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isWellFormed(text: String): Boolean {
|
||||||
|
if (text.length != 4 && text.length != 5) return false
|
||||||
|
if (text[0] !in 'a'..'h' || text[1] !in '1'..'8' || text[2] !in 'a'..'h' ||
|
||||||
|
text[3] !in '1'..'8'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return text.length == 4 || text[4] in "qrbn"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether a piece of the side with [sign] (1 white, -1 black) attacks [square]. */
|
||||||
|
private fun isAttacked(
|
||||||
|
board: IntArray,
|
||||||
|
square: Int,
|
||||||
|
sign: Int,
|
||||||
|
): Boolean {
|
||||||
|
val file = square % 8
|
||||||
|
val rank = square / 8
|
||||||
|
// A pawn attacks the rank ahead of it, so a white attacker stands one rank lower.
|
||||||
|
val pawnRank = rank - sign
|
||||||
|
if (pawnRank in 0..7) {
|
||||||
|
for (step in intArrayOf(-1, 1)) {
|
||||||
|
val f = file + step
|
||||||
|
if (f in 0..7 && board[pawnRank * 8 + f] == sign * PAWN) return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (reaches(board, file, rank, KNIGHT_STEPS, sign * KNIGHT) ||
|
||||||
|
reaches(board, file, rank, KING_STEPS, sign * KING)
|
||||||
|
) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return slides(board, file, rank, STRAIGHT, sign * ROOK, sign * QUEEN) ||
|
||||||
|
slides(board, file, rank, DIAGONAL, sign * BISHOP, sign * QUEEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reaches(
|
||||||
|
board: IntArray,
|
||||||
|
file: Int,
|
||||||
|
rank: Int,
|
||||||
|
steps: Array<IntArray>,
|
||||||
|
piece: Int,
|
||||||
|
): Boolean {
|
||||||
|
for (step in steps) {
|
||||||
|
val f = file + step[0]
|
||||||
|
val r = rank + step[1]
|
||||||
|
if (f in 0..7 && r in 0..7 && board[r * 8 + f] == piece) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the first piece met along any direction is [slider] or [queen]. */
|
||||||
|
private fun slides(
|
||||||
|
board: IntArray,
|
||||||
|
file: Int,
|
||||||
|
rank: Int,
|
||||||
|
directions: Array<IntArray>,
|
||||||
|
slider: Int,
|
||||||
|
queen: Int,
|
||||||
|
): Boolean {
|
||||||
|
for (direction in directions) {
|
||||||
|
var f = file + direction[0]
|
||||||
|
var r = rank + direction[1]
|
||||||
|
while (f in 0..7 && r in 0..7) {
|
||||||
|
val piece = board[r * 8 + f]
|
||||||
|
if (piece != 0) {
|
||||||
|
if (piece == slider || piece == queen) return true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
f += direction[0]
|
||||||
|
r += direction[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the side with [sign] can legally capture en passant onto [target]. */
|
||||||
|
private fun enPassantIsLegal(
|
||||||
|
board: IntArray,
|
||||||
|
target: Int,
|
||||||
|
sign: Int,
|
||||||
|
): Boolean {
|
||||||
|
val victim = target - 8 * sign
|
||||||
|
val king = board.indexOf(sign * KING)
|
||||||
|
if (king < 0) return false
|
||||||
|
for (step in intArrayOf(-1, 1)) {
|
||||||
|
val file = victim % 8 + step
|
||||||
|
if (file !in 0..7) continue
|
||||||
|
val from = victim - victim % 8 + file
|
||||||
|
if (board[from] != sign * PAWN) continue
|
||||||
|
val after = board.copyOf()
|
||||||
|
after[target] = sign * PAWN
|
||||||
|
after[from] = 0
|
||||||
|
after[victim] = 0
|
||||||
|
if (!isAttacked(after, king, -sign)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads FEN (four or six fields). Refuses positions the rules cannot reach: a side without
|
||||||
|
* exactly one king, a pawn on the first or last rank, the side not to move in check, or an
|
||||||
|
* en passant square with no pawn that just made the double step.
|
||||||
|
*/
|
||||||
|
internal fun fromSetup(setup: String): ChessPosition? {
|
||||||
|
val fields = setup.split(' ')
|
||||||
|
if (fields.size != 6 && fields.size != 4) return null
|
||||||
|
val rows = fields[0].split('/')
|
||||||
|
if (rows.size != 8) return null
|
||||||
|
val board = IntArray(64)
|
||||||
|
for ((index, row) in rows.withIndex()) {
|
||||||
|
val rank = 7 - index
|
||||||
|
var file = 0
|
||||||
|
for (character in row) {
|
||||||
|
if (character in '1'..'8') {
|
||||||
|
file += character - '0'
|
||||||
|
} else {
|
||||||
|
val code = code(character) ?: return null
|
||||||
|
if (file >= 8) return null
|
||||||
|
board[rank * 8 + file] = code
|
||||||
|
file++
|
||||||
|
}
|
||||||
|
if (file > 8) return null
|
||||||
|
}
|
||||||
|
if (file != 8) return null
|
||||||
|
}
|
||||||
|
val side =
|
||||||
|
when (fields[1]) {
|
||||||
|
"w" -> Side.FIRST
|
||||||
|
"b" -> Side.SECOND
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
var rights = 0
|
||||||
|
if (fields[2] != "-") {
|
||||||
|
if (fields[2].isEmpty()) return null
|
||||||
|
for (character in fields[2]) {
|
||||||
|
val bit =
|
||||||
|
when (character) {
|
||||||
|
'K' -> WHITE_KINGSIDE
|
||||||
|
'Q' -> WHITE_QUEENSIDE
|
||||||
|
'k' -> BLACK_KINGSIDE
|
||||||
|
'q' -> BLACK_QUEENSIDE
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
if ((rights and bit) != 0) return null
|
||||||
|
rights = rights or bit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A right without its king and rook at home could never be used; drop it.
|
||||||
|
if (board[4] != KING || board[7] != ROOK) rights = rights and WHITE_KINGSIDE.inv()
|
||||||
|
if (board[4] != KING || board[0] != ROOK) rights = rights and WHITE_QUEENSIDE.inv()
|
||||||
|
if (board[60] != -KING || board[63] != -ROOK) rights = rights and BLACK_KINGSIDE.inv()
|
||||||
|
if (board[60] != -KING || board[56] != -ROOK) rights = rights and BLACK_QUEENSIDE.inv()
|
||||||
|
|
||||||
|
if (board.count { it == KING } != 1 || board.count { it == -KING } != 1) return null
|
||||||
|
for (file in 0 until 8) {
|
||||||
|
if (abs(board[file]) == PAWN || abs(board[56 + file]) == PAWN) return null
|
||||||
|
}
|
||||||
|
val us = if (side == Side.FIRST) 1 else -1
|
||||||
|
if (isAttacked(board, board.indexOf(-us * KING), us)) return null
|
||||||
|
|
||||||
|
var target = -1
|
||||||
|
if (fields[3] != "-") {
|
||||||
|
val square = squareNamed(fields[3]) ?: return null
|
||||||
|
if (square / 8 != (if (us > 0) 5 else 2)) return null
|
||||||
|
// The pawn that stepped two squares stands in front of the target, its start is empty.
|
||||||
|
val pushed = square - 8 * us
|
||||||
|
val origin = square + 8 * us
|
||||||
|
if (board[pushed] != -us * PAWN || board[square] != 0 || board[origin] != 0) return null
|
||||||
|
if (enPassantIsLegal(board, square, us)) target = square
|
||||||
|
}
|
||||||
|
var clock = 0
|
||||||
|
var fullmove = 1
|
||||||
|
if (fields.size == 6) {
|
||||||
|
clock = fields[4].toIntOrNull()?.takeIf { it >= 0 } ?: return null
|
||||||
|
fullmove = fields[5].toIntOrNull()?.takeIf { it >= 1 } ?: return null
|
||||||
|
}
|
||||||
|
return ChessPosition(board, side, rights, target, clock, fullmove, emptyList())
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val START: ChessPosition =
|
||||||
|
fromSetup("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
/** The games played inside chats, by their wire names (DESIGN.md section 2). */
|
||||||
|
enum class GameType(
|
||||||
|
val wire: String,
|
||||||
|
/** The longest log accepted for this game. A longer one is refused before any of it is played. */
|
||||||
|
val maxPlies: Int,
|
||||||
|
) {
|
||||||
|
CHESS("chess", 600),
|
||||||
|
CHECKERS("checkers", 400),
|
||||||
|
REVERSI("reversi", 130),
|
||||||
|
TICTACTOE("tictactoe", 9),
|
||||||
|
;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The side that played entry [index] (zero-based) of a log played from the start.
|
||||||
|
*
|
||||||
|
* Every entry, a reversi `pass` included, is one ply by the side to move, so the sides
|
||||||
|
* alternate and entry 0 is always [Side.FIRST]'s. A receiver checks each new entry in a
|
||||||
|
* peer's log against the peer's side with this.
|
||||||
|
*/
|
||||||
|
fun sideOfPly(index: Int): Side = if (index % 2 == 0) Side.FIRST else Side.SECOND
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(wire: String): GameType? = entries.firstOrNull { it.wire == wire }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who plays. FIRST is chess white, checkers dark, reversi black and tic-tac-toe X. */
|
||||||
|
enum class Side(
|
||||||
|
val wire: String,
|
||||||
|
) {
|
||||||
|
FIRST("first"),
|
||||||
|
SECOND("second"),
|
||||||
|
;
|
||||||
|
|
||||||
|
val opponent: Side get() = if (this == FIRST) SECOND else FIRST
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromWire(wire: String): Side? = entries.firstOrNull { it.wire == wire }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a game was won. [wire] is the name used in `games-v1.json`. */
|
||||||
|
enum class WinReason(
|
||||||
|
val wire: String,
|
||||||
|
) {
|
||||||
|
CHECKMATE("checkmate"),
|
||||||
|
|
||||||
|
/** Checkers: the side to move has no legal move, which includes having no pieces left. */
|
||||||
|
NO_MOVES("no-moves"),
|
||||||
|
|
||||||
|
/** Reversi: neither side can move and the winner has more discs. */
|
||||||
|
MORE_DISCS("more-discs"),
|
||||||
|
THREE_IN_A_ROW("three-in-a-row"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a game was drawn. [wire] is the name used in `games-v1.json`. */
|
||||||
|
enum class DrawReason(
|
||||||
|
val wire: String,
|
||||||
|
) {
|
||||||
|
STALEMATE("stalemate"),
|
||||||
|
INSUFFICIENT_MATERIAL("insufficient-material"),
|
||||||
|
|
||||||
|
/** Chess: 100 plies without a capture or a pawn move. */
|
||||||
|
FIFTY_MOVES("fifty-moves"),
|
||||||
|
|
||||||
|
/** Checkers: 80 plies without a capture or a man move. */
|
||||||
|
FORTY_MOVES("forty-moves"),
|
||||||
|
|
||||||
|
/** The same position for the third time. */
|
||||||
|
REPETITION("repetition"),
|
||||||
|
|
||||||
|
/** Reversi: neither side can move and the discs are level. */
|
||||||
|
EQUAL_DISCS("equal-discs"),
|
||||||
|
FULL_BOARD("full-board"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a position stands. Resigning and agreed draws travel on the wire; only the natural ends
|
||||||
|
* that both sides compute from the log are here.
|
||||||
|
*/
|
||||||
|
sealed interface Outcome {
|
||||||
|
val isOver: Boolean get() = this != Ongoing
|
||||||
|
|
||||||
|
data object Ongoing : Outcome
|
||||||
|
|
||||||
|
data class Win(
|
||||||
|
val side: Side,
|
||||||
|
val reason: WinReason,
|
||||||
|
) : Outcome
|
||||||
|
|
||||||
|
data class Draw(
|
||||||
|
val reason: DrawReason,
|
||||||
|
) : Outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a move was refused. */
|
||||||
|
enum class MoveError(
|
||||||
|
val wire: String,
|
||||||
|
) {
|
||||||
|
/** Not written in the game's notation, for example `E2E4` or `11 - 15`. */
|
||||||
|
MALFORMED("malformed"),
|
||||||
|
|
||||||
|
/** Written correctly but not legal here. Every move is illegal once the game is over. */
|
||||||
|
ILLEGAL("illegal"),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of [Games.apply]. */
|
||||||
|
sealed interface MoveResult {
|
||||||
|
data class Applied(
|
||||||
|
val position: GamePosition,
|
||||||
|
) : MoveResult
|
||||||
|
|
||||||
|
data class Rejected(
|
||||||
|
val error: MoveError,
|
||||||
|
) : MoveResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The result of [Games.replay]. */
|
||||||
|
sealed interface ReplayResult {
|
||||||
|
data class Replayed(
|
||||||
|
val position: GamePosition,
|
||||||
|
) : ReplayResult
|
||||||
|
|
||||||
|
/** The log is longer than [GameType.maxPlies] allows. */
|
||||||
|
data class TooLong(
|
||||||
|
val plies: Int,
|
||||||
|
val max: Int,
|
||||||
|
) : ReplayResult
|
||||||
|
|
||||||
|
/** The entry at zero-based index [ply] of the log was refused. */
|
||||||
|
data class Failed(
|
||||||
|
val ply: Int,
|
||||||
|
val error: MoveError,
|
||||||
|
) : ReplayResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a piece is. Checkers uses MAN and KING, reversi DISC, tic-tac-toe MARK. */
|
||||||
|
enum class PieceKind { PAWN, KNIGHT, BISHOP, ROOK, QUEEN, KING, MAN, DISC, MARK }
|
||||||
|
|
||||||
|
/** One piece on the board. */
|
||||||
|
data class Piece(
|
||||||
|
val side: Side,
|
||||||
|
val kind: PieceKind,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A board for drawing. Row 0 is the top row as the first player sees the board: chess has rank
|
||||||
|
* 8 at the top, checkers has squares 29-32 there (dark's men start at the bottom), reversi has
|
||||||
|
* rank 1 there, tic-tac-toe has cells 1-3. Turn it round to show the second player's view.
|
||||||
|
*/
|
||||||
|
data class Board(
|
||||||
|
val rows: Int,
|
||||||
|
val columns: Int,
|
||||||
|
/** Row-major from the top-left. */
|
||||||
|
val cells: List<Piece?>,
|
||||||
|
/** The wire name of each cell (`e4`, `15`, `d3`, `5`), null where nothing can stand. */
|
||||||
|
val squares: List<String?>,
|
||||||
|
) {
|
||||||
|
fun pieceAt(
|
||||||
|
row: Int,
|
||||||
|
column: Int,
|
||||||
|
): Piece? = cells[row * columns + column]
|
||||||
|
|
||||||
|
fun squareAt(
|
||||||
|
row: Int,
|
||||||
|
column: Int,
|
||||||
|
): String? = squares[row * columns + column]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A position in one of the four games. Values are immutable; applying a move makes a new one. */
|
||||||
|
sealed class GamePosition {
|
||||||
|
abstract val type: GameType
|
||||||
|
abstract val sideToMove: Side
|
||||||
|
abstract val board: Board
|
||||||
|
|
||||||
|
/** This position in its game's setup notation; see [Games.position]. */
|
||||||
|
abstract val setup: String
|
||||||
|
|
||||||
|
internal abstract fun outcome(): Outcome
|
||||||
|
|
||||||
|
internal abstract fun legalNotations(): List<String>
|
||||||
|
|
||||||
|
internal abstract fun applyNotation(text: String): MoveResult
|
||||||
|
|
||||||
|
internal abstract fun perft(depth: Int): Long
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The rules engine for the chat games.
|
||||||
|
*
|
||||||
|
* Moves are strings in the wire notation of DESIGN.md section 2. [apply] accepts exactly the
|
||||||
|
* strings [legalMoves] returns, so a log that one app wrote replays identically on the other.
|
||||||
|
*/
|
||||||
|
object Games {
|
||||||
|
fun start(type: GameType): GamePosition =
|
||||||
|
when (type) {
|
||||||
|
GameType.CHESS -> ChessPosition.START
|
||||||
|
GameType.CHECKERS -> CheckersPosition.START
|
||||||
|
GameType.REVERSI -> ReversiPosition.START
|
||||||
|
GameType.TICTACTOE -> TicTacToePosition.START
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A position from a setup string, for tests and tools; the wire never carries one.
|
||||||
|
*
|
||||||
|
* Chess takes FEN. Checkers takes PDN FEN such as `B:W21,22:BK1,9` (B is the first, dark
|
||||||
|
* side; K marks a king). Reversi takes eight rows of `x` (first), `o` and `-` from rank 1
|
||||||
|
* to rank 8 split by `/`, a space and the side to move, `x` or `o`; tic-tac-toe the same
|
||||||
|
* with three rows of three. Returns null for anything malformed or impossible.
|
||||||
|
*/
|
||||||
|
fun position(
|
||||||
|
type: GameType,
|
||||||
|
setup: String,
|
||||||
|
): GamePosition? =
|
||||||
|
when (type) {
|
||||||
|
GameType.CHESS -> ChessPosition.fromSetup(setup)
|
||||||
|
GameType.CHECKERS -> CheckersPosition.fromSetup(setup)
|
||||||
|
GameType.REVERSI -> ReversiPosition.fromSetup(setup)
|
||||||
|
GameType.TICTACTOE -> TicTacToePosition.fromSetup(setup)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every legal move, empty once the game is over. */
|
||||||
|
fun legalMoves(position: GamePosition): List<String> = position.legalNotations()
|
||||||
|
|
||||||
|
fun apply(
|
||||||
|
position: GamePosition,
|
||||||
|
move: String,
|
||||||
|
): MoveResult = position.applyNotation(move)
|
||||||
|
|
||||||
|
fun outcome(position: GamePosition): Outcome = position.outcome()
|
||||||
|
|
||||||
|
/** The position after a whole log played from the start. */
|
||||||
|
fun replay(
|
||||||
|
type: GameType,
|
||||||
|
log: List<String>,
|
||||||
|
): ReplayResult {
|
||||||
|
if (log.size > type.maxPlies) return ReplayResult.TooLong(log.size, type.maxPlies)
|
||||||
|
return replay(start(type), log)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The position after [moves] played from [from]. A failure names the index in [moves]. */
|
||||||
|
fun replay(
|
||||||
|
from: GamePosition,
|
||||||
|
moves: List<String>,
|
||||||
|
): ReplayResult {
|
||||||
|
var current = from
|
||||||
|
for ((index, move) in moves.withIndex()) {
|
||||||
|
when (val result = apply(current, move)) {
|
||||||
|
is MoveResult.Applied -> current = result.position
|
||||||
|
is MoveResult.Rejected -> return ReplayResult.Failed(index, result.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ReplayResult.Replayed(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The number of move sequences of length [depth], for checking move generation. */
|
||||||
|
fun perft(
|
||||||
|
position: GamePosition,
|
||||||
|
depth: Int,
|
||||||
|
): Long = position.perft(depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Applies [text] if it is well formed and names one of the legal [moves]. */
|
||||||
|
internal inline fun <M> applyText(
|
||||||
|
text: String,
|
||||||
|
wellFormed: Boolean,
|
||||||
|
moves: () -> List<M>,
|
||||||
|
notation: (M) -> String,
|
||||||
|
applying: (M) -> GamePosition,
|
||||||
|
): MoveResult {
|
||||||
|
if (!wellFormed) return MoveResult.Rejected(MoveError.MALFORMED)
|
||||||
|
for (move in moves()) {
|
||||||
|
if (notation(move) == text) return MoveResult.Applied(applying(move))
|
||||||
|
}
|
||||||
|
return MoveResult.Rejected(MoveError.ILLEGAL)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun <M, P> perftOf(
|
||||||
|
position: P,
|
||||||
|
depth: Int,
|
||||||
|
moves: (P) -> List<M>,
|
||||||
|
applying: (P, M) -> P,
|
||||||
|
): Long {
|
||||||
|
if (depth <= 0) return 1
|
||||||
|
val list = moves(position)
|
||||||
|
if (depth == 1) return list.size.toLong()
|
||||||
|
var total = 0L
|
||||||
|
for (move in list) total += perftOf(applying(position, move), depth - 1, moves, applying)
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A reversi position. Files a-h run left to right and ranks 1-8 top to bottom as the first
|
||||||
|
* player draws the board.
|
||||||
|
*/
|
||||||
|
class ReversiPosition internal constructor(
|
||||||
|
/**
|
||||||
|
* Index is row * 8 + column: row 0 is rank 1, column 0 is file a. First's (black) discs are
|
||||||
|
* 1, second's (white) -1.
|
||||||
|
*/
|
||||||
|
internal val squares: IntArray,
|
||||||
|
override val sideToMove: Side,
|
||||||
|
) : GamePosition() {
|
||||||
|
override val type: GameType get() = GameType.REVERSI
|
||||||
|
|
||||||
|
private val sign: Int get() = if (sideToMove == Side.FIRST) 1 else -1
|
||||||
|
|
||||||
|
override fun outcome(): Outcome {
|
||||||
|
if (legalMoveList().isNotEmpty()) return Outcome.Ongoing
|
||||||
|
val first = squares.count { it == 1 }
|
||||||
|
val second = squares.count { it == -1 }
|
||||||
|
if (first == second) return Outcome.Draw(DrawReason.EQUAL_DISCS)
|
||||||
|
return Outcome.Win(if (first > second) Side.FIRST else Side.SECOND, WinReason.MORE_DISCS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placements, or [PASS] only when there are none and the opponent has one. Empty when neither
|
||||||
|
* side can place, which ends the game.
|
||||||
|
*/
|
||||||
|
private fun legalMoveList(): List<Int> {
|
||||||
|
val mine = placements(squares, sign)
|
||||||
|
if (mine.isNotEmpty()) return mine
|
||||||
|
return if (placements(squares, -sign).isEmpty()) emptyList() else listOf(PASS)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notation(move: Int): String = if (move == PASS) "pass" else name(move)
|
||||||
|
|
||||||
|
private fun applying(move: Int): ReversiPosition {
|
||||||
|
if (move == PASS) return ReversiPosition(squares, sideToMove.opponent)
|
||||||
|
val board = squares.copyOf()
|
||||||
|
board[move] = sign
|
||||||
|
for (flipped in flips(squares, move, sign)) board[flipped] = sign
|
||||||
|
return ReversiPosition(board, sideToMove.opponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalNotations(): List<String> = legalMoveList().map(::notation)
|
||||||
|
|
||||||
|
override fun applyNotation(text: String): MoveResult =
|
||||||
|
applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying)
|
||||||
|
|
||||||
|
override fun perft(depth: Int): Long =
|
||||||
|
perftOf(this, depth, ReversiPosition::legalMoveList, ReversiPosition::applying)
|
||||||
|
|
||||||
|
override val board: Board
|
||||||
|
get() =
|
||||||
|
Board(
|
||||||
|
8,
|
||||||
|
8,
|
||||||
|
squares.map {
|
||||||
|
if (it == 0) null else Piece(if (it > 0) Side.FIRST else Side.SECOND, PieceKind.DISC)
|
||||||
|
},
|
||||||
|
(0 until 64).map(::name),
|
||||||
|
)
|
||||||
|
|
||||||
|
override val setup: String
|
||||||
|
get() =
|
||||||
|
(0 until 8).joinToString("/") { row ->
|
||||||
|
(0 until 8).joinToString("") { column ->
|
||||||
|
when (squares[row * 8 + column]) {
|
||||||
|
1 -> "x"
|
||||||
|
-1 -> "o"
|
||||||
|
else -> "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} + if (sideToMove == Side.FIRST) " x" else " o"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is ReversiPosition && sideToMove == other.sideToMove &&
|
||||||
|
squares.contentEquals(other.squares)
|
||||||
|
|
||||||
|
override fun hashCode(): Int = squares.contentHashCode() * 31 + sideToMove.hashCode()
|
||||||
|
|
||||||
|
override fun toString(): String = "ReversiPosition($setup)"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val PASS = -1
|
||||||
|
|
||||||
|
/** (row, column) steps. */
|
||||||
|
private val STEPS =
|
||||||
|
arrayOf(
|
||||||
|
intArrayOf(-1, -1),
|
||||||
|
intArrayOf(-1, 0),
|
||||||
|
intArrayOf(-1, 1),
|
||||||
|
intArrayOf(0, -1),
|
||||||
|
intArrayOf(0, 1),
|
||||||
|
intArrayOf(1, -1),
|
||||||
|
intArrayOf(1, 0),
|
||||||
|
intArrayOf(1, 1),
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun name(index: Int): String = "${'a' + index % 8}${index / 8 + 1}"
|
||||||
|
|
||||||
|
internal fun isWellFormed(text: String): Boolean =
|
||||||
|
text == "pass" || (text.length == 2 && text[0] in 'a'..'h' && text[1] in '1'..'8')
|
||||||
|
|
||||||
|
/** The discs a placement on [index] by the side with [sign] would turn over. */
|
||||||
|
private fun flips(
|
||||||
|
board: IntArray,
|
||||||
|
index: Int,
|
||||||
|
sign: Int,
|
||||||
|
): List<Int> {
|
||||||
|
if (board[index] != 0) return emptyList()
|
||||||
|
val turned = ArrayList<Int>()
|
||||||
|
for (step in STEPS) {
|
||||||
|
var row = index / 8 + step[0]
|
||||||
|
var column = index % 8 + step[1]
|
||||||
|
val line = ArrayList<Int>()
|
||||||
|
while (row in 0..7 && column in 0..7 && board[row * 8 + column] == -sign) {
|
||||||
|
line += row * 8 + column
|
||||||
|
row += step[0]
|
||||||
|
column += step[1]
|
||||||
|
}
|
||||||
|
val closed = row in 0..7 && column in 0..7 && board[row * 8 + column] == sign
|
||||||
|
if (line.isNotEmpty() && closed) {
|
||||||
|
turned += line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return turned
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun placements(
|
||||||
|
board: IntArray,
|
||||||
|
sign: Int,
|
||||||
|
): List<Int> = (0 until 64).filter { flips(board, it, sign).isNotEmpty() }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads eight rows of `x`, `o` and `-` from rank 1 to rank 8 split by `/`, a space, and
|
||||||
|
* the side to move.
|
||||||
|
*/
|
||||||
|
internal fun fromSetup(setup: String): ReversiPosition? {
|
||||||
|
val fields = setup.split(' ')
|
||||||
|
if (fields.size != 2) return null
|
||||||
|
val rows = fields[0].split('/')
|
||||||
|
if (rows.size != 8 || rows.any { it.length != 8 }) return null
|
||||||
|
val board = IntArray(64)
|
||||||
|
for ((index, character) in rows.joinToString("").withIndex()) {
|
||||||
|
board[index] =
|
||||||
|
when (character) {
|
||||||
|
'x' -> 1
|
||||||
|
'o' -> -1
|
||||||
|
'-' -> 0
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return when (fields[1]) {
|
||||||
|
"x" -> ReversiPosition(board, Side.FIRST)
|
||||||
|
"o" -> ReversiPosition(board, Side.SECOND)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val START: ReversiPosition =
|
||||||
|
fromSetup("--------/--------/--------/---ox---/---xo---/--------/--------/-------- x")!!
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
/** A tic-tac-toe position. Cells are named 1-9 row by row from the top-left; X moves first. */
|
||||||
|
class TicTacToePosition internal constructor(
|
||||||
|
/** Cell 0 is `1`. X (first) is 1, O (second) is -1. */
|
||||||
|
internal val cells: IntArray,
|
||||||
|
override val sideToMove: Side,
|
||||||
|
) : GamePosition() {
|
||||||
|
override val type: GameType get() = GameType.TICTACTOE
|
||||||
|
|
||||||
|
private val winner: Side?
|
||||||
|
get() {
|
||||||
|
for (line in LINES) {
|
||||||
|
val mark = cells[line[0]]
|
||||||
|
if (mark != 0 && mark == cells[line[1]] && mark == cells[line[2]]) {
|
||||||
|
return if (mark > 0) Side.FIRST else Side.SECOND
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun outcome(): Outcome {
|
||||||
|
val side = this.winner
|
||||||
|
if (side != null) return Outcome.Win(side, WinReason.THREE_IN_A_ROW)
|
||||||
|
return if (cells.contains(0)) Outcome.Ongoing else Outcome.Draw(DrawReason.FULL_BOARD)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun legalMoveList(): List<Int> =
|
||||||
|
if (winner != null) emptyList() else (0 until 9).filter { cells[it] == 0 }
|
||||||
|
|
||||||
|
private fun notation(move: Int): String = (move + 1).toString()
|
||||||
|
|
||||||
|
private fun applying(move: Int): TicTacToePosition {
|
||||||
|
val next = cells.copyOf()
|
||||||
|
next[move] = if (sideToMove == Side.FIRST) 1 else -1
|
||||||
|
return TicTacToePosition(next, sideToMove.opponent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun legalNotations(): List<String> = legalMoveList().map(::notation)
|
||||||
|
|
||||||
|
override fun applyNotation(text: String): MoveResult =
|
||||||
|
applyText(text, isWellFormed(text), { legalMoveList() }, ::notation, ::applying)
|
||||||
|
|
||||||
|
override fun perft(depth: Int): Long =
|
||||||
|
perftOf(this, depth, TicTacToePosition::legalMoveList, TicTacToePosition::applying)
|
||||||
|
|
||||||
|
override val board: Board
|
||||||
|
get() =
|
||||||
|
Board(
|
||||||
|
3,
|
||||||
|
3,
|
||||||
|
cells.map {
|
||||||
|
if (it == 0) null else Piece(if (it > 0) Side.FIRST else Side.SECOND, PieceKind.MARK)
|
||||||
|
},
|
||||||
|
(1..9).map { it.toString() },
|
||||||
|
)
|
||||||
|
|
||||||
|
override val setup: String
|
||||||
|
get() =
|
||||||
|
(0 until 3).joinToString("/") { row ->
|
||||||
|
(0 until 3).joinToString("") { column ->
|
||||||
|
when (cells[row * 3 + column]) {
|
||||||
|
1 -> "x"
|
||||||
|
-1 -> "o"
|
||||||
|
else -> "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} + if (sideToMove == Side.FIRST) " x" else " o"
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean =
|
||||||
|
other is TicTacToePosition && sideToMove == other.sideToMove && cells.contentEquals(other.cells)
|
||||||
|
|
||||||
|
override fun hashCode(): Int = cells.contentHashCode() * 31 + sideToMove.hashCode()
|
||||||
|
|
||||||
|
override fun toString(): String = "TicTacToePosition($setup)"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val LINES =
|
||||||
|
arrayOf(
|
||||||
|
intArrayOf(0, 1, 2),
|
||||||
|
intArrayOf(3, 4, 5),
|
||||||
|
intArrayOf(6, 7, 8),
|
||||||
|
intArrayOf(0, 3, 6),
|
||||||
|
intArrayOf(1, 4, 7),
|
||||||
|
intArrayOf(2, 5, 8),
|
||||||
|
intArrayOf(0, 4, 8),
|
||||||
|
intArrayOf(2, 4, 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun isWellFormed(text: String): Boolean = text.length == 1 && text[0] in '1'..'9'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads three rows of `x`, `o` and `-` split by `/`, a space, and the side to move, which
|
||||||
|
* must be the one the counts say; a board where both sides have a line is refused.
|
||||||
|
*/
|
||||||
|
internal fun fromSetup(setup: String): TicTacToePosition? {
|
||||||
|
val fields = setup.split(' ')
|
||||||
|
if (fields.size != 2) return null
|
||||||
|
val rows = fields[0].split('/')
|
||||||
|
if (rows.size != 3 || rows.any { it.length != 3 }) return null
|
||||||
|
val cells = IntArray(9)
|
||||||
|
for ((index, character) in rows.joinToString("").withIndex()) {
|
||||||
|
cells[index] =
|
||||||
|
when (character) {
|
||||||
|
'x' -> 1
|
||||||
|
'o' -> -1
|
||||||
|
'-' -> 0
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val difference = cells.count { it == 1 } - cells.count { it == -1 }
|
||||||
|
val side =
|
||||||
|
when {
|
||||||
|
fields[1] == "x" && difference == 0 -> Side.FIRST
|
||||||
|
fields[1] == "o" && difference == 1 -> Side.SECOND
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
val lines =
|
||||||
|
LINES.filter {
|
||||||
|
cells[it[0]] != 0 && cells[it[0]] == cells[it[1]] && cells[it[1]] == cells[it[2]]
|
||||||
|
}
|
||||||
|
if (lines.map { cells[it[0]] }.toSet().size > 1) return null
|
||||||
|
return TicTacToePosition(cells, side)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val START = TicTacToePosition(IntArray(9), Side.FIRST)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/** The parts of the API the vectors do not reach: names, caps, board orientation, setup refusals. */
|
||||||
|
class GamesTest {
|
||||||
|
@Test
|
||||||
|
fun wireNames() {
|
||||||
|
assertEquals(
|
||||||
|
listOf("chess", "checkers", "reversi", "tictactoe"),
|
||||||
|
GameType.entries.map { it.wire },
|
||||||
|
)
|
||||||
|
assertEquals(listOf("first", "second"), Side.entries.map { it.wire })
|
||||||
|
assertEquals(listOf(600, 400, 130, 9), GameType.entries.map { it.maxPlies })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pliesAlternateFromFirst() {
|
||||||
|
for (type in GameType.entries) {
|
||||||
|
assertEquals(
|
||||||
|
listOf(Side.FIRST, Side.SECOND, Side.FIRST, Side.SECOND),
|
||||||
|
(0 until 4).map(type::sideOfPly),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun theCapIsCheckedBeforeAnyMove() {
|
||||||
|
assertEquals(ReplayResult.TooLong(10, 9), Games.replay(GameType.TICTACTOE, List(10) { "x" }))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun applyReportsWhichKindOfRefusal() {
|
||||||
|
val start = Games.start(GameType.CHESS)
|
||||||
|
assertEquals(MoveResult.Rejected(MoveError.ILLEGAL), Games.apply(start, "e2e5"))
|
||||||
|
assertEquals(MoveResult.Rejected(MoveError.MALFORMED), Games.apply(start, "E2E4"))
|
||||||
|
val moved = assertIs<MoveResult.Applied>(Games.apply(start, "e2e4")).position
|
||||||
|
assertEquals(Games.start(GameType.CHESS), start, "applying a move left the old position alone")
|
||||||
|
assertEquals(ReplayResult.Replayed(moved), Games.replay(GameType.CHESS, listOf("e2e4")))
|
||||||
|
assertEquals(Side.SECOND, moved.sideToMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chessBoardHasRankEightOnTop() {
|
||||||
|
val board = Games.start(GameType.CHESS).board
|
||||||
|
assertEquals(8, board.rows)
|
||||||
|
assertEquals("a8", board.squareAt(0, 0))
|
||||||
|
assertEquals(Piece(Side.SECOND, PieceKind.ROOK), board.pieceAt(0, 0))
|
||||||
|
assertEquals("e1", board.squareAt(7, 4))
|
||||||
|
assertEquals(Piece(Side.FIRST, PieceKind.KING), board.pieceAt(7, 4))
|
||||||
|
assertEquals(32, board.cells.count { it != null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun chessReportsCheck() {
|
||||||
|
val log = listOf("f2f3", "e7e5", "g2g4", "d8h4")
|
||||||
|
val mated = assertIs<ReplayResult.Replayed>(Games.replay(GameType.CHESS, log))
|
||||||
|
assertTrue(assertIs<ChessPosition>(mated.position).inCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun checkersBoardHasTheFirstPlayersMenAtTheBottom() {
|
||||||
|
val board = Games.start(GameType.CHECKERS).board
|
||||||
|
val bottom = (0 until 8).map { board.squareAt(7, it) }
|
||||||
|
val top = (0 until 8).map { board.squareAt(0, it) }
|
||||||
|
assertEquals(listOf("4", null, "3", null, "2", null, "1", null), bottom)
|
||||||
|
assertEquals(listOf(null, "32", null, "31", null, "30", null, "29"), top)
|
||||||
|
assertEquals(Piece(Side.FIRST, PieceKind.MAN), board.pieceAt(7, 0))
|
||||||
|
assertEquals(Piece(Side.SECOND, PieceKind.MAN), board.pieceAt(0, 1))
|
||||||
|
assertNull(board.pieceAt(4, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reversiBoardHasRankOneOnTop() {
|
||||||
|
val board = Games.start(GameType.REVERSI).board
|
||||||
|
assertEquals("a1", board.squareAt(0, 0))
|
||||||
|
assertEquals("d4", board.squareAt(3, 3))
|
||||||
|
assertEquals(Piece(Side.SECOND, PieceKind.DISC), board.pieceAt(3, 3))
|
||||||
|
assertEquals(Piece(Side.FIRST, PieceKind.DISC), board.pieceAt(3, 4))
|
||||||
|
assertEquals(Piece(Side.FIRST, PieceKind.DISC), board.pieceAt(4, 3))
|
||||||
|
assertEquals(Piece(Side.SECOND, PieceKind.DISC), board.pieceAt(4, 4))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ticTacToeBoardIsNumberedRowByRow() {
|
||||||
|
val played = assertIs<ReplayResult.Replayed>(Games.replay(GameType.TICTACTOE, listOf("5", "1")))
|
||||||
|
val board = played.position.board
|
||||||
|
assertEquals((1..9).map { it.toString() }, board.squares)
|
||||||
|
assertEquals(Piece(Side.FIRST, PieceKind.MARK), board.pieceAt(1, 1))
|
||||||
|
assertEquals(Piece(Side.SECOND, PieceKind.MARK), board.pieceAt(0, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun impossibleSetupsAreRefused() {
|
||||||
|
val refused =
|
||||||
|
listOf(
|
||||||
|
GameType.CHESS to "4k3/8/8/8/8/8/8/4K2K w - - 0 1", // two white kings
|
||||||
|
GameType.CHESS to "4k2P/8/8/8/8/8/8/4K3 w - - 0 1", // a pawn on the last rank
|
||||||
|
GameType.CHESS to "4k3/8/8/8/8/8/8/4K2R w - - 0 1 extra",
|
||||||
|
GameType.CHESS to "4k3/4R3/8/8/8/8/8/4K3 w - - 0 1", // black in check with white to move
|
||||||
|
GameType.CHESS to "4k3/8/8/8/8/8/8/4K3 w - e6 0 1", // en passant with no pawn that stepped
|
||||||
|
GameType.CHESS to "4k3/8/8/8/8/8/8/4K3 w KK - 0 1",
|
||||||
|
GameType.CHECKERS to "B:W21:B29", // a dark man where it would have been crowned
|
||||||
|
GameType.CHECKERS to "B:W21,21:B1",
|
||||||
|
GameType.CHECKERS to "B:W33:B1",
|
||||||
|
GameType.CHECKERS to "B:W21:W1",
|
||||||
|
GameType.REVERSI to "--------/--------/--------/---ox---/---xo---/--------/--------/------- x",
|
||||||
|
GameType.REVERSI to "--------/--------/--------/---ox---/---xo---/--------/--------/-------- b",
|
||||||
|
GameType.TICTACTOE to "x--/---/--- x", // X has moved, so O is to move
|
||||||
|
GameType.TICTACTOE to "xxx/ooo/--- x",
|
||||||
|
)
|
||||||
|
for ((type, setup) in refused) assertNull(Games.position(type, setup), setup)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun setupReadsBackTheStart() {
|
||||||
|
for (type in GameType.entries) {
|
||||||
|
val start = Games.start(type)
|
||||||
|
assertEquals(start, Games.position(type, start.setup), type.wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package io.flamenet.games
|
||||||
|
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
|
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs `games-v1.json`. The iOS FlamenetGames package runs the same file, byte for byte, so the
|
||||||
|
* two engines agree on every vector or one of the suites is red.
|
||||||
|
*
|
||||||
|
* Failures are collected and reported together, so one run shows every vector that disagrees.
|
||||||
|
*/
|
||||||
|
class VectorTest {
|
||||||
|
private val vectors: JSONObject by lazy {
|
||||||
|
val stream =
|
||||||
|
requireNotNull(javaClass.getResourceAsStream("/games-v1.json")) {
|
||||||
|
"games-v1.json is not on the test classpath"
|
||||||
|
}
|
||||||
|
JSONObject(stream.bufferedReader().readText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun perftMatchesTheLiterature() {
|
||||||
|
val failures = mutableListOf<String>()
|
||||||
|
forEachPerft { name, position, counts, _ ->
|
||||||
|
for (index in 0 until counts.length()) {
|
||||||
|
checkPerft(name, position, index + 1, counts.getLong(index), failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(failures.isEmpty(), failures.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The deeper published counts take seconds to minutes, so they run only on request. */
|
||||||
|
@Test
|
||||||
|
fun deepPerft() {
|
||||||
|
assumeTrue(
|
||||||
|
System.getenv("FLAMENET_GAMES_DEEP") == "1",
|
||||||
|
"set FLAMENET_GAMES_DEEP=1 to run the deep perft counts",
|
||||||
|
)
|
||||||
|
val failures = mutableListOf<String>()
|
||||||
|
forEachPerft { name, position, _, deep ->
|
||||||
|
for (index in 0 until (deep?.length() ?: 0)) {
|
||||||
|
val pair = deep!!.getJSONArray(index)
|
||||||
|
checkPerft(name, position, pair.getInt(0), pair.getLong(1), failures)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertTrue(failures.isEmpty(), failures.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun everyCase() {
|
||||||
|
val failures = mutableListOf<String>()
|
||||||
|
val cases = vectors.getJSONArray("cases")
|
||||||
|
for (index in 0 until cases.length()) check(cases.getJSONObject(index), failures)
|
||||||
|
println("games-v1.json: ${cases.length()} cases checked")
|
||||||
|
assertTrue(failures.isEmpty(), "${failures.size} failures:\n" + failures.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Guards against a vectors file that lost entries and still parses. */
|
||||||
|
@Test
|
||||||
|
fun theVectorsCoverEveryGame() {
|
||||||
|
assertEquals(1, vectors.getInt("v"))
|
||||||
|
val cases = vectors.getJSONArray("cases")
|
||||||
|
assertTrue(cases.length() >= 40)
|
||||||
|
val perft = vectors.getJSONArray("perft")
|
||||||
|
for (type in GameType.entries) {
|
||||||
|
val count = (0 until cases.length()).count { gameOf(cases, it) == type.wire }
|
||||||
|
assertTrue(count >= 10, "${type.wire} has $count cases")
|
||||||
|
assertTrue((0 until perft.length()).any { gameOf(perft, it) == type.wire }, type.wire)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun gameOf(
|
||||||
|
array: JSONArray,
|
||||||
|
index: Int,
|
||||||
|
): String = array.getJSONObject(index).getString("t")
|
||||||
|
|
||||||
|
private fun forEachPerft(body: (String, GamePosition, JSONArray, JSONArray?) -> Unit) {
|
||||||
|
val entries = vectors.getJSONArray("perft")
|
||||||
|
for (index in 0 until entries.length()) {
|
||||||
|
val entry = entries.getJSONObject(index)
|
||||||
|
val setup = entry.optString("setup").ifEmpty { null }
|
||||||
|
val position = startingPosition(entry.getString("t"), setup)
|
||||||
|
body(entry.getString("name"), position, entry.getJSONArray("counts"), entry.optJSONArray("deep"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun checkPerft(
|
||||||
|
name: String,
|
||||||
|
position: GamePosition,
|
||||||
|
depth: Int,
|
||||||
|
expected: Long,
|
||||||
|
failures: MutableList<String>,
|
||||||
|
) {
|
||||||
|
val started = System.nanoTime()
|
||||||
|
val count = Games.perft(position, depth)
|
||||||
|
println("perft $name depth $depth: $count in ${(System.nanoTime() - started) / 1_000_000} ms")
|
||||||
|
if (count != expected) failures += "perft $name depth $depth: $count, expected $expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startingPosition(
|
||||||
|
name: String,
|
||||||
|
setup: String?,
|
||||||
|
): GamePosition {
|
||||||
|
val type = requireNotNull(GameType.fromWire(name)) { "no game called $name" }
|
||||||
|
if (setup == null) return Games.start(type)
|
||||||
|
return requireNotNull(Games.position(type, setup)) { "setup refused: $setup" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun check(
|
||||||
|
vector: JSONObject,
|
||||||
|
failures: MutableList<String>,
|
||||||
|
) {
|
||||||
|
val name = vector.getString("name")
|
||||||
|
val type = GameType.fromWire(vector.getString("t"))
|
||||||
|
if (type == null) {
|
||||||
|
failures += "$name: no such game"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val setup = vector.optString("setup").ifEmpty { null }
|
||||||
|
val start = startingPosition(type.wire, setup)
|
||||||
|
val log = strings(vector.getJSONArray("log"))
|
||||||
|
val expect = vector.getJSONObject("expect")
|
||||||
|
fun fail(message: String) {
|
||||||
|
failures += "$name: $message"
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = if (setup == null) Games.replay(type, log) else Games.replay(start, log)
|
||||||
|
when (result) {
|
||||||
|
is ReplayResult.Replayed -> {
|
||||||
|
if (expect.has("error")) fail("played through, expected ${expectedError(expect)}")
|
||||||
|
val position = result.position
|
||||||
|
if (expect.has("outcome")) {
|
||||||
|
val outcome = describe(Games.outcome(position))
|
||||||
|
val wanted = expect.getString("outcome")
|
||||||
|
if (outcome != wanted) fail("outcome $outcome, expected $wanted")
|
||||||
|
}
|
||||||
|
if (expect.has("toMove") && position.sideToMove.wire != expect.getString("toMove")) {
|
||||||
|
fail("${position.sideToMove.wire} to move, expected ${expect.getString("toMove")}")
|
||||||
|
}
|
||||||
|
val legal = Games.legalMoves(position)
|
||||||
|
if (expect.has("legal")) {
|
||||||
|
val exact = strings(expect.getJSONArray("legal"))
|
||||||
|
if (legal.toSet() != exact.toSet() || legal.size != exact.size) {
|
||||||
|
fail("legal $legal, expected $exact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (move in strings(expect.optJSONArray("includes") ?: JSONArray())) {
|
||||||
|
if (move !in legal) fail("$move should be legal, legal is $legal")
|
||||||
|
}
|
||||||
|
for (move in strings(expect.optJSONArray("excludes") ?: JSONArray())) {
|
||||||
|
if (move in legal) fail("$move should not be legal")
|
||||||
|
}
|
||||||
|
if (expect.has("setup") && position.setup != expect.getString("setup")) {
|
||||||
|
fail("setup ${position.setup}, expected ${expect.getString("setup")}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
val got = describe(result)
|
||||||
|
if (got != expectedError(expect)) fail("$got, expected ${expectedError(expect)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val trace = expect.optJSONArray("trace")?.let { array -> (0 until array.length()).map(array::getInt) }
|
||||||
|
walk(name, start, log, trace, fromStart = setup == null, failures)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the log one move at a time and checks what must hold at every step: the game is over
|
||||||
|
* exactly when there are no legal moves, `apply` accepts exactly the listed moves, the side to
|
||||||
|
* move is the one `sideOfPly` names, the setup string reads back unchanged, and the number of
|
||||||
|
* legal moves matches [trace] when the vector has one.
|
||||||
|
*/
|
||||||
|
private fun walk(
|
||||||
|
name: String,
|
||||||
|
start: GamePosition,
|
||||||
|
log: List<String>,
|
||||||
|
trace: List<Int>?,
|
||||||
|
fromStart: Boolean,
|
||||||
|
failures: MutableList<String>,
|
||||||
|
) {
|
||||||
|
if (trace != null && trace.size != log.size + 1) failures += "$name: trace length ${trace.size}"
|
||||||
|
var position = start
|
||||||
|
for ((index, move) in log.withIndex()) {
|
||||||
|
val legal = Games.legalMoves(position)
|
||||||
|
if (trace != null && index < trace.size && legal.size != trace[index]) {
|
||||||
|
failures += "$name: ${legal.size} legal moves before ply $index, expected ${trace[index]}"
|
||||||
|
}
|
||||||
|
if (Games.outcome(position).isOver != legal.isEmpty()) {
|
||||||
|
failures += "$name: over and legal disagree before ply $index"
|
||||||
|
}
|
||||||
|
if (fromStart && position.sideToMove != position.type.sideOfPly(index)) {
|
||||||
|
failures += "$name: wrong side before ply $index"
|
||||||
|
}
|
||||||
|
if (Games.position(position.type, position.setup)?.setup != position.setup) {
|
||||||
|
failures += "$name: setup round trip before ply $index"
|
||||||
|
}
|
||||||
|
when (val result = Games.apply(position, move)) {
|
||||||
|
is MoveResult.Applied -> {
|
||||||
|
if (move !in legal) failures += "$name: applied $move, not listed"
|
||||||
|
position = result.position
|
||||||
|
}
|
||||||
|
is MoveResult.Rejected -> {
|
||||||
|
if (move in legal) failures += "$name: refused $move, but listed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val last = Games.legalMoves(position)
|
||||||
|
if (Games.outcome(position).isOver != last.isEmpty()) {
|
||||||
|
failures += "$name: over and legal disagree at the end"
|
||||||
|
}
|
||||||
|
if (trace != null && trace.isNotEmpty() && last.size != trace.last()) {
|
||||||
|
failures += "$name: ${last.size} legal moves at the end, expected ${trace.last()}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun strings(array: JSONArray): List<String> = (0 until array.length()).map(array::getString)
|
||||||
|
|
||||||
|
private fun describe(outcome: Outcome): String =
|
||||||
|
when (outcome) {
|
||||||
|
Outcome.Ongoing -> "ongoing"
|
||||||
|
is Outcome.Win -> "win ${outcome.side.wire} ${outcome.reason.wire}"
|
||||||
|
is Outcome.Draw -> "draw ${outcome.reason.wire}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun describe(result: ReplayResult): String =
|
||||||
|
when (result) {
|
||||||
|
is ReplayResult.TooLong -> "too-long"
|
||||||
|
is ReplayResult.Failed -> "${result.error.wire} at ${result.ply}"
|
||||||
|
is ReplayResult.Replayed -> "no error"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun expectedError(expect: JSONObject): String {
|
||||||
|
if (!expect.has("error")) return "no error"
|
||||||
|
val error = expect.getString("error")
|
||||||
|
return if (error == "too-long") error else "$error at ${expect.optInt("ply", -1)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -39,3 +39,7 @@ include(":obfs4")
|
|||||||
|
|
||||||
// The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`.
|
// The cross-engine interop driver. Not shipped; see `engine/test/interop-kotlin.mjs`.
|
||||||
include(":interop")
|
include(":interop")
|
||||||
|
|
||||||
|
// Rules for the games played inside chats. Plain JVM like `:engine`; its vectors are shared
|
||||||
|
// byte for byte with the iOS FlamenetGames package.
|
||||||
|
include(":games")
|
||||||
|
|||||||
Reference in New Issue
Block a user