This commit is contained in:
@@ -217,6 +217,8 @@ class AccountService:
|
||||
bot.name = name
|
||||
bot.createdAt = Instant.now()
|
||||
officialBotAccountRepository.persist(bot)
|
||||
bot.token = generateBotToken(bot.id, bot.name)
|
||||
officialBotAccountRepository.persist(bot)
|
||||
log.infof("Auto-registered official bot: %s", name)
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -22,7 +22,7 @@ import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.Try
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||
import java.util.function.Consumer
|
||||
|
||||
@ApplicationScoped
|
||||
@@ -46,6 +46,10 @@ class GameRedisSubscriberManager:
|
||||
|
||||
private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
|
||||
private val s2cObservers = new ConcurrentHashMap[String, Observer]()
|
||||
// Per-game single-thread executor so c2s messages are handled off the Vert.x
|
||||
// event loop (handleConnected/handleMove make blocking gRPC + Redis calls) while
|
||||
// staying ordered per game.
|
||||
private val c2sExecutors = new ConcurrentHashMap[String, ExecutorService]()
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None
|
||||
@@ -95,7 +99,14 @@ class GameRedisSubscriberManager:
|
||||
obs.emitInitialWriteback()
|
||||
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
|
||||
|
||||
val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
|
||||
val executor = c2sExecutors.computeIfAbsent(gameId, _ => Executors.newSingleThreadExecutor())
|
||||
val handler: Consumer[String] = msg =>
|
||||
val task = new Runnable:
|
||||
def run(): Unit =
|
||||
try handleC2sMessage(gameId, msg)
|
||||
catch case ex: Exception => log.warnf(ex, "Error handling c2s message for game %s", gameId)
|
||||
Try(executor.execute(task))
|
||||
()
|
||||
try
|
||||
val subscriber = reactiveRedis
|
||||
.pubsub(classOf[String])
|
||||
@@ -113,6 +124,7 @@ class GameRedisSubscriberManager:
|
||||
Option(s2cObservers.remove(gameId)).foreach { obs =>
|
||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||
}
|
||||
Option(c2sExecutors.remove(gameId)).foreach(_.shutdownNow())
|
||||
|
||||
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
|
||||
log.debugf("Unsubscribed from game %s", gameId)
|
||||
@@ -187,3 +199,4 @@ class GameRedisSubscriberManager:
|
||||
clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely())
|
||||
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely())
|
||||
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
|
||||
c2sExecutors.forEach((_, executor) => executor.shutdownNow())
|
||||
|
||||
+126
-81
@@ -2,23 +2,24 @@ package de.nowchess.bot.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.bot.BotController
|
||||
import de.nowchess.bot.Bot
|
||||
import de.nowchess.bot.BotDifficulty
|
||||
import de.nowchess.bot.client.{AccountServiceClient, SyncOfficialBotsRequest}
|
||||
import de.nowchess.bot.bots.ClassicalBot
|
||||
import de.nowchess.bot.config.RedisConfig
|
||||
import de.nowchess.io.fen.FenParser
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.ReactiveRedisDataSource
|
||||
import io.quarkus.redis.datasource.pubsub.ReactivePubSubCommands
|
||||
import io.quarkus.runtime.StartupEvent
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.event.Observes
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import java.time.Duration
|
||||
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||
import java.util.function.Consumer
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ApplicationScoped
|
||||
class OfficialBotService:
|
||||
@@ -26,100 +27,144 @@ class OfficialBotService:
|
||||
private val log = Logger.getLogger(classOf[OfficialBotService])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var botController: BotController = uninitialized
|
||||
@Inject var meterRegistry: MeterRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var accountServiceClient: AccountServiceClient = uninitialized
|
||||
@Inject var reactiveRedis: ReactiveRedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var meterRegistry: MeterRegistry = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val engine: Bot = ClassicalBot(BotDifficulty.Medium)
|
||||
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
||||
|
||||
// gameId -> (turn color -> bot account id we play that color as)
|
||||
private val watchedGames = new ConcurrentHashMap[String, ConcurrentHashMap[String, String]]()
|
||||
private val subscribers = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
|
||||
|
||||
private val terminalStatuses =
|
||||
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
|
||||
|
||||
@PostConstruct
|
||||
def initializeMetrics(): Unit =
|
||||
BotController.listBots.foreach { bot =>
|
||||
meterRegistry.timer("nowchess.bot.move.duration", "bot", bot).record(0L, TimeUnit.MILLISECONDS)
|
||||
meterRegistry.counter("nowchess.bot.moves.computed", "bot", bot).increment(0)
|
||||
}
|
||||
meterRegistry
|
||||
.timer("nowchess.bot.move.duration", "bot", engineName)
|
||||
.record(0L, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment(0)
|
||||
|
||||
// Wildcard subscription: handles gameStart events for *any* bot account,
|
||||
// whether official (easy/medium/hard/expert) or user-created. Runs at startup
|
||||
// on a context-bearing thread, so the blocking await is safe here.
|
||||
def onStart(@Observes event: StartupEvent): Unit =
|
||||
val bots = BotController.listBots
|
||||
try accountServiceClient.syncBots(SyncOfficialBotsRequest(bots))
|
||||
catch case ex: Exception => log.errorf(ex, "Failed to auto-register official bots with account service")
|
||||
bots.foreach(subscribeToEventChannel)
|
||||
|
||||
private def subscribeToEventChannel(botName: String): Unit =
|
||||
val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
|
||||
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
|
||||
val pattern = s"${redisConfig.prefix}:bot:*:events"
|
||||
val handler: Consumer[String] = msg => handleBotEvent(msg)
|
||||
reactiveRedis
|
||||
.pubsub(classOf[String])
|
||||
.subscribeToPattern(pattern, handler)
|
||||
.await()
|
||||
.atMost(Duration.ofSeconds(5))
|
||||
log.infof("Bot engine subscribed to pattern %s", pattern)
|
||||
()
|
||||
|
||||
private def handleBotEvent(botName: String, msg: String): Unit =
|
||||
private def handleBotEvent(msg: String): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
if node.path("type").asText() == "gameStart" then
|
||||
val gameId = node.path("gameId").asText()
|
||||
val playingAs = node.path("playingAs").asText()
|
||||
val difficulty = node.path("difficulty").asInt(1400)
|
||||
val botAccountId = node.path("botAccountId").asText()
|
||||
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
|
||||
catch case _: Exception => ()
|
||||
val gameId = node.path("gameId").asText()
|
||||
val playingAs = node.path("playingAs").asText()
|
||||
val playerId = node.path("botAccountId").asText()
|
||||
if gameId.nonEmpty && playerId.nonEmpty && playingAs.nonEmpty then
|
||||
log.infof("gameStart: game=%s playingAs=%s bot=%s", gameId, playingAs, playerId)
|
||||
registerColor(gameId, playingAs, playerId)
|
||||
else log.warnf("gameStart missing gameId/playingAs/botAccountId: %s", msg)
|
||||
catch case e: Exception => log.errorf(e, "Failed to handle bot event: %s", msg)
|
||||
|
||||
private def watchGame(
|
||||
botName: String,
|
||||
gameId: String,
|
||||
playingAs: String,
|
||||
difficulty: Int,
|
||||
botAccountId: String,
|
||||
): Unit =
|
||||
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
|
||||
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
|
||||
private def registerColor(gameId: String, playingAs: String, playerId: String): Unit =
|
||||
val fresh = new ConcurrentHashMap[String, String]()
|
||||
val existing = watchedGames.putIfAbsent(gameId, fresh)
|
||||
val colors = if existing == null then fresh else existing
|
||||
colors.put(playingAs, playerId)
|
||||
// Only the first color registered for a game subscribes + triggers the position.
|
||||
if existing == null then subscribeAndConnect(gameId)
|
||||
()
|
||||
|
||||
private def handleGameEvent(
|
||||
botName: String,
|
||||
gameId: String,
|
||||
playingAs: String,
|
||||
difficulty: Int,
|
||||
botAccountId: String,
|
||||
msg: String,
|
||||
): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
val status = node.path("state").path("status").asText("")
|
||||
if !terminalStatuses.contains(status) then
|
||||
val turn = node.path("state").path("turn").asText("")
|
||||
if turn == playingAs then
|
||||
val fen = node.path("state").path("fen").asText()
|
||||
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
|
||||
catch case _: Exception => ()
|
||||
|
||||
private def computeAndSendMove(
|
||||
botName: String,
|
||||
gameId: String,
|
||||
fen: String,
|
||||
difficulty: Int,
|
||||
botAccountId: String,
|
||||
): Unit =
|
||||
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
|
||||
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
|
||||
FenParser.parseFen(fen).toOption.foreach { context =>
|
||||
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", botName)
|
||||
val moveOpt = timer.recordCallable[Option[Move]](() => bot(context))
|
||||
moveOpt.foreach { move =>
|
||||
meterRegistry.counter("nowchess.bot.moves.computed", "bot", botName).increment()
|
||||
val uci = toUci(move)
|
||||
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
|
||||
redis.pubsub(classOf[String]).publish(c2sTopic, moveMsg)
|
||||
()
|
||||
}
|
||||
}
|
||||
// Called on the Redis event-loop thread (the pattern handler's thread), which
|
||||
// carries a Vert.x context. The subscribe must be issued non-blocking from this
|
||||
// context — issuing it from a plain worker thread (or awaiting it there) leaves
|
||||
// the subscribe reply with "no handler waiting" and the call times out.
|
||||
private def subscribeAndConnect(gameId: String): Unit =
|
||||
val s2c = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||
val handler: Consumer[String] = msg => handleGameEvent(gameId, msg)
|
||||
val pubsub = reactiveRedis.pubsub(classOf[String])
|
||||
val onSubscribed: Consumer[ReactivePubSubCommands.ReactiveRedisSubscriber] = { subscriber =>
|
||||
subscribers.put(gameId, subscriber)
|
||||
sendConnected(pubsub, gameId)
|
||||
}
|
||||
pubsub.subscribe(s2c, handler).subscribe().`with`(onSubscribed, logFailure(s"subscribe to game $gameId"))
|
||||
()
|
||||
|
||||
// Ask the server to emit the current position (gameFull) immediately.
|
||||
private def sendConnected(pubsub: ReactivePubSubCommands[String], gameId: String): Unit =
|
||||
val c2s = s"${redisConfig.prefix}:game:$gameId:c2s"
|
||||
val onOk: Consumer[Void] = _ => log.infof("Watching game %s", gameId)
|
||||
pubsub.publish(c2s, """{"type":"CONNECTED"}""").subscribe().`with`(onOk, logFailure(s"send CONNECTED for game $gameId"))
|
||||
()
|
||||
|
||||
private def handleGameEvent(gameId: String, msg: String): Unit =
|
||||
try
|
||||
val node = objectMapper.readTree(msg)
|
||||
// gameState has state at root; gameFull (CONNECTED reply) nests it under game.state
|
||||
val stateNode =
|
||||
if node.path("type").asText() == "gameFull" then node.path("game").path("state")
|
||||
else node.path("state")
|
||||
val status = stateNode.path("status").asText("")
|
||||
val turn = stateNode.path("turn").asText("")
|
||||
if terminalStatuses.contains(status) then stopWatching(gameId)
|
||||
else
|
||||
val colors = watchedGames.get(gameId)
|
||||
val playerId = if colors == null then null else colors.get(turn)
|
||||
if playerId != null then
|
||||
val fen = stateNode.path("fen").asText()
|
||||
workers.submit(new Runnable { def run(): Unit = computeAndSendMove(gameId, fen, playerId) })
|
||||
()
|
||||
catch case e: Exception => log.errorf(e, "Failed to handle game event for %s", gameId)
|
||||
|
||||
private def stopWatching(gameId: String): Unit =
|
||||
watchedGames.remove(gameId)
|
||||
Option(subscribers.remove(gameId)).foreach { subscriber =>
|
||||
val noop: Consumer[Void] = _ => ()
|
||||
subscriber
|
||||
.unsubscribe(s"${redisConfig.prefix}:game:$gameId:s2c")
|
||||
.subscribe()
|
||||
.`with`(noop, logFailure(s"unsubscribe game $gameId"))
|
||||
}
|
||||
()
|
||||
|
||||
// Runs on a worker thread: the engine search must stay off the event loop. The
|
||||
// move is published reactively (scheduled onto the Redis event loop), so the
|
||||
// worker thread's lack of a Vert.x context does not matter for publishing.
|
||||
private def computeAndSendMove(gameId: String, fen: String, playerId: String): Unit =
|
||||
FenParser.parseFen(fen) match
|
||||
case Left(err) => log.warnf("FEN parse failed for game %s: %s", gameId, err.toString)
|
||||
case Right(context) =>
|
||||
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", engineName)
|
||||
val moveOpt = timer.recordCallable[Option[Move]](() => engine(context))
|
||||
moveOpt match
|
||||
case None => log.warnf("Engine returned no move for game %s fen=%s", gameId, fen)
|
||||
case Some(move) =>
|
||||
meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment()
|
||||
val uci = toUci(move)
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
|
||||
val onOk: Consumer[Void] = _ => log.infof("Bot moved %s in game %s", uci, gameId)
|
||||
reactiveRedis
|
||||
.pubsub(classOf[String])
|
||||
.publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
|
||||
.subscribe()
|
||||
.`with`(onOk, logFailure(s"publish move in game $gameId"))
|
||||
()
|
||||
|
||||
private def logFailure(what: String): Consumer[Throwable] =
|
||||
err => log.errorf(err, "Failed to %s", what)
|
||||
|
||||
private def engineName: String = "classical"
|
||||
|
||||
private def toUci(move: Move): String =
|
||||
val base = s"${move.from}${move.to}"
|
||||
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package de.nowchess.bot.service
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import scala.util.Try
|
||||
|
||||
final case class TournamentBotConfig(
|
||||
serverUrl: String,
|
||||
tournamentId: String,
|
||||
token: String,
|
||||
botId: String,
|
||||
difficulty: String,
|
||||
)
|
||||
|
||||
object TournamentBotConfig:
|
||||
|
||||
private val mapper = new ObjectMapper()
|
||||
|
||||
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
|
||||
for
|
||||
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
|
||||
token <- env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
|
||||
botId <- jwtSubject(token)
|
||||
serverUrl = env.getOrElse("TOURNAMENT_SERVER_URL", "http://localhost:8089")
|
||||
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
|
||||
yield TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty)
|
||||
|
||||
def jwtSubject(token: String): Option[String] =
|
||||
Try {
|
||||
val parts = token.split("\\.")
|
||||
if parts.length >= 2 then
|
||||
val payload = new String(java.util.Base64.getUrlDecoder.decode(parts(1)))
|
||||
val sub = mapper.readTree(payload).path("sub").asText()
|
||||
Option(sub).filter(_.nonEmpty)
|
||||
else None
|
||||
}.toOption.flatten
|
||||
+219
@@ -0,0 +1,219 @@
|
||||
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.io.fen.FenParser
|
||||
import io.quarkus.runtime.Startup
|
||||
import jakarta.annotation.{PostConstruct, PreDestroy}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.io.{BufferedReader, InputStream, InputStreamReader}
|
||||
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||
|
||||
@Startup
|
||||
@ApplicationScoped
|
||||
class TournamentBotGamePlayer:
|
||||
|
||||
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var botController: BotController = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val client: Client = ClientBuilder.newClient()
|
||||
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
||||
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
||||
|
||||
private val config = TournamentBotConfig.fromEnv(System.getenv().asScala.toMap)
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@volatile private var running = true
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
@PostConstruct
|
||||
def initialize(): Unit =
|
||||
config match
|
||||
case None =>
|
||||
log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable")
|
||||
case Some(cfg) =>
|
||||
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
||||
val thread = new Thread(() => connect(cfg), s"TournamentBot-${cfg.tournamentId}")
|
||||
thread.setDaemon(true)
|
||||
thread.start()
|
||||
|
||||
@PreDestroy
|
||||
def cleanup(): Unit =
|
||||
running = false
|
||||
workers.shutdownNow()
|
||||
Try(client.close())
|
||||
log.info("Tournament bot stopped")
|
||||
|
||||
private def connect(cfg: TournamentBotConfig): Unit =
|
||||
if join(cfg) then
|
||||
while running do
|
||||
Try(streamEvents(cfg)) match
|
||||
case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000)
|
||||
case Success(_) => sleep(2000)
|
||||
|
||||
private def join(cfg: TournamentBotConfig): Boolean =
|
||||
Try {
|
||||
val response = authed(cfg, target(cfg).path("join"))
|
||||
.post(Entity.entity("", MediaType.APPLICATION_JSON))
|
||||
val ok = response.getStatus == 200
|
||||
if ok then log.infof("Joined tournament %s", cfg.tournamentId)
|
||||
else log.errorf("Failed to join tournament %s — status %d", cfg.tournamentId, response.getStatus)
|
||||
response.close()
|
||||
ok
|
||||
}.getOrElse { log.error("Join request failed"); false }
|
||||
|
||||
private def streamEvents(cfg: TournamentBotConfig): Unit =
|
||||
val response = authed(cfg, target(cfg).path("stream"))
|
||||
.header("Accept", "application/x-ndjson")
|
||||
.get()
|
||||
if response.getStatus != 200 then
|
||||
log.warnf("Tournament stream returned status %d", response.getStatus)
|
||||
response.close()
|
||||
sleep(5000)
|
||||
else
|
||||
log.infof("Listening to tournament %s event stream", cfg.tournamentId)
|
||||
forEachLine(response.readEntity(classOf[InputStream])): line =>
|
||||
parse(line).foreach: node =>
|
||||
if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
|
||||
|
||||
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
|
||||
if gameId.nonEmpty && activeGames.add(gameId) then
|
||||
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId) })
|
||||
()
|
||||
|
||||
private def playGame(cfg: TournamentBotConfig, gameId: String): Unit =
|
||||
Try {
|
||||
colorFor(cfg, gameId) match
|
||||
case None =>
|
||||
log.debugf("Game %s is not ours — ignoring", gameId)
|
||||
activeGames.remove(gameId)
|
||||
case Some(color) =>
|
||||
log.infof("Playing game %s as %s", gameId, color)
|
||||
val stream = openGameStream(cfg, gameId)
|
||||
maybeMoveFromCurrentState(cfg, gameId, color)
|
||||
if stream != null then consumeGameStream(cfg, gameId, color, stream)
|
||||
activeGames.remove(gameId)
|
||||
} match
|
||||
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
|
||||
case Success(_) => ()
|
||||
|
||||
private def colorFor(cfg: TournamentBotConfig, gameId: String): Option[String] =
|
||||
fetchGame(cfg, gameId).flatMap: game =>
|
||||
val white = game.path("white").path("id").asText()
|
||||
val black = game.path("black").path("id").asText()
|
||||
if white == cfg.botId then Some("white")
|
||||
else if black == cfg.botId then Some("black")
|
||||
else None
|
||||
|
||||
private def maybeMoveFromCurrentState(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
|
||||
fetchGame(cfg, gameId).foreach: game =>
|
||||
maybeMove(cfg, gameId, color, game.path("turn").asText(), game.path("status").asText(), game.path("fen").asText())
|
||||
|
||||
private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit =
|
||||
val reader = new BufferedReader(new InputStreamReader(stream))
|
||||
// scalafix:off DisableSyntax.var
|
||||
var done = false
|
||||
var line = reader.readLine()
|
||||
// scalafix:on DisableSyntax.var
|
||||
while line != null && running && !done do
|
||||
parse(line).foreach: node =>
|
||||
node.path("type").asText() match
|
||||
case "move" => maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText())
|
||||
case "gameEnd" => log.infof("Game %s ended — status=%s", gameId, node.path("status").asText()); done = true
|
||||
case _ => ()
|
||||
line = reader.readLine()
|
||||
|
||||
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] =
|
||||
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)
|
||||
|
||||
private def submitMove(cfg: TournamentBotConfig, gameId: String, uci: String): Unit =
|
||||
Try {
|
||||
val response = authed(cfg, target(cfg).path("game").path(gameId).path("move").path(uci))
|
||||
.post(Entity.entity("", MediaType.APPLICATION_JSON))
|
||||
if response.getStatus == 200 then log.infof("Played %s in game %s", uci, gameId)
|
||||
else log.warnf("Move %s rejected in game %s — status %d", uci, gameId, response.getStatus)
|
||||
response.close()
|
||||
} match
|
||||
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
|
||||
case Success(_) => ()
|
||||
|
||||
private def fetchGame(cfg: TournamentBotConfig, gameId: String): Option[JsonNode] =
|
||||
Try {
|
||||
val response = target(cfg).path("game").path(gameId).request(MediaType.APPLICATION_JSON).get()
|
||||
val node = if response.getStatus == 200 then Some(response.readEntity(classOf[JsonNode])) else None
|
||||
response.close()
|
||||
node
|
||||
}.getOrElse(None)
|
||||
|
||||
private def openGameStream(cfg: TournamentBotConfig, gameId: String): 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 response.readEntity(classOf[InputStream])
|
||||
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); null }
|
||||
}.getOrElse(null)
|
||||
|
||||
private def engine(cfg: TournamentBotConfig): Bot =
|
||||
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
|
||||
|
||||
private def target(cfg: TournamentBotConfig) =
|
||||
client.target(cfg.serverUrl).path("api").path("tournament").path(cfg.tournamentId)
|
||||
|
||||
private def authed(cfg: TournamentBotConfig, t: jakarta.ws.rs.client.WebTarget) =
|
||||
t.request(MediaType.APPLICATION_JSON).header("Authorization", s"Bearer ${cfg.token}")
|
||||
|
||||
private def parse(line: String): Option[JsonNode] =
|
||||
val trimmed = line.trim
|
||||
if trimmed.isEmpty then None else Try(objectMapper.readTree(trimmed)).toOption
|
||||
|
||||
private def forEachLine(stream: InputStream)(handle: String => Unit): Unit =
|
||||
val reader = new BufferedReader(new InputStreamReader(stream))
|
||||
// scalafix:off DisableSyntax.var
|
||||
var line: String = reader.readLine()
|
||||
// scalafix:on DisableSyntax.var
|
||||
while line != null && running do
|
||||
Try(handle(line)).failed.foreach(ex => log.warnf(ex, "Error handling stream line"))
|
||||
line = reader.readLine()
|
||||
|
||||
private def toUci(move: Move): String =
|
||||
val base = s"${move.from}${move.to}"
|
||||
move.moveType match
|
||||
case MoveType.Promotion(piece) => base + promotionChar(piece)
|
||||
case _ => base
|
||||
|
||||
private def promotionChar(piece: PromotionPiece): String =
|
||||
piece match
|
||||
case PromotionPiece.Knight => "n"
|
||||
case PromotionPiece.Bishop => "b"
|
||||
case PromotionPiece.Rook => "r"
|
||||
case PromotionPiece.Queen => "q"
|
||||
|
||||
private def sleep(ms: Long): Unit = Try(Thread.sleep(ms))
|
||||
+9
-1
@@ -49,7 +49,8 @@ class GameResultStreamListener:
|
||||
case Success(_) => ()
|
||||
|
||||
private def pollLoop(): Unit =
|
||||
while true do
|
||||
var running = true
|
||||
while running do
|
||||
Try {
|
||||
val messages = redis.stream(classOf[String]).xreadgroup(
|
||||
groupName,
|
||||
@@ -60,9 +61,16 @@ class GameResultStreamListener:
|
||||
)
|
||||
if messages != null then messages.forEach(msg => handleMessage(msg))
|
||||
} match
|
||||
case Failure(ex) if isInterrupted(ex) =>
|
||||
Thread.currentThread().interrupt()
|
||||
running = false
|
||||
case Failure(ex) => log.warnf(ex, "Error in result poll loop")
|
||||
case Success(_) => ()
|
||||
|
||||
private def isInterrupted(ex: Throwable): Boolean =
|
||||
ex.isInstanceOf[InterruptedException] ||
|
||||
(ex.getCause != null && ex.getCause.isInstanceOf[InterruptedException])
|
||||
|
||||
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||
val json = msg.payload().get("data")
|
||||
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
|
||||
|
||||
Reference in New Issue
Block a user