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 67884bf..2b4e1c2 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 @@ -119,6 +119,16 @@ class TournamentResource: case Some(nativeId) => tournamentService.setNativeTournamentId(t.id, nativeId) case None => log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl) + // Mirror a bot join onto the native twin so it surfaces in the UI, which reads participant and + // standings fields from the native server (see nativeOverlay). + private def joinNativeTwin(id: String, botName: String): Unit = + if nativeServerUrl.nonEmpty then + tournamentService.get(id).flatMap(nativeIdFor).foreach { nativeId => + if externalClient.joinNativeAsBot(nativeServerUrl, nativeId, botName) then + log.infof("Joined bot %s on native twin %s of tournament %s", botName, nativeId, id) + else log.warnf("Failed to join bot %s on native twin %s of tournament %s", botName, nativeId, id) + } + // Resolve the native-server twin of a local tournament. Backfills the stored id by matching // fullName against the native list for tournaments created before the id was captured. private def nativeIdFor(t: de.nowchess.tournament.domain.Tournament): Option[String] = @@ -250,7 +260,14 @@ class TournamentResource: val botId = Option(jwt.getSubject).getOrElse("") val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId) tournamentService.join(id, botId, botName) match - case Right(_) => Response.ok(OkDto()).build() + case Right(_) => + joinNativeTwin(id, botName) + Response.ok(OkDto()).build() + // Already in the NowChess participant list but possibly never mirrored onto the native + // twin (where the UI reads participants from) — make the native join idempotent. + case Left(TournamentError.AlreadyJoined) => + joinNativeTwin(id, botName) + Response.ok(OkDto()).build() case Left(error) => error match case TournamentError.NotFound(_) => 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 7e81c19..dd8d710 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 @@ -60,9 +60,12 @@ class ExternalTournamentClient: } private def registerDirector(serverUrl: String, name: String): Option[String] = + registerAccount(serverUrl, name, isBot = false) + + private def registerAccount(serverUrl: String, name: String, isBot: Boolean): Option[String] = Try { val client = buildClient() - val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":false}""" + val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":$isBot}""" val response = client .target(s"$serverUrl/api/auth/register") .request(MediaType.APPLICATION_JSON) @@ -76,6 +79,24 @@ class ExternalTournamentClient: client.close() }.getOrElse(None) + // The tournament server holds only the director token, which cannot join as a bot. Register the + // bot on the native server by name to mint a bot token, then join the native twin as that bot. + def joinNativeAsBot(serverUrl: String, tournamentId: String, botName: String): Boolean = + registerAccount(serverUrl, botName, isBot = true).exists { token => + Try { + val client = buildClient() + val response = client + .target(s"$serverUrl/api/tournament/$tournamentId/join") + .request(MediaType.APPLICATION_JSON) + .header("Authorization", s"Bearer $token") + .post(Entity.json("")) + try response.getStatus / 100 == 2 || response.getStatus == 409 + finally + response.close() + client.close() + }.getOrElse(false) + } + private def createNative(serverUrl: String, token: String, form: String): Option[String] = Try { val client = buildClient()