feat(tournament): add Swiss-system tournament module
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
Implements the full tournament lifecycle: create, join, withdraw, start, round progression, and finish with Buchholz tiebreak standings. - REST resource covering all 11 endpoints from the OpenAPI spec - Swiss pairing algorithm with bye support - Per-bot NDJSON stream with targeted gameStart events (color field) - Game result ingestion via Redis writeback stream - H2-backed integration tests for resource and pairing service Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8088
|
||||
application:
|
||||
name: nowchess-tournament
|
||||
redis:
|
||||
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||
rest-client:
|
||||
core-service:
|
||||
url: http://localhost:8080
|
||||
datasource:
|
||||
db-kind: h2
|
||||
username: sa
|
||||
password: ""
|
||||
jdbc:
|
||||
url: jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
smallrye-jwt:
|
||||
enabled: true
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
prefix: ${REDIS_PREFIX:nowchess}
|
||||
internal:
|
||||
secret: ${INTERNAL_SECRET:123abc}
|
||||
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: keys/public.pem
|
||||
issuer: nowchess
|
||||
|
||||
"%deployed":
|
||||
quarkus:
|
||||
datasource:
|
||||
db-kind: postgresql
|
||||
username: ${DB_USER:nowchess}
|
||||
password: ${DB_PASSWORD:nowchess}
|
||||
jdbc:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess}
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: update
|
||||
|
||||
"%test":
|
||||
quarkus:
|
||||
datasource:
|
||||
jdbc:
|
||||
url: jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
arc:
|
||||
exclude-types: de.nowchess.tournament.redis.GameResultStreamListener
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
|
||||
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
|
||||
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
|
||||
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
|
||||
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
|
||||
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
|
||||
WQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.nowchess.tournament.client
|
||||
|
||||
import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter}
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider}
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class CorePlayerInfo(id: String, displayName: String)
|
||||
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
|
||||
case class CoreCreateGameRequest(
|
||||
white: Option[CorePlayerInfo],
|
||||
black: Option[CorePlayerInfo],
|
||||
timeControl: Option[CoreTimeControl],
|
||||
mode: Option[String],
|
||||
)
|
||||
case class CoreGameResponse(gameId: String)
|
||||
|
||||
@Path("/api/board/game")
|
||||
@RegisterRestClient(configKey = "core-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
|
||||
trait CoreGameClient:
|
||||
|
||||
@POST
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(req: CoreCreateGameRequest): CoreGameResponse
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.nowchess.tournament.config
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class JacksonConfig extends ObjectMapperCustomizer:
|
||||
def customize(objectMapper: ObjectMapper): Unit =
|
||||
objectMapper.registerModule(DefaultScalaModule)
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.tournament.config
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class RedisConfig:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||
var prefix: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.nowchess.tournament.domain
|
||||
|
||||
import jakarta.persistence.*
|
||||
import scala.compiletime.uninitialized
|
||||
import java.time.Instant
|
||||
|
||||
@Entity
|
||||
@Table(name = "tournaments")
|
||||
class Tournament:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
var id: String = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var fullName: String = uninitialized
|
||||
|
||||
var nbRounds: Int = 0
|
||||
var clockLimit: Int = 0
|
||||
var clockIncrement: Int = 0
|
||||
var rated: Boolean = true
|
||||
|
||||
@Column(nullable = false)
|
||||
var status: String = "created"
|
||||
|
||||
var currentRound: Int = 0
|
||||
|
||||
@Column(nullable = false)
|
||||
var createdBy: String = uninitialized
|
||||
|
||||
var startsAt: Instant = uninitialized
|
||||
var winnerId: String = uninitialized
|
||||
var winnerName: String = uninitialized
|
||||
// scalafix:on
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package de.nowchess.tournament.domain
|
||||
|
||||
import jakarta.persistence.*
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "tournament_pairings")
|
||||
class TournamentPairing:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var tournamentId: String = uninitialized
|
||||
|
||||
var round: Int = 0
|
||||
var whiteId: String = uninitialized
|
||||
var whiteName: String = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var blackId: String = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var blackName: String = uninitialized
|
||||
|
||||
var gameId: String = uninitialized
|
||||
var winner: String = uninitialized
|
||||
var moveList: String = uninitialized
|
||||
// scalafix:on
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package de.nowchess.tournament.domain
|
||||
|
||||
import jakarta.persistence.*
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "tournament_participants")
|
||||
class TournamentParticipant:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var tournamentId: String = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var botId: String = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var botName: String = uninitialized
|
||||
|
||||
var points: Double = 0.0
|
||||
var tieBreak: Double = 0.0
|
||||
var nbGames: Int = 0
|
||||
var wins: Int = 0
|
||||
var draws: Int = 0
|
||||
var losses: Int = 0
|
||||
var byeCount: Int = 0
|
||||
// scalafix:on
|
||||
@@ -0,0 +1,74 @@
|
||||
package de.nowchess.tournament.dto
|
||||
|
||||
case class BotRef(id: String, name: String)
|
||||
|
||||
case class Clock(limit: Int, increment: Int)
|
||||
|
||||
case class Variant(key: String, name: String)
|
||||
|
||||
case class CreateTournamentForm(
|
||||
name: String,
|
||||
nbRounds: Int,
|
||||
clockLimit: Int,
|
||||
clockIncrement: Int,
|
||||
rated: Boolean = true,
|
||||
)
|
||||
|
||||
case class ResultDto(
|
||||
rank: Int,
|
||||
points: Double,
|
||||
tieBreak: Double,
|
||||
bot: BotRef,
|
||||
nbGames: Int,
|
||||
wins: Int,
|
||||
draws: Int,
|
||||
losses: Int,
|
||||
)
|
||||
|
||||
case class Standing(page: Int, players: List[ResultDto])
|
||||
|
||||
case class TournamentDto(
|
||||
id: String,
|
||||
fullName: String,
|
||||
clock: Clock,
|
||||
variant: Variant,
|
||||
rated: Boolean,
|
||||
nbPlayers: Int,
|
||||
nbRounds: Int,
|
||||
createdBy: String,
|
||||
startsAt: Option[String],
|
||||
status: String,
|
||||
round: Int,
|
||||
standing: Standing,
|
||||
winner: Option[BotRef],
|
||||
)
|
||||
|
||||
case class TournamentListDto(
|
||||
created: List[TournamentDto],
|
||||
started: List[TournamentDto],
|
||||
finished: List[TournamentDto],
|
||||
)
|
||||
|
||||
case class PairingDto(
|
||||
id: String,
|
||||
round: Int,
|
||||
white: Option[BotRef],
|
||||
black: BotRef,
|
||||
gameId: Option[String],
|
||||
winner: Option[String],
|
||||
)
|
||||
|
||||
case class GameExportDto(
|
||||
id: String,
|
||||
round: Int,
|
||||
white: BotRef,
|
||||
black: BotRef,
|
||||
winner: Option[String],
|
||||
moves: String,
|
||||
)
|
||||
|
||||
case class RoundPairingsDto(round: Int, pairings: List[PairingDto])
|
||||
|
||||
case class ErrorDto(error: String)
|
||||
|
||||
case class OkDto(ok: Boolean = true)
|
||||
@@ -0,0 +1,10 @@
|
||||
package de.nowchess.tournament.error
|
||||
|
||||
enum TournamentError(val message: String):
|
||||
case NotFound(id: String) extends TournamentError(s"Tournament $id not found")
|
||||
case NotDirector extends TournamentError("Not the tournament director")
|
||||
case WrongStatus(expected: String) extends TournamentError(s"Tournament must be in $expected status")
|
||||
case AlreadyJoined extends TournamentError("Already joined this tournament")
|
||||
case NotJoined extends TournamentError("Not joined this tournament")
|
||||
case NotEnoughParticipants extends TournamentError("Need at least 2 participants to start")
|
||||
case NotABot extends TournamentError("Only bot accounts can join tournaments")
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package de.nowchess.tournament.redis
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.dto.GameWritebackEventDto
|
||||
import de.nowchess.tournament.config.RedisConfig
|
||||
import de.nowchess.tournament.service.TournamentService
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs}
|
||||
import io.quarkus.runtime.Startup
|
||||
import jakarta.annotation.PostConstruct
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.context.ManagedExecutor
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.util.UUID
|
||||
|
||||
@Startup
|
||||
@ApplicationScoped
|
||||
class GameResultStreamListener:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||
@Inject var tournamentService: TournamentService = uninitialized
|
||||
@Inject var executor: ManagedExecutor = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
private val log = Logger.getLogger(classOf[GameResultStreamListener])
|
||||
private val groupName = "tournament-result"
|
||||
private val consumerId = UUID.randomUUID().toString
|
||||
|
||||
private def streamKey = s"${redisConfig.prefix}:game-writeback"
|
||||
|
||||
@PostConstruct
|
||||
def startListening(): Unit =
|
||||
createGroupIfAbsent()
|
||||
executor.submit(new Runnable:
|
||||
def run(): Unit = pollLoop()
|
||||
)
|
||||
log.infof("Tournament result listener started (consumer=%s)", consumerId)
|
||||
|
||||
private def createGroupIfAbsent(): Unit =
|
||||
Try(redis.stream(classOf[String]).xgroupCreate(streamKey, groupName, "0", new XGroupCreateArgs().mkstream())) match
|
||||
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
|
||||
case Failure(ex) => log.warnf(ex, "Failed to create consumer group")
|
||||
case Success(_) => ()
|
||||
|
||||
private def pollLoop(): Unit =
|
||||
while true do
|
||||
Try {
|
||||
val messages = redis.stream(classOf[String]).xreadgroup(
|
||||
groupName,
|
||||
consumerId,
|
||||
streamKey,
|
||||
">",
|
||||
new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)),
|
||||
)
|
||||
if messages != null then messages.forEach(msg => handleMessage(msg))
|
||||
} match
|
||||
case Failure(ex) => log.warnf(ex, "Error in result poll loop")
|
||||
case Success(_) => ()
|
||||
|
||||
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
|
||||
val json = msg.payload().get("data")
|
||||
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
|
||||
case Failure(ex) =>
|
||||
log.errorf(ex, "Unparseable game result event: %s", json)
|
||||
ack(msg.id())
|
||||
case Success(event) =>
|
||||
if event.result.isDefined then
|
||||
Try(tournamentService.handleGameResult(event.gameId, event.result.get, event.pgn)) match
|
||||
case Failure(ex) => log.errorf(ex, "Failed to handle game result for %s", event.gameId)
|
||||
case Success(_) => ()
|
||||
ack(msg.id())
|
||||
|
||||
private def ack(id: String): Unit =
|
||||
Try(redis.stream(classOf[String]).xack(streamKey, groupName, id)) match
|
||||
case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id)
|
||||
case Success(_) => ()
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package de.nowchess.tournament.repository
|
||||
|
||||
import de.nowchess.tournament.domain.TournamentPairing
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import java.util.UUID
|
||||
|
||||
@ApplicationScoped
|
||||
class PairingRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findByTournamentId(tournamentId: String): List[TournamentPairing] =
|
||||
em.createQuery("FROM TournamentPairing WHERE tournamentId = :tid", classOf[TournamentPairing])
|
||||
.setParameter("tid", tournamentId)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findByTournamentIdAndRound(tournamentId: String, round: Int): List[TournamentPairing] =
|
||||
em.createQuery(
|
||||
"FROM TournamentPairing WHERE tournamentId = :tid AND round = :round",
|
||||
classOf[TournamentPairing],
|
||||
).setParameter("tid", tournamentId)
|
||||
.setParameter("round", round)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findByGameId(gameId: String): Option[TournamentPairing] =
|
||||
em.createQuery("FROM TournamentPairing WHERE gameId = :gid", classOf[TournamentPairing])
|
||||
.setParameter("gid", gameId)
|
||||
.getResultList
|
||||
.asScala
|
||||
.headOption
|
||||
|
||||
def persist(p: TournamentPairing): TournamentPairing =
|
||||
if p.id == null then
|
||||
em.persist(p)
|
||||
p
|
||||
else em.merge(p)
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package de.nowchess.tournament.repository
|
||||
|
||||
import de.nowchess.tournament.domain.TournamentParticipant
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import java.util.UUID
|
||||
|
||||
@ApplicationScoped
|
||||
class ParticipantRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findByTournamentId(tournamentId: String): List[TournamentParticipant] =
|
||||
em.createQuery("FROM TournamentParticipant WHERE tournamentId = :tid", classOf[TournamentParticipant])
|
||||
.setParameter("tid", tournamentId)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findByTournamentIdAndBotId(tournamentId: String, botId: String): Option[TournamentParticipant] =
|
||||
em.createQuery(
|
||||
"FROM TournamentParticipant WHERE tournamentId = :tid AND botId = :bid",
|
||||
classOf[TournamentParticipant],
|
||||
).setParameter("tid", tournamentId)
|
||||
.setParameter("bid", botId)
|
||||
.getResultList
|
||||
.asScala
|
||||
.headOption
|
||||
|
||||
def persist(p: TournamentParticipant): TournamentParticipant =
|
||||
if p.id == null then
|
||||
em.persist(p)
|
||||
p
|
||||
else em.merge(p)
|
||||
|
||||
def delete(p: TournamentParticipant): Unit =
|
||||
em.remove(if em.contains(p) then p else em.merge(p))
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package de.nowchess.tournament.repository
|
||||
|
||||
import de.nowchess.tournament.domain.Tournament
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class TournamentRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findOptById(id: String): Option[Tournament] =
|
||||
Option(em.find(classOf[Tournament], id))
|
||||
|
||||
def findByStatus(status: String): List[Tournament] =
|
||||
em.createQuery("FROM Tournament WHERE status = :status", classOf[Tournament])
|
||||
.setParameter("status", status)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def persist(t: Tournament): Tournament =
|
||||
if em.contains(t) then t else em.merge(t)
|
||||
|
||||
def delete(t: Tournament): Unit =
|
||||
val managed = if em.contains(t) then t else em.merge(t)
|
||||
em.remove(managed)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
package de.nowchess.tournament.resource
|
||||
|
||||
import de.nowchess.tournament.dto.*
|
||||
import de.nowchess.tournament.error.TournamentError
|
||||
import de.nowchess.tournament.service.{TournamentService, TournamentStreamManager}
|
||||
import io.smallrye.mutiny.Multi
|
||||
import jakarta.annotation.security.{PermitAll, RolesAllowed}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response}
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Path("/api/tournament")
|
||||
@ApplicationScoped
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
class TournamentResource:
|
||||
|
||||
private val log = Logger.getLogger(classOf[TournamentResource])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var tournamentService: TournamentService = uninitialized
|
||||
@Inject var streamManager: TournamentStreamManager = uninitialized
|
||||
@Inject var jwt: JsonWebToken = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@GET
|
||||
@PermitAll
|
||||
def list(): Response =
|
||||
val (created, started, finished) = tournamentService.list()
|
||||
val dto = TournamentListDto(
|
||||
created = created.map(t => tournamentService.toDto(t)),
|
||||
started = started.map(t => tournamentService.toDto(t)),
|
||||
finished = finished.map(t => tournamentService.toDto(t)),
|
||||
)
|
||||
Response.ok(dto).build()
|
||||
|
||||
@POST
|
||||
@RolesAllowed(Array("**"))
|
||||
@Consumes(Array(MediaType.APPLICATION_FORM_URLENCODED))
|
||||
def create(
|
||||
@FormParam("name") name: String,
|
||||
@FormParam("nbRounds") nbRounds: Int,
|
||||
@FormParam("clockLimit") clockLimit: Int,
|
||||
@FormParam("clockIncrement") clockIncrement: Int,
|
||||
@FormParam("rated") @DefaultValue("true") rated: Boolean,
|
||||
): Response =
|
||||
val userId = Option(jwt.getSubject).getOrElse("")
|
||||
val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated)
|
||||
val t = tournamentService.create(userId, form)
|
||||
Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
@PermitAll
|
||||
def get(@PathParam("id") id: String): Response =
|
||||
tournamentService.get(id) match
|
||||
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||
case Some(t) =>
|
||||
val standings = tournamentService.getStandings(id)
|
||||
Response.ok(tournamentService.toDto(t, standings)).build()
|
||||
|
||||
@DELETE
|
||||
@Path("/{id}")
|
||||
@RolesAllowed(Array("**"))
|
||||
def terminate(@PathParam("id") id: String): Response =
|
||||
val userId = Option(jwt.getSubject).getOrElse("")
|
||||
tournamentService.terminate(id, userId) match
|
||||
case Right(_) => Response.noContent().build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/start")
|
||||
@RolesAllowed(Array("**"))
|
||||
def start(@PathParam("id") id: String): Response =
|
||||
val userId = Option(jwt.getSubject).getOrElse("")
|
||||
tournamentService.start(id, userId) match
|
||||
case Right(t) => Response.ok(tournamentService.toDto(t)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/join")
|
||||
@RolesAllowed(Array("**"))
|
||||
def join(@PathParam("id") id: String): Response =
|
||||
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||
if tokenType != "bot" then
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can join tournaments")).build()
|
||||
else
|
||||
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 Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/withdraw")
|
||||
@RolesAllowed(Array("**"))
|
||||
def withdraw(@PathParam("id") id: String): Response =
|
||||
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
|
||||
if tokenType != "bot" then
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can withdraw")).build()
|
||||
else
|
||||
val botId = Option(jwt.getSubject).getOrElse("")
|
||||
tournamentService.withdraw(id, botId) match
|
||||
case Right(_) => Response.ok(OkDto()).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@GET
|
||||
@Path("/{id}/results")
|
||||
@Produces(Array("application/x-ndjson"))
|
||||
@PermitAll
|
||||
def results(
|
||||
@PathParam("id") id: String,
|
||||
@QueryParam("nb") @DefaultValue("100") nb: Int,
|
||||
): Response =
|
||||
tournamentService.get(id) match
|
||||
case None => Response.status(Response.Status.NOT_FOUND).entity("").build()
|
||||
case Some(_) =>
|
||||
val ndjson = tournamentService.getResults(id).take(nb).map { r =>
|
||||
s"""{"rank":${r.rank},"points":${r.points},"tieBreak":${r.tieBreak},"bot":{"id":"${r.bot.id}","name":"${r.bot.name}"},"nbGames":${r.nbGames},"wins":${r.wins},"draws":${r.draws},"losses":${r.losses}}"""
|
||||
}.mkString("\n")
|
||||
Response.ok(ndjson).`type`("application/x-ndjson").build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}/round/{round}")
|
||||
@PermitAll
|
||||
def roundPairings(@PathParam("id") id: String, @PathParam("round") round: Int): Response =
|
||||
tournamentService.get(id) match
|
||||
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||
case Some(_) =>
|
||||
val pairings = tournamentService.getPairings(id, round)
|
||||
Response.ok(RoundPairingsDto(round, pairings)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}/export/games")
|
||||
@PermitAll
|
||||
@Produces(Array(MediaType.APPLICATION_JSON, MediaType.WILDCARD, "application/x-ndjson", "application/x-chess-pgn"))
|
||||
def exportGames(@PathParam("id") id: String, @Context headers: HttpHeaders): Response =
|
||||
tournamentService.get(id) match
|
||||
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build()
|
||||
case Some(_) =>
|
||||
val acceptHeader = Option(headers.getHeaderString("Accept")).getOrElse("")
|
||||
val pairings = tournamentService.getAllPairings(id)
|
||||
if acceptHeader.contains("application/x-ndjson") then
|
||||
val ndjson = pairings
|
||||
.filter(p => Option(p.whiteId).isDefined && Option(p.gameId).isDefined)
|
||||
.map { p =>
|
||||
val winner = Option(p.winner).map(w => s""""$w"""").getOrElse("null")
|
||||
val moves = Option(p.moveList).getOrElse("")
|
||||
s"""{"id":"${p.gameId}","round":${p.round},"white":{"id":"${p.whiteId}","name":"${p.whiteName}"},"black":{"id":"${p.blackId}","name":"${p.blackName}"},"winner":$winner,"moves":"$moves"}"""
|
||||
}
|
||||
.mkString("\n")
|
||||
Response.ok(ndjson).`type`("application/x-ndjson").build()
|
||||
else
|
||||
val pgn = pairings.flatMap(p => Option(p.moveList)).mkString("\n\n")
|
||||
Response.ok(pgn).`type`("application/x-chess-pgn").build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}/stream")
|
||||
@RolesAllowed(Array("**"))
|
||||
@Produces(Array("application/x-ndjson"))
|
||||
def stream(@PathParam("id") id: String): Multi[String] =
|
||||
tournamentService.get(id) match
|
||||
case None => Multi.createFrom().failure(new NotFoundException(s"Tournament $id not found"))
|
||||
case Some(_) =>
|
||||
val botId = Option(jwt.getSubject).getOrElse("")
|
||||
Multi.createFrom().emitter[String] { emitter =>
|
||||
streamManager.register(id, botId, emitter)
|
||||
emitter.onTermination(() => streamManager.unregister(id, botId, emitter))
|
||||
}
|
||||
|
||||
private def errorResponse(error: TournamentError): Response =
|
||||
val status = error match
|
||||
case TournamentError.NotFound(_) => Response.Status.NOT_FOUND
|
||||
case TournamentError.NotDirector => Response.Status.FORBIDDEN
|
||||
case TournamentError.NotABot => Response.Status.FORBIDDEN
|
||||
case TournamentError.WrongStatus(_) => Response.Status.CONFLICT
|
||||
case TournamentError.AlreadyJoined => Response.Status.CONFLICT
|
||||
case TournamentError.NotJoined => Response.Status.CONFLICT
|
||||
case TournamentError.NotEnoughParticipants => Response.Status.CONFLICT
|
||||
Response.status(status).entity(ErrorDto(error.message)).build()
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package de.nowchess.tournament.service
|
||||
|
||||
import de.nowchess.tournament.domain.{TournamentParticipant, TournamentPairing}
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
|
||||
object SwissPairingService:
|
||||
|
||||
def computePairings(
|
||||
participants: List[TournamentParticipant],
|
||||
pastPairings: List[TournamentPairing],
|
||||
): (List[(TournamentParticipant, TournamentParticipant)], Option[TournamentParticipant]) =
|
||||
val sorted = sortParticipants(participants)
|
||||
val (remaining, byeOpt) = extractByePlayer(sorted)
|
||||
val pairs = buildPairs(remaining, pastPairings)
|
||||
(pairs, byeOpt)
|
||||
|
||||
private def sortParticipants(participants: List[TournamentParticipant]): List[TournamentParticipant] =
|
||||
participants.sortWith { (a, b) =>
|
||||
if a.points != b.points then a.points > b.points
|
||||
else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak
|
||||
else a.botName < b.botName
|
||||
}
|
||||
|
||||
private def extractByePlayer(
|
||||
sorted: List[TournamentParticipant],
|
||||
): (List[TournamentParticipant], Option[TournamentParticipant]) =
|
||||
if sorted.size % 2 == 0 then (sorted, None)
|
||||
else
|
||||
val minByes = sorted.map(_.byeCount).min
|
||||
val byeIndex = sorted.lastIndexWhere(_.byeCount == minByes)
|
||||
val bye = sorted(byeIndex)
|
||||
(sorted.filterNot(_ eq bye), Some(bye))
|
||||
|
||||
private def buildPairs(
|
||||
players: List[TournamentParticipant],
|
||||
pastPairings: List[TournamentPairing],
|
||||
): List[(TournamentParticipant, TournamentParticipant)] =
|
||||
val arr = players.toArray
|
||||
resolveConflicts(arr, pastPairings)
|
||||
arr.grouped(2).flatMap {
|
||||
case Array(a, b) => Some(assignColors(a, b))
|
||||
case _ => None
|
||||
}.toList
|
||||
|
||||
private def resolveConflicts(arr: Array[TournamentParticipant], pastPairings: List[TournamentPairing]): Unit =
|
||||
var i = 0
|
||||
while i < arr.length - 1 do
|
||||
if havePlayedBefore(arr(i), arr(i + 1), pastPairings) && i + 2 < arr.length then
|
||||
val tmp = arr(i + 1)
|
||||
arr(i + 1) = arr(i + 2)
|
||||
arr(i + 2) = tmp
|
||||
i += 2
|
||||
|
||||
private def havePlayedBefore(
|
||||
a: TournamentParticipant,
|
||||
b: TournamentParticipant,
|
||||
pastPairings: List[TournamentPairing],
|
||||
): Boolean =
|
||||
pastPairings.exists(p =>
|
||||
(p.whiteId == a.botId && p.blackId == b.botId) ||
|
||||
(p.whiteId == b.botId && p.blackId == a.botId),
|
||||
)
|
||||
|
||||
private def assignColors(
|
||||
a: TournamentParticipant,
|
||||
b: TournamentParticipant,
|
||||
): (TournamentParticipant, TournamentParticipant) =
|
||||
if ThreadLocalRandom.current().nextBoolean() then (a, b) else (b, a)
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
package de.nowchess.tournament.service
|
||||
|
||||
import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl}
|
||||
import de.nowchess.tournament.domain.{Tournament, TournamentPairing, TournamentParticipant}
|
||||
import de.nowchess.tournament.dto.{BotRef, Clock, CreateTournamentForm, PairingDto, ResultDto, Standing, TournamentDto, Variant}
|
||||
import de.nowchess.tournament.error.TournamentError
|
||||
import de.nowchess.tournament.repository.{PairingRepository, ParticipantRepository, TournamentRepository}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.transaction.Transactional
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.util.{Failure, Success, Try}
|
||||
import java.time.Instant
|
||||
|
||||
@ApplicationScoped
|
||||
class TournamentService:
|
||||
|
||||
private val log = Logger.getLogger(classOf[TournamentService])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var tournamentRepository: TournamentRepository = uninitialized
|
||||
@Inject var participantRepository: ParticipantRepository = uninitialized
|
||||
@Inject var pairingRepository: PairingRepository = uninitialized
|
||||
@Inject var streamManager: TournamentStreamManager = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@Transactional
|
||||
def create(createdBy: String, form: CreateTournamentForm): Tournament =
|
||||
val t = new Tournament()
|
||||
t.id = scala.util.Random.alphanumeric.take(6).mkString
|
||||
t.fullName = form.name
|
||||
t.nbRounds = form.nbRounds
|
||||
t.clockLimit = form.clockLimit
|
||||
t.clockIncrement = form.clockIncrement
|
||||
t.rated = form.rated
|
||||
t.status = "created"
|
||||
t.currentRound = 0
|
||||
t.createdBy = createdBy
|
||||
tournamentRepository.persist(t)
|
||||
t
|
||||
|
||||
def get(id: String): Option[Tournament] =
|
||||
tournamentRepository.findOptById(id)
|
||||
|
||||
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
|
||||
(
|
||||
tournamentRepository.findByStatus("created"),
|
||||
tournamentRepository.findByStatus("started"),
|
||||
tournamentRepository.findByStatus("finished"),
|
||||
)
|
||||
|
||||
@Transactional
|
||||
def terminate(id: String, userId: String): Either[TournamentError, Unit] =
|
||||
for
|
||||
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
|
||||
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||
yield
|
||||
tournamentRepository.delete(t)
|
||||
|
||||
@Transactional
|
||||
def join(id: String, botId: String, botName: String): Either[TournamentError, Unit] =
|
||||
for
|
||||
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||
_ <- Either.cond(
|
||||
participantRepository.findByTournamentIdAndBotId(id, botId).isEmpty,
|
||||
(),
|
||||
TournamentError.AlreadyJoined,
|
||||
)
|
||||
yield
|
||||
val p = new TournamentParticipant()
|
||||
p.tournamentId = id
|
||||
p.botId = botId
|
||||
p.botName = botName
|
||||
participantRepository.persist(p)
|
||||
|
||||
@Transactional
|
||||
def withdraw(id: String, botId: String): Either[TournamentError, Unit] =
|
||||
for
|
||||
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||
p <- participantRepository.findByTournamentIdAndBotId(id, botId).toRight(TournamentError.NotJoined)
|
||||
yield participantRepository.delete(p)
|
||||
|
||||
@Transactional
|
||||
def start(id: String, userId: String): Either[TournamentError, Tournament] =
|
||||
for
|
||||
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
|
||||
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
|
||||
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
|
||||
participants <- validateMinParticipants(id)
|
||||
yield
|
||||
t.status = "started"
|
||||
t.currentRound = 1
|
||||
t.startsAt = Instant.now()
|
||||
tournamentRepository.persist(t)
|
||||
streamManager.publish(t.id, """{"type":"tournamentStarted"}""")
|
||||
startRound(t, 1, participants)
|
||||
t
|
||||
|
||||
private def validateMinParticipants(id: String): Either[TournamentError, List[TournamentParticipant]] =
|
||||
val ps = participantRepository.findByTournamentId(id)
|
||||
Either.cond(ps.size >= 2, ps, TournamentError.NotEnoughParticipants)
|
||||
|
||||
private def startRound(t: Tournament, round: Int, participants: List[TournamentParticipant]): Unit =
|
||||
val pastPairings = pairingRepository.findByTournamentId(t.id)
|
||||
val (pairs, byeOpt) = SwissPairingService.computePairings(participants, pastPairings)
|
||||
byeOpt.foreach(bye => createByePairing(t.id, round, bye))
|
||||
pairs.foreach { case (white, black) => createRealPairing(t.id, round, white, black, t) }
|
||||
streamManager.publish(t.id, s"""{"type":"roundStarted","round":$round}""")
|
||||
|
||||
private def createByePairing(tournamentId: String, round: Int, bye: TournamentParticipant): Unit =
|
||||
val pairing = new TournamentPairing()
|
||||
pairing.tournamentId = tournamentId
|
||||
pairing.round = round
|
||||
pairing.blackId = bye.botId
|
||||
pairing.blackName = bye.botName
|
||||
pairing.winner = "bye"
|
||||
pairingRepository.persist(pairing)
|
||||
bye.points += 0.5
|
||||
bye.byeCount += 1
|
||||
participantRepository.persist(bye)
|
||||
|
||||
private def createRealPairing(
|
||||
tournamentId: String,
|
||||
round: Int,
|
||||
white: TournamentParticipant,
|
||||
black: TournamentParticipant,
|
||||
t: Tournament,
|
||||
): Unit =
|
||||
val tc = CoreTimeControl(Some(t.clockLimit), Some(t.clockIncrement), None)
|
||||
val req = CoreCreateGameRequest(
|
||||
Some(CorePlayerInfo(white.botId, white.botName)),
|
||||
Some(CorePlayerInfo(black.botId, black.botName)),
|
||||
Some(tc),
|
||||
if t.rated then Some("Rated") else Some("Casual"),
|
||||
)
|
||||
Try(coreGameClient.createGame(req)) match
|
||||
case Failure(ex) => log.errorf(ex, "Failed to create game for round %d in tournament %s", round, tournamentId)
|
||||
case Success(resp) =>
|
||||
val pairing = new TournamentPairing()
|
||||
pairing.tournamentId = tournamentId
|
||||
pairing.round = round
|
||||
pairing.whiteId = white.botId
|
||||
pairing.whiteName = white.botName
|
||||
pairing.blackId = black.botId
|
||||
pairing.blackName = black.botName
|
||||
pairing.gameId = resp.gameId
|
||||
pairingRepository.persist(pairing)
|
||||
streamManager.publishToBot(tournamentId, white.botId, s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"white"}""")
|
||||
streamManager.publishToBot(tournamentId, black.botId, s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"black"}""")
|
||||
|
||||
@Transactional
|
||||
def handleGameResult(gameId: String, result: String, pgn: String): Unit =
|
||||
pairingRepository.findByGameId(gameId).foreach { pairing =>
|
||||
val (winnerStr, wPts, bPts) = parseResult(result)
|
||||
pairing.winner = winnerStr
|
||||
pairing.moveList = pgn
|
||||
pairingRepository.persist(pairing)
|
||||
updateParticipantStats(pairing, wPts, bPts)
|
||||
checkRoundCompletion(pairing.tournamentId)
|
||||
}
|
||||
|
||||
private def parseResult(result: String): (String, Double, Double) = result match
|
||||
case "1-0" => ("white", 1.0, 0.0)
|
||||
case "0-1" => ("black", 0.0, 1.0)
|
||||
case "1/2-1/2" => ("draw", 0.5, 0.5)
|
||||
case _ => ("draw", 0.5, 0.5)
|
||||
|
||||
private def updateParticipantStats(pairing: TournamentPairing, wPts: Double, bPts: Double): Unit =
|
||||
Option(pairing.whiteId).foreach { wId =>
|
||||
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, wId).foreach { p =>
|
||||
p.points += wPts
|
||||
p.nbGames += 1
|
||||
if wPts == 1.0 then p.wins += 1 else if wPts == 0.5 then p.draws += 1 else p.losses += 1
|
||||
participantRepository.persist(p)
|
||||
}
|
||||
}
|
||||
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, pairing.blackId).foreach { p =>
|
||||
p.points += bPts
|
||||
p.nbGames += 1
|
||||
if bPts == 1.0 then p.wins += 1 else if bPts == 0.5 then p.draws += 1 else p.losses += 1
|
||||
participantRepository.persist(p)
|
||||
}
|
||||
|
||||
private def checkRoundCompletion(tournamentId: String): Unit =
|
||||
tournamentRepository.findOptById(tournamentId).foreach { t =>
|
||||
val roundPairings = pairingRepository.findByTournamentIdAndRound(tournamentId, t.currentRound)
|
||||
val allDone = roundPairings.nonEmpty && roundPairings.forall(p => Option(p.winner).isDefined)
|
||||
if allDone then onRoundComplete(t)
|
||||
}
|
||||
|
||||
private def onRoundComplete(t: Tournament): Unit =
|
||||
streamManager.publish(t.id, s"""{"type":"roundFinished","round":${t.currentRound}}""")
|
||||
recomputeBuchholz(t.id)
|
||||
if t.currentRound >= t.nbRounds then finishTournament(t)
|
||||
else
|
||||
t.currentRound += 1
|
||||
tournamentRepository.persist(t)
|
||||
val participants = participantRepository.findByTournamentId(t.id)
|
||||
startRound(t, t.currentRound, participants)
|
||||
|
||||
private def finishTournament(t: Tournament): Unit =
|
||||
val participants = sortedStandings(participantRepository.findByTournamentId(t.id))
|
||||
participants.headOption.foreach { winner =>
|
||||
t.winnerId = winner.botId
|
||||
t.winnerName = winner.botName
|
||||
}
|
||||
t.status = "finished"
|
||||
tournamentRepository.persist(t)
|
||||
val winnerInfo = participants.headOption
|
||||
.map(w => s"""{"id":"${w.botId}","name":"${w.botName}"}""")
|
||||
.getOrElse("null")
|
||||
streamManager.publish(t.id, s"""{"type":"tournamentFinished","winner":$winnerInfo}""")
|
||||
|
||||
private def recomputeBuchholz(tournamentId: String): Unit =
|
||||
val participants = participantRepository.findByTournamentId(tournamentId)
|
||||
val pairings = pairingRepository.findByTournamentId(tournamentId)
|
||||
val pointsById = participants.map(p => p.botId -> p.points).toMap
|
||||
participants.foreach { p =>
|
||||
val opponentIds = pairings.flatMap(pair =>
|
||||
if pair.whiteId == p.botId then Some(pair.blackId)
|
||||
else if pair.blackId == p.botId && Option(pair.whiteId).isDefined then Some(pair.whiteId)
|
||||
else None,
|
||||
)
|
||||
p.tieBreak = opponentIds.flatMap(id => pointsById.get(id)).sum
|
||||
participantRepository.persist(p)
|
||||
}
|
||||
|
||||
def getStandings(tournamentId: String): List[ResultDto] =
|
||||
val participants = sortedStandings(participantRepository.findByTournamentId(tournamentId))
|
||||
participants.zipWithIndex.map { case (p, idx) =>
|
||||
ResultDto(idx + 1, p.points, p.tieBreak, BotRef(p.botId, p.botName), p.nbGames, p.wins, p.draws, p.losses)
|
||||
}
|
||||
|
||||
def getPairings(tournamentId: String, round: Int): List[PairingDto] =
|
||||
pairingRepository.findByTournamentIdAndRound(tournamentId, round).map(toPairingDto)
|
||||
|
||||
def getAllPairings(tournamentId: String): List[TournamentPairing] =
|
||||
pairingRepository.findByTournamentId(tournamentId)
|
||||
|
||||
def getResults(tournamentId: String): List[ResultDto] = getStandings(tournamentId)
|
||||
|
||||
def toDto(t: Tournament, standings: List[ResultDto] = Nil): TournamentDto =
|
||||
val participants = participantRepository.findByTournamentId(t.id)
|
||||
TournamentDto(
|
||||
id = t.id,
|
||||
fullName = t.fullName,
|
||||
clock = Clock(t.clockLimit, t.clockIncrement),
|
||||
variant = Variant("standard", "Standard"),
|
||||
rated = t.rated,
|
||||
nbPlayers = participants.size,
|
||||
nbRounds = t.nbRounds,
|
||||
createdBy = t.createdBy,
|
||||
startsAt = Option(t.startsAt).map(_.toString),
|
||||
status = t.status,
|
||||
round = t.currentRound,
|
||||
standing = Standing(1, standings),
|
||||
winner = if t.winnerId != null then Some(BotRef(t.winnerId, t.winnerName)) else None,
|
||||
)
|
||||
|
||||
private def toPairingDto(p: TournamentPairing): PairingDto =
|
||||
PairingDto(
|
||||
id = p.id.toString,
|
||||
round = p.round,
|
||||
white = Option(p.whiteId).map(id => BotRef(id, p.whiteName)),
|
||||
black = BotRef(p.blackId, p.blackName),
|
||||
gameId = Option(p.gameId),
|
||||
winner = Option(p.winner),
|
||||
)
|
||||
|
||||
private def sortedStandings(participants: List[TournamentParticipant]): List[TournamentParticipant] =
|
||||
participants.sortWith { (a, b) =>
|
||||
if a.points != b.points then a.points > b.points
|
||||
else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak
|
||||
else a.botName < b.botName
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package de.nowchess.tournament.service
|
||||
|
||||
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import java.util.concurrent.{ConcurrentHashMap, CopyOnWriteArrayList}
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class TournamentStreamManager:
|
||||
|
||||
private val tournamentEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]()
|
||||
private val botEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]()
|
||||
|
||||
private def botKey(tournamentId: String, botId: String): String = s"${tournamentId}:${botId}"
|
||||
|
||||
def register(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||
tournamentEmitters.computeIfAbsent(tournamentId, _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]()).add(emitter)
|
||||
botEmitters.computeIfAbsent(botKey(tournamentId, botId), _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]()).add(emitter)
|
||||
|
||||
def unregister(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||
Option(tournamentEmitters.get(tournamentId)).foreach(_.remove(emitter))
|
||||
Option(botEmitters.get(botKey(tournamentId, botId))).foreach(_.remove(emitter))
|
||||
|
||||
def publish(tournamentId: String, eventJson: String): Unit =
|
||||
Option(tournamentEmitters.get(tournamentId)).foreach { list =>
|
||||
list.asScala.foreach(e => scala.util.Try(e.emit(eventJson)))
|
||||
}
|
||||
|
||||
def publishToBot(tournamentId: String, botId: String, eventJson: String): Unit =
|
||||
Option(botEmitters.get(botKey(tournamentId, botId))).foreach { list =>
|
||||
list.asScala.foreach(e => scala.util.Try(e.emit(eventJson)))
|
||||
}
|
||||
Reference in New Issue
Block a user