feat(tournament): add Swiss-system tournament module
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,32 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8088
|
||||
application:
|
||||
name: nowchess-tournament
|
||||
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
|
||||
arc:
|
||||
exclude-types: de.nowchess.tournament.redis.GameResultStreamListener
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: keys/test-public.pem
|
||||
issuer: nowchess
|
||||
smallrye:
|
||||
jwt:
|
||||
sign:
|
||||
key:
|
||||
location: keys/test-private.pem
|
||||
nowchess:
|
||||
internal:
|
||||
secret: test-secret
|
||||
auth:
|
||||
enabled: false
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
|
||||
wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
|
||||
PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
|
||||
oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
|
||||
jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
|
||||
GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
|
||||
FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
|
||||
ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
|
||||
wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
|
||||
UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
|
||||
3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
|
||||
B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
|
||||
Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
|
||||
GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
|
||||
cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
|
||||
aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
|
||||
ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
|
||||
DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
|
||||
0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
|
||||
TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
|
||||
8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
|
||||
G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
|
||||
clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
|
||||
wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
|
||||
kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
|
||||
5i4ReFwm3/IWDn9kLmHT6Fg=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
|
||||
VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
|
||||
MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
|
||||
WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
|
||||
QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
|
||||
FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
|
||||
BwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.nowchess.tournament.resource
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTestProfile
|
||||
import java.util.Map as JMap
|
||||
|
||||
class H2TestProfile extends QuarkusTestProfile:
|
||||
|
||||
override def getConfigOverrides(): JMap[String, String] =
|
||||
JMap.of(
|
||||
"quarkus.datasource.db-kind", "h2",
|
||||
"quarkus.datasource.jdbc.url", "jdbc:h2:mem:nowchess-tournament;DB_CLOSE_DELAY=-1",
|
||||
"quarkus.datasource.username", "sa",
|
||||
"quarkus.datasource.password", "",
|
||||
"quarkus.hibernate-orm.schema-management.strategy", "drop-and-create",
|
||||
)
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
package de.nowchess.tournament.resource
|
||||
|
||||
import de.nowchess.tournament.client.{CoreGameClient, CoreGameResponse}
|
||||
import io.quarkus.test.InjectMock
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import io.restassured.response.ValidatableResponse
|
||||
import io.smallrye.jwt.build.Jwt
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||
import org.mockito.{ArgumentMatchers, Mockito}
|
||||
|
||||
@QuarkusTest
|
||||
class TournamentResourceTest:
|
||||
|
||||
@InjectMock
|
||||
@RestClient
|
||||
// scalafix:off DisableSyntax.var
|
||||
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@BeforeEach
|
||||
def setup(): Unit =
|
||||
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("game-test-123"))
|
||||
|
||||
private def g() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||
|
||||
private def directorToken(userId: String = "director-1"): String =
|
||||
Jwt.issuer("nowchess").subject(userId).expiresIn(3600).sign()
|
||||
|
||||
private def botToken(botId: String, botName: String): String =
|
||||
Jwt.issuer("nowchess").subject(botId).claim("type", "bot").claim("name", botName).expiresIn(3600).sign()
|
||||
|
||||
private def authed(token: String) =
|
||||
g().header("Authorization", s"Bearer $token")
|
||||
|
||||
private def formAuthed(token: String) =
|
||||
RestAssured.`given`().contentType(ContentType.URLENC).header("Authorization", s"Bearer $token")
|
||||
|
||||
private def createTournament(token: String, name: String = "Test Tour", nbRounds: Int = 3): String =
|
||||
formAuthed(token)
|
||||
.formParam("name", name)
|
||||
.formParam("nbRounds", nbRounds)
|
||||
.formParam("clockLimit", 300)
|
||||
.formParam("clockIncrement", 5)
|
||||
.formParam("rated", true)
|
||||
.when().post("/api/tournament")
|
||||
.`then`().statusCode(201).extract().path[String]("id")
|
||||
|
||||
private def postAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||
authed(token).when().post(path).`then`().statusCode(expectedStatus)
|
||||
|
||||
private def deleteAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||
authed(token).when().delete(path).`then`().statusCode(expectedStatus)
|
||||
|
||||
private def botJoin(tournamentId: String, botId: String, botName: String): ValidatableResponse =
|
||||
val bt = botToken(botId, botName)
|
||||
authed(bt).when().post(s"/api/tournament/$tournamentId/join").`then`().statusCode(200)
|
||||
|
||||
private def startTournament(token: String, tournamentId: String): ValidatableResponse =
|
||||
authed(token).when().post(s"/api/tournament/$tournamentId/start").`then`().statusCode(200)
|
||||
|
||||
@Test
|
||||
def createsTournamentWhenAuthenticated(): Unit =
|
||||
formAuthed(directorToken())
|
||||
.formParam("name", "Test Tour")
|
||||
.formParam("nbRounds", 3)
|
||||
.formParam("clockLimit", 300)
|
||||
.formParam("clockIncrement", 5)
|
||||
.formParam("rated", true)
|
||||
.when().post("/api/tournament")
|
||||
.`then`().statusCode(201)
|
||||
.body("fullName", is("Test Tour"))
|
||||
.body("status", is("created"))
|
||||
|
||||
@Test
|
||||
def returns401WhenUnauthenticated(): Unit =
|
||||
RestAssured.`given`().contentType(ContentType.URLENC)
|
||||
.formParam("name", "Test Tour")
|
||||
.formParam("nbRounds", 3)
|
||||
.formParam("clockLimit", 300)
|
||||
.formParam("clockIncrement", 5)
|
||||
.when().post("/api/tournament")
|
||||
.`then`().statusCode(401)
|
||||
|
||||
@Test
|
||||
def returnsEmptyListsOnFreshStart(): Unit =
|
||||
RestAssured.`given`().when().get("/api/tournament")
|
||||
.`then`().statusCode(200)
|
||||
.body("created", notNullValue())
|
||||
.body("started", notNullValue())
|
||||
.body("finished", notNullValue())
|
||||
|
||||
@Test
|
||||
def returnsCreatedTournamentInCreatedList(): Unit =
|
||||
val id = createTournament(directorToken("director-list"), "ListTour")
|
||||
RestAssured.`given`().when().get("/api/tournament")
|
||||
.`then`().statusCode(200)
|
||||
.body("created.id", hasItem(id))
|
||||
|
||||
@Test
|
||||
def returns404ForUnknownId(): Unit =
|
||||
RestAssured.`given`().when().get("/api/tournament/XXXXXX").`then`().statusCode(404)
|
||||
|
||||
@Test
|
||||
def returnsTournamentWithStandings(): Unit =
|
||||
val id = createTournament(directorToken("dir-get"), "GetTour")
|
||||
RestAssured.`given`().when().get(s"/api/tournament/$id")
|
||||
.`then`().statusCode(200)
|
||||
.body("id", is(id))
|
||||
.body("standing", notNullValue())
|
||||
|
||||
@Test
|
||||
def directorCanTerminateCreatedTournament(): Unit =
|
||||
val token = directorToken("dir-term")
|
||||
val id = createTournament(token, "TermTour")
|
||||
deleteAndCheck(token, s"/api/tournament/$id", 204)
|
||||
|
||||
@Test
|
||||
def nonDirectorGets403OnTerminate(): Unit =
|
||||
val id = createTournament(directorToken("dir-403"), "SecureTour")
|
||||
deleteAndCheck(directorToken("other-user-403"), s"/api/tournament/$id", 403)
|
||||
|
||||
@Test
|
||||
def cannotTerminateStartedTournament(): Unit =
|
||||
val token = directorToken("dir-started")
|
||||
val id = createTournament(token, "StartedTour")
|
||||
botJoin(id, "sbot-1", "StartBot1")
|
||||
botJoin(id, "sbot-2", "StartBot2")
|
||||
startTournament(token, id)
|
||||
deleteAndCheck(token, s"/api/tournament/$id", 409)
|
||||
|
||||
@Test
|
||||
def botJoinsSuccessfully(): Unit =
|
||||
val id = createTournament(directorToken("dir-join"), "JoinTour")
|
||||
authed(botToken("joinbot-1", "JoinBot1"))
|
||||
.when().post(s"/api/tournament/$id/join")
|
||||
.`then`().statusCode(200)
|
||||
.body("ok", is(true))
|
||||
|
||||
@Test
|
||||
def nonBotTokenReturns403OnJoin(): Unit =
|
||||
val id = createTournament(directorToken("dir-nbjoin"), "NbJoinTour")
|
||||
postAndCheck(directorToken("regular-user"), s"/api/tournament/$id/join", 403)
|
||||
|
||||
@Test
|
||||
def alreadyJoinedReturns409(): Unit =
|
||||
val id = createTournament(directorToken("dir-dbl"), "DblJoinTour")
|
||||
val bt = botToken("dblbot-1", "DblBot1")
|
||||
botJoin(id, "dblbot-1", "DblBot1")
|
||||
authed(bt).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||
|
||||
@Test
|
||||
def startedTournamentReturns409OnJoin(): Unit =
|
||||
val token = directorToken("dir-sjoin")
|
||||
val id = createTournament(token, "SjoinTour")
|
||||
botJoin(id, "sjbot-1", "SjBot1")
|
||||
botJoin(id, "sjbot-2", "SjBot2")
|
||||
startTournament(token, id)
|
||||
authed(botToken("sjbot-3", "SjBot3")).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||
|
||||
@Test
|
||||
def joinedBotCanWithdraw(): Unit =
|
||||
val id = createTournament(directorToken("dir-wd"), "WdTour")
|
||||
val bt = botToken("wdbot-1", "WdBot1")
|
||||
botJoin(id, "wdbot-1", "WdBot1")
|
||||
authed(bt).when().post(s"/api/tournament/$id/withdraw")
|
||||
.`then`().statusCode(200)
|
||||
.body("ok", is(true))
|
||||
|
||||
@Test
|
||||
def notJoinedBotReturns409OnWithdraw(): Unit =
|
||||
val id = createTournament(directorToken("dir-wdnj"), "WdnjTour")
|
||||
val bt = botToken("wdnjbot-1", "WdnjBot1")
|
||||
authed(bt).when().post(s"/api/tournament/$id/withdraw").`then`().statusCode(409)
|
||||
|
||||
@Test
|
||||
def directorStartsWith2Bots(): Unit =
|
||||
val token = directorToken("dir-start")
|
||||
val id = createTournament(token, "StartTour2")
|
||||
botJoin(id, "stbot-1", "StBot1")
|
||||
botJoin(id, "stbot-2", "StBot2")
|
||||
postAndCheck(token, s"/api/tournament/$id/start", 200)
|
||||
|
||||
@Test
|
||||
def nonDirectorReturns403OnStart(): Unit =
|
||||
val id = createTournament(directorToken("dir-ndstart"), "NdStartTour")
|
||||
botJoin(id, "ndstbot-1", "NdstBot1")
|
||||
botJoin(id, "ndstbot-2", "NdstBot2")
|
||||
postAndCheck(directorToken("other-ndstart"), s"/api/tournament/$id/start", 403)
|
||||
|
||||
@Test
|
||||
def fewerThan2BotsReturns409OnStart(): Unit =
|
||||
val token = directorToken("dir-1bot")
|
||||
val id = createTournament(token, "1BotTour")
|
||||
botJoin(id, "onebot-1", "OneBot1")
|
||||
postAndCheck(token, s"/api/tournament/$id/start", 409)
|
||||
|
||||
@Test
|
||||
def resultsReturns200WithNdjsonContentType(): Unit =
|
||||
val id = createTournament(directorToken("dir-res"), "ResTour")
|
||||
RestAssured.`given`().when().get(s"/api/tournament/$id/results")
|
||||
.`then`().statusCode(200)
|
||||
.contentType("application/x-ndjson")
|
||||
|
||||
@Test
|
||||
def returnsPairingsForRoundAfterStart(): Unit =
|
||||
val token = directorToken("dir-round")
|
||||
val id = createTournament(token, "RoundTour")
|
||||
botJoin(id, "rndbot-1", "RndBot1")
|
||||
botJoin(id, "rndbot-2", "RndBot2")
|
||||
startTournament(token, id)
|
||||
RestAssured.`given`().when().get(s"/api/tournament/$id/round/1").`then`().statusCode(200)
|
||||
|
||||
@Test
|
||||
def returns404ForUnknownTournamentRound(): Unit =
|
||||
RestAssured.`given`().when().get("/api/tournament/XXXXXX/round/1").`then`().statusCode(404)
|
||||
|
||||
@Test
|
||||
def returnsPgnByDefault(): Unit =
|
||||
val id = createTournament(directorToken("dir-pgn"), "PgnTour")
|
||||
RestAssured.`given`().when().get(s"/api/tournament/$id/export/games").`then`().statusCode(200)
|
||||
|
||||
@Test
|
||||
def returnsNdjsonWhenAcceptApplicationXNdjson(): Unit =
|
||||
val id = createTournament(directorToken("dir-ndjson"), "NdjsonTour")
|
||||
RestAssured.`given`()
|
||||
.header("Accept", "application/x-ndjson")
|
||||
.when().get(s"/api/tournament/$id/export/games")
|
||||
.`then`().statusCode(200)
|
||||
.contentType("application/x-ndjson")
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package de.nowchess.tournament.service
|
||||
|
||||
import de.nowchess.tournament.domain.{TournamentPairing, TournamentParticipant}
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.*
|
||||
|
||||
class SwissPairingServiceTest:
|
||||
|
||||
private def makeParticipant(botId: String, botName: String, points: Double = 0.0, byeCount: Int = 0): TournamentParticipant =
|
||||
val p = new TournamentParticipant()
|
||||
p.botId = botId
|
||||
p.botName = botName
|
||||
p.points = points
|
||||
p.byeCount = byeCount
|
||||
p
|
||||
|
||||
@Test
|
||||
def pairs2PlayersRandomlyAssignsColors(): Unit =
|
||||
val p1 = makeParticipant("b1", "BotOne")
|
||||
val p2 = makeParticipant("b2", "BotTwo")
|
||||
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2), Nil)
|
||||
assertEquals(1, pairs.size)
|
||||
assertTrue(bye.isEmpty)
|
||||
val (white, black) = pairs.head
|
||||
val ids = Set(white.botId, black.botId)
|
||||
assertEquals(Set("b1", "b2"), ids)
|
||||
|
||||
@Test
|
||||
def pairs4PlayersTopVsEachOther(): Unit =
|
||||
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||
val p3 = makeParticipant("b3", "C", points = 1.0)
|
||||
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3, p4), Nil)
|
||||
assertEquals(2, pairs.size)
|
||||
assertTrue(bye.isEmpty)
|
||||
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||
val pair2Ids = Set(pairs(1)._1.botId, pairs(1)._2.botId)
|
||||
assertEquals(Set("b1", "b2"), pair1Ids)
|
||||
assertEquals(Set("b3", "b4"), pair2Ids)
|
||||
|
||||
@Test
|
||||
def oddCountLowestRankedGetsBye(): Unit =
|
||||
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||
val p2 = makeParticipant("b2", "B", points = 1.0)
|
||||
val p3 = makeParticipant("b3", "C", points = 0.0)
|
||||
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||
assertEquals(1, pairs.size)
|
||||
assertTrue(bye.isDefined)
|
||||
assertEquals("b3", bye.get.botId)
|
||||
|
||||
@Test
|
||||
def avoidsRematchSwapsWhenPairAlreadyPlayed(): Unit =
|
||||
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||
val p3 = makeParticipant("b3", "C", points = 1.5)
|
||||
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||
val pastPairing = new TournamentPairing()
|
||||
pastPairing.whiteId = "b1"
|
||||
pastPairing.blackId = "b2"
|
||||
val (pairs, _) = SwissPairingService.computePairings(List(p1, p2, p3, p4), List(pastPairing))
|
||||
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||
assertFalse(pair1Ids == Set("b1", "b2"), "b1 and b2 should not be paired again")
|
||||
|
||||
@Test
|
||||
def playerWithFewerByesGetsTheByeFirst(): Unit =
|
||||
val p1 = makeParticipant("b1", "A", points = 1.0, byeCount = 1)
|
||||
val p2 = makeParticipant("b2", "B", points = 0.5, byeCount = 0)
|
||||
val p3 = makeParticipant("b3", "C", points = 0.0, byeCount = 0)
|
||||
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||
assertEquals(1, pairs.size)
|
||||
assertTrue(bye.isDefined)
|
||||
assertEquals("b3", bye.get.botId)
|
||||
Reference in New Issue
Block a user