diff --git a/docs/tournament-openapi.yaml b/docs/tournament-openapi.yaml new file mode 100644 index 0000000..4528e21 --- /dev/null +++ b/docs/tournament-openapi.yaml @@ -0,0 +1,623 @@ +openapi: 3.0.3 +info: + title: NowChess Tournament API + description: | + Swiss-system bot tournaments, modelled after the Lichess API style. + + Game moves flow through the existing board and bot endpoints — this module + handles pairings, standings, and lifecycle only. + + ## Streaming + Endpoints marked **NDJSON** return newline-delimited JSON objects + (`application/x-ndjson`). Each line is one complete JSON object. The + connection stays open until the tournament or round ends. + + ## Bot flow + ``` + POST /api/tournament # create + POST /api/tournament/{id}/join # each bot joins + POST /api/tournament/{id}/start # director starts + + GET /api/tournament/{id}/stream (NDJSON) # open before start + + -- per round -- + receive gameStart { gameId, color } + GET /bot/stream/game/{gameId} (existing, NDJSON) + POST /bot/game/{gameId}/move/{uci} (existing) + -- repeat -- + + GET /api/tournament/{id}/results (NDJSON) # final standings + ``` + version: 1.0.0 + +servers: + - url: https://st.nowchess.janis-eccarius.de + description: Staging + - url: https://nowchess.janis-eccarius.de + description: Production + - url: http://localhost:8086 + description: Local + +security: + - bearerAuth: [] + +tags: + - name: Tournament + description: Tournament lifecycle + - name: Participation + description: Join and withdraw + - name: Results + description: Standings, pairings, and game export + - name: Stream + description: NDJSON event streams + +paths: + + /api/tournament: + get: + tags: [Tournament] + summary: Get current tournaments + description: Returns tournaments grouped by status. No auth required. + security: [] + responses: + "200": + description: Tournaments by status + content: + application/json: + schema: + type: object + properties: + created: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + started: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + finished: + type: array + items: + $ref: "#/components/schemas/TournamentInfo" + + post: + tags: [Tournament] + summary: Create a new tournament + description: The authenticated user becomes the tournament director. + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: "#/components/schemas/CreateTournamentForm" + responses: + "201": + description: Tournament created + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + + /api/tournament/{id}: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Tournament] + summary: Get a tournament + description: Includes the first page of standings in the `standing` field. + security: [] + responses: + "200": + description: Tournament with embedded standings + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "404": + $ref: "#/components/responses/NotFound" + + delete: + tags: [Tournament] + summary: Terminate a tournament + description: Only the director may terminate. Only allowed while status is `created`. + responses: + "204": + description: Terminated + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/start: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Tournament] + summary: Start the tournament + description: | + Only the director may start. Requires at least 2 joined bots. + Computes round 1 pairings and creates games via `POST /api/board/game`. + responses: + "200": + description: Tournament started + content: + application/json: + schema: + $ref: "#/components/schemas/Tournament" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/join: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Participation] + summary: Join a tournament + description: | + Register the authenticated bot for the tournament. Only allowed while + status is `created`. The token subject must be a bot account. + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/withdraw: + parameters: + - $ref: "#/components/parameters/id" + + post: + tags: [Participation] + summary: Withdraw from a tournament + description: Only allowed while status is `created`. + responses: + "200": + description: Ok + content: + application/json: + schema: + $ref: "#/components/schemas/Ok" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" + "404": + $ref: "#/components/responses/NotFound" + "409": + $ref: "#/components/responses/Conflict" + + /api/tournament/{id}/results: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Results] + summary: Get results as NDJSON stream + description: | + Streams one `Result` object per line, sorted by rank ascending. + Available at any point during or after the tournament. + security: [] + parameters: + - name: nb + in: query + description: Max number of results to stream (default all) + schema: + type: integer + minimum: 1 + responses: + "200": + description: NDJSON stream of results + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/Result" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/round/{round}: + parameters: + - $ref: "#/components/parameters/id" + - name: round + in: path + required: true + schema: + type: integer + minimum: 1 + + get: + tags: [Results] + summary: Get pairings for a round + security: [] + responses: + "200": + description: Pairings for the specified round + content: + application/json: + schema: + type: object + properties: + round: + type: integer + example: 2 + pairings: + type: array + items: + $ref: "#/components/schemas/Pairing" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/export/games: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Results] + summary: Export all games + description: | + Returns all games of the tournament. Accepts both PGN and NDJSON via + the `Accept` header. + security: [] + parameters: + - name: Accept + in: header + schema: + type: string + enum: + - application/x-chess-pgn + - application/x-ndjson + default: application/x-chess-pgn + responses: + "200": + description: Games in the requested format + content: + application/x-chess-pgn: + schema: + type: string + description: Standard PGN, one game per block + application/x-ndjson: + schema: + $ref: "#/components/schemas/GameExport" + "404": + $ref: "#/components/responses/NotFound" + + /api/tournament/{id}/stream: + parameters: + - $ref: "#/components/parameters/id" + + get: + tags: [Stream] + summary: Stream tournament events + description: | + NDJSON stream scoped to one tournament. Keep this connection open for + the full tournament lifetime. + + On `gameStart` the bot connects to the existing bot endpoints: + - `GET /bot/stream/game/{gameId}` — stream game state (existing) + - `POST /bot/game/{gameId}/move/{uci}` — submit moves (existing) + responses: + "200": + description: NDJSON event stream + content: + application/x-ndjson: + schema: + $ref: "#/components/schemas/TournamentEvent" + "401": + $ref: "#/components/responses/Unauthorized" + "404": + $ref: "#/components/responses/NotFound" + +components: + + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + parameters: + id: + name: id + in: path + required: true + schema: + type: string + example: t7kXq2 + + schemas: + + Clock: + type: object + required: [limit, increment] + properties: + limit: + type: integer + description: Base time in seconds + example: 300 + increment: + type: integer + description: Increment per move in seconds + example: 3 + + Variant: + type: object + properties: + key: + type: string + example: standard + name: + type: string + example: Standard + + BotRef: + type: object + properties: + id: + type: string + example: bot_abc + name: + type: string + example: StockfishClone + + Standing: + type: object + properties: + page: + type: integer + example: 1 + players: + type: array + items: + $ref: "#/components/schemas/Result" + + TournamentInfo: + description: Lightweight tournament summary used in list responses. + type: object + properties: + id: + type: string + example: t7kXq2 + fullName: + type: string + example: Friday Night Bots Swiss + clock: + $ref: "#/components/schemas/Clock" + variant: + $ref: "#/components/schemas/Variant" + rated: + type: boolean + example: true + nbPlayers: + type: integer + example: 8 + nbRounds: + type: integer + example: 5 + createdBy: + type: string + example: userId + startsAt: + type: string + format: date-time + + Tournament: + allOf: + - $ref: "#/components/schemas/TournamentInfo" + - type: object + properties: + status: + type: string + enum: [created, started, finished] + example: started + round: + type: integer + description: Current round number + example: 2 + standing: + $ref: "#/components/schemas/Standing" + winner: + description: Present only when status is `finished` + allOf: + - $ref: "#/components/schemas/BotRef" + nullable: true + + CreateTournamentForm: + type: object + required: [name, nbRounds, clockLimit, clockIncrement] + properties: + name: + type: string + example: Friday Night Bots + nbRounds: + type: integer + minimum: 1 + example: 5 + clockLimit: + type: integer + description: Base time in seconds + example: 300 + clockIncrement: + type: integer + description: Increment per move in seconds + example: 3 + rated: + type: boolean + default: true + + Result: + type: object + properties: + rank: + type: integer + example: 1 + points: + type: number + format: double + example: 3.5 + tieBreak: + type: number + format: double + description: Buchholz score (sum of opponents' points) + example: 9.0 + bot: + $ref: "#/components/schemas/BotRef" + nbGames: + type: integer + example: 4 + wins: + type: integer + example: 3 + draws: + type: integer + example: 1 + losses: + type: integer + example: 0 + + Pairing: + type: object + properties: + round: + type: integer + example: 2 + white: + $ref: "#/components/schemas/BotRef" + black: + $ref: "#/components/schemas/BotRef" + gameId: + type: string + example: j0nPtcjl + winner: + type: string + enum: [white, black, draw] + nullable: true + description: Null while the game is ongoing + + GameExport: + description: One game object per NDJSON line. + type: object + properties: + id: + type: string + example: j0nPtcjl + round: + type: integer + example: 2 + white: + $ref: "#/components/schemas/BotRef" + black: + $ref: "#/components/schemas/BotRef" + winner: + type: string + enum: [white, black, draw] + nullable: true + moves: + type: string + description: Space-separated UCI moves + example: e2e4 e7e5 g1f3 + + TournamentEvent: + description: | + One JSON object per NDJSON line. Discriminate on `type`. + + | type | extra fields | + |------|-------------| + | `tournamentStarted` | — | + | `roundStarted` | `round` | + | `gameStart` | `round`, `gameId`, `color` | + | `roundFinished` | `round` | + | `tournamentFinished` | `winner` | + type: object + required: [type] + properties: + type: + type: string + enum: + - tournamentStarted + - roundStarted + - gameStart + - roundFinished + - tournamentFinished + round: + type: integer + example: 2 + gameId: + type: string + example: j0nPtcjl + color: + type: string + enum: [white, black] + winner: + $ref: "#/components/schemas/BotRef" + + Ok: + type: object + properties: + ok: + type: boolean + example: true + + Error: + type: object + properties: + error: + type: string + example: tournament already started + + responses: + BadRequest: + description: Invalid request body or parameters + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Unauthorized: + description: Missing or invalid JWT + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Forbidden: + description: Action not permitted for this user or bot + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + NotFound: + description: Tournament not found + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + Conflict: + description: Conflicting state (e.g. already started, bot already joined) + content: + application/json: + schema: + $ref: "#/components/schemas/Error" diff --git a/modules/tournament/build.gradle.kts b/modules/tournament/build.gradle.kts new file mode 100644 index 0000000..c47f4a4 --- /dev/null +++ b/modules/tournament/build.gradle.kts @@ -0,0 +1,120 @@ +plugins { + id("scala") + id("org.scoverage") version "8.1" + id("io.quarkus") +} + +group = "de.nowchess" +version = "1.0-SNAPSHOT" + +@Suppress("UNCHECKED_CAST") +val versions = rootProject.extra["VERSIONS"] as Map + +repositories { + mavenCentral() +} + +scala { + scalaVersion = versions["SCALA3"]!! +} + +scoverage { + scoverageVersion.set(versions["SCOVERAGE"]!!) +} + +tasks.withType { + scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") +} + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +dependencies { + implementation(project(":modules:api")) + implementation(project(":modules:security")) + + runtimeOnly("io.quarkus:quarkus-jdbc-h2") + + compileOnly("org.scala-lang:scala3-compiler_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + implementation("org.scala-lang:scala3-library_3") { + version { + strictly(versions["SCALA3"]!!) + } + } + + implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-rest") + implementation("io.quarkus:quarkus-rest-jackson") + implementation("io.quarkus:quarkus-rest-client-jackson") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-hibernate-orm-panache") + implementation("io.quarkus:quarkus-jdbc-postgresql") + implementation("io.quarkus:quarkus-smallrye-jwt") + implementation("io.quarkus:quarkus-elytron-security-common") + implementation("io.quarkus:quarkus-smallrye-health") + implementation("io.quarkus:quarkus-logging-json") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-opentelemetry") + implementation("io.quarkus:quarkus-smallrye-openapi") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + implementation("io.quarkus:quarkus-redis-client") + + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("io.quarkus:quarkus-smallrye-jwt-build") + testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") + testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.quarkus:quarkus-junit5-mockito") + testImplementation("io.rest-assured:rest-assured") + testImplementation("io.quarkus:quarkus-jdbc-h2") + testImplementation("io.quarkus:quarkus-test-security") + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +configurations.matching { !it.name.startsWith("scoverage") }.configureEach { + resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}") +} +configurations.scoverage { + resolutionStrategy.eachDependency { + if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) { + useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0") + } + } +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + +tasks.test { + useJUnitPlatform { + includeEngines("scalatest", "junit-jupiter") + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = true + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + } + finalizedBy(tasks.reportScoverage) +} +tasks.reportScoverage { + dependsOn(tasks.test) +} + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.INCLUDE +} diff --git a/modules/tournament/src/main/resources/application.yml b/modules/tournament/src/main/resources/application.yml new file mode 100644 index 0000000..e7a0b36 --- /dev/null +++ b/modules/tournament/src/main/resources/application.yml @@ -0,0 +1,59 @@ +quarkus: + http: + port: 8088 + application: + name: nowchess-tournament + redis: + hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379} + rest-client: + core-service: + url: http://localhost:8080 + 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 + smallrye-jwt: + enabled: true + +nowchess: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + prefix: ${REDIS_PREFIX:nowchess} + internal: + secret: ${INTERNAL_SECRET:123abc} + +mp: + jwt: + verify: + publickey: + location: keys/public.pem + issuer: nowchess + +"%deployed": + quarkus: + datasource: + db-kind: postgresql + username: ${DB_USER:nowchess} + password: ${DB_PASSWORD:nowchess} + jdbc: + url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess} + hibernate-orm: + schema-management: + strategy: update + +"%test": + quarkus: + datasource: + 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 diff --git a/modules/tournament/src/main/resources/keys/public.pem b/modules/tournament/src/main/resources/keys/public.pem new file mode 100644 index 0000000..6b6b842 --- /dev/null +++ b/modules/tournament/src/main/resources/keys/public.pem @@ -0,0 +1,9 @@ +-----BEGIN PUBLIC KEY----- +MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ +g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J +Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf +634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH +YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr +7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn +WQIDAQAB +-----END PUBLIC KEY----- diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/client/CoreGameClient.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/client/CoreGameClient.scala new file mode 100644 index 0000000..7c46eef --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/client/CoreGameClient.scala @@ -0,0 +1,28 @@ +package de.nowchess.tournament.client + +import de.nowchess.security.{InternalClientHeadersFactory, InternalSecretClientFilter} +import jakarta.ws.rs.* +import jakarta.ws.rs.core.MediaType +import org.eclipse.microprofile.rest.client.annotation.{RegisterClientHeaders, RegisterProvider} +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient + +case class CorePlayerInfo(id: String, displayName: String) +case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int]) +case class CoreCreateGameRequest( + white: Option[CorePlayerInfo], + black: Option[CorePlayerInfo], + timeControl: Option[CoreTimeControl], + mode: Option[String], +) +case class CoreGameResponse(gameId: String) + +@Path("/api/board/game") +@RegisterRestClient(configKey = "core-service") +@RegisterProvider(classOf[InternalSecretClientFilter]) +@RegisterClientHeaders(classOf[InternalClientHeadersFactory]) +trait CoreGameClient: + + @POST + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def createGame(req: CoreCreateGameRequest): CoreGameResponse diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/config/JacksonConfig.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/config/JacksonConfig.scala new file mode 100644 index 0000000..0498342 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/config/JacksonConfig.scala @@ -0,0 +1,11 @@ +package de.nowchess.tournament.config + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.scala.DefaultScalaModule +import io.quarkus.jackson.ObjectMapperCustomizer +import jakarta.inject.Singleton + +@Singleton +class JacksonConfig extends ObjectMapperCustomizer: + def customize(objectMapper: ObjectMapper): Unit = + objectMapper.registerModule(DefaultScalaModule) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/config/RedisConfig.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/config/RedisConfig.scala new file mode 100644 index 0000000..1539695 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/config/RedisConfig.scala @@ -0,0 +1,12 @@ +package de.nowchess.tournament.config + +import jakarta.enterprise.context.ApplicationScoped +import org.eclipse.microprofile.config.inject.ConfigProperty +import scala.compiletime.uninitialized + +@ApplicationScoped +class RedisConfig: + // scalafix:off DisableSyntax.var + @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess") + var prefix: String = uninitialized + // scalafix:on DisableSyntax.var diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/domain/Tournament.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/Tournament.scala new file mode 100644 index 0000000..5198c8b --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/Tournament.scala @@ -0,0 +1,33 @@ +package de.nowchess.tournament.domain + +import jakarta.persistence.* +import scala.compiletime.uninitialized +import java.time.Instant + +@Entity +@Table(name = "tournaments") +class Tournament: + // scalafix:off DisableSyntax.var + @Id + var id: String = uninitialized + + @Column(nullable = false) + var fullName: String = uninitialized + + var nbRounds: Int = 0 + var clockLimit: Int = 0 + var clockIncrement: Int = 0 + var rated: Boolean = true + + @Column(nullable = false) + var status: String = "created" + + var currentRound: Int = 0 + + @Column(nullable = false) + var createdBy: String = uninitialized + + var startsAt: Instant = uninitialized + var winnerId: String = uninitialized + var winnerName: String = uninitialized + // scalafix:on diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentPairing.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentPairing.scala new file mode 100644 index 0000000..79a7d25 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentPairing.scala @@ -0,0 +1,31 @@ +package de.nowchess.tournament.domain + +import jakarta.persistence.* +import scala.compiletime.uninitialized +import java.util.UUID + +@Entity +@Table(name = "tournament_pairings") +class TournamentPairing: + // scalafix:off DisableSyntax.var + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(nullable = false) + var tournamentId: String = uninitialized + + var round: Int = 0 + var whiteId: String = uninitialized + var whiteName: String = uninitialized + + @Column(nullable = false) + var blackId: String = uninitialized + + @Column(nullable = false) + var blackName: String = uninitialized + + var gameId: String = uninitialized + var winner: String = uninitialized + var moveList: String = uninitialized + // scalafix:on diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentParticipant.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentParticipant.scala new file mode 100644 index 0000000..3b5651a --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/domain/TournamentParticipant.scala @@ -0,0 +1,31 @@ +package de.nowchess.tournament.domain + +import jakarta.persistence.* +import scala.compiletime.uninitialized +import java.util.UUID + +@Entity +@Table(name = "tournament_participants") +class TournamentParticipant: + // scalafix:off DisableSyntax.var + @Id + @GeneratedValue(strategy = GenerationType.UUID) + var id: UUID = uninitialized + + @Column(nullable = false) + var tournamentId: String = uninitialized + + @Column(nullable = false) + var botId: String = uninitialized + + @Column(nullable = false) + var botName: String = uninitialized + + var points: Double = 0.0 + var tieBreak: Double = 0.0 + var nbGames: Int = 0 + var wins: Int = 0 + var draws: Int = 0 + var losses: Int = 0 + var byeCount: Int = 0 + // scalafix:on diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/dto/Dtos.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/dto/Dtos.scala new file mode 100644 index 0000000..1ec6c7f --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/dto/Dtos.scala @@ -0,0 +1,74 @@ +package de.nowchess.tournament.dto + +case class BotRef(id: String, name: String) + +case class Clock(limit: Int, increment: Int) + +case class Variant(key: String, name: String) + +case class CreateTournamentForm( + name: String, + nbRounds: Int, + clockLimit: Int, + clockIncrement: Int, + rated: Boolean = true, +) + +case class ResultDto( + rank: Int, + points: Double, + tieBreak: Double, + bot: BotRef, + nbGames: Int, + wins: Int, + draws: Int, + losses: Int, +) + +case class Standing(page: Int, players: List[ResultDto]) + +case class TournamentDto( + id: String, + fullName: String, + clock: Clock, + variant: Variant, + rated: Boolean, + nbPlayers: Int, + nbRounds: Int, + createdBy: String, + startsAt: Option[String], + status: String, + round: Int, + standing: Standing, + winner: Option[BotRef], +) + +case class TournamentListDto( + created: List[TournamentDto], + started: List[TournamentDto], + finished: List[TournamentDto], +) + +case class PairingDto( + id: String, + round: Int, + white: Option[BotRef], + black: BotRef, + gameId: Option[String], + winner: Option[String], +) + +case class GameExportDto( + id: String, + round: Int, + white: BotRef, + black: BotRef, + winner: Option[String], + moves: String, +) + +case class RoundPairingsDto(round: Int, pairings: List[PairingDto]) + +case class ErrorDto(error: String) + +case class OkDto(ok: Boolean = true) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/error/TournamentError.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/error/TournamentError.scala new file mode 100644 index 0000000..06c2734 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/error/TournamentError.scala @@ -0,0 +1,10 @@ +package de.nowchess.tournament.error + +enum TournamentError(val message: String): + case NotFound(id: String) extends TournamentError(s"Tournament $id not found") + case NotDirector extends TournamentError("Not the tournament director") + case WrongStatus(expected: String) extends TournamentError(s"Tournament must be in $expected status") + case AlreadyJoined extends TournamentError("Already joined this tournament") + case NotJoined extends TournamentError("Not joined this tournament") + case NotEnoughParticipants extends TournamentError("Need at least 2 participants to start") + case NotABot extends TournamentError("Only bot accounts can join tournaments") diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/redis/GameResultStreamListener.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/redis/GameResultStreamListener.scala new file mode 100644 index 0000000..bd77bda --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/redis/GameResultStreamListener.scala @@ -0,0 +1,82 @@ +package de.nowchess.tournament.redis + +import com.fasterxml.jackson.databind.ObjectMapper +import de.nowchess.api.dto.GameWritebackEventDto +import de.nowchess.tournament.config.RedisConfig +import de.nowchess.tournament.service.TournamentService +import io.quarkus.redis.datasource.RedisDataSource +import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs} +import io.quarkus.runtime.Startup +import jakarta.annotation.PostConstruct +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.eclipse.microprofile.context.ManagedExecutor +import org.jboss.logging.Logger +import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* +import scala.util.{Failure, Success, Try} +import java.util.UUID + +@Startup +@ApplicationScoped +class GameResultStreamListener: + // scalafix:off DisableSyntax.var + @Inject var redis: RedisDataSource = uninitialized + @Inject var objectMapper: ObjectMapper = uninitialized + @Inject var tournamentService: TournamentService = uninitialized + @Inject var executor: ManagedExecutor = uninitialized + @Inject var redisConfig: RedisConfig = uninitialized + // scalafix:on + + private val log = Logger.getLogger(classOf[GameResultStreamListener]) + private val groupName = "tournament-result" + private val consumerId = UUID.randomUUID().toString + + private def streamKey = s"${redisConfig.prefix}:game-writeback" + + @PostConstruct + def startListening(): Unit = + createGroupIfAbsent() + executor.submit(new Runnable: + def run(): Unit = pollLoop() + ) + log.infof("Tournament result listener started (consumer=%s)", consumerId) + + private def createGroupIfAbsent(): Unit = + Try(redis.stream(classOf[String]).xgroupCreate(streamKey, groupName, "0", new XGroupCreateArgs().mkstream())) match + case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => () + case Failure(ex) => log.warnf(ex, "Failed to create consumer group") + case Success(_) => () + + private def pollLoop(): Unit = + while true do + Try { + val messages = redis.stream(classOf[String]).xreadgroup( + groupName, + consumerId, + streamKey, + ">", + new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)), + ) + if messages != null then messages.forEach(msg => handleMessage(msg)) + } match + case Failure(ex) => log.warnf(ex, "Error in result poll loop") + case Success(_) => () + + private def handleMessage(msg: StreamMessage[String, String, String]): Unit = + val json = msg.payload().get("data") + Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match + case Failure(ex) => + log.errorf(ex, "Unparseable game result event: %s", json) + ack(msg.id()) + case Success(event) => + if event.result.isDefined then + Try(tournamentService.handleGameResult(event.gameId, event.result.get, event.pgn)) match + case Failure(ex) => log.errorf(ex, "Failed to handle game result for %s", event.gameId) + case Success(_) => () + ack(msg.id()) + + private def ack(id: String): Unit = + Try(redis.stream(classOf[String]).xack(streamKey, groupName, id)) match + case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id) + case Success(_) => () diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/repository/PairingRepository.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/PairingRepository.scala new file mode 100644 index 0000000..31edcd6 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/PairingRepository.scala @@ -0,0 +1,47 @@ +package de.nowchess.tournament.repository + +import de.nowchess.tournament.domain.TournamentPairing +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.persistence.EntityManager +import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* +import java.util.UUID + +@ApplicationScoped +class PairingRepository: + + @Inject + // scalafix:off DisableSyntax.var + var em: EntityManager = uninitialized + // scalafix:on + + def findByTournamentId(tournamentId: String): List[TournamentPairing] = + em.createQuery("FROM TournamentPairing WHERE tournamentId = :tid", classOf[TournamentPairing]) + .setParameter("tid", tournamentId) + .getResultList + .asScala + .toList + + def findByTournamentIdAndRound(tournamentId: String, round: Int): List[TournamentPairing] = + em.createQuery( + "FROM TournamentPairing WHERE tournamentId = :tid AND round = :round", + classOf[TournamentPairing], + ).setParameter("tid", tournamentId) + .setParameter("round", round) + .getResultList + .asScala + .toList + + def findByGameId(gameId: String): Option[TournamentPairing] = + em.createQuery("FROM TournamentPairing WHERE gameId = :gid", classOf[TournamentPairing]) + .setParameter("gid", gameId) + .getResultList + .asScala + .headOption + + def persist(p: TournamentPairing): TournamentPairing = + if p.id == null then + em.persist(p) + p + else em.merge(p) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/repository/ParticipantRepository.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/ParticipantRepository.scala new file mode 100644 index 0000000..d9b6ba6 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/ParticipantRepository.scala @@ -0,0 +1,43 @@ +package de.nowchess.tournament.repository + +import de.nowchess.tournament.domain.TournamentParticipant +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.persistence.EntityManager +import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* +import java.util.UUID + +@ApplicationScoped +class ParticipantRepository: + + @Inject + // scalafix:off DisableSyntax.var + var em: EntityManager = uninitialized + // scalafix:on + + def findByTournamentId(tournamentId: String): List[TournamentParticipant] = + em.createQuery("FROM TournamentParticipant WHERE tournamentId = :tid", classOf[TournamentParticipant]) + .setParameter("tid", tournamentId) + .getResultList + .asScala + .toList + + def findByTournamentIdAndBotId(tournamentId: String, botId: String): Option[TournamentParticipant] = + em.createQuery( + "FROM TournamentParticipant WHERE tournamentId = :tid AND botId = :bid", + classOf[TournamentParticipant], + ).setParameter("tid", tournamentId) + .setParameter("bid", botId) + .getResultList + .asScala + .headOption + + def persist(p: TournamentParticipant): TournamentParticipant = + if p.id == null then + em.persist(p) + p + else em.merge(p) + + def delete(p: TournamentParticipant): Unit = + em.remove(if em.contains(p) then p else em.merge(p)) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/repository/TournamentRepository.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/TournamentRepository.scala new file mode 100644 index 0000000..bd22db7 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/repository/TournamentRepository.scala @@ -0,0 +1,33 @@ +package de.nowchess.tournament.repository + +import de.nowchess.tournament.domain.Tournament +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.persistence.EntityManager +import scala.compiletime.uninitialized +import scala.jdk.CollectionConverters.* + +@ApplicationScoped +class TournamentRepository: + + @Inject + // scalafix:off DisableSyntax.var + var em: EntityManager = uninitialized + // scalafix:on + + def findOptById(id: String): Option[Tournament] = + Option(em.find(classOf[Tournament], id)) + + def findByStatus(status: String): List[Tournament] = + em.createQuery("FROM Tournament WHERE status = :status", classOf[Tournament]) + .setParameter("status", status) + .getResultList + .asScala + .toList + + def persist(t: Tournament): Tournament = + if em.contains(t) then t else em.merge(t) + + def delete(t: Tournament): Unit = + val managed = if em.contains(t) then t else em.merge(t) + em.remove(managed) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala new file mode 100644 index 0000000..a33165d --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/resource/TournamentResource.scala @@ -0,0 +1,184 @@ +package de.nowchess.tournament.resource + +import de.nowchess.tournament.dto.* +import de.nowchess.tournament.error.TournamentError +import de.nowchess.tournament.service.{TournamentService, TournamentStreamManager} +import io.smallrye.mutiny.Multi +import jakarta.annotation.security.{PermitAll, RolesAllowed} +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.ws.rs.* +import jakarta.ws.rs.core.{Context, HttpHeaders, MediaType, Response} +import org.eclipse.microprofile.jwt.JsonWebToken +import org.jboss.logging.Logger +import scala.compiletime.uninitialized + +@Path("/api/tournament") +@ApplicationScoped +@Produces(Array(MediaType.APPLICATION_JSON)) +@Consumes(Array(MediaType.APPLICATION_JSON)) +class TournamentResource: + + private val log = Logger.getLogger(classOf[TournamentResource]) + + // scalafix:off DisableSyntax.var + @Inject var tournamentService: TournamentService = uninitialized + @Inject var streamManager: TournamentStreamManager = uninitialized + @Inject var jwt: JsonWebToken = uninitialized + // scalafix:on + + @GET + @PermitAll + def list(): Response = + val (created, started, finished) = tournamentService.list() + val dto = TournamentListDto( + created = created.map(t => tournamentService.toDto(t)), + started = started.map(t => tournamentService.toDto(t)), + finished = finished.map(t => tournamentService.toDto(t)), + ) + Response.ok(dto).build() + + @POST + @RolesAllowed(Array("**")) + @Consumes(Array(MediaType.APPLICATION_FORM_URLENCODED)) + def create( + @FormParam("name") name: String, + @FormParam("nbRounds") nbRounds: Int, + @FormParam("clockLimit") clockLimit: Int, + @FormParam("clockIncrement") clockIncrement: Int, + @FormParam("rated") @DefaultValue("true") rated: Boolean, + ): Response = + val userId = Option(jwt.getSubject).getOrElse("") + val form = CreateTournamentForm(name, nbRounds, clockLimit, clockIncrement, rated) + val t = tournamentService.create(userId, form) + Response.status(Response.Status.CREATED).entity(tournamentService.toDto(t)).build() + + @GET + @Path("/{id}") + @PermitAll + def get(@PathParam("id") id: String): Response = + tournamentService.get(id) match + case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() + case Some(t) => + val standings = tournamentService.getStandings(id) + Response.ok(tournamentService.toDto(t, standings)).build() + + @DELETE + @Path("/{id}") + @RolesAllowed(Array("**")) + def terminate(@PathParam("id") id: String): Response = + val userId = Option(jwt.getSubject).getOrElse("") + tournamentService.terminate(id, userId) match + case Right(_) => Response.noContent().build() + case Left(error) => errorResponse(error) + + @POST + @Path("/{id}/start") + @RolesAllowed(Array("**")) + def start(@PathParam("id") id: String): Response = + val userId = Option(jwt.getSubject).getOrElse("") + tournamentService.start(id, userId) match + case Right(t) => Response.ok(tournamentService.toDto(t)).build() + case Left(error) => errorResponse(error) + + @POST + @Path("/{id}/join") + @RolesAllowed(Array("**")) + def join(@PathParam("id") id: String): Response = + val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("") + if tokenType != "bot" then + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can join tournaments")).build() + else + val botId = Option(jwt.getSubject).getOrElse("") + val botName = Option(jwt.getClaim[AnyRef]("name")).map(_.toString).getOrElse(botId) + tournamentService.join(id, botId, botName) match + case Right(_) => Response.ok(OkDto()).build() + case Left(error) => errorResponse(error) + + @POST + @Path("/{id}/withdraw") + @RolesAllowed(Array("**")) + def withdraw(@PathParam("id") id: String): Response = + val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("") + if tokenType != "bot" then + Response.status(Response.Status.FORBIDDEN).entity(ErrorDto("Only bots can withdraw")).build() + else + val botId = Option(jwt.getSubject).getOrElse("") + tournamentService.withdraw(id, botId) match + case Right(_) => Response.ok(OkDto()).build() + case Left(error) => errorResponse(error) + + @GET + @Path("/{id}/results") + @Produces(Array("application/x-ndjson")) + @PermitAll + def results( + @PathParam("id") id: String, + @QueryParam("nb") @DefaultValue("100") nb: Int, + ): Response = + tournamentService.get(id) match + case None => Response.status(Response.Status.NOT_FOUND).entity("").build() + case Some(_) => + val ndjson = tournamentService.getResults(id).take(nb).map { r => + s"""{"rank":${r.rank},"points":${r.points},"tieBreak":${r.tieBreak},"bot":{"id":"${r.bot.id}","name":"${r.bot.name}"},"nbGames":${r.nbGames},"wins":${r.wins},"draws":${r.draws},"losses":${r.losses}}""" + }.mkString("\n") + Response.ok(ndjson).`type`("application/x-ndjson").build() + + @GET + @Path("/{id}/round/{round}") + @PermitAll + def roundPairings(@PathParam("id") id: String, @PathParam("round") round: Int): Response = + tournamentService.get(id) match + case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() + case Some(_) => + val pairings = tournamentService.getPairings(id, round) + Response.ok(RoundPairingsDto(round, pairings)).build() + + @GET + @Path("/{id}/export/games") + @PermitAll + @Produces(Array(MediaType.APPLICATION_JSON, MediaType.WILDCARD, "application/x-ndjson", "application/x-chess-pgn")) + def exportGames(@PathParam("id") id: String, @Context headers: HttpHeaders): Response = + tournamentService.get(id) match + case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Tournament $id not found")).build() + case Some(_) => + val acceptHeader = Option(headers.getHeaderString("Accept")).getOrElse("") + val pairings = tournamentService.getAllPairings(id) + if acceptHeader.contains("application/x-ndjson") then + val ndjson = pairings + .filter(p => Option(p.whiteId).isDefined && Option(p.gameId).isDefined) + .map { p => + val winner = Option(p.winner).map(w => s""""$w"""").getOrElse("null") + val moves = Option(p.moveList).getOrElse("") + s"""{"id":"${p.gameId}","round":${p.round},"white":{"id":"${p.whiteId}","name":"${p.whiteName}"},"black":{"id":"${p.blackId}","name":"${p.blackName}"},"winner":$winner,"moves":"$moves"}""" + } + .mkString("\n") + Response.ok(ndjson).`type`("application/x-ndjson").build() + else + val pgn = pairings.flatMap(p => Option(p.moveList)).mkString("\n\n") + Response.ok(pgn).`type`("application/x-chess-pgn").build() + + @GET + @Path("/{id}/stream") + @RolesAllowed(Array("**")) + @Produces(Array("application/x-ndjson")) + def stream(@PathParam("id") id: String): Multi[String] = + tournamentService.get(id) match + case None => Multi.createFrom().failure(new NotFoundException(s"Tournament $id not found")) + case Some(_) => + val botId = Option(jwt.getSubject).getOrElse("") + Multi.createFrom().emitter[String] { emitter => + streamManager.register(id, botId, emitter) + emitter.onTermination(() => streamManager.unregister(id, botId, emitter)) + } + + private def errorResponse(error: TournamentError): Response = + val status = error match + case TournamentError.NotFound(_) => Response.Status.NOT_FOUND + case TournamentError.NotDirector => Response.Status.FORBIDDEN + case TournamentError.NotABot => Response.Status.FORBIDDEN + case TournamentError.WrongStatus(_) => Response.Status.CONFLICT + case TournamentError.AlreadyJoined => Response.Status.CONFLICT + case TournamentError.NotJoined => Response.Status.CONFLICT + case TournamentError.NotEnoughParticipants => Response.Status.CONFLICT + Response.status(status).entity(ErrorDto(error.message)).build() diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/SwissPairingService.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/SwissPairingService.scala new file mode 100644 index 0000000..cf2fa01 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/SwissPairingService.scala @@ -0,0 +1,68 @@ +package de.nowchess.tournament.service + +import de.nowchess.tournament.domain.{TournamentParticipant, TournamentPairing} +import java.util.concurrent.ThreadLocalRandom + +object SwissPairingService: + + def computePairings( + participants: List[TournamentParticipant], + pastPairings: List[TournamentPairing], + ): (List[(TournamentParticipant, TournamentParticipant)], Option[TournamentParticipant]) = + val sorted = sortParticipants(participants) + val (remaining, byeOpt) = extractByePlayer(sorted) + val pairs = buildPairs(remaining, pastPairings) + (pairs, byeOpt) + + private def sortParticipants(participants: List[TournamentParticipant]): List[TournamentParticipant] = + participants.sortWith { (a, b) => + if a.points != b.points then a.points > b.points + else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak + else a.botName < b.botName + } + + private def extractByePlayer( + sorted: List[TournamentParticipant], + ): (List[TournamentParticipant], Option[TournamentParticipant]) = + if sorted.size % 2 == 0 then (sorted, None) + else + val minByes = sorted.map(_.byeCount).min + val byeIndex = sorted.lastIndexWhere(_.byeCount == minByes) + val bye = sorted(byeIndex) + (sorted.filterNot(_ eq bye), Some(bye)) + + private def buildPairs( + players: List[TournamentParticipant], + pastPairings: List[TournamentPairing], + ): List[(TournamentParticipant, TournamentParticipant)] = + val arr = players.toArray + resolveConflicts(arr, pastPairings) + arr.grouped(2).flatMap { + case Array(a, b) => Some(assignColors(a, b)) + case _ => None + }.toList + + private def resolveConflicts(arr: Array[TournamentParticipant], pastPairings: List[TournamentPairing]): Unit = + var i = 0 + while i < arr.length - 1 do + if havePlayedBefore(arr(i), arr(i + 1), pastPairings) && i + 2 < arr.length then + val tmp = arr(i + 1) + arr(i + 1) = arr(i + 2) + arr(i + 2) = tmp + i += 2 + + private def havePlayedBefore( + a: TournamentParticipant, + b: TournamentParticipant, + pastPairings: List[TournamentPairing], + ): Boolean = + pastPairings.exists(p => + (p.whiteId == a.botId && p.blackId == b.botId) || + (p.whiteId == b.botId && p.blackId == a.botId), + ) + + private def assignColors( + a: TournamentParticipant, + b: TournamentParticipant, + ): (TournamentParticipant, TournamentParticipant) = + if ThreadLocalRandom.current().nextBoolean() then (a, b) else (b, a) diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentService.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentService.scala new file mode 100644 index 0000000..3112214 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentService.scala @@ -0,0 +1,284 @@ +package de.nowchess.tournament.service + +import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl} +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 jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import jakarta.transaction.Transactional +import org.eclipse.microprofile.rest.client.inject.RestClient +import org.jboss.logging.Logger +import scala.compiletime.uninitialized +import scala.util.{Failure, Success, Try} +import java.time.Instant + +@ApplicationScoped +class TournamentService: + + private val log = Logger.getLogger(classOf[TournamentService]) + + // scalafix:off DisableSyntax.var + @Inject var tournamentRepository: TournamentRepository = uninitialized + @Inject var participantRepository: ParticipantRepository = uninitialized + @Inject var pairingRepository: PairingRepository = uninitialized + @Inject var streamManager: TournamentStreamManager = uninitialized + + @Inject + @RestClient + var coreGameClient: CoreGameClient = uninitialized + // scalafix:on + + @Transactional + def create(createdBy: String, form: CreateTournamentForm): Tournament = + val t = new Tournament() + t.id = scala.util.Random.alphanumeric.take(6).mkString + t.fullName = form.name + t.nbRounds = form.nbRounds + t.clockLimit = form.clockLimit + t.clockIncrement = form.clockIncrement + t.rated = form.rated + t.status = "created" + t.currentRound = 0 + t.createdBy = createdBy + tournamentRepository.persist(t) + t + + def get(id: String): Option[Tournament] = + tournamentRepository.findOptById(id) + + def list(): (List[Tournament], List[Tournament], List[Tournament]) = + ( + tournamentRepository.findByStatus("created"), + tournamentRepository.findByStatus("started"), + tournamentRepository.findByStatus("finished"), + ) + + @Transactional + def terminate(id: String, userId: String): Either[TournamentError, Unit] = + for + t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id)) + _ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector) + _ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created")) + yield + tournamentRepository.delete(t) + + @Transactional + def join(id: String, botId: String, botName: String): Either[TournamentError, Unit] = + for + t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id)) + _ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created")) + _ <- Either.cond( + participantRepository.findByTournamentIdAndBotId(id, botId).isEmpty, + (), + TournamentError.AlreadyJoined, + ) + yield + val p = new TournamentParticipant() + p.tournamentId = id + p.botId = botId + p.botName = botName + participantRepository.persist(p) + + @Transactional + def withdraw(id: String, botId: String): Either[TournamentError, Unit] = + for + t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id)) + _ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created")) + p <- participantRepository.findByTournamentIdAndBotId(id, botId).toRight(TournamentError.NotJoined) + yield participantRepository.delete(p) + + @Transactional + def start(id: String, userId: String): Either[TournamentError, Tournament] = + for + t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id)) + _ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector) + _ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created")) + participants <- validateMinParticipants(id) + yield + t.status = "started" + t.currentRound = 1 + t.startsAt = Instant.now() + tournamentRepository.persist(t) + streamManager.publish(t.id, """{"type":"tournamentStarted"}""") + startRound(t, 1, participants) + t + + private def validateMinParticipants(id: String): Either[TournamentError, List[TournamentParticipant]] = + val ps = participantRepository.findByTournamentId(id) + Either.cond(ps.size >= 2, ps, TournamentError.NotEnoughParticipants) + + private def startRound(t: Tournament, round: Int, participants: List[TournamentParticipant]): Unit = + val pastPairings = pairingRepository.findByTournamentId(t.id) + val (pairs, byeOpt) = SwissPairingService.computePairings(participants, pastPairings) + byeOpt.foreach(bye => createByePairing(t.id, round, bye)) + pairs.foreach { case (white, black) => createRealPairing(t.id, round, white, black, t) } + streamManager.publish(t.id, s"""{"type":"roundStarted","round":$round}""") + + private def createByePairing(tournamentId: String, round: Int, bye: TournamentParticipant): Unit = + val pairing = new TournamentPairing() + pairing.tournamentId = tournamentId + pairing.round = round + pairing.blackId = bye.botId + pairing.blackName = bye.botName + pairing.winner = "bye" + pairingRepository.persist(pairing) + bye.points += 0.5 + bye.byeCount += 1 + participantRepository.persist(bye) + + private def createRealPairing( + tournamentId: String, + round: Int, + white: TournamentParticipant, + black: TournamentParticipant, + t: Tournament, + ): Unit = + val tc = CoreTimeControl(Some(t.clockLimit), Some(t.clockIncrement), None) + val req = CoreCreateGameRequest( + Some(CorePlayerInfo(white.botId, white.botName)), + Some(CorePlayerInfo(black.botId, black.botName)), + Some(tc), + if t.rated then Some("Rated") else Some("Casual"), + ) + Try(coreGameClient.createGame(req)) match + case Failure(ex) => log.errorf(ex, "Failed to create game for round %d in tournament %s", round, tournamentId) + case Success(resp) => + val pairing = new TournamentPairing() + pairing.tournamentId = tournamentId + pairing.round = round + pairing.whiteId = white.botId + pairing.whiteName = white.botName + pairing.blackId = black.botId + pairing.blackName = black.botName + pairing.gameId = resp.gameId + 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"}""") + + @Transactional + def handleGameResult(gameId: String, result: String, pgn: String): Unit = + pairingRepository.findByGameId(gameId).foreach { pairing => + val (winnerStr, wPts, bPts) = parseResult(result) + pairing.winner = winnerStr + pairing.moveList = pgn + pairingRepository.persist(pairing) + updateParticipantStats(pairing, wPts, bPts) + checkRoundCompletion(pairing.tournamentId) + } + + private def parseResult(result: String): (String, Double, Double) = result match + case "1-0" => ("white", 1.0, 0.0) + case "0-1" => ("black", 0.0, 1.0) + case "1/2-1/2" => ("draw", 0.5, 0.5) + case _ => ("draw", 0.5, 0.5) + + private def updateParticipantStats(pairing: TournamentPairing, wPts: Double, bPts: Double): Unit = + Option(pairing.whiteId).foreach { wId => + participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, wId).foreach { p => + p.points += wPts + p.nbGames += 1 + if wPts == 1.0 then p.wins += 1 else if wPts == 0.5 then p.draws += 1 else p.losses += 1 + participantRepository.persist(p) + } + } + participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, pairing.blackId).foreach { p => + p.points += bPts + p.nbGames += 1 + if bPts == 1.0 then p.wins += 1 else if bPts == 0.5 then p.draws += 1 else p.losses += 1 + participantRepository.persist(p) + } + + private def checkRoundCompletion(tournamentId: String): Unit = + tournamentRepository.findOptById(tournamentId).foreach { t => + val roundPairings = pairingRepository.findByTournamentIdAndRound(tournamentId, t.currentRound) + val allDone = roundPairings.nonEmpty && roundPairings.forall(p => Option(p.winner).isDefined) + if allDone then onRoundComplete(t) + } + + private def onRoundComplete(t: Tournament): Unit = + streamManager.publish(t.id, s"""{"type":"roundFinished","round":${t.currentRound}}""") + recomputeBuchholz(t.id) + if t.currentRound >= t.nbRounds then finishTournament(t) + else + t.currentRound += 1 + tournamentRepository.persist(t) + val participants = participantRepository.findByTournamentId(t.id) + startRound(t, t.currentRound, participants) + + private def finishTournament(t: Tournament): Unit = + val participants = sortedStandings(participantRepository.findByTournamentId(t.id)) + participants.headOption.foreach { winner => + t.winnerId = winner.botId + t.winnerName = winner.botName + } + t.status = "finished" + tournamentRepository.persist(t) + val winnerInfo = participants.headOption + .map(w => s"""{"id":"${w.botId}","name":"${w.botName}"}""") + .getOrElse("null") + streamManager.publish(t.id, s"""{"type":"tournamentFinished","winner":$winnerInfo}""") + + private def recomputeBuchholz(tournamentId: String): Unit = + val participants = participantRepository.findByTournamentId(tournamentId) + val pairings = pairingRepository.findByTournamentId(tournamentId) + val pointsById = participants.map(p => p.botId -> p.points).toMap + participants.foreach { p => + val opponentIds = pairings.flatMap(pair => + if pair.whiteId == p.botId then Some(pair.blackId) + else if pair.blackId == p.botId && Option(pair.whiteId).isDefined then Some(pair.whiteId) + else None, + ) + p.tieBreak = opponentIds.flatMap(id => pointsById.get(id)).sum + participantRepository.persist(p) + } + + def getStandings(tournamentId: String): List[ResultDto] = + val participants = sortedStandings(participantRepository.findByTournamentId(tournamentId)) + participants.zipWithIndex.map { case (p, idx) => + ResultDto(idx + 1, p.points, p.tieBreak, BotRef(p.botId, p.botName), p.nbGames, p.wins, p.draws, p.losses) + } + + def getPairings(tournamentId: String, round: Int): List[PairingDto] = + pairingRepository.findByTournamentIdAndRound(tournamentId, round).map(toPairingDto) + + def getAllPairings(tournamentId: String): List[TournamentPairing] = + pairingRepository.findByTournamentId(tournamentId) + + def getResults(tournamentId: String): List[ResultDto] = getStandings(tournamentId) + + def toDto(t: Tournament, standings: List[ResultDto] = Nil): TournamentDto = + val participants = participantRepository.findByTournamentId(t.id) + TournamentDto( + id = t.id, + fullName = t.fullName, + clock = Clock(t.clockLimit, t.clockIncrement), + variant = Variant("standard", "Standard"), + rated = t.rated, + nbPlayers = participants.size, + nbRounds = t.nbRounds, + createdBy = t.createdBy, + startsAt = Option(t.startsAt).map(_.toString), + status = t.status, + round = t.currentRound, + standing = Standing(1, standings), + winner = if t.winnerId != null then Some(BotRef(t.winnerId, t.winnerName)) else None, + ) + + private def toPairingDto(p: TournamentPairing): PairingDto = + PairingDto( + id = p.id.toString, + round = p.round, + white = Option(p.whiteId).map(id => BotRef(id, p.whiteName)), + black = BotRef(p.blackId, p.blackName), + gameId = Option(p.gameId), + winner = Option(p.winner), + ) + + private def sortedStandings(participants: List[TournamentParticipant]): List[TournamentParticipant] = + participants.sortWith { (a, b) => + if a.points != b.points then a.points > b.points + else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak + else a.botName < b.botName + } diff --git a/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentStreamManager.scala b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentStreamManager.scala new file mode 100644 index 0000000..8204d44 --- /dev/null +++ b/modules/tournament/src/main/scala/de/nowchess/tournament/service/TournamentStreamManager.scala @@ -0,0 +1,32 @@ +package de.nowchess.tournament.service + +import io.smallrye.mutiny.subscription.MultiEmitter +import jakarta.enterprise.context.ApplicationScoped +import java.util.concurrent.{ConcurrentHashMap, CopyOnWriteArrayList} +import scala.jdk.CollectionConverters.* + +@ApplicationScoped +class TournamentStreamManager: + + private val tournamentEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]() + private val botEmitters = new ConcurrentHashMap[String, CopyOnWriteArrayList[MultiEmitter[? >: String]]]() + + private def botKey(tournamentId: String, botId: String): String = s"${tournamentId}:${botId}" + + def register(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit = + tournamentEmitters.computeIfAbsent(tournamentId, _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]()).add(emitter) + botEmitters.computeIfAbsent(botKey(tournamentId, botId), _ => new CopyOnWriteArrayList[MultiEmitter[? >: String]]()).add(emitter) + + def unregister(tournamentId: String, botId: String, emitter: MultiEmitter[? >: String]): Unit = + Option(tournamentEmitters.get(tournamentId)).foreach(_.remove(emitter)) + Option(botEmitters.get(botKey(tournamentId, botId))).foreach(_.remove(emitter)) + + def publish(tournamentId: String, eventJson: String): Unit = + Option(tournamentEmitters.get(tournamentId)).foreach { list => + list.asScala.foreach(e => scala.util.Try(e.emit(eventJson))) + } + + def publishToBot(tournamentId: String, botId: String, eventJson: String): Unit = + Option(botEmitters.get(botKey(tournamentId, botId))).foreach { list => + list.asScala.foreach(e => scala.util.Try(e.emit(eventJson))) + } diff --git a/modules/tournament/src/test/resources/application.yml b/modules/tournament/src/test/resources/application.yml new file mode 100644 index 0000000..64d5041 --- /dev/null +++ b/modules/tournament/src/test/resources/application.yml @@ -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 diff --git a/modules/tournament/src/test/resources/keys/test-private.pem b/modules/tournament/src/test/resources/keys/test-private.pem new file mode 100644 index 0000000..ad772e8 --- /dev/null +++ b/modules/tournament/src/test/resources/keys/test-private.pem @@ -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----- diff --git a/modules/tournament/src/test/resources/keys/test-public.pem b/modules/tournament/src/test/resources/keys/test-public.pem new file mode 100644 index 0000000..a5b79c0 --- /dev/null +++ b/modules/tournament/src/test/resources/keys/test-public.pem @@ -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----- diff --git a/modules/tournament/src/test/scala/de/nowchess/tournament/resource/H2TestProfile.scala b/modules/tournament/src/test/scala/de/nowchess/tournament/resource/H2TestProfile.scala new file mode 100644 index 0000000..951ea49 --- /dev/null +++ b/modules/tournament/src/test/scala/de/nowchess/tournament/resource/H2TestProfile.scala @@ -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", + ) diff --git a/modules/tournament/src/test/scala/de/nowchess/tournament/resource/TournamentResourceTest.scala b/modules/tournament/src/test/scala/de/nowchess/tournament/resource/TournamentResourceTest.scala new file mode 100644 index 0000000..de159db --- /dev/null +++ b/modules/tournament/src/test/scala/de/nowchess/tournament/resource/TournamentResourceTest.scala @@ -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") diff --git a/modules/tournament/src/test/scala/de/nowchess/tournament/service/SwissPairingServiceTest.scala b/modules/tournament/src/test/scala/de/nowchess/tournament/service/SwissPairingServiceTest.scala new file mode 100644 index 0000000..9b03166 --- /dev/null +++ b/modules/tournament/src/test/scala/de/nowchess/tournament/service/SwissPairingServiceTest.scala @@ -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) diff --git a/settings.gradle.kts b/settings.gradle.kts index 5912991..7a018a5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -26,4 +26,5 @@ include( "modules:ws", "modules:store", "modules:coordinator", + "modules:tournament", ) \ No newline at end of file