Compare commits
2 Commits
ui-0.1.0
...
core-0.7.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 412ed986a9 | |||
| 8bbeead702 |
@@ -3,3 +3,4 @@
|
||||
## (2026-03-28)
|
||||
## (2026-03-29)
|
||||
## (2026-03-31)
|
||||
## (2026-04-01)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
PATCH=5
|
||||
PATCH=6
|
||||
|
||||
@@ -79,3 +79,23 @@
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
## (2026-04-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add GameRules stub with PositionStatus enum ([76d4168](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76d4168038de23e5d6083d4e8f0504fbf31d15a3))
|
||||
* add MovedInCheck/Checkmate/Stalemate MoveResult variants (stub dispatch) ([8b7ec57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8b7ec57e5ea6ee1615a1883848a426dc07d26364))
|
||||
* implement GameRules with isInCheck, legalMoves, gameStatus ([94a02ff](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/94a02ff6849436d9496c70a0f16c21666dae8e4e))
|
||||
* implement legal castling ([#1](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/1)) ([00d326c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/00d326c1ba67711fbe180f04e1100c3f01dd0254))
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
* NCS-6 Implementing FEN & PGN ([#7](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/7)) ([f28e69d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f28e69dc181416aa2f221fdc4b45c2cda5efbf07))
|
||||
* NCS-9 En passant implementation ([#8](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/8)) ([919beb3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/919beb3b4bfa8caf2f90976a415fe9b19b7e9747))
|
||||
* wire check/checkmate/stalemate into processMove and gameLoop ([5264a22](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5264a225418b885c5e6ea6411b96f85e38837f6c))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing kings to gameLoop capture test board ([aedd787](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/aedd787b77203c2af934751dba7b784eaf165032))
|
||||
* correct test board positions and captureOutput/withInput interaction ([f0481e2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f0481e2561b779df00925b46ee281dc36a795150))
|
||||
* update main class path in build configuration and adjust VCS directory mapping ([7b1f8b1](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7b1f8b117623d327232a1a92a8a44d18582e0189))
|
||||
* update move validation to check for king safety ([#13](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/13)) ([e5e20c5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e5e20c566e368b12ca1dc59680c34e9112bf6762))
|
||||
|
||||
@@ -63,7 +63,8 @@ object GameController:
|
||||
case PromotionPiece.Bishop => PieceType.Bishop
|
||||
case PromotionPiece.Knight => PieceType.Knight
|
||||
val newBoard = boardAfterMove.updated(to, Piece(turn, promotedPieceType))
|
||||
val newHistory = history.addMove(from, to, None, Some(piece))
|
||||
// Promotion is always a pawn move → clock resets
|
||||
val newHistory = history.addMove(from, to, None, Some(piece), wasPawnMove = true)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,7 +92,9 @@ object GameController:
|
||||
val capturedSq = EnPassantCalculator.capturedPawnSquare(to, turn)
|
||||
(b.removed(capturedSq), board.pieceAt(capturedSq))
|
||||
else (b, cap)
|
||||
val newHistory = history.addMove(from, to, castleOpt)
|
||||
val wasPawnMove = board.pieceAt(from).exists(_.pieceType == PieceType.Pawn)
|
||||
val wasCapture = captured.isDefined
|
||||
val newHistory = history.addMove(from, to, castleOpt, wasPawnMove = wasPawnMove, wasCapture = wasCapture)
|
||||
toMoveResult(newBoard, newHistory, captured, turn)
|
||||
|
||||
private def toMoveResult(newBoard: Board, newHistory: GameHistory, captured: Option[Piece], turn: Color): MoveResult =
|
||||
|
||||
@@ -67,6 +67,19 @@ class GameEngine(
|
||||
case "redo" =>
|
||||
performRedo()
|
||||
|
||||
case "draw" =>
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
invoker.clear()
|
||||
notifyObservers(DrawClaimedEvent(currentBoard, currentHistory, currentTurn))
|
||||
else
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
"Draw cannot be claimed: the 50-move rule has not been triggered."
|
||||
))
|
||||
|
||||
case "" =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
@@ -77,70 +90,65 @@ class GameEngine(
|
||||
notifyObservers(event)
|
||||
|
||||
case moveInput =>
|
||||
// Try to parse as a move
|
||||
Parser.parseMove(moveInput) match
|
||||
case None =>
|
||||
val event = InvalidMoveEvent(
|
||||
currentBoard,
|
||||
currentHistory,
|
||||
currentTurn,
|
||||
notifyObservers(InvalidMoveEvent(
|
||||
currentBoard, currentHistory, currentTurn,
|
||||
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4."
|
||||
)
|
||||
notifyObservers(event)
|
||||
|
||||
))
|
||||
case Some((from, to)) =>
|
||||
// Create a move command with current state snapshot
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
|
||||
// Execute the move through GameController
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
// Move succeeded - store result and execute through invoker
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
// Move succeeded with check
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
// Move resulted in checkmate
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
// Move resulted in stalemate
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
handleParsedMove(from, to, moveInput)
|
||||
}
|
||||
|
||||
private def handleParsedMove(from: Square, to: Square, moveInput: String): Unit =
|
||||
val cmd = MoveCommand(
|
||||
from = from,
|
||||
to = to,
|
||||
previousBoard = Some(currentBoard),
|
||||
previousHistory = Some(currentHistory),
|
||||
previousTurn = Some(currentTurn)
|
||||
)
|
||||
GameController.processMove(currentBoard, currentHistory, currentTurn, moveInput) match
|
||||
case MoveResult.InvalidFormat(_) | MoveResult.NoPiece | MoveResult.WrongColor | MoveResult.IllegalMove | MoveResult.Quit =>
|
||||
handleFailedMove(moveInput)
|
||||
|
||||
case MoveResult.Moved(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.MovedInCheck(newBoard, newHistory, captured, newTurn) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(newBoard, newHistory, newTurn, captured)))
|
||||
invoker.execute(updatedCmd)
|
||||
updateGameState(newBoard, newHistory, newTurn)
|
||||
emitMoveEvent(from.toString, to.toString, captured, newTurn)
|
||||
notifyObservers(CheckDetectedEvent(currentBoard, currentHistory, currentTurn))
|
||||
if currentHistory.halfMoveClock >= 100 then
|
||||
notifyObservers(FiftyMoveRuleAvailableEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.Checkmate(winner) =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(CheckmateEvent(currentBoard, currentHistory, currentTurn, winner))
|
||||
|
||||
case MoveResult.Stalemate =>
|
||||
val updatedCmd = cmd.copy(moveResult = Some(de.nowchess.chess.command.MoveResult.Successful(Board.initial, GameHistory.empty, Color.White, None)))
|
||||
invoker.execute(updatedCmd)
|
||||
currentBoard = Board.initial
|
||||
currentHistory = GameHistory.empty
|
||||
currentTurn = Color.White
|
||||
notifyObservers(StalemateEvent(currentBoard, currentHistory, currentTurn))
|
||||
|
||||
case MoveResult.PromotionRequired(promFrom, promTo, boardBefore, histBefore, _, promotingTurn) =>
|
||||
pendingPromotion = Some(PendingPromotion(promFrom, promTo, boardBefore, histBefore, promotingTurn))
|
||||
notifyObservers(PromotionRequiredEvent(currentBoard, currentHistory, currentTurn, promFrom, promTo))
|
||||
|
||||
/** Undo the last move. */
|
||||
def undo(): Unit = synchronized {
|
||||
performUndo()
|
||||
|
||||
@@ -11,21 +11,36 @@ case class HistoryMove(
|
||||
promotionPiece: Option[PromotionPiece] = None
|
||||
)
|
||||
|
||||
/** Complete game history: ordered list of moves. */
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty):
|
||||
/** Complete game history: ordered list of moves plus the half-move clock for the 50-move rule.
|
||||
*
|
||||
* @param moves moves played so far, oldest first
|
||||
* @param halfMoveClock plies since the last pawn move or capture (FIDE 50-move rule counter)
|
||||
*/
|
||||
case class GameHistory(moves: List[HistoryMove] = List.empty, halfMoveClock: Int = 0):
|
||||
|
||||
/** Add a raw HistoryMove record. Clock increments by 1.
|
||||
* Use the coordinate overload when you know whether the move is a pawn move or capture.
|
||||
*/
|
||||
def addMove(move: HistoryMove): GameHistory =
|
||||
GameHistory(moves :+ move)
|
||||
|
||||
def addMove(from: Square, to: Square): GameHistory =
|
||||
addMove(HistoryMove(from, to, None))
|
||||
GameHistory(moves :+ move, halfMoveClock + 1)
|
||||
|
||||
/** Add a move by coordinates.
|
||||
*
|
||||
* @param wasPawnMove true when the moving piece is a pawn — resets the clock to 0
|
||||
* @param wasCapture true when a piece was captured (including en passant) — resets the clock to 0
|
||||
*
|
||||
* If neither flag is set the clock increments by 1.
|
||||
*/
|
||||
def addMove(
|
||||
from: Square,
|
||||
to: Square,
|
||||
castleSide: Option[CastleSide] = None,
|
||||
promotionPiece: Option[PromotionPiece] = None
|
||||
promotionPiece: Option[PromotionPiece] = None,
|
||||
wasPawnMove: Boolean = false,
|
||||
wasCapture: Boolean = false
|
||||
): GameHistory =
|
||||
addMove(HistoryMove(from, to, castleSide, promotionPiece))
|
||||
val newClock = if wasPawnMove || wasCapture then 0 else halfMoveClock + 1
|
||||
GameHistory(moves :+ HistoryMove(from, to, castleSide, promotionPiece), newClock)
|
||||
|
||||
object GameHistory:
|
||||
val empty: GameHistory = GameHistory()
|
||||
|
||||
@@ -22,7 +22,8 @@ object PgnExporter:
|
||||
if blackMoveStr.isEmpty then s"$moveNum. $whiteMoveStr"
|
||||
else s"$moveNum. $whiteMoveStr $blackMoveStr"
|
||||
|
||||
moveLines.mkString(" ") + " *"
|
||||
val termination = headers.getOrElse("Result", "*")
|
||||
moveLines.mkString(" ") + s" $termination"
|
||||
|
||||
if headerLines.isEmpty then moveText
|
||||
else if moveText.isEmpty then headerLines
|
||||
|
||||
@@ -67,6 +67,20 @@ case class BoardResetEvent(
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired after any move where the half-move clock reaches 100 — the 50-move rule is now claimable. */
|
||||
case class FiftyMoveRuleAvailableEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player successfully claims a draw under the 50-move rule. */
|
||||
case class DrawClaimedEvent(
|
||||
board: Board,
|
||||
history: GameHistory,
|
||||
turn: Color
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
@@ -488,3 +488,39 @@ class GameControllerTest extends AnyFunSuite with Matchers:
|
||||
PromotionPiece.Knight, Color.White
|
||||
)
|
||||
result should be (MoveResult.Stalemate)
|
||||
|
||||
// ──── half-move clock propagation ────────────────────────────────────
|
||||
|
||||
test("processMove: non-pawn non-capture increments halfMoveClock"):
|
||||
// g1f3 is a knight move — not a pawn, not a capture
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 1
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: pawn move resets halfMoveClock to 0"):
|
||||
processMove(Board.initial, GameHistory.empty, Color.White, "e2e4") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: capture resets halfMoveClock to 0"):
|
||||
// White pawn on e5, Black pawn on d6 — exd6 is a capture
|
||||
val board = Board(Map(
|
||||
sq(File.E, Rank.R5) -> Piece.WhitePawn,
|
||||
sq(File.D, Rank.R6) -> Piece.BlackPawn,
|
||||
sq(File.E, Rank.R1) -> Piece.WhiteKing,
|
||||
sq(File.E, Rank.R8) -> Piece.BlackKing
|
||||
))
|
||||
val history = GameHistory(halfMoveClock = 10)
|
||||
processMove(board, history, Color.White, "e5d6") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 0
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
test("processMove: clock carries from previous history on non-pawn non-capture"):
|
||||
val history = GameHistory(halfMoveClock = 5)
|
||||
processMove(Board.initial, history, Color.White, "g1f3") match
|
||||
case MoveResult.Moved(_, newHistory, _, _) =>
|
||||
newHistory.halfMoveClock shouldBe 6
|
||||
case other => fail(s"Expected Moved, got $other")
|
||||
|
||||
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.{Board, Color}
|
||||
import de.nowchess.chess.logic.GameHistory
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent}
|
||||
import de.nowchess.chess.observer.{Observer, GameEvent, MoveExecutedEvent, CheckDetectedEvent, BoardResetEvent, InvalidMoveEvent, FiftyMoveRuleAvailableEvent, DrawClaimedEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
@@ -302,6 +302,47 @@ class GameEngineTest extends AnyFunSuite with Matchers:
|
||||
engine.board shouldBe boardAfterSecondMove
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
// ──── 50-move rule ───────────────────────────────────────────────────
|
||||
|
||||
test("GameEngine: 'draw' rejected when halfMoveClock < 100"):
|
||||
val engine = new GameEngine()
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[InvalidMoveEvent]
|
||||
|
||||
test("GameEngine: 'draw' accepted and fires DrawClaimedEvent when halfMoveClock >= 100"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("draw")
|
||||
observer.events.size shouldBe 1
|
||||
observer.events.head shouldBe a[DrawClaimedEvent]
|
||||
|
||||
test("GameEngine: state resets to initial after draw claimed"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 100))
|
||||
engine.processUserInput("draw")
|
||||
engine.board shouldBe Board.initial
|
||||
engine.history shouldBe GameHistory.empty
|
||||
engine.turn shouldBe Color.White
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent fired when move brings clock to 100"):
|
||||
// Start at clock 99; a knight move (non-pawn, non-capture) increments to 100
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 99))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3") // knight move on initial board
|
||||
// Should receive MoveExecutedEvent AND FiftyMoveRuleAvailableEvent
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe true
|
||||
|
||||
test("GameEngine: FiftyMoveRuleAvailableEvent not fired when clock is below 100 after move"):
|
||||
val engine = new GameEngine(initialHistory = GameHistory(halfMoveClock = 5))
|
||||
val observer = new MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("g1f3")
|
||||
observer.events.exists(_.isInstanceOf[FiftyMoveRuleAvailableEvent]) shouldBe false
|
||||
|
||||
// Mock Observer for testing
|
||||
private class MockObserver extends Observer:
|
||||
val events = mutable.ListBuffer[GameEvent]()
|
||||
|
||||
@@ -69,3 +69,36 @@ class GameHistoryTest extends AnyFunSuite with Matchers:
|
||||
newHistory.moves should have length 1
|
||||
newHistory.moves.head.castleSide should be (None)
|
||||
newHistory.moves.head.promotionPiece should be (Some(PromotionPiece.Queen))
|
||||
|
||||
// ──── half-move clock ────────────────────────────────────────────────
|
||||
|
||||
test("halfMoveClock starts at 0"):
|
||||
GameHistory.empty.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock increments on a non-pawn non-capture move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("halfMoveClock resets to 0 on a pawn move"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 on a capture"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock resets to 0 when both wasPawnMove and wasCapture are true"):
|
||||
val h = GameHistory.empty.addMove(sq(File.E, Rank.R5), sq(File.D, Rank.R6), wasPawnMove = true, wasCapture = true)
|
||||
h.halfMoveClock shouldBe 0
|
||||
|
||||
test("halfMoveClock carries across multiple moves"):
|
||||
val h = GameHistory.empty
|
||||
.addMove(sq(File.G, Rank.R1), sq(File.F, Rank.R3)) // +1 → 1
|
||||
.addMove(sq(File.G, Rank.R8), sq(File.F, Rank.R6)) // +1 → 2
|
||||
.addMove(sq(File.E, Rank.R2), sq(File.E, Rank.R4), wasPawnMove = true) // reset → 0
|
||||
.addMove(sq(File.B, Rank.R1), sq(File.C, Rank.R3)) // +1 → 1
|
||||
h.halfMoveClock shouldBe 1
|
||||
|
||||
test("GameHistory can be initialised with a non-zero halfMoveClock"):
|
||||
val h = GameHistory(halfMoveClock = 42)
|
||||
h.halfMoveClock shouldBe 42
|
||||
|
||||
@@ -67,3 +67,22 @@ class FenExporterTest extends AnyFunSuite with Matchers:
|
||||
)
|
||||
val fen = FenExporter.gameStateToFen(gameState)
|
||||
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.chess.logic.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 fen = FenExporter.gameStateToFen(gameState)
|
||||
FenParser.parseFen(fen) match
|
||||
case Some(gs) => gs.halfMoveClock shouldBe 42
|
||||
case None => fail("FEN parsing failed")
|
||||
|
||||
@@ -100,3 +100,15 @@ class PgnExporterTest extends AnyFunSuite with Matchers:
|
||||
pgn should include ("e2e4")
|
||||
pgn should not include ("=")
|
||||
}
|
||||
|
||||
test("exportGame uses Result header as termination marker"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map("Result" -> "1/2-1/2"), history)
|
||||
pgn should endWith("1/2-1/2")
|
||||
|
||||
test("exportGame with no Result header still uses * as default"):
|
||||
val history = GameHistory()
|
||||
.addMove(HistoryMove(Square(File.E, Rank.R2), Square(File.E, Rank.R4), None))
|
||||
val pgn = PgnExporter.exportGame(Map.empty, history)
|
||||
pgn shouldBe "1. e2e4 *"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=5
|
||||
MINOR=6
|
||||
PATCH=0
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
## (2026-04-01)
|
||||
|
||||
### Features
|
||||
|
||||
* NCS-10 Implement Pawn Promotion ([#12](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/12)) ([13bfc16](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/13bfc16cfe25db78ec607db523ca6d993c13430c))
|
||||
* NCS-16 Core Separation via Patterns ([#10](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/10)) ([1361dfc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1361dfc89553b146864fb8ff3526cf12cf3f293a))
|
||||
@@ -0,0 +1,3 @@
|
||||
MAJOR=0
|
||||
MINOR=1
|
||||
PATCH=0
|
||||
Reference in New Issue
Block a user