feat(bot): implement bot architecture with difficulty levels and game context handling

This commit is contained in:
2026-04-28 00:59:32 +02:00
parent 6b59e68e04
commit c10a4d7e64
121 changed files with 1010 additions and 1358 deletions
@@ -3,19 +3,15 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{
BotParticipant,
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
Human,
LiveClockState,
Participant,
TimeControl,
WinReason,
}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.api.error.GameError
@@ -25,7 +21,6 @@ import de.nowchess.api.rules.RuleSet
import java.time.Instant
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
import scala.concurrent.{ExecutionContext, Future}
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
@@ -33,10 +28,6 @@ import scala.concurrent.{ExecutionContext, Future}
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
),
val timeControl: TimeControl = TimeControl.Unlimited,
initialClockState: Option[ClockState] = None,
initialDrawOffer: Option[Color] = None,
@@ -69,8 +60,6 @@ class GameEngine(
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck)
private implicit val ec: ExecutionContext = ExecutionContext.global
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
@@ -325,9 +314,6 @@ class GameEngine(
notifyObservers(DrawEvent(currentContext, reason))
}
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
/** Inject clock state directly (for testing). */
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
@@ -426,7 +412,6 @@ class GameEngine(
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -477,47 +462,6 @@ class GameEngine(
case _ =>
context.board.pieceAt(move.to)
/** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
*/
private def requestBotMoveIfNeeded(): Unit =
val pendingBotMove = synchronized {
participants.get(currentContext.turn) match
case Some(BotParticipant(bot)) => Some((bot, currentContext))
case _ => None
}
pendingBotMove.foreach { case (bot, contextAtRequest) =>
Future {
bot.nextMove(contextAtRequest) match
case Some(move) => applyBotMove(move)
case None => handleBotNoMove()
}
}
private def applyBotMove(move: Move): Unit =
synchronized {
val color = currentContext.turn
val from = move.from
val to = move.to
currentContext.board.pieceAt(from) match
case Some(piece) if piece.color == color =>
val legal = ruleSet.legalMoves(currentContext)(from)
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) => executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
}
private def handleBotNoMove(): Unit =
synchronized {
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
}
private def replayContextFromMoves(moves: List[Move]): GameContext =
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
@@ -79,6 +79,11 @@ class GameResource:
val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
private def assertIsBot(): Unit =
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
if !Set("bot", "official-bot").contains(botType) then
throw ForbiddenException("Only bots can make moves")
// scalafix:on DisableSyntax.throw
// ── mapping ──────────────────────────────────────────────────────────────
@@ -184,6 +189,7 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
assertIsBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
assertIsCurrentPlayer(entry)
@@ -1,266 +0,0 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.{BotParticipant, GameContext, Human}
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.{BotController, BotDifficulty}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
private class NoMoveBot extends Bot:
def name: String = "nomove"
def nextMove(context: GameContext): Option[Move] = None
private class FixedMoveBot(move: Move) extends Bot:
def name: String = "fixed"
def nextMove(context: GameContext): Option[Move] = Some(move)
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
// Collect events
val moveCount = new AtomicInteger(0)
val checkmateDetected = new AtomicBoolean(false)
val gameEnded = new AtomicBoolean(false)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent =>
moveCount.incrementAndGet()
case _: CheckmateEvent =>
checkmateDetected.set(true)
gameEnded.set(true)
case _: DrawEvent =>
gameEnded.set(true)
case _ => ()
engine.subscribe(observer)
// Play a few moves: e2e4, then let the bot respond
engine.processUserInput("e2e4")
// Wait a bit for the bot to respond asynchronously
Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded
moveCount.get() should be >= 2
test("BotController can list and retrieve bots"):
val bots = BotController.listBots
bots should contain("easy")
bots should contain("medium")
bots should contain("hard")
bots should contain("expert")
BotController.getBot("easy") should not be None
BotController.getBot("medium") should not be None
BotController.getBot("hard") should not be None
BotController.getBot("expert") should not be None
BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"):
val hardBot = BotController.getBot("hard").get
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
)
engine.turn should equal(Color.White)
val movesMade = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// White moves
engine.processUserInput("d2d4")
Thread.sleep(500) // Wait for bot response
// At least white moved, possibly black also responded
movesMade.get() should be >= 1
test("GameEngine plays valid bot moves"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val moveCount = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => moveCount.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// Play a normal move
engine.processUserInput("e2e4")
Thread.sleep(1000)
// The game should have progressed with at least one move
moveCount.get() should be >= 1
// Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true)
test("startGame triggers bot when the starting player is a bot"):
val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val movesMade = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
movesMade.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
val bot = new FixedMoveBot(illegalMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
val bot = new FixedMoveBot(invalidMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
// White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R2) -> Piece.BlackQueen,
Square(File.B, Rank.R8) -> Piece.BlackRook,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val checkmateCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => checkmateCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
checkmateCount.get() should be >= 1
test("handleBotNoMove fires DrawEvent when position is stalemate"):
// White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R3) -> Piece.BlackQueen,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val drawCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: DrawEvent => drawCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
drawCount.get() should be >= 1
test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val unexpectedEvents = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
case _: DrawEvent => unexpectedEvents.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
unexpectedEvents.get() shouldBe 0
@@ -12,6 +12,7 @@ import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -34,8 +35,13 @@ class GameResourceIntegrationTest:
@InjectMock
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
var jwt: JsonWebToken = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(jwt.getClaim[AnyRef]("type")).thenReturn("bot")
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),