feat: NCS-82 add Swiss-system tournament module (#55)
Build & Test (NowChessSystems) TeamCity build finished

## Summary

  - Implements the full tournament lifecycle (create, join, withdraw, start,
    round progression, finish) as a standalone Quarkus module
  - All 11 endpoints from the OpenAPI spec (`docs/tournament-openapi.yaml`) are covered
  - Swiss pairing algorithm with Buchholz tiebreak and bye support
  - Per-bot NDJSON event stream with targeted `gameStart` events carrying
    the correct `color` field
  - Game results ingested via Redis writeback stream (`GameResultStreamListener`)

  ## Known gaps (deferred)

  - `GET /results` `nb` param defaults to 100 instead of all
  - `PairingDto` exposes an internal `id` field not in the spec
  - `GameExport.moves` emits PGN instead of UCI (upstream `GameWritebackEventDto`
    does not carry UCI moves)
  - `Pairing.white` can be `null` for bye rounds (spec has no bye concept)

  ## Test plan

  - [x] 23 `TournamentResourceTest` integration tests (H2, mocked core client) — all pass
  - [x] 5 `SwissPairingServiceTest` unit tests — all pass
  - [x] Redis listener excluded in test/dev profiles; no Docker required to run tests

---------

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #55
This commit was merged in pull request #55.
This commit is contained in:
2026-06-09 15:09:53 +02:00
parent 3b6c5297f6
commit c5661de4a0
36 changed files with 2666 additions and 8 deletions
@@ -51,7 +51,7 @@ class BotAccount extends PanacheEntityBase:
@JoinColumn(name = "owner_id", nullable = false)
var owner: UserAccount = uninitialized
@Column(unique = true, nullable = false, length = 256)
@Column(unique = true, nullable = false, length = 1024)
var token: String = uninitialized
var rating: Int = 1500
@@ -75,4 +75,7 @@ class OfficialBotAccount extends PanacheEntityBase:
var rating: Int = 1500
var createdAt: Instant = uninitialized
@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)
@@ -199,7 +199,7 @@ class AccountResource:
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,
)
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),
)
@@ -153,9 +153,10 @@ class AccountService:
val bot = new BotAccount()
bot.name = botName
bot.owner = owner
bot.token = generateBotToken(bot.id)
bot.token = UUID.randomUUID().toString
bot.createdAt = Instant.now()
botAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
log.infof("Bot account %s created for owner %s", botName, ownerId.toString)
Right(bot)
@@ -194,7 +195,7 @@ class AccountService:
case Some(bot) =>
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
else
bot.token = generateBotToken(botId)
bot.token = generateBotToken(botId, bot.name)
botAccountRepository.persist(bot)
Right(bot)
@@ -204,6 +205,8 @@ class AccountService:
bot.name = botName
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
officialBotAccountRepository.persist(bot)
Right(bot)
@Transactional
@@ -214,6 +217,8 @@ class AccountService:
bot.name = name
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
officialBotAccountRepository.persist(bot)
log.infof("Auto-registered official bot: %s", name)
}
@@ -228,12 +233,13 @@ class AccountService:
officialBotAccountRepository.delete(botId)
Right(())
private def generateBotToken(botId: UUID): String =
private def generateBotToken(botId: UUID, botName: String): String =
Jwt
.issuer("nowchess")
.subject(botId.toString)
.expiresAt(Long.MaxValue)
.claim("type", "bot")
.claim("name", botName)
.sign()
@Transactional