Compare commits

...

6 Commits

Author SHA1 Message Date
TeamCity bace029a8a ci: bump version with Build-148 2026-06-23 20:31:27 +00:00
Janis Eccarius 7664042193 fix(tournament): mirror bot join onto native twin
Build & Test (NowChessSystems) TeamCity build finished
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 <noreply@anthropic.com>
2026-06-23 22:20:56 +02:00
TeamCity a604b4ad42 ci: bump version with Build-147 2026-06-23 19:33:36 +00:00
Janis Eccarius fdf4c94811 fix(official-bots): resolve per-difficulty bot token on tournament join
Build & Test (NowChessSystems) TeamCity build finished
joinTournament only ever had a token for the startup difficulty
(default medium); other difficulties fell back to the single shared
TOURNAMENT_BOT_TOKEN, which our tournament server rejects (401),
surfacing as 400 "Failed to join tournament" in the UI. Resolve and
cache a token for the requested difficulty instead.

Prefer the account-service token over anonymous register in
resolveToken so the bot joins as its canonical identity rather than a
throwaway account (medium joined but never appeared as a participant).

Add NativeReflectionConfig for JoinTournamentRequest/Response so the
success path serializes in native image instead of returning an empty
200 body.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:23:41 +02:00
TeamCity d9f30f0bfe ci: bump version with Build-146 2026-06-23 18:45:05 +00:00
Janis 1f4e9c8498 fix(tournament): sync native-server participants and route start (#78)
Build & Test (NowChessSystems) TeamCity build finished
Bots joining a published tournament directly on the native server were not
reflected in NowChess (0 players) and the tournament could not be started,
because create() kept a local copy plus a separate native copy whose id was
discarded — leaving the two records disconnected.

- Capture the native tournament id: createNative/publishNative now return the
  id instead of Boolean; persist it on Tournament.nativeTournamentId.
- Reverse-sync on read: get()/list() overlay nbPlayers/standing/status/round/
  winner from the native twin (with a fullName backfill for tournaments created
  before the id was captured).
- start(): proxy to the native twin (director token via authFor) so the native
  participants are used; mirror the started status locally.
- Skip the native server in the replicate loop (it has no /replicate endpoint),
  removing the per-create "Failed to replicate" warning.
- Isolate native integration in tournament unit tests (native-server-url no
  longer defaults to the live server).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #78
2026-06-23 20:34:30 +02:00
11 changed files with 291 additions and 69 deletions
+46
View File
@@ -797,3 +797,49 @@
### Reverts ### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656)) * Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-23)
### Features
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
* **events:** migrate game-creation and bot flows to Redis Streams NCS-89 ([#62](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/62)) ([a24924c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a24924c23057db3d700a75dbc4333557789cd991))
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
* **official-bots:** consume GameOver stream for bot cleanup ([#67](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/67)) ([db9d153](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/db9d1533912f4b41c4d1ca80ccffdde5d23d6ff6))
* **official-bots:** make HybridBot veto actionable and use it for expert ([1df29cf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1df29cf3a6e21af3f396b2b7a6da67d978f941ae))
* **official-bots:** park expert bot on tournament server at startup ([#75](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/75)) ([30295a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/30295a4bb95855ee8261c92278bb9ebc80ee12ee))
* **official-bots:** resolve tournament bot token from Redis and account service ([386ddc5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/386ddc5c19f8f893b16c6422aa5393b54c872e45))
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
### Bug Fixes
* enable official bots to connect to external tournament server ([#71](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/71)) ([688d30e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/688d30e2b10026923372be5fca3c63eaaee2de2a))
* **official-bots:** configure JWT verification ([#72](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/72)) ([98c64fc](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/98c64fc0d56dc542beb31c75f4b9056d91de03cd))
* **official-bots:** correct parkOn path from /api/bots to /api/account/bots ([1be9949](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1be9949c0b5c6a1db535696620d77735050d6c93))
* **official-bots:** discover tournament games by polling, not just the stream ([10113fd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/10113fd0579b614d15870798d933bc9c495d2049))
* **official-bots:** make botToken optional, fall back to env, fix 502 status ([f43d193](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f43d1930d80670d810c57b54eaa3789854fa082c))
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
* **official-bots:** park on external tournament servers using correct endpoint and token ([3188241](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/31882417377468b41bbe3ff94506aa4928024450))
* **official-bots:** play games by polling state instead of NDJSON stream ([bfb15c7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/bfb15c7299bd471d5e064a577ed10af98e2ea90a))
* **official-bots:** play only own tournament games with correct color ([4651bb7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4651bb796f07a21bd013d9521b2dfe2e1078cebb))
* **official-bots:** prioritize Redis token over stale env var in joinTournament ([83dd2d4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/83dd2d4335ca48eb3e5aa234a75367574276ba63))
* **official-bots:** register with tournament server directly to get correct token ([64b5d55](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/64b5d5567f110c2fe152558c7de275a1e0b30e21))
* **official-bots:** resolve per-difficulty bot token on tournament join ([fdf4c94](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fdf4c94811d086996447bb4657fac1d9bd6e5a93))
* **official-bots:** resume tournaments already joined after restart ([285b73e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/285b73efbd6dd98cec410ade9eead9881d693a8f))
* **official-bots:** sync bots before token fetch on first startup after DB wipe ([b0ddb27](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0ddb274d23bca8b1b3f691ce0d643f33e0b54cd))
### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
@@ -0,0 +1,12 @@
package de.nowchess.bot.config
import de.nowchess.bot.resource.{JoinTournamentRequest, JoinTournamentResponse}
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[JoinTournamentRequest],
classOf[JoinTournamentResponse],
),
)
class NativeReflectionConfig
@@ -28,10 +28,10 @@ class TournamentBotGamePlayer:
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer]) private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var botController: BotController = uninitialized
@Inject var redis: RedisDataSource = uninitialized @Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized @Inject @RestClient var accountServiceClient: AccountServiceClient = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@@ -119,7 +119,9 @@ class TournamentBotGamePlayer:
yield result yield result
private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] = private def findBotGame(pairings: JsonNode, botId: String): Option[(String, String)] =
pairings.elements().asScala pairings
.elements()
.asScala
.flatMap { p => .flatMap { p =>
val whiteId = p.path("white").path("id").asText() val whiteId = p.path("white").path("id").asText()
val blackId = p.path("black").path("id").asText() val blackId = p.path("black").path("id").asText()
@@ -129,7 +131,9 @@ class TournamentBotGamePlayer:
.nextOption() .nextOption()
private def activeMatch(matches: JsonNode): Option[String] = private def activeMatch(matches: JsonNode): Option[String] =
matches.elements().asScala matches
.elements()
.asScala
.find(m => m.path("gameId").asText().nonEmpty && !(m.has("outcome") && !m.path("outcome").isNull)) .find(m => m.path("gameId").asText().nonEmpty && !(m.has("outcome") && !m.path("outcome").isNull))
.map(_.path("gameId").asText()) .map(_.path("gameId").asText())
@@ -151,9 +155,12 @@ class TournamentBotGamePlayer:
private def openTournaments(): List[String] = private def openTournaments(): List[String] =
Try { Try {
val response = client.target(autoJoinServerUrl) val response = client
.path("api").path("tournament") .target(autoJoinServerUrl)
.request(MediaType.APPLICATION_JSON).get() .path("api")
.path("tournament")
.request(MediaType.APPLICATION_JSON)
.get()
if response.getStatus == 200 then if response.getStatus == 200 then
val node = objectMapper.readTree(response.readEntity(classOf[String])) val node = objectMapper.readTree(response.readEntity(classOf[String]))
response.close() response.close()
@@ -164,8 +171,8 @@ class TournamentBotGamePlayer:
private def resolveToken(difficulty: String): Option[String] = private def resolveToken(difficulty: String): Option[String] =
val name = botName(difficulty) val name = botName(difficulty)
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name" val redisKey = s"${redisConfig.prefix}:tournament-bot:token:$name"
registerWithServer(tournamentServiceUrl, name) fetchTokenFromAccountService(name)
.orElse(fetchTokenFromAccountService(name)) .orElse(registerWithServer(tournamentServiceUrl, name))
.map { token => .map { token =>
redis.value(classOf[String]).set(redisKey, token) redis.value(classOf[String]).set(redisKey, token)
log.infof("Refreshed bot token for %s — stored in Redis", name) log.infof("Refreshed bot token for %s — stored in Redis", name)
@@ -187,8 +194,11 @@ class TournamentBotGamePlayer:
private def registerWithServer(serverUrl: String, name: String): Option[String] = private def registerWithServer(serverUrl: String, name: String): Option[String] =
Try { Try {
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}""" val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":true}"""
val response = client.target(serverUrl) val response = client
.path("api").path("auth").path("register") .target(serverUrl)
.path("api")
.path("auth")
.path("register")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
val status = response.getStatus val status = response.getStatus
@@ -201,11 +211,11 @@ class TournamentBotGamePlayer:
log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody) log.warnf("Register %s on %s returned status %d: %s", name, serverUrl, status, errBody)
response.close() response.close()
None None
}.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None } }.recover { case ex => log.warnf(ex, "Register %s on %s failed", name, serverUrl); None }.toOption.flatten
.toOption.flatten
private def fetchTokenFromAccountService(name: String): Option[String] = private def fetchTokenFromAccountService(name: String): Option[String] =
Try(accountServiceClient.getBotToken(name).token).toOption.filter(_.nonEmpty) Try(accountServiceClient.getBotToken(name).token).toOption
.filter(_.nonEmpty)
.orElse { .orElse {
Try { Try {
val allNames = BotController.listBots.map(botName) val allNames = BotController.listBots.map(botName)
@@ -231,9 +241,13 @@ class TournamentBotGamePlayer:
private def fetchRemoteServers(): List[String] = private def fetchRemoteServers(): List[String] =
Try { Try {
val response = client.target(tournamentServiceUrl) val response = client
.path("api").path("tournament").path("servers") .target(tournamentServiceUrl)
.request(MediaType.APPLICATION_JSON).get() .path("api")
.path("tournament")
.path("servers")
.request(MediaType.APPLICATION_JSON)
.get()
if response.getStatus == 200 then if response.getStatus == 200 then
val node = objectMapper.readTree(response.readEntity(classOf[String])) val node = objectMapper.readTree(response.readEntity(classOf[String]))
response.close() response.close()
@@ -244,7 +258,11 @@ class TournamentBotGamePlayer:
private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit = private def parkOnAccountService(serverUrl: String, difficulty: String, token: String): Unit =
Try { Try {
val body = s"""{"name":"${botName(difficulty)}"}""" val body = s"""{"name":"${botName(difficulty)}"}"""
val response = client.target(serverUrl).path("api").path("account").path("bots") val response = client
.target(serverUrl)
.path("api")
.path("account")
.path("bots")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer $token") .header("Authorization", s"Bearer $token")
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
@@ -258,7 +276,10 @@ class TournamentBotGamePlayer:
private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit = private def parkOnTournamentServer(serverUrl: String, name: String, token: String): Unit =
Try { Try {
val body = s"""{"name":"${name.replace("\"", "\\\"")}"}""" val body = s"""{"name":"${name.replace("\"", "\\\"")}"}"""
val response = client.target(serverUrl).path("api").path("bots") val response = client
.target(serverUrl)
.path("api")
.path("bots")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer $token") .header("Authorization", s"Bearer $token")
.post(Entity.entity(body, MediaType.APPLICATION_JSON)) .post(Entity.entity(body, MediaType.APPLICATION_JSON))
@@ -276,10 +297,11 @@ class TournamentBotGamePlayer:
botToken: Option[String], botToken: Option[String],
difficulty: String, difficulty: String,
): Either[String, String] = ): Either[String, String] =
val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}" val redisKey = s"${redisConfig.prefix}:tournament-bot:token:${botName(difficulty)}"
val resolvedToken = botToken.filter(_.nonEmpty) val resolvedToken = botToken
.filter(_.nonEmpty)
.orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty)) .orElse(Option(redis.value(classOf[String]).get(redisKey)).filter(_.nonEmpty))
.orElse(System.getenv().asScala.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)) .orElse(resolveToken(difficulty))
resolvedToken match resolvedToken match
case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured") case None => Left("No bot token provided and TOURNAMENT_BOT_TOKEN not configured")
case Some(token) => case Some(token) =>
@@ -336,8 +358,7 @@ class TournamentBotGamePlayer:
log.infof("Listening to tournament %s event stream", cfg.tournamentId) log.infof("Listening to tournament %s event stream", cfg.tournamentId)
forEachLine(response.readEntity(classOf[InputStream])): line => forEachLine(response.readEntity(classOf[InputStream])): line =>
parse(line).foreach: node => parse(line).foreach: node =>
if node.path("type").asText() == "gameStart" then if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
onGameStart(cfg, node.path("gameId").asText())
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit = private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
if gameId.isEmpty then () if gameId.isEmpty then ()
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=32 MINOR=33
PATCH=0 PATCH=0
+43
View File
@@ -94,3 +94,46 @@
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5)) * **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6)) * **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054)) * wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
## (2026-06-23)
### Features
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
### Bug Fixes
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
* **tournament:** sync native-server participants and route start ([#78](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/78)) ([1f4e9c8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1f4e9c8498f55d95ab48758df60c7618445bf6ca))
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
## (2026-06-23)
### Features
* **analytics:** add Spark batch analytics module ([#70](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/70)) ([39f1657](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39f1657e1db6e84889af338c43be8cb5c03c3ec3))
* NCS-121 pipeline for tournament ([#68](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/68)) ([145f467](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/145f4676483f92bfe6f2d9ca40e2cb4200982e87))
* NCS-82 add Swiss-system tournament module ([#55](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/55)) ([c5661de](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c5661de4a0ebf4b33211f5a391840dcf744656b7))
* **reflection:** add GameWritebackEventDto to native reflection configuration ([1aee39c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1aee39c1ad286984501ac4b47da2b72d60b58a6f))
* **reflection:** add native reflection configuration for tournament classes ([65bc6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/65bc6a759937543df2d29905688bfa9e68d0c9d4))
* **tournament:** auto-join external tournaments and publish created ones ([#77](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/77)) ([9978b7e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9978b7ea78eb658a225a461b9cd339386c0c14f3))
* **tournament:** federate tournaments across clusters with DB replication ([5b000a6](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5b000a6e5f04ea6770d1c7ab6bfdaded77a99172))
* **tournament:** remove dynamic server add/remove endpoints ([6d06edd](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6d06edda69a50de65cd9efa27f26a4cc6b437f9d))
* **tournament:** seed external server registry from env var on startup ([845dc9c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/845dc9c2935c8bc1be42541dfaf31c9a861d3272))
### Bug Fixes
* **tournament:** mirror bot join onto native twin ([7664042](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/76640421930c26a9260da002c90e2966b97a57a4))
* **tournament:** replace scala.util.Random singleton with UUID for native image ([a50884a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a50884a11b1de500e74c18fd08d2d102d53cc3e9))
* **tournament:** sync native-server participants and route start ([#78](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/78)) ([1f4e9c8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/1f4e9c8498f55d95ab48758df60c7618445bf6ca))
* **tournament:** use HS256 director token for native tournament-server calls ([b98bdd2](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b98bdd2a64eb6c8279bd3cfe15d70628025ef0e5))
* **tournament:** use Optional[String] for selfUrl ConfigProperty to avoid startup failure ([28cbc2e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/28cbc2e18447aa8a04a5868889a49b555075d0c6))
* wrap server list response in ExternalTournamentServerList ([f2d79e4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f2d79e4952aea6bde762c294eb202474b7827054))
@@ -7,7 +7,7 @@ import java.time.Instant
@Entity @Entity
@Table(name = "tournaments") @Table(name = "tournaments")
class Tournament: class Tournament:
// scalafix:off DisableSyntax.var // scalafix:off
@Id @Id
var id: String = uninitialized var id: String = uninitialized
@@ -33,4 +33,7 @@ class Tournament:
@Column(nullable = true) @Column(nullable = true)
var originServerUrl: String = null var originServerUrl: String = null
@Column(nullable = true)
var nativeTournamentId: String = null
// scalafix:on // scalafix:on
@@ -52,9 +52,9 @@ class TournamentResource:
@PermitAll @PermitAll
def list(): Response = def list(): Response =
val (created, started, finished) = tournamentService.list() val (created, started, finished) = tournamentService.list()
val internalCreated = created.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) val internalCreated = created.map(nativeOverlay)
val internalStarted = started.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) val internalStarted = started.map(nativeOverlay)
val internalFinished = finished.map(t => objectMapper.valueToTree[JsonNode](tournamentService.toDto(t))) val internalFinished = finished.map(nativeOverlay)
val (extCreated, extStarted, extFinished) = registry val (extCreated, extStarted, extFinished) = registry
.serverUrls() .serverUrls()
@@ -96,7 +96,7 @@ class TournamentResource:
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated) val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
val t = tournamentService.create(userId, form) val t = tournamentService.create(userId, form)
selfUrl.ifPresent { url => selfUrl.ifPresent { url =>
registry.serverUrls().foreach { remoteUrl => registry.serverUrls().filterNot(externalClient.isNativeServer).foreach { remoteUrl =>
if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then if !externalClient.replicateTournament(remoteUrl, toReplicateRequest(t), url) then
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl) log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
} }
@@ -115,8 +115,52 @@ class TournamentResource:
"rated" -> t.rated.toString, "rated" -> t.rated.toString,
), ),
) )
if !externalClient.publishNative(nativeServerUrl, directorName, form) then externalClient.publishNative(nativeServerUrl, directorName, form) match
log.warnf("Failed to publish tournament %s to native server %s", t.id, nativeServerUrl) 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] =
if nativeServerUrl.isEmpty then None
else
Option(t.nativeTournamentId).filter(_.nonEmpty).orElse {
val found = externalClient
.fetchList(nativeServerUrl)
.flatMap { node =>
Seq("created", "started", "finished").iterator
.flatMap(k => node.path(k).elements().asScala)
.find(_.path("fullName").asText() == t.fullName)
.map(_.path("id").asText())
.filter(_.nonEmpty)
}
found.foreach(id => tournamentService.setNativeTournamentId(t.id, id))
found
}
// Overlay live participant/standings/status fields from the native twin onto a local DTO so
// bots that joined directly on the native server are reflected in NowChess.
private def nativeOverlay(t: de.nowchess.tournament.domain.Tournament): JsonNode =
val standings = tournamentService.getStandings(t.id)
val dto = objectMapper.valueToTree[JsonNode](tournamentService.toDto(t, standings))
nativeIdFor(t).flatMap(nid => externalClient.fetch(nativeServerUrl, nid)) match
case Some(native) =>
val merged = dto.deepCopy[com.fasterxml.jackson.databind.node.ObjectNode]()
Seq("nbPlayers", "standing", "status", "round", "winner").foreach { field =>
if native.has(field) then merged.set(field, native.get(field))
}
merged
case None => dto
private def encodeForm(params: Map[String, String]): String = private def encodeForm(params: Map[String, String]): String =
params params
@@ -132,8 +176,7 @@ class TournamentResource:
def get(@PathParam("id") id: String): Response = def get(@PathParam("id") id: String): Response =
tournamentService.get(id) match tournamentService.get(id) match
case Some(t) => case Some(t) =>
val standings = tournamentService.getStandings(id) Response.ok(nativeOverlay(t)).build()
Response.ok(tournamentService.toDto(t, standings)).build()
case None => case None =>
resolveServer(id) resolveServer(id)
.flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build())) .flatMap(url => externalClient.fetch(url, id).map(node => Response.ok(node).build()))
@@ -179,19 +222,26 @@ class TournamentResource:
val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth) val (status, body) = externalClient.proxyPost(originUrl, s"api/tournament/$id/start", auth)
Response.status(status).entity(body).build() Response.status(status).entity(body).build()
case None => case None =>
tournamentService.start(id, userId) match tournamentService.get(id).flatMap(nativeIdFor) match
case Right(t) => Response.ok(tournamentService.toDto(t)).build() case Some(nativeId) =>
case Left(error) => val auth = Option(headers.getHeaderString("Authorization"))
error match val (status, body) = externalClient.proxyPost(nativeServerUrl, s"api/tournament/$nativeId/start", auth)
case TournamentError.NotFound(_) => if status / 100 == 2 then tournamentService.markStatus(id, "started")
val auth = Option(headers.getHeaderString("Authorization")) Response.status(status).entity(body).build()
resolveServer(id) case None =>
.map { url => tournamentService.start(id, userId) match
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth) case Right(t) => Response.ok(tournamentService.toDto(t)).build()
Response.status(status).entity(body).build() case Left(error) =>
} error match
.getOrElse(errorResponse(error)) case TournamentError.NotFound(_) =>
case _ => errorResponse(error) val auth = Option(headers.getHeaderString("Authorization"))
resolveServer(id)
.map { url =>
val (status, body) = externalClient.proxyPost(url, s"api/tournament/$id/start", auth)
Response.status(status).entity(body).build()
}
.getOrElse(errorResponse(error))
case _ => errorResponse(error)
@POST @POST
@Path("/{id}/join") @Path("/{id}/join")
@@ -210,7 +260,14 @@ class TournamentResource:
val botId = Option(jwt.getSubject).getOrElse("") val botId = Option(jwt.getSubject).getOrElse("")
val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId) val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId)
tournamentService.join(id, botId, botName) match 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) => case Left(error) =>
error match error match
case TournamentError.NotFound(_) => case TournamentError.NotFound(_) =>
@@ -317,7 +374,8 @@ class TournamentResource:
tournamentService.get(id) match tournamentService.get(id) match
case Some(t) if Option(t.originServerUrl).isDefined => case Some(t) if Option(t.originServerUrl).isDefined =>
val auth = Option(headers.getHeaderString("Authorization")) val auth = Option(headers.getHeaderString("Authorization"))
externalClient.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth) externalClient
.proxyGetStream(t.originServerUrl, s"api/tournament/$id/stream", auth)
.map { inputStream => .map { inputStream =>
Response Response
.ok(new StreamingOutput { .ok(new StreamingOutput {
@@ -334,10 +392,12 @@ class TournamentResource:
.`type`("application/x-ndjson") .`type`("application/x-ndjson")
.build() .build()
} }
.getOrElse(Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build()) .getOrElse(
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id stream unavailable")).build(),
)
case Some(_) => case Some(_) =>
val botId = Option(jwt.getSubject).getOrElse("") val botId = Option(jwt.getSubject).getOrElse("")
val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]() val queue = new java.util.concurrent.LinkedBlockingQueue[Option[String]]()
val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] { val emitter = new io.smallrye.mutiny.subscription.MultiEmitter[String] {
def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] = def emit(item: String): io.smallrye.mutiny.subscription.MultiEmitter[String] =
queue.put(Some(item)); this queue.put(Some(item)); this
@@ -358,7 +418,7 @@ class TournamentResource:
var cont = true var cont = true
while cont do while cont do
queue.take() match queue.take() match
case None => cont = false case None => cont = false
case Some(line) => case Some(line) =>
output.write((line + "\n").getBytes("UTF-8")) output.write((line + "\n").getBytes("UTF-8"))
output.flush() output.flush()
@@ -440,7 +500,8 @@ class TournamentResource:
.getOrElse(Response.status(Response.Status.NOT_FOUND).build()) .getOrElse(Response.status(Response.Status.NOT_FOUND).build())
private def resolveServer(tournamentId: String): Option[String] = private def resolveServer(tournamentId: String): Option[String] =
tournamentService.get(tournamentId) tournamentService
.get(tournamentId)
.flatMap(t => Option(t.originServerUrl)) .flatMap(t => Option(t.originServerUrl))
.orElse(registry.findServerUrl(tournamentId)) .orElse(registry.findServerUrl(tournamentId))
.orElse { .orElse {
@@ -14,7 +14,7 @@ import scala.util.Try
class ExternalTournamentClient: class ExternalTournamentClient:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@volatile private var directorToken: Option[String] = None @volatile private var directorToken: Option[String] = None
@ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086") @ConfigProperty(name = "nowchess.tournament.native-server-url", defaultValue = "http://141.37.123.132:8086")
@@ -30,7 +30,7 @@ class ExternalTournamentClient:
// RS256 user token to it — swap in the director token registered on that server. // RS256 user token to it — swap in the director token registered on that server.
private def normalize(url: String): String = url.stripSuffix("/") private def normalize(url: String): String = url.stripSuffix("/")
private def isNativeServer(serverUrl: String): Boolean = def isNativeServer(serverUrl: String): Boolean =
nativeServerUrl.nonEmpty && normalize(serverUrl) == normalize(nativeServerUrl) nativeServerUrl.nonEmpty && normalize(serverUrl) == normalize(nativeServerUrl)
private def directorBearer(): Option[String] = private def directorBearer(): Option[String] =
@@ -45,24 +45,27 @@ class ExternalTournamentClient:
private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] = private def authFor(serverUrl: String, userAuth: Option[String]): Option[String] =
if isNativeServer(serverUrl) then directorBearer() else userAuth if isNativeServer(serverUrl) then directorBearer() else userAuth
def publishNative(serverUrl: String, directorName: String, form: String): Boolean = def publishNative(serverUrl: String, directorName: String, form: String): Option[String] =
val token = directorToken.orElse { val token = directorToken.orElse {
val fresh = registerDirector(serverUrl, directorName) val fresh = registerDirector(serverUrl, directorName)
directorToken = fresh directorToken = fresh
fresh fresh
} }
token.exists { tok => token.flatMap { tok =>
if createNative(serverUrl, tok, form) then true createNative(serverUrl, tok, form).orElse {
else
val refreshed = registerDirector(serverUrl, directorName) val refreshed = registerDirector(serverUrl, directorName)
directorToken = refreshed directorToken = refreshed
refreshed.exists(createNative(serverUrl, _, form)) refreshed.flatMap(createNative(serverUrl, _, form))
}
} }
private def registerDirector(serverUrl: String, name: String): Option[String] = 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 { Try {
val client = buildClient() val client = buildClient()
val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":false}""" val body = s"""{"name":"${name.replace("\"", "\\\"")}","isBot":$isBot}"""
val response = client val response = client
.target(s"$serverUrl/api/auth/register") .target(s"$serverUrl/api/auth/register")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
@@ -76,7 +79,25 @@ class ExternalTournamentClient:
client.close() client.close()
}.getOrElse(None) }.getOrElse(None)
private def createNative(serverUrl: String, token: String, form: String): Boolean = // 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 { Try {
val client = buildClient() val client = buildClient()
val response = client val response = client
@@ -84,11 +105,14 @@ class ExternalTournamentClient:
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
.header("Authorization", s"Bearer $token") .header("Authorization", s"Bearer $token")
.post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED)) .post(Entity.entity(form, MediaType.APPLICATION_FORM_URLENCODED))
try response.getStatus / 100 == 2 try
if response.getStatus / 100 == 2 then
Option(objectMapper.readTree(response.readEntity(classOf[String])).path("id").asText()).filter(_.nonEmpty)
else None
finally finally
response.close() response.close()
client.close() client.close()
}.getOrElse(false) }.getOrElse(None)
def fetchList(serverUrl: String): Option[JsonNode] = def fetchList(serverUrl: String): Option[JsonNode] =
Try { Try {
@@ -141,8 +165,8 @@ class ExternalTournamentClient:
def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean = def replicateTournament(serverUrl: String, req: ReplicateTournamentRequest, selfUrl: String): Boolean =
Try { Try {
val client = buildClient() val client = buildClient()
val body = objectMapper.writeValueAsString(req) val body = objectMapper.writeValueAsString(req)
val response = client val response = client
.target(s"$serverUrl/api/tournament/replicate") .target(s"$serverUrl/api/tournament/replicate")
.request(MediaType.APPLICATION_JSON) .request(MediaType.APPLICATION_JSON)
@@ -82,6 +82,14 @@ class TournamentService:
def get(id: String): Option[Tournament] = def get(id: String): Option[Tournament] =
tournamentRepository.findOptById(id) tournamentRepository.findOptById(id)
@Transactional
def setNativeTournamentId(id: String, nativeId: String): Unit =
tournamentRepository.findOptById(id).foreach(_.nativeTournamentId = nativeId)
@Transactional
def markStatus(id: String, status: String): Unit =
tournamentRepository.findOptById(id).foreach(_.status = status)
def list(): (List[Tournament], List[Tournament], List[Tournament]) = def list(): (List[Tournament], List[Tournament], List[Tournament]) =
( (
tournamentRepository.findByStatus("created"), tournamentRepository.findByStatus("created"),
@@ -30,3 +30,7 @@ nowchess:
secret: test-secret secret: test-secret
auth: auth:
enabled: false enabled: false
tournament:
self-url: ""
external-servers: ""
native-server-url: "http://localhost:1"
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=7 MINOR=9
PATCH=0 PATCH=0