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:
+25
-2
@@ -22,7 +22,7 @@ import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.util.Try
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
|
||||
import java.util.function.Consumer
|
||||
|
||||
@ApplicationScoped
|
||||
@@ -46,6 +46,10 @@ class GameRedisSubscriberManager:
|
||||
|
||||
private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
|
||||
private val s2cObservers = new ConcurrentHashMap[String, Observer]()
|
||||
// Per-game single-thread executor so c2s messages are handled off the Vert.x
|
||||
// event loop (handleConnected/handleMove make blocking gRPC + Redis calls) while
|
||||
// staying ordered per game.
|
||||
private val c2sExecutors = new ConcurrentHashMap[String, ExecutorService]()
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None
|
||||
@@ -95,7 +99,14 @@ class GameRedisSubscriberManager:
|
||||
obs.emitInitialWriteback()
|
||||
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
|
||||
|
||||
val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
|
||||
val executor = c2sExecutors.computeIfAbsent(gameId, _ => Executors.newSingleThreadExecutor())
|
||||
val handler: Consumer[String] = msg =>
|
||||
val task = new Runnable:
|
||||
def run(): Unit =
|
||||
try handleC2sMessage(gameId, msg)
|
||||
catch case ex: Exception => log.warnf(ex, "Error handling c2s message for game %s", gameId)
|
||||
Try(executor.execute(task))
|
||||
()
|
||||
try
|
||||
val subscriber = reactiveRedis
|
||||
.pubsub(classOf[String])
|
||||
@@ -106,6 +117,16 @@ class GameRedisSubscriberManager:
|
||||
log.debugf("Subscribed to game %s", gameId)
|
||||
catch case ex: Exception => log.warnf(ex, "Redis subscription failed for game %s", gameId)
|
||||
|
||||
// Notify the official-bots service to start playing a side of a game. Mirrors
|
||||
// the event the tournament service publishes; official-bots subscribes to
|
||||
// "<prefix>:bot:*:events".
|
||||
def publishBotGameStart(gameId: String, botId: String, playingAs: String): Unit =
|
||||
val channel = s"${redisConfig.prefix}:bot:$botId:events"
|
||||
val payload = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","botAccountId":"$botId"}"""
|
||||
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
|
||||
case scala.util.Failure(ex) => log.warnf(ex, "Failed to publish bot gameStart for game %s", gameId)
|
||||
case scala.util.Success(_) => ()
|
||||
|
||||
def unsubscribeGame(gameId: String): Unit =
|
||||
Option(c2sListeners.remove(gameId)).foreach { subscriber =>
|
||||
subscriber.unsubscribe(c2sTopic(gameId)).subscribe().`with`(_ => (), _ => ())
|
||||
@@ -113,6 +134,7 @@ class GameRedisSubscriberManager:
|
||||
Option(s2cObservers.remove(gameId)).foreach { obs =>
|
||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||
}
|
||||
Option(c2sExecutors.remove(gameId)).foreach(_.shutdownNow())
|
||||
|
||||
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
|
||||
log.debugf("Unsubscribed from game %s", gameId)
|
||||
@@ -187,3 +209,4 @@ class GameRedisSubscriberManager:
|
||||
clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely())
|
||||
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely())
|
||||
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
|
||||
c2sExecutors.forEach((_, executor) => executor.shutdownNow())
|
||||
|
||||
@@ -25,6 +25,7 @@ import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import de.nowchess.security.InternalOnly
|
||||
import jakarta.annotation.security.PermitAll
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
@@ -179,6 +180,32 @@ class GameResource:
|
||||
)
|
||||
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||
|
||||
// Player-facing game creation for "play vs bot". Unlike createGame this is not
|
||||
// internal-only: a logged-in (or anonymous) player creates the game directly,
|
||||
// and core notifies the official-bots service to play the bot side.
|
||||
@POST
|
||||
@Path("/vs-bot")
|
||||
@PermitAll
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createBotGame(body: CreateGameRequestDto): Response =
|
||||
val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
|
||||
val white = playerInfoFrom(req.white, DefaultWhite)
|
||||
val black = playerInfoFrom(req.black, DefaultBlack)
|
||||
val tc = toTimeControl(req.timeControl)
|
||||
val entry = newEntry(GameContext.initial, white, black, tc, GameMode.Open)
|
||||
registry.store(entry)
|
||||
subscriberManager.subscribeGame(entry.gameId)
|
||||
notifyBotSide(entry)
|
||||
log.infof("Bot game %s created — white=%s black=%s", entry.gameId, white.displayName, black.displayName)
|
||||
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||
|
||||
private def notifyBotSide(entry: GameEntry): Unit =
|
||||
if entry.black.id.value.startsWith("bot-") then
|
||||
subscriberManager.publishBotGameStart(entry.gameId, entry.black.id.value, "black")
|
||||
else if entry.white.id.value.startsWith("bot-") then
|
||||
subscriberManager.publishBotGameStart(entry.gameId, entry.white.id.value, "white")
|
||||
|
||||
@GET
|
||||
@Path("/{gameId}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
|
||||
Reference in New Issue
Block a user