Compare commits

...

2 Commits

Author SHA1 Message Date
Janis 33e785d22a feat: NCS-40 Rework Draw System (#34)
Reviewed-on: #34
Reviewed-by: Shahd Lala <shosho996@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2026-04-19 22:44:48 +02:00
TeamCity d16cec176b ci: bump version with Build-42 2026-04-19 14:01:11 +00:00
18 changed files with 522 additions and 26 deletions
+9
View File
@@ -42,3 +42,12 @@
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
## (2026-04-19)
### Features
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=6
MINOR=7
PATCH=0
+27
View File
@@ -286,3 +286,30 @@
* 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))
## (2026-04-19)
### 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-11 50-move rule ([#9](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/9)) ([412ed98](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/412ed986a95703a3b282276540153480ceed229d))
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* 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-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
* 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))
+5
View File
@@ -63,3 +63,8 @@ tasks.test {
tasks.reportScoverage {
dependsOn(tasks.test)
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
@@ -31,7 +31,9 @@ class GameEngine(
else initialContext
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
private val invoker = new CommandInvoker()
@SuppressWarnings(Array("DisableSyntax.var"))
private var pendingDrawOffer: Option[Color] = None
private val invoker = new CommandInvoker()
private implicit val ec: ExecutionContext = ExecutionContext.global
@@ -77,12 +79,12 @@ class GameEngine(
notifyObservers(
InvalidMoveEvent(
currentContext,
"Draw cannot be claimed: neither the 50-move rule nor threefold repetition has been triggered.",
InvalidMoveReason.DrawCannotBeClaimed,
),
)
case "" =>
notifyObservers(InvalidMoveEvent(currentContext, "Please enter a valid move or command."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.EmptyInput))
case moveInput =>
Parser.parseMove(moveInput) match
@@ -90,7 +92,7 @@ class GameEngine(
notifyObservers(
InvalidMoveEvent(
currentContext,
s"Invalid move format '$moveInput'. Use coordinate notation, e.g. e2e4.",
InvalidMoveReason.InvalidMoveFormat,
),
)
case Some((from, to, promotionPiece: Option[PromotionPiece])) =>
@@ -100,26 +102,26 @@ class GameEngine(
private def handleParsedMove(from: Square, to: Square, promotionPiece: Option[PromotionPiece]): Unit =
currentContext.board.pieceAt(from) match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, "No piece on that square."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoSourcePiece))
case Some(piece) if piece.color != currentContext.turn =>
notifyObservers(InvalidMoveEvent(currentContext, "That is not your piece."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NotYourPiece))
case Some(piece) =>
val legal = ruleSet.legalMoves(currentContext)(from)
// Find all legal moves going to `to`
val candidates = legal.filter(_.to == to)
candidates match
case Nil =>
notifyObservers(InvalidMoveEvent(currentContext, "Illegal move."))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.IllegalMove))
case _ if isPromotionMove(piece, to) =>
if promotionPiece.isEmpty then
notifyObservers(
InvalidMoveEvent(currentContext, "Promotion piece required: append q, r, b, or n to the move."),
InvalidMoveEvent(currentContext, InvalidMoveReason.PromotionPieceRequired),
)
else
candidates.find(_.moveType == MoveType.Promotion(promotionPiece.get)) match
case None =>
notifyObservers(
InvalidMoveEvent(currentContext, "Error completing promotion: no matching legal move."),
InvalidMoveEvent(currentContext, InvalidMoveReason.PromotionPieceInvalid),
)
case Some(move) => executeMove(move)
case move :: _ =>
@@ -137,6 +139,62 @@ class GameEngine(
/** Redo the last undone move. */
def redo(): Unit = synchronized(performRedo())
/** Resign from the game. The opponent wins. */
def resign(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite)))
pendingDrawOffer = None
invoker.clear()
notifyObservers(ResignEvent(currentContext, color))
}
/** Offer a draw. */
def offerDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case Some(_) =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawOfferPending))
case None =>
pendingDrawOffer = Some(color)
notifyObservers(DrawOfferEvent(currentContext, color))
}
/** Accept a pending draw offer. */
def acceptDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoDrawOfferToAccept))
case Some(offerer) if offerer == color =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotAcceptOwnDrawOffer))
case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None
invoker.clear()
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
}
/** Decline a pending draw offer. */
def declineDraw(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
pendingDrawOffer match
case None =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoDrawOfferToDecline))
case Some(offerer) if offerer == color =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotDeclineOwnDrawOffer))
case Some(_) =>
pendingDrawOffer = None
notifyObservers(DrawOfferDeclinedEvent(currentContext, color))
}
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
@@ -145,6 +203,7 @@ class GameEngine(
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
pendingDrawOffer = None
notifyObservers(PgnLoadedEvent(currentContext))
}
}
@@ -186,6 +245,7 @@ class GameEngine(
if newContext.moves.isEmpty then newContext.copy(initialBoard = newContext.board)
else newContext
currentContext = contextWithInitialBoard
pendingDrawOffer = None
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -193,6 +253,7 @@ class GameEngine(
/** Reset the board to initial position. */
def reset(): Unit = synchronized {
currentContext = GameContext.initial
pendingDrawOffer = None
invoker.clear()
notifyObservers(BoardResetEvent(currentContext))
}
@@ -323,9 +384,9 @@ class GameEngine(
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) => executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, s"Bot move ${from}${to} is illegal"))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, "Bot move has invalid source square"))
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
}
private def handleBotNoMove(): Unit =
@@ -344,7 +405,7 @@ class GameEngine(
moveCmd.previousContext.foreach(currentContext = _)
invoker.undo()
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to undo."))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
private def performRedo(): Unit =
if invoker.canRedo then
@@ -364,4 +425,4 @@ class GameEngine(
capturedDesc,
),
)
else notifyObservers(InvalidMoveEvent(currentContext, "Nothing to redo."))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
@@ -0,0 +1,21 @@
package de.nowchess.chess.observer
enum InvalidMoveReason:
case GameAlreadyOver
case NoSourcePiece
case NotYourPiece
case IllegalMove
case PromotionPieceRequired
case PromotionPieceInvalid
case InvalidMoveFormat
case EmptyInput
case DrawCannotBeClaimed
case NothingToUndo
case NothingToRedo
case BotMoveIllegal
case BotMoveInvalidSource
case DrawOfferPending
case NoDrawOfferToAccept
case CannotAcceptOwnDrawOffer
case NoDrawOfferToDecline
case CannotDeclineOwnDrawOffer
@@ -36,7 +36,7 @@ case class DrawEvent(
/** Fired when a move is invalid. */
case class InvalidMoveEvent(
context: GameContext,
reason: String,
reason: InvalidMoveReason,
) extends GameEvent
/** Fired when the board is reset. */
@@ -74,6 +74,24 @@ case class PgnLoadedEvent(
context: GameContext,
) extends GameEvent
/** Fired when a player resigns. The opponent wins. */
case class ResignEvent(
context: GameContext,
resignedColor: Color,
) extends GameEvent
/** Fired when a player offers a draw. Waiting for opponent to accept or decline. */
case class DrawOfferEvent(
context: GameContext,
offeredBy: Color,
) extends GameEvent
/** Fired when the opponent declines a draw offer. */
case class DrawOfferDeclinedEvent(
context: GameContext,
declinedBy: Color,
) extends GameEvent
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
@@ -0,0 +1,241 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.{DrawReason, GameResult}
import de.nowchess.chess.observer.{
DrawEvent,
DrawOfferDeclinedEvent,
DrawOfferEvent,
GameEvent,
InvalidMoveEvent,
InvalidMoveReason,
Observer,
}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
test("White offers draw"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: DrawOfferEvent =>
event.offeredBy shouldBe Color.White
case other =>
fail(s"Expected DrawOfferEvent, but got $other")
test("Black accepts White's draw offer"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.acceptDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: DrawEvent =>
event.reason shouldBe DrawReason.Agreement
event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement))
case other =>
fail(s"Expected DrawEvent, but got $other")
test("Black declines White's draw offer"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.declineDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: DrawOfferDeclinedEvent =>
event.declinedBy shouldBe Color.Black
case other =>
fail(s"Expected DrawOfferDeclinedEvent, but got $other")
test("Black offers draw"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: DrawOfferEvent =>
event.offeredBy shouldBe Color.Black
case other =>
fail(s"Expected DrawOfferEvent, but got $other")
test("White accepts Black's draw offer"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.Black)
observer.events.clear()
engine.acceptDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: DrawEvent =>
event.reason shouldBe DrawReason.Agreement
event.context.result shouldBe Some(GameResult.Draw(DrawReason.Agreement))
case other =>
fail(s"Expected DrawEvent, but got $other")
test("Cannot accept draw when no offer pending"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.acceptDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.NoDrawOfferToAccept
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline draw when no offer pending"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.declineDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.NoDrawOfferToDecline
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot offer draw when game is already over"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// End the game with checkmate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.events.clear()
engine.processUserInput("d8h4")
// Try to offer draw
observer.events.clear()
engine.offerDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot accept your own draw offer"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.acceptDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.CannotAcceptOwnDrawOffer
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline your own draw offer"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.declineDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.CannotDeclineOwnDrawOffer
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot make second draw offer when one is already pending"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.offerDraw(Color.Black)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.DrawOfferPending
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Draw offer is cleared when game ends by resignation (accept)"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
// Try to accept the now-cleared draw offer
observer.events.clear()
engine.acceptDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
test("Draw offer is cleared when game ends by resignation (decline)"):
val engine = new GameEngine()
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.offerDraw(Color.White)
observer.events.clear()
engine.resign(Color.Black)
// Try to accept the now-cleared draw offer
observer.events.clear()
engine.declineDraw(Color.White)
observer.events should have length 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
private class DrawOfferMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
@@ -3,7 +3,7 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, MoveRedoneEvent, Observer}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -50,8 +50,8 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.processUserInput("e2e5")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Illegal move")
case _ => false
case InvalidMoveEvent(_, InvalidMoveReason.IllegalMove) => true
case _ => false
} shouldBe true
test("loadGame returns Left when importer fails"):
@@ -4,7 +4,16 @@ import de.nowchess.api.board.{Board, Color, File, Piece, PieceType, Rank, Square
import de.nowchess.api.game.{DrawReason, GameContext}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.*
import de.nowchess.chess.observer.{
CheckDetectedEvent,
CheckmateEvent,
DrawEvent,
GameEvent,
InvalidMoveEvent,
InvalidMoveReason,
MoveExecutedEvent,
Observer,
}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
@@ -30,8 +39,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
engine.processUserInput("e7e8")
events.exists {
case InvalidMoveEvent(_, reason) => reason.contains("Promotion piece required")
case _ => false
case InvalidMoveEvent(_, InvalidMoveReason.PromotionPieceRequired) => true
case _ => false
} should be(true)
}
@@ -198,5 +207,5 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
case _ => false
} should be(true)
val invalidEvt = events.collect { case e: InvalidMoveEvent => e }.last
invalidEvt.reason should include("Error completing promotion")
invalidEvt.reason shouldBe InvalidMoveReason.PromotionPieceInvalid
}
@@ -0,0 +1,70 @@
package de.nowchess.chess.engine
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameResult
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class GameEngineResignTest extends AnyFunSuite with Matchers:
test("White resigns"):
val engine = new GameEngine()
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.White)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.White
event.context.result shouldBe Some(GameResult.Win(Color.Black))
case other =>
fail(s"Expected ResignEvent, but got $other")
test("Black resigns"):
val engine = new GameEngine()
val observer = new ResignMockObserver()
engine.subscribe(observer)
engine.resign(Color.Black)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.Black
event.context.result shouldBe Some(GameResult.Win(Color.White))
case other =>
fail(s"Expected ResignEvent, but got $other")
test("Cannot resign when game is already over"):
val engine = new GameEngine()
val observer = new ResignMockObserver()
engine.subscribe(observer)
// End the game with checkmate
engine.processUserInput("f2f3")
engine.processUserInput("e7e5")
engine.processUserInput("g2g4")
observer.events.clear()
engine.processUserInput("d8h4")
// Try to resign
observer.events.clear()
engine.resign(Color.White)
// Should get InvalidMoveEvent with GameAlreadyOver reason
observer.events.length shouldBe 1
observer.events.head match
case event: InvalidMoveEvent =>
event.reason shouldBe InvalidMoveReason.GameAlreadyOver
case other =>
fail(s"Expected InvalidMoveEvent, but got $other")
private class ResignMockObserver extends Observer:
val events = mutable.ListBuffer[GameEvent]()
override def onGameEvent(event: GameEvent): Unit =
events += event
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=14
MINOR=15
PATCH=0
+10
View File
@@ -56,3 +56,13 @@
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
## (2026-04-19)
### Features
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
* NCS-30 FEN Parser using ParserCombinators ([#21](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/21)) ([b4bc72f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b4bc72f7e49f94d6e1bc805c68680e5fe8ef8e36))
* NCS-31 FastParse FEN ([#22](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/22)) ([7a045d3](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7a045d31d757bbc5aa6f4bad2664ebe8b8519cac))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=8
MINOR=9
PATCH=0
+12
View File
@@ -55,3 +55,15 @@
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
## (2026-04-19)
### Features
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
### Bug Fixes
* NCS-32 Queenside Castle doesn't care about pieces in the way ([#23](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/23)) ([fe8e3c0](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fe8e3c05397f433bfa34d1999e9738c82790adf7))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=3
MINOR=4
PATCH=0
+13
View File
@@ -91,3 +91,16 @@
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
## (2026-04-19)
### 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-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* 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-17 Implement basic ScalaFX UI ([#14](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/14)) ([3ff8031](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ff80318b4f16c59733a46498581a5c27f048287))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-29 JSON - Cherry Picked ([#28](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/28)) ([dbcafd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dbcafd286993e0604a6fa286c5543581a149439e))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([dceab08](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/dceab0875e6d15f7d3958633cf5dd5b29a851b1d))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=11
MINOR=12
PATCH=0