diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala index f47f213..70001d9 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala @@ -3,4 +3,33 @@ package de.nowchess.bot import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move -type Bot = GameContext => Option[Move] +/** Remaining wall-clock for the side to move and the Fischer increment, both in milliseconds. [[TimeControl.Unlimited]] + * is the sentinel for callers without a real clock (local play, self-play, tests): bots then fall back to their own + * fixed budgets. + */ +final case class TimeControl(remainingMs: Long, incrementMs: Long): + def isClocked: Boolean = remainingMs >= 0L + def budgetMs: Long = TimeControl.budget(remainingMs, incrementMs) + +object TimeControl: + val Unlimited: TimeControl = TimeControl(-1L, 0L) + + private val OverheadMs = 1500L + private val PanicMs = 20000L + private val MaxBudget = 8000L + private val PanicCap = 2500L + private val FloorMs = 50L + + def budget(remainingMs: Long, incrementMs: Long): Long = + val usable = math.max(0L, remainingMs - OverheadMs) + if usable <= 0L then FloorMs + else if remainingMs < PanicMs then clamp(usable / 15 + incrementMs / 2, PanicCap) + else clamp(usable / 30 + incrementMs * 4 / 5, MaxBudget) + + private def clamp(value: Long, ceiling: Long): Long = + math.max(FloorMs, math.min(value, ceiling)) + +trait Bot: + def move(context: GameContext, time: TimeControl): Option[Move] + def apply(context: GameContext): Option[Move] = move(context, TimeControl.Unlimited) + def apply(context: GameContext, time: TimeControl): Option[Move] = move(context, time) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala index ed4427a..13bab54 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala @@ -1,25 +1,28 @@ package de.nowchess.bot.bots -import de.nowchess.bot.Bot import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move import de.nowchess.api.rules.RuleSet import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.PolyglotBook -import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, TimeControl} import de.nowchess.rules.sets.DefaultRules object ClassicalBot: + private val defaultBudgetMs = 1000L + def apply( difficulty: BotDifficulty, rules: RuleSet = DefaultRules, book: Option[PolyglotBook] = None, ): Bot = - val search = AlphaBetaSearch(rules, weights = EvaluationClassic) - val timeBudgetMs = 1000L - context => - val blockedMoves = BotMoveRepetition.blockedMoves(context) - book - .flatMap(_.probe(context)) - .filterNot(blockedMoves.contains) - .orElse(search.bestMoveWithTime(context, timeBudgetMs, blockedMoves)) + val search = AlphaBetaSearch(rules, weights = EvaluationClassic) + new Bot: + def move(context: GameContext, time: TimeControl): Option[Move] = + val budget = if time.isClocked then time.budgetMs else defaultBudgetMs + val blockedMoves = BotMoveRepetition.blockedMoves(context) + book + .flatMap(_.probe(context)) + .filterNot(blockedMoves.contains) + .orElse(search.bestMoveWithTime(context, budget, blockedMoves)) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index 0d6e3e2..b16b455 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -9,7 +9,7 @@ import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable} import de.nowchess.bot.util.PolyglotBook -import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, Config, TimeControl} import de.nowchess.rules.sets.DefaultRules object HybridBot: @@ -17,6 +17,10 @@ object HybridBot: private def defaultThreads: Int = sys.env.get("NNUE_SEARCH_THREADS").flatMap(_.toIntOption).filter(_ >= 1).getOrElse(1) + // The veto re-search must share the move's budget, not double it: give the main search the bulk and + // reserve a slice for the at-most-one veto re-search so a vetoed move never costs two full budgets. + private val MainSearchShare = 0.7 + def apply( difficulty: BotDifficulty, rules: RuleSet = DefaultRules, @@ -28,26 +32,30 @@ object HybridBot: ): Bot = // Use ParallelSearch to enable multi-threaded (SMP) search similar to NNUEBot val search = ParallelSearch(rules, TranspositionTable(), () => classicalEvaluation, searchThreads) - context => - val blockedMoves = BotMoveRepetition.blockedMoves(context) + new Bot: + def move(context: GameContext, time: TimeControl): Option[Move] = + val totalBudget = if time.isClocked then time.budgetMs else Config.TIME_LIMIT_MS + val mainBudget = math.max(1L, (totalBudget * MainSearchShare).toLong) + val vetoBudget = math.max(1L, totalBudget - mainBudget) + val blockedMoves = BotMoveRepetition.blockedMoves(context) - def nnueScore(move: Move): Int = nnueEvaluation.evaluate(rules.applyMove(context)(move)) - def classicalScore(move: Move): Int = classicalEvaluation.evaluate(rules.applyMove(context)(move)) + def nnueScore(m: Move): Int = nnueEvaluation.evaluate(rules.applyMove(context)(m)) + def classicalScore(m: Move): Int = classicalEvaluation.evaluate(rules.applyMove(context)(m)) - def refine(move: Move): Move = - val moveNnue = nnueScore(move) - if (classicalScore(move) - moveNnue).abs <= Config.VETO_THRESHOLD then move - else - search - .bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves + move) - .filterNot(blockedMoves.contains) - .filter(alt => nnueScore(alt) < moveNnue) - .map { alt => - vetoReporter(f"[Veto] ${move.from}->${move.to} replaced by ${alt.from}->${alt.to} — NNUE prefers it") - alt - } - .getOrElse(move) + def refine(m: Move): Move = + val moveNnue = nnueScore(m) + if (classicalScore(m) - moveNnue).abs <= Config.VETO_THRESHOLD then m + else + search + .bestMoveWithTime(context, vetoBudget, blockedMoves + m) + .filterNot(blockedMoves.contains) + .filter(alt => nnueScore(alt) < moveNnue) + .map { alt => + vetoReporter(f"[Veto] ${m.from}->${m.to} replaced by ${alt.from}->${alt.to} — NNUE prefers it") + alt + } + .getOrElse(m) - book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse { - search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map(refine) - } + book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse { + search.bestMoveWithTime(context, mainBudget, blockedMoves).map(refine) + } diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala index 75747c8..a6d3027 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala @@ -1,13 +1,12 @@ package de.nowchess.bot.bots -import de.nowchess.bot.Bot import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.api.rules.RuleSet import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{ParallelSearch, TranspositionTable} import de.nowchess.bot.util.{PolyglotBook, ZobristHash} -import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} +import de.nowchess.bot.{Bot, BotDifficulty, BotMoveRepetition, TimeControl} import de.nowchess.rules.sets.DefaultRules object NNUEBot: @@ -22,20 +21,21 @@ object NNUEBot: searchThreads: Int = defaultThreads, ): Bot = val search = ParallelSearch(rules, TranspositionTable(), () => EvaluationNNUE.freshEvaluator(), searchThreads) - context => - val blockedMoves = BotMoveRepetition.blockedMoves(context) - book - .flatMap(_.probe(context)) - .filterNot(blockedMoves.contains) - .orElse { - val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context)) - if moves.isEmpty then None - else - val scored = batchEvaluateRoot(rules, context, moves) - val bestMove = scored.maxBy(_._2)._1 - val budget = fixedMoveTimeMs.getOrElse(allocateTime(scored)) - search.bestMoveWithTime(context, budget, blockedMoves, scored.toMap).orElse(Some(bestMove)) - } + new Bot: + def move(context: GameContext, time: TimeControl): Option[Move] = + val blockedMoves = BotMoveRepetition.blockedMoves(context) + book + .flatMap(_.probe(context)) + .filterNot(blockedMoves.contains) + .orElse { + val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context)) + if moves.isEmpty then None + else + val scored = batchEvaluateRoot(rules, context, moves) + val bestMove = scored.maxBy(_._2)._1 + val budget = fixedMoveTimeMs.getOrElse(if time.isClocked then time.budgetMs else allocateTime(scored)) + search.bestMoveWithTime(context, budget, blockedMoves, scored.toMap).orElse(Some(bestMove)) + } private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] = EvaluationNNUE.initAccumulator(context) diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/BotVsBotMain.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/BotVsBotMain.scala index 85f22c5..8ead6ce 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/BotVsBotMain.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/BotVsBotMain.scala @@ -4,7 +4,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.api.rules.RuleSet import de.nowchess.api.board.Color -import de.nowchess.bot.BotDifficulty +import de.nowchess.bot.{Bot, BotDifficulty} import de.nowchess.bot.bots.{HybridBot, NNUEBot} import de.nowchess.io.fen.FenExporter import de.nowchess.rules.sets.DefaultRules @@ -19,11 +19,11 @@ enum GameResult: case Bot2Wins case Draw -/** Standalone bot-vs-bot harness. Runs two NNUEBots against each other from randomised openings and writes the - * visited positions as one FEN per line. Each bot can use different difficulty levels and weight files. +/** Standalone bot-vs-bot harness. Runs two NNUEBots against each other from randomised openings and writes the visited + * positions as one FEN per line. Each bot can use different difficulty levels and weight files. * - * Games run sequentially. Bots alternate: bot1 plays white, then bot1 plays black, alternating per game to ensure - * fair evaluation. + * Games run sequentially. Bots alternate: bot1 plays white, then bot1 plays black, alternating per game to ensure fair + * evaluation. */ object BotVsBotMain: @@ -43,7 +43,7 @@ object BotVsBotMain: def main(args: Array[String]): Unit = val config = parse(args.toList, Config()) - val rules = DefaultRules + val rules = DefaultRules val difficulty1 = parseDifficulty(config.difficulty1) val difficulty2 = parseDifficulty(config.difficulty2) @@ -53,11 +53,11 @@ object BotVsBotMain: config.weights2.foreach(System.setProperty("nnue.weights", _)) val bot2 = HybridBot(difficulty2, rules) - val rng = new Random(config.seed) - val seen = mutable.HashSet.empty[String] + val rng = new Random(config.seed) + val seen = mutable.HashSet.empty[String] var bot1Wins = 0 var bot2Wins = 0 - var draws = 0 + var draws = 0 Files.createDirectories(Path.of(config.out).toAbsolutePath.getParent) val writer = new BufferedWriter(new FileWriter(config.out)) @@ -65,7 +65,7 @@ object BotVsBotMain: var game = 0 while game < config.games do val bot1AsWhite = game % 2 == 0 - val result = playGame(rules, bot1, bot2, rng, config, seen, writer, bot1AsWhite) + val result = playGame(rules, bot1, bot2, rng, config, seen, writer, bot1AsWhite) game += 1 result match @@ -83,7 +83,7 @@ object BotVsBotMain: if game % 25 == 0 then writer.flush() println( - s" Progress: $game/${config.games} | Positions: ${seen.size} | Bot1: $bot1Wins, Bot2: $bot2Wins, Draws: $draws\n" + s" Progress: $game/${config.games} | Positions: ${seen.size} | Bot1: $bot1Wins, Bot2: $bot2Wins, Draws: $draws\n", ) finally writer.close() println(s"\nFinal Statistics:") @@ -94,8 +94,8 @@ object BotVsBotMain: private def playGame( rules: RuleSet, - bot1: GameContext => Option[Move], - bot2: GameContext => Option[Move], + bot1: Bot, + bot2: Bot, rng: Random, config: Config, seen: mutable.HashSet[String], @@ -105,16 +105,16 @@ object BotVsBotMain: randomOpening(rules, rng, config.randomPlies, GameContext.initial) match case None => None case Some(start) => - var ctx = start + var ctx = start var plies = config.randomPlies - var live = true + var live = true while live && plies < config.maxPlies do if isTerminal(rules, ctx) then live = false else - val currentBot = if (plies - config.randomPlies) % 2 == 0 then - if bot1AsWhite then bot1 else bot2 - else if bot1AsWhite then bot2 - else bot1 + val currentBot = + if (plies - config.randomPlies) % 2 == 0 then if bot1AsWhite then bot1 else bot2 + else if bot1AsWhite then bot2 + else bot1 currentBot(ctx) match case None => live = false @@ -127,22 +127,18 @@ object BotVsBotMain: private def determineWinner(rules: RuleSet, ctx: GameContext, bot1AsWhite: Boolean): Option[GameResult] = val legalMoves = rules.allLegalMoves(ctx) - - if legalMoves.nonEmpty then - Some(GameResult.Draw) + + if legalMoves.nonEmpty then Some(GameResult.Draw) else if rules.isCheck(ctx) then - if ctx.turn == (if bot1AsWhite then Color.Black else Color.White) then - Some(GameResult.Bot1Wins) - else - Some(GameResult.Bot2Wins) + if ctx.turn == (if bot1AsWhite then Color.Black else Color.White) then Some(GameResult.Bot1Wins) + else Some(GameResult.Bot2Wins) else if rules.isFiftyMoveRule(ctx) || rules.isThreefoldRepetition(ctx) || rules.isInsufficientMaterial(ctx) then Some(GameResult.Draw) - else - Some(GameResult.Draw) + else Some(GameResult.Draw) private def randomOpening(rules: RuleSet, rng: Random, plies: Int, start: GameContext): Option[GameContext] = var ctx = start - var i = 0 + var i = 0 while i < plies do val legal = rules.allLegalMoves(ctx) if legal.isEmpty then return None diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/SelfPlayMain.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/SelfPlayMain.scala index e112df0..d2cf247 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/SelfPlayMain.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/selfplay/SelfPlayMain.scala @@ -3,7 +3,7 @@ package de.nowchess.bot.selfplay import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.api.rules.RuleSet -import de.nowchess.bot.BotDifficulty +import de.nowchess.bot.{Bot, BotDifficulty} import de.nowchess.bot.bots.NNUEBot import de.nowchess.io.fen.FenExporter import de.nowchess.rules.sets.DefaultRules @@ -55,7 +55,7 @@ object SelfPlayMain: private def playGame( rules: RuleSet, - bot: GameContext => Option[Move], + bot: Bot, rng: Random, config: Config, seen: mutable.HashSet[String], diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala index c6c55a1..d87b0b2 100644 --- a/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala +++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/TournamentBotGamePlayer.scala @@ -2,7 +2,7 @@ package de.nowchess.bot.service import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.bot.{Bot, BotController} +import de.nowchess.bot.{Bot, BotController, TimeControl} import de.nowchess.bot.client.AccountServiceClient import de.nowchess.bot.config.RedisConfig import de.nowchess.io.fen.FenParser @@ -20,6 +20,7 @@ import scala.jdk.CollectionConverters.* import scala.util.{Failure, Success, Try} import java.io.{BufferedReader, InputStream, InputStreamReader} import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors} +import java.util.concurrent.atomic.AtomicReference @Startup @ApplicationScoped @@ -42,6 +43,8 @@ class TournamentBotGamePlayer: private val hardestDifficulty = "expert" private val autoJoinIntervalMs = 15000L + // Detect the opponent's move fast: every poll spent waiting runs our clock without us thinking. + private val pollIntervalMs = 250L private val gameTerminalStatuses = Set("checkmate", "stalemate", "draw", "resigned", "timeout", "aborted", "finished") @@ -393,12 +396,58 @@ class TournamentBotGamePlayer: private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit = Try { log.infof("Playing game %s as %s", gameId, color) - pollGameLoop(cfg, gameId, color) + if !streamGameLoop(cfg, gameId, color) then + log.infof("Stream unavailable for game %s — falling back to polling", gameId) + pollGameLoop(cfg, gameId, color) activeGames.remove(gameId) } match case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId) case Success(_) => () + // Push-based play: the game stream delivers the opponent's move the instant it lands, so our clock + // is not burned waiting between polls. Heartbeats (every 10s) keep the NDJSON connection flushing. + // Returns true if the game was driven to completion via the stream; false to fall back to polling. + private def streamGameLoop(cfg: TournamentBotConfig, gameId: String, color: String): Boolean = + val myColor = resolveColor(cfg, gameId).getOrElse(color) + val lastFen = AtomicReference("") + Try { + val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream")) + .header("Accept", "application/x-ndjson") + .get() + try + if response.getStatus != 200 then + log.warnf("Game stream %s returned status %d", gameId, response.getStatus) + false + else + log.infof("Streaming game %s as %s", gameId, myColor) + forEachLine(response.readEntity(classOf[InputStream])): line => + parse(line).foreach(node => handleStreamEvent(cfg, gameId, myColor, node, lastFen)) + true + finally response.close() + } match + case Success(completed) => completed + case Failure(ex) => log.warnf(ex, "Game stream %s failed", gameId); false + + private def handleStreamEvent( + cfg: TournamentBotConfig, + gameId: String, + myColor: String, + node: JsonNode, + lastFen: AtomicReference[String], + ): Unit = + val eventType = node.path("type").asText() + if eventType == "move" || eventType == "gameState" then + val status = node.path("status").asText("ongoing") + val turn = node.path("turn").asText() + val fen = node.path("fen").asText() + if !gameTerminalStatuses.contains(status) && turn == myColor && fen.nonEmpty && fen != lastFen.get then + lastFen.set(fen) + val time = readTimeControl(node, myColor) + log.infof("Our turn (stream) in game %s — computing move (fen=%s, budget=%dms)", gameId, fen, time.budgetMs) + computeUci(cfg, fen, time) match + case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen) + case Some(uci) => submitMove(cfg, gameId, uci) + // The native JAX-RS client buffers streaming responses, so reading the NDJSON game stream blocks // forever. Poll the game state with plain GETs (which work) and move when it is our turn. private def pollGameLoop(cfg: TournamentBotConfig, gameId: String, color: String): Unit = @@ -426,16 +475,28 @@ class TournamentBotGamePlayer: val fen = node.path("fen").asText() if turn == myColor && status == "ongoing" && fen.nonEmpty && fen != lastFen then lastFen = fen - log.infof("Our turn in game %s — computing move (fen=%s)", gameId, fen) - computeUci(cfg, fen) match + val time = readTimeControl(node, myColor) + log.infof("Our turn in game %s — computing move (fen=%s, budget=%dms)", gameId, fen, time.budgetMs) + computeUci(cfg, fen, time) match case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen) case Some(uci) => submitMove(cfg, gameId, uci) - sleep(1000) + sleep(pollIntervalMs) - private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] = + // Server clock is reported in seconds; convert to a millisecond TimeControl so the engine can + // size its move budget against the real clock instead of a fixed guess. + private def readTimeControl(node: JsonNode, myColor: String): TimeControl = + val clock = node.path("clock") + if clock.isMissingNode || clock.isNull then TimeControl.Unlimited + else + val field = if myColor == "white" then "whiteTime" else "blackTime" + val remainingMs = (clock.path(field).asDouble(0.0) * 1000.0).toLong + val incrementMs = (clock.path("increment").asDouble(0.0) * 1000.0).toLong + TimeControl(remainingMs, incrementMs) + + private def computeUci(cfg: TournamentBotConfig, fen: String, time: TimeControl): Option[String] = FenParser.parseFen(fen) match case Left(err) => log.warnf("FEN parse failed: %s (%s)", fen, err.toString); None - case Right(context) => engine(cfg).apply(context).map(toUci) + case Right(context) => engine(cfg).move(context, time).map(toUci) private def submitMove(cfg: TournamentBotConfig, gameId: String, uci: String): Unit = Try {