refactor(core): integrate Rule module and update GameContext handling
This commit is contained in:
@@ -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
|
||||
|
||||
+11
-11
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
}
|
||||
|
||||
+8
-4
@@ -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
|
||||
|
||||
+6
-2
@@ -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))
|
||||
|
||||
+4
-2
@@ -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"
|
||||
|
||||
|
||||
+11
-9
@@ -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)
|
||||
|
||||
|
||||
+2
-1
@@ -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")
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+2
-1
@@ -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 *"
|
||||
|
||||
+3
-2
@@ -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
|
||||
}
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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))
|
||||
Reference in New Issue
Block a user