Compare commits
5 Commits
api-0.4.0
...
rule-0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a045d31d7 | |||
| b518c704fa | |||
| fe8e3c0539 | |||
| 1b16adcc72 | |||
| b4bc72f7e4 |
+3
-1
@@ -32,7 +32,9 @@ val versions = mapOf(
|
||||
"SCOVERAGE" to "2.1.1",
|
||||
"SCALAFX" to "21.0.0-R32",
|
||||
"JAVAFX" to "21.0.1",
|
||||
"JUNIT_BOM" to "5.13.4"
|
||||
"JUNIT_BOM" to "5.13.4",
|
||||
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||
"FASTPARSE" to "3.0.2"
|
||||
)
|
||||
extra["VERSIONS"] = versions
|
||||
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
## (2026-04-06)
|
||||
## (2026-04-07)
|
||||
## (2026-04-07)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
|
||||
|
||||
@@ -38,6 +38,9 @@ dependencies {
|
||||
}
|
||||
}
|
||||
|
||||
implementation("org.scala-lang.modules:scala-parser-combinators_3:${versions["SCALA_PARSER_COMBINATORS"]!!}")
|
||||
implementation("com.lihaoyi:fastparse_3:${versions["FASTPARSE"]!!}")
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:rule"))
|
||||
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import scala.util.parsing.combinator.RegexParsers
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserCombinators extends RegexParsers with GameContextImport:
|
||||
|
||||
override val skipWhitespace: Boolean = false
|
||||
|
||||
// ── Piece character ──────────────────────────────────────────────────────
|
||||
|
||||
private def pieceChar: Parser[Piece] =
|
||||
"[prnbqkPRNBQK]".r ^^ { s =>
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
|
||||
private def emptyCount: Parser[Int] =
|
||||
"[1-8]".r ^^ { s => s.toInt }
|
||||
|
||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
||||
|
||||
private def rankToken: Parser[RankToken] =
|
||||
pieceChar ^^ PieceToken.apply | emptyCount ^^ EmptyToken.apply
|
||||
|
||||
private def rankTokens: Parser[List[RankToken]] = rep1(rankToken)
|
||||
|
||||
/** Parse rank string for a given Rank, producing (Square, Piece) pairs.
|
||||
* Fails if total file count != 8 or any piece placement exceeds board bounds. */
|
||||
private def rankParser(rank: Rank): Parser[List[(Square, Piece)]] =
|
||||
rankTokens >> { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => success(squares)
|
||||
case None => failure(s"Rank $rank is invalid")
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def rankSep: Parser[String] = "/"
|
||||
|
||||
/** Parse all 8 rank strings separated by '/', rank 8 down to rank 1. */
|
||||
private def boardParser: Parser[Board] =
|
||||
rankParser(Rank.R8) ~
|
||||
(rankSep ~> rankParser(Rank.R7)) ~
|
||||
(rankSep ~> rankParser(Rank.R6)) ~
|
||||
(rankSep ~> rankParser(Rank.R5)) ~
|
||||
(rankSep ~> rankParser(Rank.R4)) ~
|
||||
(rankSep ~> rankParser(Rank.R3)) ~
|
||||
(rankSep ~> rankParser(Rank.R2)) ~
|
||||
(rankSep ~> rankParser(Rank.R1)) ^^ {
|
||||
case r8 ~ r7 ~ r6 ~ r5 ~ r4 ~ r3 ~ r2 ~ r1 =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def colorParser: Parser[Color] =
|
||||
("w" | "b") ^^ {
|
||||
case "w" => Color.White
|
||||
case _ => Color.Black
|
||||
}
|
||||
|
||||
// ── Castling parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def castlingParser: Parser[CastlingRights] =
|
||||
"-" ^^^ CastlingRights.None |
|
||||
"[KQkq]{1,4}".r ^^ { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser: Parser[Option[Square]] =
|
||||
"-" ^^^ Option.empty[Square] |
|
||||
"[a-h][1-8]".r ^^ { s => Square.fromAlgebraic(s) }
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def clockParser: Parser[Int] =
|
||||
"""\d+""".r ^^ { _.toInt }
|
||||
|
||||
// ── Full FEN parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def fenParser: Parser[GameContext] =
|
||||
boardParser ~ (" " ~> colorParser) ~ (" " ~> castlingParser) ~
|
||||
(" " ~> enPassantParser) ~ (" " ~> clockParser) ~ (" " ~> clockParser) ^^ {
|
||||
case board ~ color ~ castling ~ ep ~ halfMove ~ _ =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
parseAll(fenParser, fen) match
|
||||
case Success(ctx, _) => Right(ctx)
|
||||
case other => Left(s"Invalid FEN: ${other.toString}")
|
||||
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
parseAll(boardParser, fen) match
|
||||
case Success(board, _) => Some(board)
|
||||
case _ => None
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import fastparse.*
|
||||
import fastparse.NoWhitespace.*
|
||||
import de.nowchess.api.board.*
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.io.GameContextImport
|
||||
import FenParserSupport.*
|
||||
|
||||
object FenParserFastParse extends GameContextImport:
|
||||
|
||||
// ── Low-level parsers ────────────────────────────────────────────────────
|
||||
|
||||
private def pieceChar(using P[Any]): P[Piece] =
|
||||
CharIn("prnbqkPRNBQK").!.map { s =>
|
||||
val c = s.head
|
||||
val color = if c.isUpper then Color.White else Color.Black
|
||||
Piece(color, charToPieceType(c.toLower))
|
||||
}
|
||||
|
||||
private def emptyCount(using P[Any]): P[Int] =
|
||||
CharIn("1-8").!.map(_.toInt)
|
||||
|
||||
private def rankToken(using P[Any]): P[RankToken] =
|
||||
pieceChar.map(PieceToken.apply) | emptyCount.map(EmptyToken.apply)
|
||||
|
||||
// ── Rank parser ──────────────────────────────────────────────────────────
|
||||
|
||||
private def rankParser(rank: Rank)(using P[Any]): P[List[(Square, Piece)]] =
|
||||
rankToken.rep(1).flatMap { tokens =>
|
||||
buildSquares(rank, tokens) match
|
||||
case Some(squares) => Pass(squares)
|
||||
case None => Fail
|
||||
}
|
||||
|
||||
// ── Board parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sep(using P[Any]): P[Unit] = LiteralStr("/").map(_ => ())
|
||||
|
||||
private def boardParser(using P[Any]): P[Board] =
|
||||
(rankParser(Rank.R8) ~ sep ~
|
||||
rankParser(Rank.R7) ~ sep ~
|
||||
rankParser(Rank.R6) ~ sep ~
|
||||
rankParser(Rank.R5) ~ sep ~
|
||||
rankParser(Rank.R4) ~ sep ~
|
||||
rankParser(Rank.R3) ~ sep ~
|
||||
rankParser(Rank.R2) ~ sep ~
|
||||
rankParser(Rank.R1)).map { case (r8, r7, r6, r5, r4, r3, r2, r1) =>
|
||||
Board((r8 ++ r7 ++ r6 ++ r5 ++ r4 ++ r3 ++ r2 ++ r1).toMap)
|
||||
}
|
||||
|
||||
// ── Color parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def colorParser(using P[Any]): P[Color] =
|
||||
(LiteralStr("w") | LiteralStr("b")).!.map {
|
||||
case "w" => Color.White
|
||||
case _ => Color.Black
|
||||
}
|
||||
|
||||
// ── Castling parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def castlingParser(using P[Any]): P[CastlingRights] =
|
||||
LiteralStr("-").map(_ => CastlingRights.None) |
|
||||
CharsWhileIn("KQkq").!.map { s =>
|
||||
CastlingRights(
|
||||
whiteKingSide = s.contains('K'),
|
||||
whiteQueenSide = s.contains('Q'),
|
||||
blackKingSide = s.contains('k'),
|
||||
blackQueenSide = s.contains('q')
|
||||
)
|
||||
}
|
||||
|
||||
// ── En passant parser ────────────────────────────────────────────────────
|
||||
|
||||
private def enPassantParser(using P[Any]): P[Option[Square]] =
|
||||
LiteralStr("-").map(_ => Option.empty[Square]) |
|
||||
(CharIn("a-h") ~ CharIn("1-8")).!.map(s => Square.fromAlgebraic(s))
|
||||
|
||||
// ── Clock parser ─────────────────────────────────────────────────────────
|
||||
|
||||
private def clockParser(using P[Any]): P[Int] =
|
||||
CharsWhileIn("0-9").!.map(_.toInt)
|
||||
|
||||
// ── Space helper ─────────────────────────────────────────────────────────
|
||||
|
||||
private def sp(using P[Any]): P[Unit] = LiteralStr(" ").map(_ => ())
|
||||
|
||||
// ── Full FEN parser ──────────────────────────────────────────────────────
|
||||
|
||||
private def fenParser(using P[Any]): P[GameContext] =
|
||||
(boardParser ~ sp ~ colorParser ~ sp ~ castlingParser ~ sp ~
|
||||
enPassantParser ~ sp ~ clockParser ~ sp ~ clockParser ~ End).map {
|
||||
case (board, color, castling, ep, halfMove, _) =>
|
||||
GameContext(
|
||||
board = board,
|
||||
turn = color,
|
||||
castlingRights = castling,
|
||||
enPassantSquare = ep,
|
||||
halfMoveClock = halfMove,
|
||||
moves = List.empty
|
||||
)
|
||||
}
|
||||
|
||||
// ── Public API ───────────────────────────────────────────────────────────
|
||||
|
||||
def parseFen(fen: String): Either[String, GameContext] =
|
||||
parse(fen, fenParser(using _)) match
|
||||
case Parsed.Success(ctx, _) => Right(ctx)
|
||||
case f: Parsed.Failure => Left(s"Invalid FEN: ${f.msg}")
|
||||
|
||||
private def boardParserFull(using P[Any]): P[Board] =
|
||||
boardParser ~ End
|
||||
|
||||
def parseBoard(fen: String): Option[Board] =
|
||||
parse(fen, boardParserFull(using _)) match
|
||||
case Parsed.Success(board, _) => Some(board)
|
||||
case _ => None
|
||||
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
parseFen(input)
|
||||
@@ -0,0 +1,32 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
|
||||
private[fen] object FenParserSupport:
|
||||
|
||||
sealed trait RankToken
|
||||
case class PieceToken(piece: Piece) extends RankToken
|
||||
case class EmptyToken(count: Int) extends RankToken
|
||||
|
||||
val charToPieceType: Map[Char, PieceType] = Map(
|
||||
'p' -> PieceType.Pawn,
|
||||
'r' -> PieceType.Rook,
|
||||
'n' -> PieceType.Knight,
|
||||
'b' -> PieceType.Bishop,
|
||||
'q' -> PieceType.Queen,
|
||||
'k' -> PieceType.King
|
||||
)
|
||||
|
||||
def buildSquares(rank: Rank, tokens: Seq[RankToken]): Option[List[(Square, Piece)]] =
|
||||
tokens.foldLeft(Option((List.empty[(Square, Piece)], 0))):
|
||||
case (None, _) => None
|
||||
case (Some((acc, fileIdx)), PieceToken(piece)) =>
|
||||
if fileIdx > 7 then None
|
||||
else
|
||||
val sq = Square(File.values(fileIdx), rank)
|
||||
Some((acc :+ (sq -> piece), fileIdx + 1))
|
||||
case (Some((acc, fileIdx)), EmptyToken(n)) =>
|
||||
val next = fileIdx + n
|
||||
if next > 8 then None
|
||||
else Some((acc, next))
|
||||
.flatMap { case (squares, total) => if total == 8 then Some(squares) else None }
|
||||
@@ -0,0 +1,60 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserCombinatorsTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
FenParserCombinators.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
FenParserCombinators.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||
FenParserCombinators.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
FenParserCombinators.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||
FenParserCombinators.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
FenParserCombinators.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
|
||||
|
||||
test("importGameContext returns Right for valid and Left for invalid FEN"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
FenParserCombinators.importGameContext(fen).isRight shouldBe true
|
||||
FenParserCombinators.importGameContext("invalid fen string").isLeft shouldBe true
|
||||
|
||||
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||
FenParserCombinators.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserCombinators.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
test("parseBoard rejects ranks that overflow via multiple tokens"):
|
||||
// EmptyToken overflow: piece then 8 empties = 9 total
|
||||
FenParserCombinators.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
|
||||
// fold short-circuit: 8 empties followed by two pieces = 10 total, exercises the None-propagation path
|
||||
FenParserCombinators.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||
@@ -0,0 +1,58 @@
|
||||
package de.nowchess.io.fen
|
||||
|
||||
import de.nowchess.api.board.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class FenParserFastParseTest extends AnyFunSuite with Matchers:
|
||||
|
||||
test("parseBoard parses canonical positions and supports round-trip"):
|
||||
val initial = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
||||
val empty = "8/8/8/8/8/8/8/8"
|
||||
val partial = "8/8/4k3/8/4K3/8/8/8"
|
||||
|
||||
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R2))) shouldBe Some(Some(Piece.WhitePawn))
|
||||
FenParserFastParse.parseBoard(initial).map(_.pieceAt(Square(File.E, Rank.R8))) shouldBe Some(Some(Piece.BlackKing))
|
||||
FenParserFastParse.parseBoard(empty).map(_.pieces.size) shouldBe Some(0)
|
||||
FenParserFastParse.parseBoard(partial).map(_.pieceAt(Square(File.E, Rank.R6))) shouldBe Some(Some(Piece.BlackKing))
|
||||
|
||||
FenParserFastParse.parseBoard(initial).map(FenExporter.boardToFen) shouldBe Some(initial)
|
||||
FenParserFastParse.parseBoard(empty).map(FenExporter.boardToFen) shouldBe Some(empty)
|
||||
|
||||
test("parseFen parses full state for common valid inputs"):
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.White
|
||||
ctx.castlingRights.whiteKingSide shouldBe true
|
||||
ctx.enPassantSquare shouldBe None
|
||||
ctx.halfMoveClock shouldBe 0
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.turn shouldBe Color.Black
|
||||
ctx.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
|
||||
)
|
||||
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1").fold(_ => fail(), ctx =>
|
||||
ctx.castlingRights.whiteKingSide shouldBe false
|
||||
ctx.castlingRights.blackQueenSide shouldBe false
|
||||
)
|
||||
|
||||
test("parseFen rejects invalid color and castling tokens"):
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1").isLeft shouldBe true
|
||||
FenParserFastParse.parseFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1").isLeft shouldBe true
|
||||
|
||||
test("importGameContext returns Right for valid and Left for invalid FEN"):
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
FenParserFastParse.importGameContext(fen).isRight shouldBe true
|
||||
FenParserFastParse.importGameContext("invalid fen string").isLeft shouldBe true
|
||||
|
||||
test("parseBoard rejects malformed board shapes and invalid piece symbols"):
|
||||
FenParserFastParse.parseBoard("8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("9/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8p/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("7/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8/8/8/8/8/8/8/7X") shouldBe None
|
||||
|
||||
test("parseBoard rejects ranks that overflow via multiple tokens"):
|
||||
FenParserFastParse.parseBoard("p8/8/8/8/8/8/8/8") shouldBe None
|
||||
FenParserFastParse.parseBoard("8pp/8/8/8/8/8/8/8") shouldBe None
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=2
|
||||
MINOR=1
|
||||
PATCH=0
|
||||
|
||||
@@ -1,2 +1,7 @@
|
||||
## (2026-04-06)
|
||||
## (2026-04-07)
|
||||
## (2026-04-07)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
|
||||
|
||||
@@ -163,6 +163,12 @@ object DefaultRules extends RuleSet:
|
||||
CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside))
|
||||
moves.toList
|
||||
|
||||
private def queensideBSquare(kingToAlg: String): List[String] =
|
||||
kingToAlg match
|
||||
case "c1" => List("b1")
|
||||
case "c8" => List("b8")
|
||||
case _ => List.empty
|
||||
|
||||
private def addCastleMove(
|
||||
context: GameContext,
|
||||
moves: scala.collection.mutable.ListBuffer[Move],
|
||||
@@ -170,7 +176,8 @@ object DefaultRules extends RuleSet:
|
||||
castlingMove: CastlingMove
|
||||
): Unit =
|
||||
if castlingRight then
|
||||
val clearSqs = List(castlingMove.middleAlg, castlingMove.kingToAlg).flatMap(Square.fromAlgebraic)
|
||||
val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
|
||||
.flatMap(Square.fromAlgebraic)
|
||||
if squaresEmpty(context.board, clearSqs) then
|
||||
for
|
||||
kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
|
||||
|
||||
@@ -52,7 +52,7 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
// King must move; e2 should be valid but d1 might be blocked by rook if still on same file
|
||||
moves.filter(m => m.from == Square(File.E, Rank.R1)).nonEmpty shouldBe true
|
||||
moves.exists(m => m.from == Square(File.E, Rank.R1)) shouldBe true
|
||||
|
||||
test("king cannot move to square attacked by opponent"):
|
||||
// FEN: white king e1, black rook e2 defended by black king e3
|
||||
@@ -109,6 +109,28 @@ class DefaultRulesTest extends AnyFunSuite with Matchers:
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleKingside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
test("castling queenside is illegal when knight blocks on b8"):
|
||||
// Black king e8, black rook a8, black knight b8 (blocks queenside path)
|
||||
val board = Board(Map(
|
||||
Square(File.A, Rank.R8) -> Piece(Color.Black, PieceType.Rook),
|
||||
Square(File.B, Rank.R8) -> Piece(Color.Black, PieceType.Knight),
|
||||
Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King),
|
||||
Square(File.A, Rank.R1) -> Piece(Color.White, PieceType.Rook),
|
||||
Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King)
|
||||
))
|
||||
val context = GameContext(
|
||||
board = board,
|
||||
turn = Color.Black,
|
||||
castlingRights = CastlingRights(whiteKingSide = true, whiteQueenSide = true, blackKingSide = true, blackQueenSide = true),
|
||||
enPassantSquare = None,
|
||||
halfMoveClock = 0,
|
||||
moves = List.empty
|
||||
)
|
||||
val moves = rules.allLegalMoves(context)
|
||||
|
||||
val castles = moves.filter(m => m.moveType == MoveType.CastleQueenside)
|
||||
castles.isEmpty shouldBe true
|
||||
|
||||
// ── En passant legality ──────────────────────────────────────────
|
||||
|
||||
test("en passant is legal when en passant square is set"):
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=2
|
||||
PATCH=3
|
||||
|
||||
@@ -41,3 +41,11 @@
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
## (2026-04-07)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
|
||||
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=6
|
||||
MINOR=7
|
||||
PATCH=0
|
||||
|
||||
Reference in New Issue
Block a user