feat: NCS-82 add Swiss-system tournament module (#55)
Build & Test (NowChessSystems) TeamCity build finished
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user