Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
5e111e1
Write a function that figures out if a king can move from a starting …
johndoknjas Aug 6, 2025
e66944e
Store test cases in a csv file.
johndoknjas Aug 8, 2025
4ef5857
Do not allow the user to include the starting position of a king as o…
johndoknjas Aug 8, 2025
cc0d238
Implement and test function that checks if only kings and pawns on bo…
johndoknjas Aug 14, 2025
0934f82
Implement and test function that checks for all pawns being locked.
johndoknjas Aug 14, 2025
b96af03
Improve the efficiency of `allPawnsLocked`.
johndoknjas Aug 14, 2025
9b8f132
Accept a Position instead of Board (in order to check for en passant)…
johndoknjas Aug 15, 2025
3ca804b
Finish implementing `kingPawnFortresses`, and inline some functions.
johndoknjas Aug 15, 2025
e19afa8
Test `kingPawnFortress`.
johndoknjas Aug 15, 2025
313cff6
Update `apply` functions to use `kingPawnFortress`.
johndoknjas Aug 15, 2025
6eab5b9
Require no king being attacked in a fortress. Also test 3-check FENs.
johndoknjas Aug 15, 2025
3e88823
Remove three-check FEN testing. Also add a few test cases where the s…
johndoknjas Aug 15, 2025
86d7e31
Merge remote-tracking branch 'upstream/master' into king-pawn-fortresses
johndoknjas Aug 15, 2025
5e5a959
Add on to some tests, including a new failure for `playerHasInsuffici…
johndoknjas Aug 17, 2025
5c83ea8
As turn is now also required in more variants (standard, chess960, fr…
johndoknjas Aug 17, 2025
bdb5d3e
Add more benchmarks.
johndoknjas Aug 17, 2025
ad7403b
Make `hasInsufficientMaterial` protected.
johndoknjas Aug 17, 2025
1b3f04f
Return an Option instead of throwing an exception.
johndoknjas Aug 21, 2025
2411e2b
Format with scalafmt.
johndoknjas Aug 21, 2025
9df3001
Rewrite `kingPathExists` using functional programming.
johndoknjas Aug 21, 2025
4135db0
Format with scalafix.
johndoknjas Aug 21, 2025
321618d
Merge branch 'master' into king-pawn-fortresses
johndoknjas Aug 31, 2025
7b4ac27
Do not use quotes in csv.
johndoknjas Aug 31, 2025
0f84b3f
Merge branch 'master' into king-pawn-fortresses
johndoknjas Oct 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions bench/src/main/scala/benchmarks/InsufficientMaterialBench.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import org.openjdk.jmh.annotations.*
import java.util.concurrent.TimeUnit
import chess.format.{ FullFen, Fen }
import chess.variant.Horde
import chess.variant.Standard
import chess.InsufficientMatingMaterial

@State(Scope.Thread)
@BenchmarkMode(Array(Mode.Throughput))
Expand Down Expand Up @@ -42,3 +44,40 @@ class InsufficientMaterialBench:
def horde() =
hordeGames.map: board =>
board.variant.isInsufficientMaterial(board)

var fens = List(
"4k3/8/8/8/8/8/8/4K3 w - - 0 1",
"4k3/8/8/8/8/8/4P3/4K3 w - - 0 1",
"4k3/p4p2/p4p2/p4p2/Pp2pPp1/1Pp1P1Pp/2P1P2P/4K3 w - - 0 1",
"4k3/p4p2/p4p2/p4p2/Pp2pPp1/1Pp1P1Pp/2P1P2P/4K3 b - f3 0 1"
).map(FullFen(_)).map(Fen.read(Standard, _).get)

@Benchmark
def pawnsLocked() =
fens.map: position =>
InsufficientMatingMaterial.allPawnsLocked(position.board)

@Benchmark
def kingPawnFortress() =
fens.map: position =>
InsufficientMatingMaterial.kingPawnFortress(position)

@Benchmark
def insufficientMatingMaterial() =
fens.map: position =>
InsufficientMatingMaterial(position)

@Benchmark
def isInsufficientMaterial() =
fens.map: position =>
position.variant.isInsufficientMaterial(position)

@Benchmark
def playerHasInsufficientMaterial() =
fens.map: position =>
position.variant.playerHasInsufficientMaterial(position)

@Benchmark
def opponentHasInsufficientMaterial() =
fens.map: position =>
position.variant.opponentHasInsufficientMaterial(position)
2 changes: 2 additions & 0 deletions core/src/main/scala/Bitboard.scala
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ object Bitboard:
inline def apply(inline xs: Iterable[Square]): Bitboard = xs.foldLeft(empty)((b, s) => b | s.bl)
inline def apply(inline xs: Square*): Bitboard = apply(xs.toList)

def fromKeys(keys: String*): Bitboard = Bitboard(keys.flatMap(Square.fromKey))

val empty: Bitboard = 0L
val all: Bitboard = -1L
// E4, D4, E5, D5
Expand Down
7 changes: 7 additions & 0 deletions core/src/main/scala/Board.scala
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@ case class Board(occupied: Bitboard, byColor: ByColor[Bitboard], byRole: ByRole[
s.pawnAttacks(!attacker) & pawns
)

def squaresAttackedByPawns(attacker: Color): Bitboard =
var enemyPawnAttacks = Bitboard.empty
byPiece(attacker, Pawn).foreach { sq =>
enemyPawnAttacks |= sq.pawnAttacks(attacker)
}
enemyPawnAttacks

/* is a king of this color in check */
def isCheck(color: Color): Check =
Check(kings(color).exists(attacks(_, !color)))
Expand Down
69 changes: 61 additions & 8 deletions core/src/main/scala/InsufficientMatingMaterial.scala
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ object InsufficientMatingMaterial:
board.bishops.intersects(Bitboard.lightSquares) &&
board.bishops.intersects(Bitboard.darkSquares)

/*
/**
* Returns true if a pawn cannot progress forward because it is blocked by a pawn
* and it doesn't have any capture
*/
Expand All @@ -28,15 +28,67 @@ object InsufficientMatingMaterial:
}
)

/*
/**
* Returns whether some square in `destinations` can be reached by a king moving from `startSquare`,
* while avoiding all squares in `forbidden`.
*
* `destinations` must not contain `startSquare`, or any square in `forbidden`.
*/
def kingPathExists(startSquare: Square, destinations: Bitboard, forbidden: Bitboard): Option[Boolean] =
if destinations.intersects(forbidden.add(startSquare)) then None
else
@annotation.tailrec
def bfs(frontier: Bitboard, skip: Bitboard): Boolean =
if frontier.isEmpty then false
else if frontier.intersects(destinations) then true
else
val updatedSkip = skip | frontier
bfs(frontier.fold(Bitboard.empty)((acc, sq) => acc | sq.kingAttacks) & ~updatedSkip, updatedSkip)
Some(bfs(startSquare.bb, forbidden))

/**
* Checks if all pawns are locked, just with respect to each other. Other pieces that could allow the
* pawns to make captures are not considered.
*/
def allPawnsLocked(board: Board): Boolean =
List(White, Black).forall: color =>
board.squaresAttackedByPawns(color).isDisjoint(board.byPiece(!color, Pawn)) &&
board
.byPiece(color, Pawn)
.forall: pawnSq =>
pawnSq
.nextRank(color)
.exists: frontSq =>
board.pawns.contains(frontSq)

def kingPawnFortress(position: Position): Boolean =
val board = position.board
(board.kings | board.pawns) == board.occupied &&
allPawnsLocked(board) &&
(
List(White, Black).forall: color =>
val squaresAttackedByEnemyPawns = board.squaresAttackedByPawns(!color)
val squareOfKing = board.kingPosOf(color).get
!squaresAttackedByEnemyPawns.contains(squareOfKing) && !kingPathExists(
squareOfKing,
board.byPiece(!color, Pawn) & ~squaresAttackedByEnemyPawns,
board.byPiece(color, Pawn) | squaresAttackedByEnemyPawns
).get
) &&
position.enPassantSquare.isEmpty

/**
* Determines whether a board position is an automatic draw due to neither player
* being able to mate the other as informed by the traditional chess rules.
*/
def apply(board: Board): Boolean =
board.kingsAndMinorsOnly &&
(board.nbPieces <= 3 || (board.kingsAndBishopsOnly && !bishopsOnOppositeColors(board)))
def apply(position: Position): Boolean =
val board = position.board
(
board.kingsAndMinorsOnly &&
(board.nbPieces <= 3 || (board.kingsAndBishopsOnly && !bishopsOnOppositeColors(board)))
) || kingPawnFortress(position)

/*
/**
* Determines whether a color does not have mating material. In general:
* King by itself is not mating material
* King + knight mates against king + any(rook, bishop, knight, pawn)
Expand All @@ -49,13 +101,14 @@ object InsufficientMatingMaterial:
* - opposite color bishop(s)
* - or knight(s) or pawn(s)
*/
def apply(board: Board, color: Color): Boolean =
def apply(position: Position, color: Color): Boolean =
import board.*
val board = position.board
inline def onlyKing = kingsOnlyOf(color)
inline def KN =
onlyOf(color, King, Knight) && count(color, Knight) == 1 && onlyOf(!color, King, Queen)
inline def KB =
onlyOf(color, King, Bishop) &&
!(bishopsOnOppositeColors(board) || byPiece(!color, Knight, Pawn).nonEmpty)

onlyKing || KN || KB
onlyKing || KN || KB || kingPawnFortress(position)
9 changes: 3 additions & 6 deletions core/src/main/scala/variant/Antichess.scala
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,9 @@ case object Antichess

// In antichess, if the only remaining pieces are a knight each, then exactly one
// player can win (depending on whose turn it is).

override def opponentHasInsufficientMaterial(position: Position): Boolean =
justOneKnightEach(position) && allOnSameColourSquares(position)

override def playerHasInsufficientMaterial(position: Position): Boolean =
justOneKnightEach(position) && !allOnSameColourSquares(position)
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean =
val isPlayer = color == position.color
justOneKnightEach(position) && allOnSameColourSquares(position) != isPlayer

// No player can win if the only remaining pieces are opposing bishops on different coloured
// diagonals. There may be pawns that are incapable of moving and do not attack the right color
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/variant/Atomic.scala
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ case object Atomic
* a piece in the opponent's king's proximity. On the other hand, a king alone or a king with
* immobile pawns is not sufficient material to win with.
*/
override def opponentHasInsufficientMaterial(position: Position) =
position.kingsOnlyOf(!position.color)
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean =
position.kingsOnlyOf(color)

/** Atomic chess has a special end where a king has been killed by exploding with an adjacent captured piece */
override def specialEnd(position: Position): Boolean = position.kings.count < 2
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/scala/variant/Crazyhouse.scala
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ case object Crazyhouse
super.checkmate(position) && !canDropStuff(position)

// there is always sufficient mating material in Crazyhouse
override def opponentHasInsufficientMaterial(position: Position): Boolean = false
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean = false
override def isInsufficientMaterial(position: Position): Boolean = false

// if the king is not in check, all drops are possible, we just return None
Expand Down
13 changes: 7 additions & 6 deletions core/src/main/scala/variant/Horde.scala
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,9 @@ case object Horde
* Technically there are some positions where stalemate is unavoidable which
* this method does not detect; however, such are trivial to premove.
*/
override def opponentHasInsufficientMaterial(position: Position): Boolean =
hasInsufficientMaterial(position.board, !position.color) || isInsufficientMaterial(position)

override def playerHasInsufficientMaterial(position: Position): Boolean =
hasInsufficientMaterial(position.board, position.color) || isInsufficientMaterial(position)
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean =
pureMaterialInsufficiency(position.board, color) || isInsufficientMaterial(position)

/** If the horde is stalemated and all of Black's moves keep the stalemate, it's a fortress draw.
* This does not consider imminent fortresses such as 8/p7/P7/8/8/P7/8/k7 b - -
Expand All @@ -87,7 +85,7 @@ case object Horde
val bishops = board.bishops & board.byColor(side)
bishops.intersects(Bitboard.lightSquares) && bishops.intersects(Bitboard.darkSquares)

private[chess] def hasInsufficientMaterial(board: Board, color: Color): Boolean =
private[chess] def pureMaterialInsufficiency(board: Board, color: Color): Boolean =
import SquareColor.*
// Black can always win by capturing the horde
if color.black then false
Expand Down Expand Up @@ -141,7 +139,10 @@ case object Horde
val pawnSquare = (board.pawns & board.byColor(Color.white)).first.get // we know there is a pawn
val promoteToQueen = board.putOrReplace(White.queen, pawnSquare)
val promoteToKnight = board.putOrReplace(White.knight, pawnSquare)
hasInsufficientMaterial(promoteToQueen, color) && hasInsufficientMaterial(promoteToKnight, color)
pureMaterialInsufficiency(promoteToQueen, color) && pureMaterialInsufficiency(
promoteToKnight,
color
)
else if horde.rook == 1 then
// A lone rook mates a king on A8 bounded by a pawn/rook on A7 and a
// pawn/knight on B7. We ignore every other case, since it can be
Expand Down
2 changes: 1 addition & 1 deletion core/src/main/scala/variant/KingOfTheHill.scala
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,5 @@ case object KingOfTheHill

/** You only need a king to be able to win in this variant
*/
override def opponentHasInsufficientMaterial(position: Position): Boolean = false
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean = false
override def isInsufficientMaterial(position: Position): Boolean = false
2 changes: 1 addition & 1 deletion core/src/main/scala/variant/RacingKings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ case object RacingKings
super.valid(position, strict) && (!strict || position.check.no)

override def isInsufficientMaterial(position: Position): Boolean = false
override def opponentHasInsufficientMaterial(position: Position): Boolean = false
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean = false

// It is a win, when exactly one king made it to the goal. When white reaches
// the goal and black can make it on the next ply, he is given a chance to
Expand Down
4 changes: 2 additions & 2 deletions core/src/main/scala/variant/ThreeCheck.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ case object ThreeCheck

/** It's not possible to check or checkmate the opponent with only a king
*/
override def opponentHasInsufficientMaterial(position: Position): Boolean =
position.kingsOnlyOf(!position.color)
override protected def hasInsufficientMaterial(position: Position, color: Color): Boolean =
position.kingsOnlyOf(color)

/**
* When there is insufficient mating material, there is still potential to win by checking the opponent 3 times
Expand Down
14 changes: 8 additions & 6 deletions core/src/main/scala/variant/Variant.scala
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,20 @@ abstract class Variant private[variant] (

/** Returns true if neither player can win. The game should end immediately.
*/
def isInsufficientMaterial(position: Position): Boolean = InsufficientMatingMaterial(position.board)
def isInsufficientMaterial(position: Position): Boolean = InsufficientMatingMaterial(position)

protected def hasInsufficientMaterial(position: Position, color: Color): Boolean =
InsufficientMatingMaterial(position, color)

/** Returns true if the other player cannot win. This is relevant when the
* side to move times out or disconnects. Instead of losing on time,
* the game should be drawn.
*/
def opponentHasInsufficientMaterial(position: Position): Boolean =
InsufficientMatingMaterial(position.board, !position.color)
final def opponentHasInsufficientMaterial(position: Position): Boolean =
hasInsufficientMaterial(position, !position.color)

def playerHasInsufficientMaterial(position: Position): Boolean =
// For all variants except Antichess and Horde, considering turn isn't needed:
opponentHasInsufficientMaterial(position.withColor(!position.color))
final def playerHasInsufficientMaterial(position: Position): Boolean =
hasInsufficientMaterial(position, position.color)

def fiftyMoves(history: History): Boolean =
history.halfMoveClock >= HalfMoveClock(100)
Expand Down
4 changes: 4 additions & 0 deletions test-kit/src/test/resources/king_pawn_fortresses.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
id,fen,color,shouldBreakthrough,reachables
fen1-white,8/p1p3p1/P1Ppk1Pp/3Pp2P/3pPp2/3PpP2/4P3/1K6 b - - 0 1,white,true,a1 c1-h1 a2-c2 g2-h2 h3 h4 g4 a3-b3 a4-c4 a5-b5 b7 a8-h8 f5 e6 a7-h7
fen2-white,8/p1p3p1/P1Ppk1Pp/p1pPp2P/P1PpPp2/p1pPpP2/P1P1P3/1K6 b - - 0 1,white,true,a1 c1-h1 g2-h2 h3 g4-h4 f5 e6 a7-h7 a8-h8
fen2-black,8/p1p3p1/P1Ppk1Pp/p1pPp2P/P1PpPp2/p1pPpP2/P1P1P3/1K6 b - - 0 1,black,true,a8-h8 e7 f6 g5-h5 h4 g3-h3 a2-h2 a1-h1
19 changes: 18 additions & 1 deletion test-kit/src/test/scala/AtomicVariantTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -285,14 +285,22 @@ class AtomicVariantTest extends ChessTest:
.isRight
)

test("Identify that a player does not have sufficient material to win when they only have a king"):
test("Identify that a player has insufficient material to win when they only have a king"):
val position = FullFen("8/8/8/8/7p/2k4q/2K3P1/8 w - - 19 54")
val game = fenToGame(position, Atomic)
assertNot(game.position.end)
game
.playMoves(Square.G2 -> Square.H3)
.assertRight: game =>
assert(game.position.opponentHasInsufficientMaterial)
assertNot(game.position.playerHasInsufficientMaterial)
assertNot(game.position.autoDraw)
game
.playMoves(Square.G2 -> Square.H3, Square.C3 -> Square.D4)
.assertRight: game =>
assertNot(game.position.opponentHasInsufficientMaterial)
assert(game.position.playerHasInsufficientMaterial)
assertNot(game.position.autoDraw)

test("An automatic draw in a closed position with only kings and pawns which cannot move"):
val position = FullFen("8/8/6p1/3K4/6P1/2k5/8/8 w - -")
Expand All @@ -302,6 +310,15 @@ class AtomicVariantTest extends ChessTest:
assert(game.position.autoDraw)
assert(game.position.end)

test(
"Not draw inappropriately in seemingly closed position where en passant possible"
):
val position = FullFen("3k4/8/6p1/5pP1/5P2/8/3K4/8 w - f6 0 1")
val game = fenToGame(position, Atomic)
assertNot(game.position.playerHasInsufficientMaterial)
assertNot(game.position.opponentHasInsufficientMaterial)
assertNot(game.position.autoDraw)

test("Not draw inappropriately on bishops vs bishops (where an explosion taking out the king is possible)"):
val position = FullFen("B2BBBB1/7P/8/8/8/8/3kb3/4K3 w - - 1 53")
fenToGame(position, Atomic)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,4 @@ object HordeInsufficientMaterialTest extends SimpleIOSuite:
private case class Case(fen: FullFen, expected: Boolean, comment: Option[String]):
def run(variant: Variant): Boolean =
val board = Fen.read(variant, fen).get
Horde.hasInsufficientMaterial(board.board, !board.color) == expected
Horde.pureMaterialInsufficiency(board.board, !board.color) == expected
Loading