fix(official-bots): play games by polling state instead of NDJSON stream
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
In the native image the JAX-RS client buffers streaming responses, so reading
the NDJSON game stream blocks forever — the bot discovered its game ("Playing
game …") but never saw its turn and never moved, with no error. Replace the
game-stream consumer with a poll loop over plain GET game-state calls (which
work natively): when it is our turn, compute and submit. Drop the now-unused
stream consumer, move helper, and game-stream opener. Auto-join no longer
spawns per-tournament event-stream threads; polling handles discovery + play.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+27
-56
@@ -43,6 +43,9 @@ class TournamentBotGamePlayer:
|
|||||||
private val hardestDifficulty = "expert"
|
private val hardestDifficulty = "expert"
|
||||||
private val autoJoinIntervalMs = 15000L
|
private val autoJoinIntervalMs = 15000L
|
||||||
|
|
||||||
|
private val gameTerminalStatuses =
|
||||||
|
Set("checkmate", "stalemate", "draw", "resigned", "timeout", "aborted", "finished")
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@volatile private var running = true
|
@volatile private var running = true
|
||||||
@volatile private var autoJoinToken: Option[String] = None
|
@volatile private var autoJoinToken: Option[String] = None
|
||||||
@@ -87,8 +90,7 @@ class TournamentBotGamePlayer:
|
|||||||
open.foreach { tournamentId =>
|
open.foreach { tournamentId =>
|
||||||
if joinedTournaments.add(tournamentId) then
|
if joinedTournaments.add(tournamentId) then
|
||||||
val cfg = TournamentBotConfig(autoJoinServerUrl, tournamentId, token, botId, hardestDifficulty)
|
val cfg = TournamentBotConfig(autoJoinServerUrl, tournamentId, token, botId, hardestDifficulty)
|
||||||
if joinedOrParticipating(cfg) then startAsync(cfg)
|
if !joinedOrParticipating(cfg) then joinedTournaments.remove(tournamentId)
|
||||||
else joinedTournaments.remove(tournamentId)
|
|
||||||
}
|
}
|
||||||
playPendingGames(token, botId)
|
playPendingGames(token, botId)
|
||||||
}
|
}
|
||||||
@@ -370,58 +372,36 @@ class TournamentBotGamePlayer:
|
|||||||
private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
private def playGame(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||||
Try {
|
Try {
|
||||||
log.infof("Playing game %s as %s", gameId, color)
|
log.infof("Playing game %s as %s", gameId, color)
|
||||||
openGameStream(cfg, gameId).foreach(consumeGameStream(cfg, gameId, color, _))
|
pollGameLoop(cfg, gameId, color)
|
||||||
activeGames.remove(gameId)
|
activeGames.remove(gameId)
|
||||||
} match
|
} match
|
||||||
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
|
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
|
||||||
case Success(_) => ()
|
case Success(_) => ()
|
||||||
|
|
||||||
private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit =
|
// The native JAX-RS client buffers streaming responses, so reading the NDJSON game stream blocks
|
||||||
val reader = new BufferedReader(new InputStreamReader(stream))
|
// 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 =
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
var done = false
|
var done = false
|
||||||
|
var lastFen = ""
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
Iterator
|
while running && !done do
|
||||||
.continually(reader.readLine())
|
fetchJson(cfg, target(cfg).path("game").path(gameId)) match
|
||||||
.map(Option(_))
|
case None => sleep(2000)
|
||||||
.takeWhile(opt => opt.isDefined && running && !done)
|
case Some(node) =>
|
||||||
.flatten
|
val status = node.path("status").asText()
|
||||||
.foreach { line =>
|
if gameTerminalStatuses.contains(status) then
|
||||||
parse(line).foreach: node =>
|
log.infof("Game %s ended — status=%s", gameId, status); done = true
|
||||||
node.path("type").asText() match
|
else
|
||||||
case "gameState" =>
|
val turn = node.path("turn").asText()
|
||||||
maybeMove(
|
val fen = node.path("fen").asText()
|
||||||
cfg,
|
if turn == color && status == "ongoing" && fen.nonEmpty && fen != lastFen then
|
||||||
gameId,
|
lastFen = fen
|
||||||
color,
|
log.infof("Our turn in game %s — computing move (fen=%s)", gameId, fen)
|
||||||
node.path("turn").asText(),
|
computeUci(cfg, fen) match
|
||||||
node.path("status").asText(),
|
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
|
||||||
node.path("fen").asText(),
|
case Some(uci) => submitMove(cfg, gameId, uci)
|
||||||
)
|
sleep(1000)
|
||||||
case "move" =>
|
|
||||||
maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText())
|
|
||||||
case "gameEnd" =>
|
|
||||||
log.infof(
|
|
||||||
"Game %s ended — status=%s winner=%s",
|
|
||||||
gameId,
|
|
||||||
node.path("status").asText(),
|
|
||||||
node.path("winner").asText(),
|
|
||||||
); done = true
|
|
||||||
case _ => ()
|
|
||||||
}
|
|
||||||
|
|
||||||
private def maybeMove(
|
|
||||||
cfg: TournamentBotConfig,
|
|
||||||
gameId: String,
|
|
||||||
color: String,
|
|
||||||
turn: String,
|
|
||||||
status: String,
|
|
||||||
fen: String,
|
|
||||||
): Unit =
|
|
||||||
if turn == color && status == "ongoing" && fen.nonEmpty then
|
|
||||||
computeUci(cfg, fen) match
|
|
||||||
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
|
|
||||||
case Some(uci) => submitMove(cfg, gameId, uci)
|
|
||||||
|
|
||||||
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
|
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
|
||||||
FenParser.parseFen(fen) match
|
FenParser.parseFen(fen) match
|
||||||
@@ -439,15 +419,6 @@ class TournamentBotGamePlayer:
|
|||||||
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
|
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
|
||||||
case Success(_) => ()
|
case Success(_) => ()
|
||||||
|
|
||||||
private def openGameStream(cfg: TournamentBotConfig, gameId: String): Option[InputStream] =
|
|
||||||
Try {
|
|
||||||
val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream"))
|
|
||||||
.header("Accept", "application/x-ndjson")
|
|
||||||
.get()
|
|
||||||
if response.getStatus == 200 then Some(response.readEntity(classOf[InputStream]))
|
|
||||||
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); None }
|
|
||||||
}.getOrElse(None)
|
|
||||||
|
|
||||||
private def engine(cfg: TournamentBotConfig): Bot =
|
private def engine(cfg: TournamentBotConfig): Bot =
|
||||||
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
|
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user