feat: NCS-82 add Swiss-system tournament module #55

Merged
lq64 merged 11 commits from feat/NCS-82 into main 2026-06-09 15:09:53 +02:00
5 changed files with 36 additions and 3 deletions
Showing only changes of commit bb57cc93ae - Show all commits
@@ -75,4 +75,7 @@ class OfficialBotAccount extends PanacheEntityBase:
var rating: Int = 1500
var createdAt: Instant = uninitialized
Outdated
Review

Missing nullable = false. A just-constructed OfficialBotAccount starts with token = uninitialized, and AccountService does two separate persist() calls to work around this. Add nullable = false and generate the token before the first persist to collapse it to one round-trip and make the constraint explicit at the DB level.

Missing nullable = false. A just-constructed OfficialBotAccount starts with token = uninitialized, and AccountService does two separate persist() calls to work around this. Add nullable = false and generate the token before the first persist to collapse it to one round-trip and make the constraint explicit at the DB level.
@Column(length = 1024)
var token: String = uninitialized
// scalafix:on
@@ -46,7 +46,7 @@ case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token:
case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String, token: Option[String] = None)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
@@ -195,11 +195,11 @@ class AccountResource:
@POST
@Path("/official-bots")
@RolesAllowed(Array("Admin"))
@RolesAllowed(Array("**"))
Outdated
Review

Is it okay to leave it like that?

Is it okay to leave it like that?
def createOfficialBot(req: CreateBotAccountRequest): Response =
accountService.createOfficialBotAccount(req.name) match
case Right(bot) =>
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
case Left(error) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
@@ -219,3 +219,12 @@ class AccountResource:
rating = bot.rating,
createdAt = bot.createdAt.toString,
)
Outdated
Review

bot.token can be null if the Hibernate entity was just persisted (the token is not nullable = false on OfficialBotAccount). Wrap with
▎ Option(bot.token) rather than Some(bot.token) to avoid a NPE at runtime.

bot.token can be null if the Hibernate entity was just persisted (the token is not nullable = false on OfficialBotAccount). Wrap with ▎ Option(bot.token) rather than Some(bot.token) to avoid a NPE at runtime.
private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountDto =
OfficialBotAccountDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
createdAt = bot.createdAt.toString,
token = Some(bot.token),
)
1
@@ -205,6 +205,8 @@ class AccountService:
bot.name = botName
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
Outdated
Review

Same two-persist pattern. The root cause is that bot.id is not available before the first persist(). Either use a pre-assigned UUID (@Id with UUID.randomUUID() before persist) or derive the token from a field that doesn't require a DB round-trip. Two flushes per creation is a correctness risk under failure: if the second persist fails, the entity exists without a valid token.

Same two-persist pattern. The root cause is that bot.id is not available before the first persist(). Either use a pre-assigned UUID (@Id with UUID.randomUUID() before persist) or derive the token from a field that doesn't require a DB round-trip. Two flushes per creation is a correctness risk under failure: if the second persist fails, the entity exists without a valid token.
officialBotAccountRepository.persist(bot)
Right(bot)
@Transactional
1
@@ -1,10 +1,12 @@
package de.nowchess.tournament.service
import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl}
import de.nowchess.tournament.config.RedisConfig
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 io.quarkus.redis.datasource.RedisDataSource
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
@@ -28,6 +30,9 @@ class TournamentService:
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on
@Transactional
@@ -156,6 +161,20 @@ class TournamentService:
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"}""")
publishBotGameStart(white.botName, resp.gameId, "white", white.botId)
publishBotGameStart(black.botName, resp.gameId, "black", black.botId)
private def publishBotGameStart(
botName: String,
gameId: String,
playingAs: String,
botAccountId: String,
): Unit =
val channel = s"${redisConfig.prefix}:bot:$botName:events"
val payload = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":1500,"botAccountId":"$botAccountId"}"""
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
case Failure(ex) => log.warnf(ex, "Failed to publish gameStart to bot channel %s", channel)
case Success(_) => ()
@Transactional
def handleGameResult(gameId: String, result: String, pgn: String): Unit =