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.observer.*
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.sets.StandardRules
import de.nowchess.rules.sets.DefaultRules
/** Pure game engine that manages game state and notifies observers of state changes.
* All rule queries delegate to the injected RuleSet.
@@ -16,7 +16,7 @@ import de.nowchess.rules.sets.StandardRules
*/
class GameEngine(
initialContext: GameContext = GameContext.initial,
ruleSet: RuleSet = StandardRules
ruleSet: RuleSet = DefaultRules
) extends Observable:
private var currentContext: GameContext = initialContext
private val invoker = new CommandInvoker()
@@ -146,25 +146,16 @@ class GameEngine(
invoker.clear()
var error: Option[String] = None
import scala.util.control.Breaks._
breakable {
game.moves.foreach { histMove =>
handleParsedMove(histMove.from, histMove.to)
histMove.promotionPiece.foreach(completePromotion)
// $COVERAGE-OFF$ unreachable: PgnParser.validatePgn ensures promotion annotation is present
game.moves.foreach: histMove =>
handleParsedMove(histMove.from, histMove.to)
histMove.promotionPiece.foreach(completePromotion)
if pendingPromotion.isDefined && histMove.promotionPiece.isEmpty then
error = Some(s"Promotion required for move ${histMove.from}${histMove.to}")
break()
// $COVERAGE-ON$
}
}
error = Some(s"Promotion required for move ${histMove.from}${histMove.to}")
error match
// $COVERAGE-OFF$ unreachable: error is only set in the unreachable block above
case Some(err) =>
currentContext = savedContext
Left(err)
// $COVERAGE-ON$
case None =>
notifyObservers(PgnLoadedEvent(currentContext))
Right(())
@@ -151,7 +151,7 @@ class GameEngineLoadPgnTest extends AnyFunSuite with Matchers:
test("loadPosition: fires BoardResetEvent and updates context"):
import de.nowchess.api.board.{Board, Color}
import de.nowchess.api.game.GameContext
import de.nowchess.chess.notation.FenParser
import de.nowchess.io.fen.FenParser
val engine = new GameEngine()
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.game.GameContext
import de.nowchess.api.move.PromotionPiece
import de.nowchess.chess.notation.FenParser
import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
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.game.GameContext
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.rules.RuleSet
import de.nowchess.rules.sets.StandardRules
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -154,27 +154,27 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
// triggering the "Error completing promotion." branch.
val delegatingRuleSet: RuleSet = new RuleSet:
def candidateMoves(context: GameContext, square: Square): List[Move] =
StandardRules.candidateMoves(context, square)
DefaultRules.candidateMoves(context, square)
def legalMoves(context: GameContext, square: Square): List[Move] =
StandardRules.legalMoves(context, square).map { m =>
DefaultRules.legalMoves(context, square).map { m =>
m.moveType match
case MoveType.Promotion(_) => Move(m.from, m.to, MoveType.Normal)
case _ => m
}
def allLegalMoves(context: GameContext): List[Move] =
StandardRules.allLegalMoves(context)
DefaultRules.allLegalMoves(context)
def isCheck(context: GameContext): Boolean =
StandardRules.isCheck(context)
DefaultRules.isCheck(context)
def isCheckmate(context: GameContext): Boolean =
StandardRules.isCheckmate(context)
DefaultRules.isCheckmate(context)
def isStalemate(context: GameContext): Boolean =
StandardRules.isStalemate(context)
DefaultRules.isStalemate(context)
def isInsufficientMaterial(context: GameContext): Boolean =
StandardRules.isInsufficientMaterial(context)
DefaultRules.isInsufficientMaterial(context)
def isFiftyMoveRule(context: GameContext): Boolean =
StandardRules.isFiftyMoveRule(context)
DefaultRules.isFiftyMoveRule(context)
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 initialCtx = GameContext.initial.withBoard(promotionBoard).withTurn(Color.White)
+1
View File
@@ -39,6 +39,7 @@ dependencies {
}
implementation(project(":modules:api"))
implementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -4,6 +4,6 @@ import de.nowchess.api.game.GameContext
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.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 '/'). */
def boardToFen(board: Board): String =
@@ -23,7 +24,7 @@ object FenExporter:
if emptyCount > 0 then
rankChars += emptyCount.toString.charAt(0)
emptyCount = 0
rankChars += pieceToPgnChar(piece)
rankChars += pieceToFenChar(piece)
case None =>
emptyCount += 1
@@ -39,6 +40,8 @@ object FenExporter:
val fullMoveNumber = 1 + (context.moves.length / 2)
s"$piecePlacement $activeColor $castling $enPassant ${context.halfMoveClock} $fullMoveNumber"
def exportGameContext(context: GameContext): String = gameContextToFen(context)
/** Convert castling rights to FEN notation. */
private def castlingString(rights: CastlingRights): String =
val wk = if rights.whiteKingSide then "K" else ""
@@ -49,7 +52,7 @@ object FenExporter:
if result.isEmpty then "-" else result
/** 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
case PieceType.Pawn => 'p'
case PieceType.Knight => 'n'
@@ -58,3 +61,4 @@ object FenExporter:
case PieceType.Queen => 'q'
case PieceType.King => 'k'
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.game.GameContext
import de.nowchess.io.GameContextImport
object FenParser:
object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext.
* Returns None if the format is invalid. */
@@ -27,6 +28,8 @@ object FenParser:
moves = List.empty
)
def importGameContext(input: String): Option[GameContext] = parseFen(input)
/** Parse active color ("w" or "b"). */
private def parseColor(s: String): Option[Color] =
if s == "w" then Some(Color.White)
@@ -102,3 +105,4 @@ object FenParser:
case 'k' => Some(PieceType.King)
case _ => None
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.game.{GameHistory, HistoryMove}
@@ -52,3 +52,5 @@ object PgnExporter:
case PieceType.Rook => s"R$capStr$dest$promSuffix"
case PieceType.Queen => s"Q$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.move.{Move, MoveType, PromotionPiece}
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. */
case class PgnGame(
@@ -35,7 +35,7 @@ object PgnParser:
/** Parse PGN header lines of the form [Key "Value"]. */
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
/** 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 Some(move) =>
val histMove = toHistoryMove(move, ctx, color)
val nextCtx = StandardRules.applyMove(ctx, move)
val nextCtx = DefaultRules.applyMove(ctx, move)
(nextCtx, color.opposite, acc :+ histMove)
moves
@@ -82,12 +82,12 @@ object PgnParser:
case "O-O" | "O-O+" | "O-O#" =>
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)
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#" =>
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)
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 _ =>
parseRegularMove(notation, ctx, color)
@@ -118,7 +118,7 @@ object PgnParser:
val promotion = extractPromotion(notation)
// 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 =>
move.to == toSquare &&
ctx.board.pieceAt(move.from).exists(p =>
@@ -146,7 +146,7 @@ object PgnParser:
case Some(pp) => move.moveType == MoveType.Promotion(pp)
/** 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
promotionPattern.findFirstMatchIn(notation).flatMap { m =>
m.group(1) match
@@ -181,7 +181,9 @@ object PgnParser:
case None => Left(s"Illegal or impossible move: '$token'")
case Some(move) =>
val histMove = toHistoryMove(move, ctx, color)
val nextCtx = StandardRules.applyMove(ctx, move)
val nextCtx = DefaultRules.applyMove(ctx, move)
Right((nextCtx, color.opposite, moves :+ histMove))
}
}.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.game.GameContext
@@ -99,3 +99,4 @@ class FenExporterTest extends AnyFunSuite with Matchers:
FenParser.parseFen(fen) match
case Some(ctx) => ctx.halfMoveClock shouldBe 42
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 org.scalatest.funsuite.AnyFunSuite
@@ -130,3 +130,4 @@ class FenParserTest extends AnyFunSuite with Matchers:
val board = FenParser.parseBoard(fen)
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.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))
val pgn = PgnExporter.exportGame(Map.empty, history)
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.move.{MoveType, PromotionPiece}
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.matchers.should.Matchers
@@ -437,3 +437,4 @@ class PgnParserTest extends AnyFunSuite with Matchers:
val result = PgnParser.extractPromotion("e7e8")
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.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
val pgn = "[Event \"T\"]\n\n1. e4"
PgnParser.validatePgn(pgn).isRight shouldBe true
@@ -10,7 +10,7 @@ import scala.annotation.tailrec
/** Standard chess rules implementation.
* Handles move generation, validation, check/checkmate/stalemate detection.
*/
object StandardRules extends RuleSet:
object DefaultRules extends RuleSet:
// ── Direction vectors ──────────────────────────────────────────────
private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))