From 76640421930c26a9260da002c90e2966b97a57a4 Mon Sep 17 00:00:00 2001 From: Janis Eccarius Date: Tue, 23 Jun 2026 22:20:06 +0200 Subject: [PATCH] fix(tournament): mirror bot join onto native twin The UI reads participant/standings fields from the native-server twin (nativeOverlay), but bot join only wrote the NowChess participant list, so bots never appeared in replicated/native-published tournaments. On join, register the bot on the native server by name and join the twin as that bot. Also run this for the AlreadyJoined case so bots stuck in the NowChess list (but missing on native) get reconciled, and return 200 instead of 409 for it. Co-Authored-By: Claude Opus 4.8 --- .../resource/TournamentResource.scala | 19 ++++++++++++++- .../service/ExternalTournamentClient.scala | 23 ++++++++++++++++++- 2 files changed, 40 insertions(+), 2 deletions(-) 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()