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
@@ -8,6 +8,7 @@ import de.nowchess.api.board.{Color, Square}
* @param kingSide king-side castling still legally available
* @param queenSide queen-side castling still legally available
*/
@deprecated("Use de.nowchess.api.board.CastlingRights via GameContext.", "NCS-22")
final case class CastlingRights(kingSide: Boolean, queenSide: Boolean)
object CastlingRights:
@@ -15,12 +16,14 @@ object CastlingRights:
val Both: CastlingRights = CastlingRights(kingSide = true, queenSide = true)
/** Outcome of a finished game. */
@deprecated("Use GameContext and derive game lifecycle from rules/engine state.", "NCS-22")
enum GameResult:
case WhiteWins
case BlackWins
case Draw
/** Lifecycle state of a game. */
@deprecated("Use GameContext and engine events for lifecycle handling.", "NCS-22")
enum GameStatus:
case NotStarted
case InProgress
@@ -42,6 +45,7 @@ enum GameStatus:
* @param fullMoveNumber increments after Black's move, starts at 1
* @param status current lifecycle status of the game
*/
@deprecated("Use GameContext for runtime state; keep GameState only for legacy compatibility.", "NCS-22")
final case class GameState(
piecePlacement: String,
activeColor: Color,
@@ -0,0 +1,63 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.move.Move
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameContextTest extends AnyFunSuite with Matchers:
test("GameContext.initial has initial board"):
GameContext.initial.board shouldBe Board.initial
test("GameContext.initial active color is White"):
GameContext.initial.turn shouldBe Color.White
test("GameContext.initial has full castling rights"):
GameContext.initial.castlingRights shouldBe CastlingRights.Initial
test("GameContext.initial en-passant square is None"):
GameContext.initial.enPassantSquare shouldBe None
test("GameContext.initial half-move clock is 0"):
GameContext.initial.halfMoveClock shouldBe 0
test("GameContext.initial move history is empty"):
GameContext.initial.moves shouldBe List.empty
test("withBoard updates only board"):
val square = Square(File.E, Rank.R4)
val updatedBoard = Board.initial.updated(square, de.nowchess.api.board.Piece.WhiteQueen)
val updated = GameContext.initial.withBoard(updatedBoard)
updated.board shouldBe updatedBoard
updated.turn shouldBe GameContext.initial.turn
updated.castlingRights shouldBe GameContext.initial.castlingRights
updated.enPassantSquare shouldBe GameContext.initial.enPassantSquare
updated.halfMoveClock shouldBe GameContext.initial.halfMoveClock
updated.moves shouldBe GameContext.initial.moves
test("withTurn updates only turn"):
val updated = GameContext.initial.withTurn(Color.Black)
updated.turn shouldBe Color.Black
updated.board shouldBe GameContext.initial.board
test("withCastlingRights updates castling rights"):
val rights = CastlingRights(
whiteKingSide = true,
whiteQueenSide = false,
blackKingSide = false,
blackQueenSide = true
)
GameContext.initial.withCastlingRights(rights).castlingRights shouldBe rights
test("withEnPassantSquare updates en-passant square"):
val square = Some(Square(File.E, Rank.R3))
GameContext.initial.withEnPassantSquare(square).enPassantSquare shouldBe square
test("withHalfMoveClock updates half-move clock"):
GameContext.initial.withHalfMoveClock(17).halfMoveClock shouldBe 17
test("withMove appends move to history"):
val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))
GameContext.initial.withMove(move).moves shouldBe List(move)
@@ -1,77 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.board.Color
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameStateTest extends AnyFunSuite with Matchers:
test("CastlingRights.None has both flags false") {
CastlingRights.None.kingSide shouldBe false
CastlingRights.None.queenSide shouldBe false
}
test("CastlingRights.Both has both flags true") {
CastlingRights.Both.kingSide shouldBe true
CastlingRights.Both.queenSide shouldBe true
}
test("CastlingRights constructor sets fields") {
val cr = CastlingRights(kingSide = true, queenSide = false)
cr.kingSide shouldBe true
cr.queenSide shouldBe false
}
test("GameResult cases exist") {
GameResult.WhiteWins shouldBe GameResult.WhiteWins
GameResult.BlackWins shouldBe GameResult.BlackWins
GameResult.Draw shouldBe GameResult.Draw
}
test("GameStatus.NotStarted") {
GameStatus.NotStarted shouldBe GameStatus.NotStarted
}
test("GameStatus.InProgress") {
GameStatus.InProgress shouldBe GameStatus.InProgress
}
test("GameStatus.Finished carries result") {
val status = GameStatus.Finished(GameResult.Draw)
status shouldBe GameStatus.Finished(GameResult.Draw)
status match
case GameStatus.Finished(r) => r shouldBe GameResult.Draw
case _ => fail("expected Finished")
}
test("GameState.initial has standard FEN piece placement") {
GameState.initial.piecePlacement shouldBe "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
}
test("GameState.initial active color is White") {
GameState.initial.activeColor shouldBe Color.White
}
test("GameState.initial white has full castling rights") {
GameState.initial.castlingWhite shouldBe CastlingRights.Both
}
test("GameState.initial black has full castling rights") {
GameState.initial.castlingBlack shouldBe CastlingRights.Both
}
test("GameState.initial en-passant target is None") {
GameState.initial.enPassantTarget shouldBe None
}
test("GameState.initial half-move clock is 0") {
GameState.initial.halfMoveClock shouldBe 0
}
test("GameState.initial full-move number is 1") {
GameState.initial.fullMoveNumber shouldBe 1
}
test("GameState.initial status is InProgress") {
GameState.initial.status shouldBe GameStatus.InProgress
}
@@ -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
@@ -1,8 +1,10 @@
package de.nowchess.rules
package de.nowchess.rules.sets
import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square, PieceType, Piece}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.rules.RuleSet
import scala.annotation.tailrec
/** Standard chess rules implementation.
@@ -10,10 +10,8 @@ import scalafx.scene.shape.Rectangle
import scalafx.scene.text.{Font, Text}
import scalafx.stage.Stage
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square, File, Rank}
import de.nowchess.api.game.{CastlingRights, GameState, GameStatus, GameHistory}
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.notation.{FenExporter, FenParser, PgnExporter, PgnParser}
/** ScalaFX chess board view that displays the game state.
* Uses chess sprites and color palette.