feat(bot): clock-aware time management and stream-driven tournament play
Build & Test (NowChessSystems) TeamCity build finished

Tournament bots flagged in 5+3 classical: budgets were fixed and
clock-blind, HybridBot's veto re-search double-spent (up to 4s/move),
and the game loop polled every 1s, burning our clock waiting on the
opponent.

- Bot is now a trait taking a TimeControl (remaining + increment);
  apply(ctx) defaults to Unlimited so local/self-play/tests keep their
  fixed budgets.
- TimeControl.budget derives a per-move budget from the real clock with
  an overhead reserve, a panic mode under 20s, and a hard ceiling, so a
  bot can no longer flag from thinking.
- HybridBot splits one budget across main (0.7) and veto (0.3) searches
  instead of running two full searches.
- TournamentBotGamePlayer reads the server clock (seconds -> ms) and
  plays stream-driven via GET /game/{id}/stream (NDJSON, heartbeat-kept),
  so the opponent's move arrives instantly; polling stays as a fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 22:12:08 +02:00
parent b2683a7f5a
commit 45b5719d63
7 changed files with 183 additions and 86 deletions
@@ -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)
@@ -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))
@@ -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)
}
@@ -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)
@@ -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
@@ -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],
@@ -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 {