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 5ece59e..98961c2 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 @@ -38,14 +38,22 @@ class TournamentBotGamePlayer: private val client: Client = ClientBuilder.newClient() private val workers: ExecutorService = Executors.newCachedThreadPool() private val activeGames = ConcurrentHashMap.newKeySet[String]() + private val joinedTournaments = ConcurrentHashMap.newKeySet[String]() + + private val hardestDifficulty = "expert" + private val autoJoinIntervalMs = 15000L // scalafix:off DisableSyntax.var - @volatile private var running = true + @volatile private var running = true + @volatile private var autoJoinToken: Option[String] = None // scalafix:on DisableSyntax.var val tournamentServiceUrl: String = System.getenv().asScala.getOrElse("TOURNAMENT_SERVICE_URL", "http://localhost:8086") + val autoJoinServerUrl: String = + System.getenv().asScala.getOrElse("TOURNAMENT_AUTO_JOIN_URL", "http://141.37.123.132:8086") + @PostConstruct def initialize(): Unit = val env = System.getenv().asScala.toMap @@ -58,6 +66,49 @@ class TournamentBotGamePlayer: case Some(cfg) => log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId) startAsync(cfg) + startAutoJoin() + + private def startAutoJoin(): Unit = + val thread = new Thread(() => autoJoinLoop(), "TournamentBot-auto-join") + thread.setDaemon(true) + thread.start() + log.infof("Auto-join enabled — server=%s difficulty=%s", autoJoinServerUrl, hardestDifficulty) + + private def autoJoinLoop(): Unit = + while running do + Try(autoJoinScan()).failed.foreach(ex => log.warnf(ex, "Auto-join scan failed")) + sleep(autoJoinIntervalMs) + + private def autoJoinScan(): Unit = + resolveAutoJoinToken().foreach { token => + TournamentBotConfig.jwtSubject(token).foreach { botId => + openTournaments().foreach { tournamentId => + if joinedTournaments.add(tournamentId) then + val cfg = TournamentBotConfig(autoJoinServerUrl, tournamentId, token, botId, hardestDifficulty) + if join(cfg) then startAsync(cfg) + else joinedTournaments.remove(tournamentId) + } + } + } + + private def resolveAutoJoinToken(): Option[String] = + autoJoinToken match + case some @ Some(_) => some + case None => + autoJoinToken = registerWithServer(autoJoinServerUrl, botName(hardestDifficulty)) + autoJoinToken + + private def openTournaments(): List[String] = + Try { + val response = client.target(autoJoinServerUrl) + .path("api").path("tournament") + .request(MediaType.APPLICATION_JSON).get() + if response.getStatus == 200 then + val node = objectMapper.readTree(response.readEntity(classOf[String])) + response.close() + node.path("created").elements().asScala.toList.map(_.path("id").asText()).filter(_.nonEmpty) + else { response.close(); Nil } + }.getOrElse(Nil) private def resolveToken(difficulty: String): Option[String] = val name = botName(difficulty) diff --git a/modules/tournament/src/main/resources/application.yml b/modules/tournament/src/main/resources/application.yml index 7ce7122..3c3576c 100644 --- a/modules/tournament/src/main/resources/application.yml +++ b/modules/tournament/src/main/resources/application.yml @@ -30,6 +30,8 @@ nowchess: tournament: self-url: "" external-servers: "" + native-server-url: ${TOURNAMENT_NATIVE_SERVER_URL:http://141.37.123.132:8086} + director-name: ${TOURNAMENT_DIRECTOR_NAME:NowChess System} mp: jwt: @@ -53,6 +55,8 @@ mp: tournament: self-url: ${TOURNAMENT_SELF_URL:} external-servers: ${TOURNAMENT_EXTERNAL_SERVERS:} + native-server-url: ${TOURNAMENT_NATIVE_SERVER_URL:http://141.37.123.132:8086} + director-name: ${TOURNAMENT_DIRECTOR_NAME:NowChess System} "%test": quarkus: diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala index 3f2eeee..b90495b 100644 --- a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala @@ -40,6 +40,12 @@ class TournamentResource: @ConfigProperty(name = "nowchess.tournament.self-url") var selfUrl: Optional[String] = uninitialized + + @ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086") + var nativeServerUrl: String = uninitialized + + @ConfigProperty(name = "nowchess.tournament.director-name", defaultValue = "NowChess System") + var directorName: String = uninitialized // scalafix:on @GET @@ -95,8 +101,31 @@ class TournamentResource: log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl) } } + publishToNativeServer(t) Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build() + private def publishToNativeServer(t: de.nowchess.tournament.domain.Tournament): Unit = + if nativeServerUrl.nonEmpty then + val form = encodeForm( + Map( + "name" -> t.fullName, + "nbRounds" -> t.nbRounds.toString, + "clockLimit" -> t.clockLimit.toString, + "clockIncrement" -> t.clockIncrement.toString, + "rated" -> t.rated.toString, + ), + ) + if !externalClient.publishNative(nativeServerUrl, directorName, form) then + log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl) + + private def encodeForm(params: Map[String, String]): String = + params + .map((k, v) => s"${enc(k)}=${enc(v)}") + .mkString("&") + + private def enc(s: String): String = + java.net.URLEncoder.encode(s, "UTF-8") + @GET @Path("/{id}") @PermitAll diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala index d676a88..bbecd80 100644 --- a/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/ExternalTournamentClient.scala @@ -14,10 +14,56 @@ class ExternalTournamentClient: // scalafix:off DisableSyntax.var @Inject var objectMapper: ObjectMapper = uninitialized + @volatile private var directorToken: Option[String] = None // scalafix:on private def buildClient(): Client = ClientBuilder.newClient() + def publishNative(serverUrl: String, directorName: String, form: String): Boolean = + val token = directorToken.orElse { + val fresh = registerDirector(serverUrl, directorName) + directorToken = fresh + fresh + } + token.exists { tok => + if createNative(serverUrl, tok, form) then true + else + val refreshed = registerDirector(serverUrl, directorName) + directorToken = refreshed + refreshed.exists(createNative(serverUrl, _, form)) + } + + private def registerDirector(serverUrl: String, name: String): Option[String] = + Try { + val client = buildClient() + val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":false}""" + val response = client + .target(s"$serverUrl/api/auth/register") + .request(MediaType.APPLICATION_JSON) + .post(Entity.entity(body, MediaType.APPLICATION_JSON)) + try + if response.getStatus / 100 == 2 then + Option(objectMapper.readTree(response.readEntity(classOf[String])).path("token").asText()).filter(_.nonEmpty) + else None + finally + response.close() + client.close() + }.getOrElse(None) + + private def createNative(serverUrl: String, token: String, form: String): Boolean = + Try { + val client = buildClient() + val response = client + .target(s"$serverUrl/api/tournament") + .request(MediaType.APPLICATION_JSON) + .header("Authorization", s"Bearer $token") + .post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED)) + try response.getStatus / 100 == 2 + finally + response.close() + client.close() + }.getOrElse(false) + def fetchList(serverUrl: String): Option[JsonNode] = Try { val client = buildClient()