refactor(core): migrate GameState to GameContext and update FEN handling
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-04 17:55:08 +02:00
parent 6283db85c0
commit c08e5f8c62
12 changed files with 196 additions and 193 deletions
@@ -7,7 +7,8 @@ import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.chess.notation.{PgnExporter, PgnParser}
import de.nowchess.rules.{RuleSet, StandardRules}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.StandardRules
/** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet.
@@ -1,8 +1,7 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState}
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext
object FenExporter:
@@ -31,20 +30,21 @@ object FenExporter:
if emptyCount > 0 then rankChars += emptyCount.toString.charAt(0)
rankChars.mkString
/** Convert a GameState to a complete FEN string. */
def gameStateToFen(state: GameState): String =
val piecePlacement = state.piecePlacement
val activeColor = if state.activeColor == Color.White then "w" else "b"
val castling = castlingString(state.castlingWhite, state.castlingBlack)
val enPassant = state.enPassantTarget.map(_.toString).getOrElse("-")
s"$piecePlacement $activeColor $castling $enPassant ${state.halfMoveClock} ${state.fullMoveNumber}"
/** Convert a GameContext to a complete FEN string. */
def gameContextToFen(context: GameContext): String =
val piecePlacement = boardToFen(context.board)
val activeColor = if context.turn == Color.White then "w" else "b"
val castling = castlingString(context.castlingRights)
val enPassant = context.enPassantSquare.map(_.toString).getOrElse("-")
val fullMoveNumber = 1 + (context.moves.length / 2)
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
/** Convert castling rights to FEN notation. */
private def castlingString(white: CastlingRights, black: CastlingRights): String =
val wk = if white.kingSide then "K" else ""
val wq = if white.queenSide then "Q" else ""
val bk = if black.kingSide then "k" else ""
val bq = if black.queenSide then "q" else ""
private def castlingString(rights: CastlingRights): String =
val wk = if rights.whiteKingSide then "K" else ""
val wq = if rights.whiteQueenSide then "Q" else ""
val bk = if rights.blackKingSide then "k" else ""
val bq = if rights.blackQueenSide then "q" else ""
val result = s"$wk$wq$bk$bq"
if result.isEmpty then "-" else result
@@ -1,32 +1,30 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
import de.nowchess.api.game.GameContext
object FenParser:
/** Parse a complete FEN string into a GameState.
/** Parse a complete FEN string into a GameContext.
* Returns None if the format is invalid. */
def parseFen(fen: String): Option[GameState] =
def parseFen(fen: String): Option[GameContext] =
val parts = fen.trim.split("\\s+")
Option.when(parts.length == 6)(parts).flatMap: parts =>
for
_ <- parseBoard(parts(0))
board <- parseBoard(parts(0))
activeColor <- parseColor(parts(1))
castlingRights <- parseCastling(parts(2))
enPassant <- parseEnPassant(parts(3))
halfMoveClock <- parts(4).toIntOption
fullMoveNumber <- parts(5).toIntOption
if halfMoveClock >= 0 && fullMoveNumber >= 1
yield GameState(
piecePlacement = parts(0),
activeColor = activeColor,
castlingWhite = castlingRights._1,
castlingBlack = castlingRights._2,
enPassantTarget = enPassant,
yield GameContext(
board = board,
turn = activeColor,
castlingRights = castlingRights,
enPassantSquare = enPassant,
halfMoveClock = halfMoveClock,
fullMoveNumber = fullMoveNumber,
status = GameStatus.InProgress
moves = List.empty
)
/** Parse active color ("w" or "b"). */
@@ -35,14 +33,17 @@ object FenParser:
else if s == "b" then Some(Color.Black)
else None
/** Parse castling rights string (e.g. "KQkq", "K", "-") into rights for White and Black. */
private def parseCastling(s: String): Option[(CastlingRights, CastlingRights)] =
/** Parse castling rights string (e.g. "KQkq", "K", "-") into unified castling rights. */
private def parseCastling(s: String): Option[CastlingRights] =
if s == "-" then
Some((CastlingRights.None, CastlingRights.None))
Some(CastlingRights.None)
else if s.length <= 4 && s.forall(c => "KQkq".contains(c)) then
val white = CastlingRights(kingSide = s.contains('K'), queenSide = s.contains('Q'))
val black = CastlingRights(kingSide = s.contains('k'), queenSide = s.contains('q'))
Some((white, black))
Some(CastlingRights(
whiteKingSide = s.contains('K'),
whiteQueenSide = s.contains('Q'),
blackKingSide = s.contains('k'),
blackQueenSide = s.contains('q')
))
else
None
@@ -3,7 +3,7 @@ package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{GameContext, HistoryMove}
import de.nowchess.rules.StandardRules
import de.nowchess.rules.sets.StandardRules
/** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame(
@@ -5,7 +5,8 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.notation.FenParser
import de.nowchess.chess.observer.*
import de.nowchess.rules.{RuleSet, StandardRules}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.StandardRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -1,89 +1,101 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus}
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class FenExporterTest extends AnyFunSuite with Matchers:
private def context(
piecePlacement: String,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
halfMoveClock: Int,
moveCount: Int
): GameContext =
val board = FenParser.parseBoard(piecePlacement).getOrElse(
fail(s"Invalid test board FEN: $piecePlacement")
)
val dummyMove = Move(Square(File.A, Rank.R2), Square(File.A, Rank.R3))
GameContext(
board = board,
turn = turn,
castlingRights = castlingRights,
enPassantSquare = enPassantSquare,
halfMoveClock = halfMoveClock,
moves = List.fill(moveCount)(dummyMove)
)
test("export initial position to FEN"):
val gameState = GameState.initial
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(GameContext.initial)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
test("export position after e4"):
val gameState = GameState(
val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR",
activeColor = Color.Black,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = Some(Square(File.E, Rank.R3)),
turn = Color.Black,
castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.E, Rank.R3)),
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
moveCount = 0
)
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
test("export position with no castling"):
val gameState = GameState(
val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.None,
castlingBlack = CastlingRights.None,
enPassantTarget = None,
turn = Color.White,
castlingRights = CastlingRights.None,
enPassantSquare = None,
halfMoveClock = 0,
fullMoveNumber = 1,
status = GameStatus.InProgress
moveCount = 0
)
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
test("export position with partial castling"):
val gameState = GameState(
val gameContext = context(
piecePlacement = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights(kingSide = true, queenSide = false),
castlingBlack = CastlingRights(kingSide = false, queenSide = true),
enPassantTarget = None,
turn = Color.White,
castlingRights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
),
enPassantSquare = None,
halfMoveClock = 5,
fullMoveNumber = 3,
status = GameStatus.InProgress
moveCount = 4
)
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w Kq - 5 3"
test("export position with en passant and move counts"):
val gameState = GameState(
val gameContext = context(
piecePlacement = "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR",
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = Some(Square(File.C, Rank.R6)),
turn = Color.White,
castlingRights = CastlingRights.All,
enPassantSquare = Some(Square(File.C, Rank.R6)),
halfMoveClock = 2,
fullMoveNumber = 3,
status = GameStatus.InProgress
moveCount = 4
)
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(gameContext)
fen shouldBe "rnbqkbnr/pp1ppppp/8/2pP4/8/8/PPPP1PPP/RNBQKBNR w KQkq c6 2 3"
test("halfMoveClock round-trips through FEN export and import"):
import de.nowchess.api.game.GameHistory
import de.nowchess.chess.notation.FenParser
val history = GameHistory(halfMoveClock = 42)
val gameState = GameState(
piecePlacement = FenExporter.boardToFen(de.nowchess.api.board.Board.initial),
activeColor = Color.White,
castlingWhite = CastlingRights.Both,
castlingBlack = CastlingRights.Both,
enPassantTarget = None,
halfMoveClock = history.halfMoveClock,
fullMoveNumber = 1,
status = GameStatus.InProgress
val gameContext = GameContext(
board = Board.initial,
turn = Color.White,
castlingRights = CastlingRights.All,
enPassantSquare = None,
halfMoveClock = 42,
moves = List.empty
)
val fen = FenExporter.gameStateToFen(gameState)
val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match
case Some(gs) => gs.halfMoveClock shouldBe 42
case Some(ctx) => ctx.halfMoveClock shouldBe 42
case None => fail("FEN parsing failed")
@@ -1,7 +1,6 @@
package de.nowchess.chess.notation
import de.nowchess.api.board.*
import de.nowchess.api.game.*
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -65,52 +64,51 @@ class FenParserTest extends AnyFunSuite with Matchers:
test("parse full FEN - initial position"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.isDefined shouldBe true
gameState.get.activeColor shouldBe Color.White
gameState.get.castlingWhite.kingSide shouldBe true
gameState.get.castlingWhite.queenSide shouldBe true
gameState.get.castlingBlack.kingSide shouldBe true
gameState.get.castlingBlack.queenSide shouldBe true
gameState.get.enPassantTarget shouldBe None
gameState.get.halfMoveClock shouldBe 0
gameState.get.fullMoveNumber shouldBe 1
context.isDefined shouldBe true
context.get.turn shouldBe Color.White
context.get.castlingRights.whiteKingSide shouldBe true
context.get.castlingRights.whiteQueenSide shouldBe true
context.get.castlingRights.blackKingSide shouldBe true
context.get.castlingRights.blackQueenSide shouldBe true
context.get.enPassantSquare shouldBe None
context.get.halfMoveClock shouldBe 0
test("parse full FEN - after e4"):
val fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.get.activeColor shouldBe Color.Black
gameState.get.enPassantTarget shouldBe Some(Square(File.E, Rank.R3))
context.get.turn shouldBe Color.Black
context.get.enPassantSquare shouldBe Some(Square(File.E, Rank.R3))
test("parse full FEN - invalid parts count"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
context.isDefined shouldBe false
test("parse full FEN - invalid color"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR x KQkq - 0 1"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
context.isDefined shouldBe false
test("parse full FEN - invalid castling"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w XYZ - 0 1"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.isDefined shouldBe false
context.isDefined shouldBe false
test("parseFen: castling '-' produces CastlingRights.None for both sides"):
test("parseFen: castling '-' produces no castling rights"):
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w - - 0 1"
val gameState = FenParser.parseFen(fen)
val context = FenParser.parseFen(fen)
gameState.isDefined shouldBe true
gameState.get.castlingWhite.kingSide shouldBe false
gameState.get.castlingWhite.queenSide shouldBe false
gameState.get.castlingBlack.kingSide shouldBe false
gameState.get.castlingBlack.queenSide shouldBe false
context.isDefined shouldBe true
context.get.castlingRights.whiteKingSide shouldBe false
context.get.castlingRights.whiteQueenSide shouldBe false
context.get.castlingRights.blackKingSide shouldBe false
context.get.castlingRights.blackQueenSide shouldBe false
test("parseBoard: returns None when a rank has too many files (overflow beyond 8)"):
// "9" alone would advance fileIdx to 9, exceeding 8 → None