feat: NCS-13 Implement Threefold Repetition (#31)
Build & Test (NowChessSystems) TeamCity build finished

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-04-16 18:49:20 +02:00
parent b2e62dc60c
commit 767d3051a7
14 changed files with 205 additions and 4 deletions
@@ -98,6 +98,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = DefaultRules.applyMove(context)(move)
val engine = new GameEngine(ruleSet = permissiveRules)
@@ -119,6 +120,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
def isStalemate(context: GameContext): Boolean = false
def isInsufficientMaterial(context: GameContext): Boolean = false
def isFiftyMoveRule(context: GameContext): Boolean = false
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val engine = new GameEngine(ruleSet = noLegalMoves)
@@ -221,3 +221,75 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.InsufficientMaterial
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
// ── Threefold Repetition ──────────────────────────────────────────
test("draw command rejected when neither 50-move rule nor threefold repetition available"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("e2e4")
observer.clear()
engine.processUserInput("draw")
observer.hasEvent[InvalidMoveEvent] shouldBe true
test("threefold repetition fires ThreefoldRepetitionAvailableEvent after 8-move shuffle"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Both knights shuffle home: initial position occurs 3 times on move 8 (Ng8)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
observer.clear()
engine.processUserInput("f6g8") // 3rd occurrence of initial position
observer.hasEvent[ThreefoldRepetitionAvailableEvent] shouldBe true
engine.context.result shouldBe None // claimable, not automatic
test("draw claim via threefold repetition ends game with DrawEvent"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8")
engine.processUserInput("g1f3")
engine.processUserInput("g8f6")
engine.processUserInput("f3g1")
engine.processUserInput("f6g8") // threefold now available
observer.clear()
engine.processUserInput("draw")
val evt = observer.getEvent[DrawEvent]
evt.isDefined shouldBe true
evt.get.reason shouldBe DrawReason.ThreefoldRepetition
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
test("loadPosition with non-empty moves preserves context as-is"):
val engine = EngineTestHelpers.makeEngine()
val observer = new EngineTestHelpers.MockObserver()
engine.subscribe(observer)
// Build a context that already has a move in its history
val move = de.nowchess.api.move.Move(
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R2),
de.nowchess.api.board.Square(de.nowchess.api.board.File.E, de.nowchess.api.board.Rank.R4),
)
val ctxWithMove = de.nowchess.api.game.GameContext.initial.withMove(move)
engine.loadPosition(ctxWithMove)
observer.hasEvent[BoardResetEvent] shouldBe true
@@ -173,6 +173,8 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
DefaultRules.isInsufficientMaterial(context)
def isFiftyMoveRule(context: GameContext): Boolean =
DefaultRules.isFiftyMoveRule(context)
def isThreefoldRepetition(context: GameContext): Boolean =
DefaultRules.isThreefoldRepetition(context)
def applyMove(context: GameContext)(move: Move): GameContext =
DefaultRules.applyMove(context)(move)