refactor(core): integrate Rule module and update GameContext handling

This commit is contained in:
2026-04-04 20:25:20 +02:00
parent 6def31dd80
commit 9b3bbfbcb7
16 changed files with 64 additions and 55 deletions
@@ -6,9 +6,9 @@ import de.nowchess.api.game.GameContext
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.chess.notation.{PgnExporter, PgnParser} import de.nowchess.io.pgn.PgnParser
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.StandardRules import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes. /** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet. * All rule queries delegate to the injected RuleSet.
@@ -16,7 +16,7 @@ import de.nowchess.rules.sets.StandardRules
*/ */
class GameEngine( class GameEngine(
initialContext: GameContext = GameContext.initial, initialContext: GameContext = GameContext.initial,
ruleSet: RuleSet = StandardRules ruleSet: RuleSet = DefaultRules
) extends Observable: ) extends Observable:
private var currentContext: GameContext = initialContext private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker() private val invoker = new CommandInvoker()
@@ -146,25 +146,16 @@ class GameEngine(
invoker.clear() invoker.clear()
var error: Option[String] = None var error: Option[String] = None
import scala.util.control.Breaks._ game.moves.foreach: histMove =>
breakable {
game.moves.foreach { histMove =>
handleParsedMove(histMove.from, histMove.to) handleParsedMove(histMove.from, histMove.to)
histMove.promotionPiece.foreach(completePromotion) histMove.promotionPiece.foreach(completePromotion)
// $COVERAGE-OFF$ unreachable: PgnParser.validatePgn ensures promotion annotation is present
if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then
error = Some(s"Promotion required for move ${histMove.from}${histMove.to}") error = Some(s"Promotion required for move ${histMove.from}${histMove.to}")
break()
// $COVERAGE-ON$
}
}
error match error match
// $COVERAGE-OFF$ unreachable: error is only set in the unreachable block above
case Some(err) => case Some(err) =>
currentContext = savedContext currentContext = savedContext
Left(err) Left(err)
// $COVERAGE-ON$
case None => case None =>
notifyObservers(PgnLoadedEvent(currentContext)) notifyObservers(PgnLoadedEvent(currentContext))
Right(()) Right(())
@@ -151,7 +151,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
test("loadPosition: fires BoardResetEvent and updates context"): test("loadPosition: fires BoardResetEvent and updates context"):
import de.nowchess.api.board.{Board, Color} import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.notation.FenParser import de.nowchess.io.fen.FenParser
val engine = new GameEngine() val engine = new GameEngine()
val cap = new EventCapture() val cap = new EventCapture()
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.notation.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -3,10 +3,10 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square} import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.notation.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.rules.RuleSet import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.StandardRules import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -154,27 +154,27 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
// triggering the "Error completing promotion." branch. // triggering the "Error completing promotion." branch.
val delegatingRuleSet: RuleSet = new RuleSet: val delegatingRuleSet: RuleSet = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] = def candidateMoves(context: GameContext, square: Square): List[Move] =
StandardRules.candidateMoves(context, square) DefaultRules.candidateMoves(context, square)
def legalMoves(context: GameContext, square: Square): List[Move] = def legalMoves(context: GameContext, square: Square): List[Move] =
StandardRules.legalMoves(context, square).map { m => DefaultRules.legalMoves(context, square).map { m =>
m.moveType match m.moveType match
case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal) case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal)
case _ => m case _ => m
} }
def allLegalMoves(context: GameContext): List[Move] = def allLegalMoves(context: GameContext): List[Move] =
StandardRules.allLegalMoves(context) DefaultRules.allLegalMoves(context)
def isCheck(context: GameContext): Boolean = def isCheck(context: GameContext): Boolean =
StandardRules.isCheck(context) DefaultRules.isCheck(context)
def isCheckmate(context: GameContext): Boolean = def isCheckmate(context: GameContext): Boolean =
StandardRules.isCheckmate(context) DefaultRules.isCheckmate(context)
def isStalemate(context: GameContext): Boolean = def isStalemate(context: GameContext): Boolean =
StandardRules.isStalemate(context) DefaultRules.isStalemate(context)
def isInsufficientMaterial(context: GameContext): Boolean = def isInsufficientMaterial(context: GameContext): Boolean =
StandardRules.isInsufficientMaterial(context) DefaultRules.isInsufficientMaterial(context)
def isFiftyMoveRule(context: GameContext): Boolean = def isFiftyMoveRule(context: GameContext): Boolean =
StandardRules.isFiftyMoveRule(context) DefaultRules.isFiftyMoveRule(context)
def applyMove(context: GameContext, move: Move): GameContext = def applyMove(context: GameContext, move: Move): GameContext =
StandardRules.applyMove(context, move) DefaultRules.applyMove(context, move)
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White) val initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
+1
View File
@@ -39,6 +39,7 @@ dependencies {
} }
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -4,6 +4,6 @@ import de.nowchess.api.game.GameContext
trait GameContextImport { trait GameContextImport {
def importGameContext(input: String): GameContext def importGameContext(input: String): Option[GameContext]
} }
@@ -1,9 +1,10 @@
package de.nowchess.chess.notation package de.nowchess.io.fen
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextExport
object FenExporter: object FenExporter extends GameContextExport:
/** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */ /** Convert a Board to FEN piece-placement string (rank 8 to rank 1, separated by '/'). */
def boardToFen(board: Board): String = def boardToFen(board: Board): String =
@@ -23,7 +24,7 @@ object FenExporter:
if emptyCount > 0 then if emptyCount > 0 then
rankChars += emptyCount.toString.charAt(0) rankChars += emptyCount.toString.charAt(0)
emptyCount = 0 emptyCount = 0
rankChars += pieceToPgnChar(piece) rankChars += pieceToFenChar(piece)
case None => case None =>
emptyCount += 1 emptyCount += 1
@@ -39,6 +40,8 @@ object FenExporter:
val fullMoveNumber = 1 + (context.moves.length / 2) val fullMoveNumber = 1 + (context.moves.length / 2)
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber" s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
def exportGameContext(context: GameContext): String = gameContextToFen(context)
/** Convert castling rights to FEN notation. */ /** Convert castling rights to FEN notation. */
private def castlingString(rights: CastlingRights): String = private def castlingString(rights: CastlingRights): String =
val wk = if rights.whiteKingSide then "K" else "" val wk = if rights.whiteKingSide then "K" else ""
@@ -49,7 +52,7 @@ object FenExporter:
if result.isEmpty then "-" else result if result.isEmpty then "-" else result
/** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */ /** Convert a Piece to its FEN character (uppercase = White, lowercase = Black). */
private def pieceToPgnChar(piece: Piece): Char = private def pieceToFenChar(piece: Piece): Char =
val base = piece.pieceType match val base = piece.pieceType match
case PieceType.Pawn => 'p' case PieceType.Pawn => 'p'
case PieceType.Knight => 'n' case PieceType.Knight => 'n'
@@ -58,3 +61,4 @@ object FenExporter:
case PieceType.Queen => 'q' case PieceType.Queen => 'q'
case PieceType.King => 'k' case PieceType.King => 'k'
if piece.color == Color.White then base.toUpper else base if piece.color == Color.White then base.toUpper else base
@@ -1,9 +1,10 @@
package de.nowchess.chess.notation package de.nowchess.io.fen
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.io.GameContextImport
object FenParser: object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext. /** Parse a complete FEN string into a GameContext.
* Returns None if the format is invalid. */ * Returns None if the format is invalid. */
@@ -27,6 +28,8 @@ object FenParser:
moves = List.empty moves = List.empty
) )
def importGameContext(input: String): Option[GameContext] = parseFen(input)
/** Parse active color ("w" or "b"). */ /** Parse active color ("w" or "b"). */
private def parseColor(s: String): Option[Color] = private def parseColor(s: String): Option[Color] =
if s == "w" then Some(Color.White) if s == "w" then Some(Color.White)
@@ -102,3 +105,4 @@ object FenParser:
case 'k' => Some(PieceType.King) case 'k' => Some(PieceType.King)
case _ => None case _ => None
pieceTypeOpt.map(pt => Piece(color, pt)) pieceTypeOpt.map(pt => Piece(color, pt))
@@ -1,6 +1,6 @@
package de.nowchess.chess.notation package de.nowchess.io.pgn
import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.PromotionPiece
import de.nowchess.api.game.{GameHistory, HistoryMove} import de.nowchess.api.game.{GameHistory, HistoryMove}
@@ -52,3 +52,5 @@ object PgnExporter:
case PieceType.Rook => s"R$capStr$dest$promSuffix" case PieceType.Rook => s"R$capStr$dest$promSuffix"
case PieceType.Queen => s"Q$capStr$dest$promSuffix" case PieceType.Queen => s"Q$capStr$dest$promSuffix"
case PieceType.King => s"K$capStr$dest$promSuffix" case PieceType.King => s"K$capStr$dest$promSuffix"
@@ -1,9 +1,9 @@
package de.nowchess.chess.notation package de.nowchess.io.pgn
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{GameContext, HistoryMove} import de.nowchess.api.game.{GameContext, HistoryMove}
import de.nowchess.rules.sets.StandardRules import de.nowchess.rules.sets.DefaultRules
/** A parsed PGN game containing headers and the resolved move list. */ /** A parsed PGN game containing headers and the resolved move list. */
case class PgnGame( case class PgnGame(
@@ -35,7 +35,7 @@ object PgnParser:
/** Parse PGN header lines of the form [Key "Value"]. */ /** Parse PGN header lines of the form [Key "Value"]. */
private def parseHeaders(lines: Array[String]): Map[String, String] = private def parseHeaders(lines: Array[String]): Map[String, String] =
val pattern = """^\[(\w+)\s+"([^"]*)"\s*\]$""".r val pattern = """^\[(\w+)\s+"([^"]*)"\s*]$""".r
lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap lines.flatMap(line => pattern.findFirstMatchIn(line).map(m => m.group(1) -> m.group(2))).toMap
/** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */ /** Parse the move-text section (e.g. "1. e4 e5 2. Nf3") into resolved HistoryMoves. */
@@ -51,7 +51,7 @@ object PgnParser:
case None => state case None => state
case Some(move) => case Some(move) =>
val histMove = toHistoryMove(move, ctx, color) val histMove = toHistoryMove(move, ctx, color)
val nextCtx = StandardRules.applyMove(ctx, move) val nextCtx = DefaultRules.applyMove(ctx, move)
(nextCtx, color.opposite, acc :+ histMove) (nextCtx, color.opposite, acc :+ histMove)
moves moves
@@ -82,12 +82,12 @@ object PgnParser:
case "O-O" | "O-O+" | "O-O#" => case "O-O" | "O-O+" | "O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside) val move = Move(Square(File.E, rank), Square(File.G, rank), MoveType.CastleKingside)
Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
case "O-O-O" | "O-O-O+" | "O-O-O#" => case "O-O-O" | "O-O-O+" | "O-O-O#" =>
val rank = if color == Color.White then Rank.R1 else Rank.R8 val rank = if color == Color.White then Rank.R1 else Rank.R8
val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside) val move = Move(Square(File.E, rank), Square(File.C, rank), MoveType.CastleQueenside)
Option.when(StandardRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move) Option.when(DefaultRules.legalMoves(ctx, Square(File.E, rank)).contains(move))(move)
case _ => case _ =>
parseRegularMove(notation, ctx, color) parseRegularMove(notation, ctx, color)
@@ -118,7 +118,7 @@ object PgnParser:
val promotion = extractPromotion(notation) val promotion = extractPromotion(notation)
// Get all legal moves for this color that reach toSquare // Get all legal moves for this color that reach toSquare
val allLegal = StandardRules.allLegalMoves(ctx) val allLegal = DefaultRules.allLegalMoves(ctx)
val candidates = allLegal.filter { move => val candidates = allLegal.filter { move =>
move.to == toSquare && move.to == toSquare &&
ctx.board.pieceAt(move.from).exists(p => ctx.board.pieceAt(move.from).exists(p =>
@@ -146,7 +146,7 @@ object PgnParser:
case Some(pp) => move.moveType == MoveType.Promotion(pp) case Some(pp) => move.moveType == MoveType.Promotion(pp)
/** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */ /** Extract a promotion piece from a notation string containing =Q/=R/=B/=N. */
private[notation] def extractPromotion(notation: String): Option[PromotionPiece] = private[pgn] def extractPromotion(notation: String): Option[PromotionPiece] =
val promotionPattern = """=([A-Z])""".r val promotionPattern = """=([A-Z])""".r
promotionPattern.findFirstMatchIn(notation).flatMap { m => promotionPattern.findFirstMatchIn(notation).flatMap { m =>
m.group(1) match m.group(1) match
@@ -181,7 +181,9 @@ object PgnParser:
case None => Left(s"Illegal or impossible move: '$token'") case None => Left(s"Illegal or impossible move: '$token'")
case Some(move) => case Some(move) =>
val histMove = toHistoryMove(move, ctx, color) val histMove = toHistoryMove(move, ctx, color)
val nextCtx = StandardRules.applyMove(ctx, move) val nextCtx = DefaultRules.applyMove(ctx, move)
Right((nextCtx, color.opposite, moves :+ histMove)) Right((nextCtx, color.opposite, moves :+ histMove))
} }
}.map(_._3) }.map(_._3)
@@ -1,4 +1,4 @@
package de.nowchess.chess.notation package de.nowchess.io.fen
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
@@ -99,3 +99,4 @@ class FenExporterTest extends AnyFunSuite with Matchers:
FenParser.parseFen(fen) match FenParser.parseFen(fen) match
case Some(ctx) => ctx.halfMoveClock shouldBe 42 case Some(ctx) => ctx.halfMoveClock shouldBe 42
case None => fail("FEN parsing failed") case None => fail("FEN parsing failed")
@@ -1,4 +1,4 @@
package de.nowchess.chess.notation package de.nowchess.io.fen
import de.nowchess.api.board.* import de.nowchess.api.board.*
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
@@ -130,3 +130,4 @@ class FenParserTest extends AnyFunSuite with Matchers:
val board = FenParser.parseBoard(fen) val board = FenParser.parseBoard(fen)
board shouldBe empty board shouldBe empty
@@ -1,4 +1,4 @@
package de.nowchess.chess.notation package de.nowchess.io.pgn
import de.nowchess.api.board.{PieceType, *} import de.nowchess.api.board.{PieceType, *}
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.PromotionPiece
@@ -112,3 +112,4 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None)) .addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
val pgn = PgnExporter.exportGame(Map.empty, history) val pgn = PgnExporter.exportGame(Map.empty, history)
pgn shouldBe "1. e4 *" pgn shouldBe "1. e4 *"
@@ -1,9 +1,9 @@
package de.nowchess.chess.notation package de.nowchess.io.pgn
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.{MoveType, PromotionPiece} import de.nowchess.api.move.{MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.chess.notation.FenParser import de.nowchess.io.fen.FenParser
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -437,3 +437,4 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val result = PgnParser.extractPromotion("e7e8") val result = PgnParser.extractPromotion("e7e8")
result shouldBe None result shouldBe None
} }
@@ -1,4 +1,4 @@
package de.nowchess.chess.notation package de.nowchess.io.pgn
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.move.PromotionPiece import de.nowchess.api.move.PromotionPiece
@@ -117,3 +117,4 @@ class PgnValidatorTest extends AnyFunSuite with Matchers:
// This is hard to construct via PGN alone; verify via a known impossible disambiguation // This is hard to construct via PGN alone; verify via a known impossible disambiguation
val pgn = "[Event \"T\"]\n\n1. e4" val pgn = "[Event \"T\"]\n\n1. e4"
PgnParser.validatePgn(pgn).isRight shouldBe true PgnParser.validatePgn(pgn).isRight shouldBe true
@@ -10,7 +10,7 @@ import scala.annotation.tailrec
/** Standard chess rules implementation. /** Standard chess rules implementation.
* Handles move generation, validation, check/checkmate/stalemate detection. * Handles move generation, validation, check/checkmate/stalemate detection.
*/ */
object StandardRules extends RuleSet: object DefaultRules extends RuleSet:
// ── Direction vectors ────────────────────────────────────────────── // ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1)) private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))