feat(tournament): auto-join external tournaments and publish created ones #77
+52
-1
@@ -38,14 +38,22 @@ class TournamentBotGamePlayer:
|
|||||||
private val client: Client = ClientBuilder.newClient()
|
private val client: Client = ClientBuilder.newClient()
|
||||||
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
private val workers: ExecutorService = Executors.newCachedThreadPool()
|
||||||
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
private val activeGames = ConcurrentHashMap.newKeySet[String]()
|
||||||
|
private val joinedTournaments = ConcurrentHashMap.newKeySet[String]()
|
||||||
|
|
||||||
|
private val hardestDifficulty = "expert"
|
||||||
|
private val autoJoinIntervalMs = 15000L
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// 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
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
val tournamentServiceUrl: String =
|
val tournamentServiceUrl: String =
|
||||||
System.getenv().asScala.getOrElse("TOURNAMENT_SERVICE_URL", "http://localhost:8086")
|
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
|
@PostConstruct
|
||||||
def initialize(): Unit =
|
def initialize(): Unit =
|
||||||
val env = System.getenv().asScala.toMap
|
val env = System.getenv().asScala.toMap
|
||||||
@@ -58,6 +66,49 @@ class TournamentBotGamePlayer:
|
|||||||
case Some(cfg) =>
|
case Some(cfg) =>
|
||||||
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
|
||||||
startAsync(cfg)
|
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] =
|
private def resolveToken(difficulty: String): Option[String] =
|
||||||
val name = botName(difficulty)
|
val name = botName(difficulty)
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ nowchess:
|
|||||||
tournament:
|
tournament:
|
||||||
self-url: ""
|
self-url: ""
|
||||||
external-servers: ""
|
external-servers: ""
|
||||||
|
native-server-url: ${TOURNAMENT_NATIVE_SERVER_URL:http://141.37.123.132:8086}
|
||||||
|
director-name: ${TOURNAMENT_DIRECTOR_NAME:NowChess System}
|
||||||
|
|
||||||
mp:
|
mp:
|
||||||
jwt:
|
jwt:
|
||||||
@@ -53,6 +55,8 @@ mp:
|
|||||||
tournament:
|
tournament:
|
||||||
self-url: ${TOURNAMENT_SELF_URL:}
|
self-url: ${TOURNAMENT_SELF_URL:}
|
||||||
external-servers: ${TOURNAMENT_EXTERNAL_SERVERS:}
|
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":
|
"%test":
|
||||||
quarkus:
|
quarkus:
|
||||||
|
|||||||
+29
@@ -40,6 +40,12 @@ class TournamentResource:
|
|||||||
|
|
||||||
@ConfigProperty(name = "nowchess.tournament.self-url")
|
@ConfigProperty(name = "nowchess.tournament.self-url")
|
||||||
var selfUrl: Optional[String] = uninitialized
|
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
|
// scalafix:on
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@@ -95,8 +101,31 @@ class TournamentResource:
|
|||||||
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
log.warnf("Failed to replicate tournament %s to %s", t.id, remoteUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
publishToNativeServer(t)
|
||||||
Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build()
|
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
|
@GET
|
||||||
@Path("/{id}")
|
@Path("/{id}")
|
||||||
@PermitAll
|
@PermitAll
|
||||||
|
|||||||
+46
@@ -14,10 +14,56 @@ 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
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
private def buildClient(): Client = ClientBuilder.newClient()
|
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] =
|
def fetchList(serverUrl: String): Option[JsonNode] =
|
||||||
Try {
|
Try {
|
||||||
val client = buildClient()
|
val client = buildClient()
|
||||||
|
|||||||
Reference in New Issue
Block a user