feat(bot): clock-aware time management and stream-driven tournament play
Build & Test (NowChessSystems) TeamCity build finished
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:
@@ -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],
|
||||
|
||||
+68
-7
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user