Compare commits

...

20 Commits

Author SHA1 Message Date
Lala, Shahd 9f9afd3094 feat: bot vs. player
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 20:22:38 +00:00
Lala, Shahd 3b42aea416 feat: local tour WORKS bots play
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 18:13:34 +00:00
Lala, Shahd a46cff5342 fix: Merge branch 'main' of git.janis-eccarius.de:NowChess/NowChessSystems into feat/NCS-82
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 13:46:59 +00:00
TeamCity 4c5302d525 ci: bump version with Build-111
Build & Test (NowChessSystems) TeamCity build failed
2026-06-04 11:14:24 +00:00
shosho996 a12f979023 fix(official-bots): NCS-70-auto-register official bots with account service (#59)
Co-authored-by: Janis <janis-e@gmx.de>
Reviewed-on: #59
2026-06-04 11:14:24 +00:00
TeamCity d0c09c6039 ci: bump version with Build-110 2026-06-04 11:14:24 +00:00
Janis e4011e7337 fix(store): cap game-writeback stream with MAXLEN trimming (#58)
Add approximate MAXLEN ~1000 to all xadd calls in
GameWritebackStreamListener. Without trimming, ACKed messages
accumulate in the stream indefinitely, wasting Redis memory.

Closes NCS-88
https://knockoutwhist.youtrack.cloud/issue/NCS-88

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #58
2026-06-04 11:14:24 +00:00
Janis 554b56ff1d docs: add automated workflows for defect fixing, user story creation, and issue estimation 2026-06-04 11:14:24 +00:00
TeamCity 38992bfa41 ci: bump version with Build-108 2026-06-04 11:14:24 +00:00
Janis 5949b4cb60 fix(pgn): add SAN disambiguation and check/checkmate suffixes [NCS-42] (#56)
Two bugs in move notation causing PGN import failures in LiChess:

1. Disambiguation: when two pieces of same type can reach same square,
   SAN requires file/rank/full-square prefix (e.g. "Ndf3" not "Nf3").
   Added disambiguate() in PgnExporter and disambiguatePiece() in
   GameEngine, both querying allLegalMoves to find competing pieces.

2. Check/checkmate suffix: "+" and "#" were never appended.
   PgnExporter now threads ctxAfter through moveToAlgebraic and
   calls DefaultRules.isCheck/isCheckmate. GameEngine passes
   PostMoveStatus to translateMoveToNotation for the same result.

Also removes dead notation code in executeMoveBody (result was never
used — not passed to MoveExecutedEvent).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #56
2026-06-04 11:14:24 +00:00
TeamCity 000afb235a ci: bump version with Build-107 2026-06-04 11:14:24 +00:00
Janis Eccarius ad2cb7431c refactor: improve code formatting and readability 2026-06-04 11:14:24 +00:00
Janis Eccarius 4b2f3a256e fix(store): replace null check with Option for stream messages 2026-06-04 11:14:24 +00:00
Janis Eccarius 65ddf55575 ci(native-image): add x86_v2 arch to build matrix
Adds x86-64-v2 microarchitecture as a second build target alongside
default. Images tagged with -x86_v2 suffix; GHA cache scoped per arch.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:14:24 +00:00
TeamCity 4762f6c0c3 ci: bump version with Build-111 2026-06-03 11:50:50 +00:00
shosho996 7117a93376 fix(official-bots): NCS-70-auto-register official bots with account service (#59)
Build & Test (NowChessSystems) TeamCity build finished
Co-authored-by: Janis <janis-e@gmx.de>
Reviewed-on: #59
2026-06-03 13:27:03 +02:00
LQ63 8255731d50 feat(tournament): wire official bots into tournaments via JWT and Redis
Build & Test (NowChessSystems) TeamCity build failed
- Add token field to OfficialBotAccount and generate a non-expiring bot
  JWT on creation so official bots can authenticate
- Expose token in POST /api/account/official-bots response and open the
  endpoint to any authenticated user
- Bridge TournamentService to Redis: publish gameStart events to
  nowchess:bot:<name>:events after each pairing so OfficialBotService
  picks up games automatically

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:06:06 +02:00
LQ63 acd8a26685 fix(ws): revert incompatible handshake.query usage
handshake.query(String) does not exist in the current Quarkus WebSocket API version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 00:05:48 +02:00
Lala, Shahd fd6698c7a7 fix: bot name and id return, websocket
Build & Test (NowChessSystems) TeamCity build failed
2026-05-31 21:28:04 +00:00
LQ63 5c586b9003 feat(tournament): add Swiss-system tournament module
Build & Test (NowChessSystems) TeamCity build failed
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>
2026-05-29 00:46:56 +02:00
46 changed files with 2848 additions and 83 deletions
+5
View File
@@ -48,3 +48,8 @@ graphify-out/
.DS_Store .DS_Store
/jacoco-reporter/.venv/ /jacoco-reporter/.venv/
/.claude/settings.local.json /.claude/settings.local.json
modules/tournament/src/main/resources/keys/dev-public.pem
modules/account/src/main/resources/keys/dev-private.pem
modules/account/src/main/resources/keys/dev-public.pem
modules/core/src/main/resources/keys/dev-public.pem
java_pid2736.hprof
+623
View File
@@ -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"
+34
View File
@@ -348,3 +348,37 @@
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83)) * Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656)) * Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-03)
### Features
* **account:** implement token pair handling for login and refresh endpoints ([9296db8](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/9296db88b7131bbda9b9b0da65c327ef9063ee31))
* add authentication permissions for metrics endpoints in application.yml ([04edd4d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/04edd4d6fd8a63196c36f6d67992832febc9bebb))
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **config:** add H2 database configuration for testing environment ([39c9e49](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/39c9e492cef2515368c074da9406f95e9c0c9e64))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
* **logging:** add DEBUG/INFO/WARN logging across services (NCS-72) ([#41](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/41)) ([804a4bf](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/804a4bf179e3dfb19e2be4390e7e543caf5237c6))
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
* update application.yml with new API root paths and add Micrometer and OpenTelemetry dependencies ([72ce262](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/72ce262bc491f94297700e6002fb5d0812e2cc2a))
### Bug Fixes
* **account:** configure JDBC connection pool size to prevent exhaustion under load ([29072ef](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/29072efbfb1cfa1c3b1a85b4c1a587c971d245f9))
* **auth:** add InternalClientHeadersFactory for custom client headers management ([e279c39](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/e279c39246470156bf11e745ee72204018d4229d))
* NCS-84 More Verbose Logging ([#51](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/51)) ([4ad92ab](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/4ad92ab23698267f8faa59c4e18388d4a0042cca))
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
* **tests:** update token path to accessToken in ChallengeResourceTest ([354db11](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/354db11972342c47a1034303c11bccfb92e60109))
### Reverts
* Revert "feat: add authentication permissions for metrics endpoints in application.yml" ([a298417](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a298417b9e4d68dc73bbf40be63d9484536e9f83))
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
@@ -51,7 +51,7 @@ class BotAccount extends PanacheEntityBase:
@JoinColumn(name = "owner_id", nullable = false) @JoinColumn(name = "owner_id", nullable = false)
var owner: UserAccount = uninitialized var owner: UserAccount = uninitialized
@Column(unique = true, nullable = false, length = 256) @Column(unique = true, nullable = false, length = 1024)
var token: String = uninitialized var token: String = uninitialized
var rating: Int = 1500 var rating: Int = 1500
@@ -75,4 +75,7 @@ class OfficialBotAccount extends PanacheEntityBase:
var rating: Int = 1500 var rating: Int = 1500
var createdAt: Instant = uninitialized var createdAt: Instant = uninitialized
@Column(length = 1024)
var token: String = uninitialized
// scalafix:on // scalafix:on
@@ -46,6 +46,8 @@ case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token:
case class RotatedTokenDto(token: String) case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String) case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String, token: Option[String] = None)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int) case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
case class SyncOfficialBotsRequest(bots: List[String])
@@ -89,6 +89,13 @@ class OfficialBotAccountRepository:
def findAll(): List[OfficialBotAccount] = def findAll(): List[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
def findByName(name: String): Option[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount WHERE name = :name", classOf[OfficialBotAccount])
.setParameter("name", name)
.getResultList
.asScala
.headOption
def persist(bot: OfficialBotAccount): OfficialBotAccount = def persist(bot: OfficialBotAccount): OfficialBotAccount =
em.persist(bot) em.persist(bot)
bot bot
@@ -4,6 +4,7 @@ import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
import de.nowchess.account.dto.* import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService import de.nowchess.account.service.AccountService
import de.nowchess.security.InternalOnly
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -179,6 +180,13 @@ class AccountResource:
createdAt = bot.createdAt.toString, createdAt = bot.createdAt.toString,
) )
@POST
@Path("/official-bots/sync")
@InternalOnly
def syncOfficialBots(req: SyncOfficialBotsRequest): Response =
accountService.syncOfficialBots(req.bots)
Response.noContent().build()
@GET @GET
@Path("/official-bots") @Path("/official-bots")
def getOfficialBots: Response = def getOfficialBots: Response =
@@ -187,11 +195,11 @@ class AccountResource:
@POST @POST
@Path("/official-bots") @Path("/official-bots")
@RolesAllowed(Array("Admin")) @RolesAllowed(Array("**"))
def createOfficialBot(req: CreateBotAccountRequest): Response = def createOfficialBot(req: CreateBotAccountRequest): Response =
accountService.createOfficialBotAccount(req.name) match accountService.createOfficialBotAccount(req.name) match
case Right(bot) => case Right(bot) =>
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build() Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
case Left(error) => case Left(error) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build() Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
@@ -211,3 +219,12 @@ class AccountResource:
rating = bot.rating, rating = bot.rating,
createdAt = bot.createdAt.toString, createdAt = bot.createdAt.toString,
) )
private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountDto =
OfficialBotAccountDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
createdAt = bot.createdAt.toString,
token = Some(bot.token),
)
@@ -153,9 +153,10 @@ class AccountService:
val bot = new BotAccount() val bot = new BotAccount()
bot.name = botName bot.name = botName
bot.owner = owner bot.owner = owner
bot.token = generateBotToken(bot.id) bot.token = UUID.randomUUID().toString
bot.createdAt = Instant.now() bot.createdAt = Instant.now()
botAccountRepository.persist(bot) botAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
log.infof("Bot account %s created for owner %s", botName, ownerId.toString) log.infof("Bot account %s created for owner %s", botName, ownerId.toString)
Right(bot) Right(bot)
@@ -194,7 +195,7 @@ class AccountService:
case Some(bot) => case Some(bot) =>
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized) if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
else else
bot.token = generateBotToken(botId) bot.token = generateBotToken(botId, bot.name)
botAccountRepository.persist(bot) botAccountRepository.persist(bot)
Right(bot) Right(bot)
@@ -204,8 +205,23 @@ class AccountService:
bot.name = botName bot.name = botName
bot.createdAt = Instant.now() bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot) officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
officialBotAccountRepository.persist(bot)
Right(bot) Right(bot)
@Transactional
def syncOfficialBots(botNames: List[String]): Unit =
botNames.foreach { name =>
if officialBotAccountRepository.findByName(name).isEmpty then
val bot = new OfficialBotAccount()
bot.name = name
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
bot.token = generateBotToken(bot.id, bot.name)
officialBotAccountRepository.persist(bot)
log.infof("Auto-registered official bot: %s", name)
}
def getOfficialBotAccounts(): List[OfficialBotAccount] = def getOfficialBotAccounts(): List[OfficialBotAccount] =
officialBotAccountRepository.findAll() officialBotAccountRepository.findAll()
@@ -217,12 +233,13 @@ class AccountService:
officialBotAccountRepository.delete(botId) officialBotAccountRepository.delete(botId)
Right(()) Right(())
private def generateBotToken(botId: UUID): String = private def generateBotToken(botId: UUID, botName: String): String =
Jwt Jwt
.issuer("nowchess") .issuer("nowchess")
.subject(botId.toString) .subject(botId.toString)
.expiresAt(Long.MaxValue) .expiresAt(Long.MaxValue)
.claim("type", "bot") .claim("type", "bot")
.claim("name", botName)
.sign() .sign()
@Transactional @Transactional
@@ -154,3 +154,35 @@ class AccountResourceTest:
.post("/api/account/refresh") .post("/api/account/refresh")
.`then`() .`then`()
.statusCode(401) .statusCode(401)
@Test
def syncOfficialBotsCreatesNewBots(): Unit =
givenRequest()
.body("""{"bots":["sync-easy","sync-hard"]}""")
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
RestAssured
.`given`()
.when()
.get("/api/account/official-bots")
.`then`()
.statusCode(200)
.body("name", hasItems("sync-easy", "sync-hard"))
@Test
def syncOfficialBotsIsIdempotent(): Unit =
val body = """{"bots":["idempotent-bot"]}"""
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
givenRequest()
.body(body)
.when()
.post("/api/account/official-bots/sync")
.`then`()
.statusCode(204)
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=18 MINOR=19
PATCH=0 PATCH=0
@@ -22,7 +22,7 @@ import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
import scala.util.Try import scala.util.Try
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
import java.util.function.Consumer import java.util.function.Consumer
@ApplicationScoped @ApplicationScoped
@@ -46,6 +46,10 @@ class GameRedisSubscriberManager:
private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]() private val c2sListeners = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
private val s2cObservers = new ConcurrentHashMap[String, Observer]() private val s2cObservers = new ConcurrentHashMap[String, Observer]()
// Per-game single-thread executor so c2s messages are handled off the Vert.x
// event loop (handleConnected/handleMove make blocking gRPC + Redis calls) while
// staying ordered per game.
private val c2sExecutors = new ConcurrentHashMap[String, ExecutorService]()
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None private var clockExpireSubscriber: Option[ReactivePubSubCommands.ReactiveRedisSubscriber] = None
@@ -95,7 +99,14 @@ class GameRedisSubscriberManager:
obs.emitInitialWriteback() obs.emitInitialWriteback()
heartbeatServiceOpt.foreach(_.addGameSubscription(gameId)) heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg) val executor = c2sExecutors.computeIfAbsent(gameId, _ => Executors.newSingleThreadExecutor())
val handler: Consumer[String] = msg =>
val task = new Runnable:
def run(): Unit =
try handleC2sMessage(gameId, msg)
catch case ex: Exception => log.warnf(ex, "Error handling c2s message for game %s", gameId)
Try(executor.execute(task))
()
try try
val subscriber = reactiveRedis val subscriber = reactiveRedis
.pubsub(classOf[String]) .pubsub(classOf[String])
@@ -106,6 +117,16 @@ class GameRedisSubscriberManager:
log.debugf("Subscribed to game %s", gameId) log.debugf("Subscribed to game %s", gameId)
catch case ex: Exception => log.warnf(ex, "Redis subscription failed for game %s", gameId) catch case ex: Exception => log.warnf(ex, "Redis subscription failed for game %s", gameId)
// Notify the official-bots service to start playing a side of a game. Mirrors
// the event the tournament service publishes; official-bots subscribes to
// "<prefix>:bot:*:events".
def publishBotGameStart(gameId: String, botId: String, playingAs: String): Unit =
val channel = s"${redisConfig.prefix}:bot:$botId:events"
val payload = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","botAccountId":"$botId"}"""
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
case scala.util.Failure(ex) => log.warnf(ex, "Failed to publish bot gameStart for game %s", gameId)
case scala.util.Success(_) => ()
def unsubscribeGame(gameId: String): Unit = def unsubscribeGame(gameId: String): Unit =
Option(c2sListeners.remove(gameId)).foreach { subscriber => Option(c2sListeners.remove(gameId)).foreach { subscriber =>
subscriber.unsubscribe(c2sTopic(gameId)).subscribe().`with`(_ => (), _ => ()) subscriber.unsubscribe(c2sTopic(gameId)).subscribe().`with`(_ => (), _ => ())
@@ -113,6 +134,7 @@ class GameRedisSubscriberManager:
Option(s2cObservers.remove(gameId)).foreach { obs => Option(s2cObservers.remove(gameId)).foreach { obs =>
registry.get(gameId).foreach(_.engine.unsubscribe(obs)) registry.get(gameId).foreach(_.engine.unsubscribe(obs))
} }
Option(c2sExecutors.remove(gameId)).foreach(_.shutdownNow())
heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId)) heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
log.debugf("Unsubscribed from game %s", gameId) log.debugf("Unsubscribed from game %s", gameId)
@@ -187,3 +209,4 @@ class GameRedisSubscriberManager:
clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely()) clockExpireSubscriber.foreach(_.unsubscribe(clockExpireChannel).await().indefinitely())
c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely()) c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)).await().indefinitely())
s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs))) s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
c2sExecutors.forEach((_, executor) => executor.shutdownNow())
@@ -25,6 +25,7 @@ import de.nowchess.chess.observer.*
import de.nowchess.chess.redis.GameRedisSubscriberManager import de.nowchess.chess.redis.GameRedisSubscriberManager
import de.nowchess.chess.registry.{GameEntry, GameRegistry} import de.nowchess.chess.registry.{GameEntry, GameRegistry}
import de.nowchess.security.InternalOnly import de.nowchess.security.InternalOnly
import jakarta.annotation.security.PermitAll
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -179,6 +180,32 @@ class GameResource:
) )
created(GameDtoMapper.toGameFullDto(entry, ioClient)) created(GameDtoMapper.toGameFullDto(entry, ioClient))
// Player-facing game creation for "play vs bot". Unlike createGame this is not
// internal-only: a logged-in (or anonymous) player creates the game directly,
// and core notifies the official-bots service to play the bot side.
@POST
@Path("/vs-bot")
@PermitAll
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createBotGame(body: CreateGameRequestDto): Response =
val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
val white = playerInfoFrom(req.white, DefaultWhite)
val black = playerInfoFrom(req.black, DefaultBlack)
val tc = toTimeControl(req.timeControl)
val entry = newEntry(GameContext.initial, white, black, tc, GameMode.Open)
registry.store(entry)
subscriberManager.subscribeGame(entry.gameId)
notifyBotSide(entry)
log.infof("Bot game %s created — white=%s black=%s", entry.gameId, white.displayName, black.displayName)
created(GameDtoMapper.toGameFullDto(entry, ioClient))
private def notifyBotSide(entry: GameEntry): Unit =
if entry.black.id.value.startsWith("bot-") then
subscriberManager.publishBotGameStart(entry.gameId, entry.black.id.value, "black")
else if entry.white.id.value.startsWith("bot-") then
subscriberManager.publishBotGameStart(entry.gameId, entry.white.id.value, "white")
@GET @GET
@Path("/{gameId}") @Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
+23
View File
@@ -156,3 +156,26 @@
### Reverts ### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656)) * Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
## (2026-06-03)
### Features
* add initialization metrics for various services ([d438e97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d438e97f32bdde0bfc63c1b4a8cc810cdd093166))
* add OpenTelemetry trace configuration with parentbased sampler ([3904d5a](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3904d5ad8ad4930ddee65287a7bfab785a6148f5))
* **config:** update application.yml for PostgreSQL and remove staging/production configurations ([2404e61](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/2404e6164c3b50ffccbea5238d636060d6abe4d6))
* **config:** update application.yml for staging and production environments ([6113432](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/6113432a14c476a3a0dfc0d449e17d023697f2ba))
* configure logging and add OpenTelemetry support ([#49](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/49)) ([d57c488](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d57c4886612d1d92da0e1b79209fc83e6ef537a1))
* **docker:** add .dockerignore and .gitignore files for build exclusions ([c987d8e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/c987d8e258c0e6c4cfbdaa8381c64c410d7a2b83))
* **docker:** add Dockerfiles for building Quarkus application in native and JVM modes ([3f2d2bb](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3f2d2bb4c97fa8cddba66e1da4427c54236dfeed))
* **docker:** add Dockerfiles for Quarkus application in JVM and native modes ([34b9933](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/34b993304670cf2aa62cd2f6460cee7b9864b08e))
* NCS-78 Add Traceability to the Applications ([#46](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/46)) ([649566e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/649566eb3fcf38f91c8896a739f74ea318af312d))
* NCS-78 Add Traceability to the Applications ([#47](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/47)) ([87dfc6c](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/87dfc6c2bcce7f7d58fc641bd8d468a2e584c108))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
### Bug Fixes
* **official-bots:** NCS-70-auto-register official bots with account service ([#59](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/59)) ([7117a93](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/7117a93376272094d0b1a6abf2121254ce396684))
### Reverts
* Revert "refactor: update metrics paths formatting in application.yml for clarity" ([3870566](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/38705663498d5f47c40dafe2f26198589ede8656))
+2
View File
@@ -77,6 +77,8 @@ dependencies {
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:security"))
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}") implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("io.quarkus:quarkus-redis-client") implementation("io.quarkus:quarkus-redis-client")
@@ -5,6 +5,9 @@ quarkus:
name: nowchess-official-bots name: nowchess-official-bots
redis: redis:
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379} hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
rest-client:
account-service:
url: http://localhost:8083
smallrye-jwt: smallrye-jwt:
enabled: true enabled: true
log: log:
@@ -15,6 +18,8 @@ nowchess:
host: localhost host: localhost
port: 6379 port: 6379
prefix: nowchess prefix: nowchess
internal:
secret: 123abc
"%deployed": "%deployed":
quarkus: quarkus:
@@ -28,8 +33,13 @@ nowchess:
exporter: exporter:
otlp: otlp:
endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317} endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:http://localhost:4317}
rest-client:
account-service:
url: ${ACCOUNT_SERVICE_URL}
nowchess: nowchess:
redis: redis:
host: ${REDIS_HOST:localhost} host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379} port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess} prefix: ${REDIS_PREFIX:nowchess}
internal:
secret: ${INTERNAL_SECRET}
@@ -0,0 +1,20 @@
package de.nowchess.bot.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 SyncOfficialBotsRequest(bots: List[String])
@Path("/api/account/official-bots")
@RegisterRestClient(configKey = "account-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@RegisterClientHeaders(classOf[InternalClientHeadersFactory])
trait AccountServiceClient:
@POST
@Path("/sync")
@Consumes(Array(MediaType.APPLICATION_JSON))
def syncBots(req: SyncOfficialBotsRequest): Unit
@@ -2,112 +2,169 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.BotController import de.nowchess.bot.Bot
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.config.RedisConfig import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import io.micrometer.core.instrument.MeterRegistry import io.micrometer.core.instrument.MeterRegistry
import io.quarkus.redis.datasource.RedisDataSource import io.quarkus.redis.datasource.ReactiveRedisDataSource
import io.quarkus.redis.datasource.pubsub.ReactivePubSubCommands
import io.quarkus.runtime.StartupEvent import io.quarkus.runtime.StartupEvent
import jakarta.annotation.PostConstruct import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes import jakarta.enterprise.event.Observes
import jakarta.inject.Inject import jakarta.inject.Inject
import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.time.Duration
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
import java.util.function.Consumer import java.util.function.Consumer
import java.util.concurrent.TimeUnit
@ApplicationScoped @ApplicationScoped
class OfficialBotService: class OfficialBotService:
private val log = Logger.getLogger(classOf[OfficialBotService])
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized @Inject var reactiveRedis: ReactiveRedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized @Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized @Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized @Inject var meterRegistry: MeterRegistry = uninitialized
@Inject var meterRegistry: MeterRegistry = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val engine: Bot = ClassicalBot(BotDifficulty.Medium)
private val workers: ExecutorService = Executors.newCachedThreadPool()
// gameId -> (turn color -> bot account id we play that color as)
private val watchedGames = new ConcurrentHashMap[String, ConcurrentHashMap[String, String]]()
private val subscribers = new ConcurrentHashMap[String, ReactivePubSubCommands.ReactiveRedisSubscriber]()
private val terminalStatuses = private val terminalStatuses =
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw") Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
@PostConstruct @PostConstruct
def initializeMetrics(): Unit = def initializeMetrics(): Unit =
BotController.listBots.foreach { bot => meterRegistry
meterRegistry.timer("nowchess.bot.move.duration", "bot", bot).record(0L, TimeUnit.MILLISECONDS) .timer("nowchess.bot.move.duration", "bot", engineName)
meterRegistry.counter("nowchess.bot.moves.computed", "bot", bot).increment(0) .record(0L, java.util.concurrent.TimeUnit.MILLISECONDS)
} meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment(0)
// Wildcard subscription: handles gameStart events for *any* bot account,
// whether official (easy/medium/hard/expert) or user-created. Runs at startup
// on a context-bearing thread, so the blocking await is safe here.
def onStart(@Observes event: StartupEvent): Unit = def onStart(@Observes event: StartupEvent): Unit =
BotController.listBots.foreach(subscribeToEventChannel) val pattern = s"${redisConfig.prefix}:bot:*:events"
val handler: Consumer[String] = msg => handleBotEvent(msg)
private def subscribeToEventChannel(botName: String): Unit = reactiveRedis
val handler: Consumer[String] = msg => handleBotEvent(botName, msg) .pubsub(classOf[String])
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler) .subscribeToPattern(pattern, handler)
.await()
.atMost(Duration.ofSeconds(5))
log.infof("Bot engine subscribed to pattern %s", pattern)
() ()
private def handleBotEvent(botName: String, msg: String): Unit = private def handleBotEvent(msg: String): Unit =
try try
val node = objectMapper.readTree(msg) val node = objectMapper.readTree(msg)
if node.path("type").asText() == "gameStart" then if node.path("type").asText() == "gameStart" then
val gameId = node.path("gameId").asText() val gameId = node.path("gameId").asText()
val playingAs = node.path("playingAs").asText() val playingAs = node.path("playingAs").asText()
val difficulty = node.path("difficulty").asInt(1400) val playerId = node.path("botAccountId").asText()
val botAccountId = node.path("botAccountId").asText() if gameId.nonEmpty && playerId.nonEmpty && playingAs.nonEmpty then
watchGame(botName, gameId, playingAs, difficulty, botAccountId) log.infof("gameStart: game=%s playingAs=%s bot=%s", gameId, playingAs, playerId)
catch case _: Exception => () registerColor(gameId, playingAs, playerId)
else log.warnf("gameStart missing gameId/playingAs/botAccountId: %s", msg)
catch case e: Exception => log.errorf(e, "Failed to handle bot event: %s", msg)
private def watchGame( private def registerColor(gameId: String, playingAs: String, playerId: String): Unit =
botName: String, val fresh = new ConcurrentHashMap[String, String]()
gameId: String, val existing = watchedGames.putIfAbsent(gameId, fresh)
playingAs: String, val colors = if existing == null then fresh else existing
difficulty: Int, colors.put(playingAs, playerId)
botAccountId: String, // Only the first color registered for a game subscribes + triggers the position.
): Unit = if existing == null then subscribeAndConnect(gameId)
val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
() ()
private def handleGameEvent( // Called on the Redis event-loop thread (the pattern handler's thread), which
botName: String, // carries a Vert.x context. The subscribe must be issued non-blocking from this
gameId: String, // context — issuing it from a plain worker thread (or awaiting it there) leaves
playingAs: String, // the subscribe reply with "no handler waiting" and the call times out.
difficulty: Int, private def subscribeAndConnect(gameId: String): Unit =
botAccountId: String, val s2c = s"${redisConfig.prefix}:game:$gameId:s2c"
msg: String, val handler: Consumer[String] = msg => handleGameEvent(gameId, msg)
): Unit = val pubsub = reactiveRedis.pubsub(classOf[String])
try val onSubscribed: Consumer[ReactivePubSubCommands.ReactiveRedisSubscriber] = { subscriber =>
val node = objectMapper.readTree(msg) subscribers.put(gameId, subscriber)
val status = node.path("state").path("status").asText("") sendConnected(pubsub, gameId)
if !terminalStatuses.contains(status) then
val turn = node.path("state").path("turn").asText("")
if turn == playingAs then
val fen = node.path("state").path("fen").asText()
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
catch case _: Exception => ()
private def computeAndSendMove(
botName: String,
gameId: String,
fen: String,
difficulty: Int,
botAccountId: String,
): Unit =
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
FenParser.parseFen(fen).toOption.foreach { context =>
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", botName)
val moveOpt = timer.recordCallable[Option[Move]](() => bot(context))
moveOpt.foreach { move =>
meterRegistry.counter("nowchess.bot.moves.computed", "bot", botName).increment()
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
redis.pubsub(classOf[String]).publish(c2sTopic, moveMsg)
()
}
}
} }
pubsub.subscribe(s2c, handler).subscribe().`with`(onSubscribed, logFailure(s"subscribe to game $gameId"))
()
// Ask the server to emit the current position (gameFull) immediately.
private def sendConnected(pubsub: ReactivePubSubCommands[String], gameId: String): Unit =
val c2s = s"${redisConfig.prefix}:game:$gameId:c2s"
val onOk: Consumer[Void] = _ => log.infof("Watching game %s", gameId)
pubsub.publish(c2s, """{"type":"CONNECTED"}""").subscribe().`with`(onOk, logFailure(s"send CONNECTED for game $gameId"))
()
private def handleGameEvent(gameId: String, msg: String): Unit =
try
val node = objectMapper.readTree(msg)
// gameState has state at root; gameFull (CONNECTED reply) nests it under game.state
val stateNode =
if node.path("type").asText() == "gameFull" then node.path("game").path("state")
else node.path("state")
val status = stateNode.path("status").asText("")
val turn = stateNode.path("turn").asText("")
if terminalStatuses.contains(status) then stopWatching(gameId)
else
val colors = watchedGames.get(gameId)
val playerId = if colors == null then null else colors.get(turn)
if playerId != null then
val fen = stateNode.path("fen").asText()
workers.submit(new Runnable { def run(): Unit = computeAndSendMove(gameId, fen, playerId) })
()
catch case e: Exception => log.errorf(e, "Failed to handle game event for %s", gameId)
private def stopWatching(gameId: String): Unit =
watchedGames.remove(gameId)
Option(subscribers.remove(gameId)).foreach { subscriber =>
val noop: Consumer[Void] = _ => ()
subscriber
.unsubscribe(s"${redisConfig.prefix}:game:$gameId:s2c")
.subscribe()
.`with`(noop, logFailure(s"unsubscribe game $gameId"))
}
()
// Runs on a worker thread: the engine search must stay off the event loop. The
// move is published reactively (scheduled onto the Redis event loop), so the
// worker thread's lack of a Vert.x context does not matter for publishing.
private def computeAndSendMove(gameId: String, fen: String, playerId: String): Unit =
FenParser.parseFen(fen) match
case Left(err) => log.warnf("FEN parse failed for game %s: %s", gameId, err.toString)
case Right(context) =>
val timer = meterRegistry.timer("nowchess.bot.move.duration", "bot", engineName)
val moveOpt = timer.recordCallable[Option[Move]](() => engine(context))
moveOpt match
case None => log.warnf("Engine returned no move for game %s fen=%s", gameId, fen)
case Some(move) =>
meterRegistry.counter("nowchess.bot.moves.computed", "bot", engineName).increment()
val uci = toUci(move)
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
val onOk: Consumer[Void] = _ => log.infof("Bot moved %s in game %s", uci, gameId)
reactiveRedis
.pubsub(classOf[String])
.publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
.subscribe()
.`with`(onOk, logFailure(s"publish move in game $gameId"))
()
private def logFailure(what: String): Consumer[Throwable] =
err => log.errorf(err, "Failed to %s", what)
private def engineName: String = "classical"
private def toUci(move: Move): String = private def toUci(move: Move): String =
val base = s"${move.from}${move.to}" val base = s"${move.from}${move.to}"
@@ -0,0 +1,35 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
import scala.util.Try
final case class TournamentBotConfig(
serverUrl: String,
tournamentId: String,
token: String,
botId: String,
difficulty: String,
)
object TournamentBotConfig:
private val mapper = new ObjectMapper()
def fromEnv(env: Map[String, String]): Option[TournamentBotConfig] =
for
tournamentId <- env.get("TOURNAMENT_ID").filter(_.nonEmpty)
token <- env.get("TOURNAMENT_BOT_TOKEN").filter(_.nonEmpty)
botId <- jwtSubject(token)
serverUrl = env.getOrElse("TOURNAMENT_SERVER_URL", "http://localhost:8089")
difficulty = env.getOrElse("TOURNAMENT_BOT_DIFFICULTY", "medium")
yield TournamentBotConfig(serverUrl, tournamentId, token, botId, difficulty)
def jwtSubject(token: String): Option[String] =
Try {
val parts = token.split("\\.")
if parts.length >= 2 then
val payload = new String(java.util.Base64.getUrlDecoder.decode(parts(1)))
val sub = mapper.readTree(payload).path("sub").asText()
Option(sub).filter(_.nonEmpty)
else None
}.toOption.flatten
@@ -0,0 +1,219 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.{JsonNode, ObjectMapper}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.{Bot, BotController}
import de.nowchess.io.fen.FenParser
import io.quarkus.runtime.Startup
import jakarta.annotation.{PostConstruct, PreDestroy}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.client.{Client, ClientBuilder, Entity}
import jakarta.ws.rs.core.MediaType
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
import scala.util.{Failure, Success, Try}
import java.io.{BufferedReader, InputStream, InputStreamReader}
import java.util.concurrent.{ConcurrentHashMap, ExecutorService, Executors}
@Startup
@ApplicationScoped
class TournamentBotGamePlayer:
private val log = Logger.getLogger(classOf[TournamentBotGamePlayer])
// scalafix:off DisableSyntax.var
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized
// scalafix:on DisableSyntax.var
private val client: Client = ClientBuilder.newClient()
private val workers: ExecutorService = Executors.newCachedThreadPool()
private val activeGames = ConcurrentHashMap.newKeySet[String]()
private val config = TournamentBotConfig.fromEnv(System.getenv().asScala.toMap)
// scalafix:off DisableSyntax.var
@volatile private var running = true
// scalafix:on DisableSyntax.var
@PostConstruct
def initialize(): Unit =
config match
case None =>
log.info("Tournament bot disabled — set TOURNAMENT_ID and TOURNAMENT_BOT_TOKEN to enable")
case Some(cfg) =>
log.infof("Tournament bot enabled — server=%s tournament=%s bot=%s", cfg.serverUrl, cfg.tournamentId, cfg.botId)
val thread = new Thread(() => connect(cfg), s"TournamentBot-${cfg.tournamentId}")
thread.setDaemon(true)
thread.start()
@PreDestroy
def cleanup(): Unit =
running = false
workers.shutdownNow()
Try(client.close())
log.info("Tournament bot stopped")
private def connect(cfg: TournamentBotConfig): Unit =
if join(cfg) then
while running do
Try(streamEvents(cfg)) match
case Failure(ex) => log.warnf(ex, "Tournament event stream dropped — reconnecting"); sleep(5000)
case Success(_) => sleep(2000)
private def join(cfg: TournamentBotConfig): Boolean =
Try {
val response = authed(cfg, target(cfg).path("join"))
.post(Entity.entity("", MediaType.APPLICATION_JSON))
val ok = response.getStatus == 200
if ok then log.infof("Joined tournament %s", cfg.tournamentId)
else log.errorf("Failed to join tournament %s — status %d", cfg.tournamentId, response.getStatus)
response.close()
ok
}.getOrElse { log.error("Join request failed"); false }
private def streamEvents(cfg: TournamentBotConfig): Unit =
val response = authed(cfg, target(cfg).path("stream"))
.header("Accept", "application/x-ndjson")
.get()
if response.getStatus != 200 then
log.warnf("Tournament stream returned status %d", response.getStatus)
response.close()
sleep(5000)
else
log.infof("Listening to tournament %s event stream", cfg.tournamentId)
forEachLine(response.readEntity(classOf[InputStream])): line =>
parse(line).foreach: node =>
if node.path("type").asText() == "gameStart" then onGameStart(cfg, node.path("gameId").asText())
private def onGameStart(cfg: TournamentBotConfig, gameId: String): Unit =
if gameId.nonEmpty && activeGames.add(gameId) then
workers.submit(new Runnable { def run(): Unit = playGame(cfg, gameId) })
()
private def playGame(cfg: TournamentBotConfig, gameId: String): Unit =
Try {
colorFor(cfg, gameId) match
case None =>
log.debugf("Game %s is not ours — ignoring", gameId)
activeGames.remove(gameId)
case Some(color) =>
log.infof("Playing game %s as %s", gameId, color)
val stream = openGameStream(cfg, gameId)
maybeMoveFromCurrentState(cfg, gameId, color)
if stream != null then consumeGameStream(cfg, gameId, color, stream)
activeGames.remove(gameId)
} match
case Failure(ex) => log.errorf(ex, "Game %s crashed", gameId); activeGames.remove(gameId)
case Success(_) => ()
private def colorFor(cfg: TournamentBotConfig, gameId: String): Option[String] =
fetchGame(cfg, gameId).flatMap: game =>
val white = game.path("white").path("id").asText()
val black = game.path("black").path("id").asText()
if white == cfg.botId then Some("white")
else if black == cfg.botId then Some("black")
else None
private def maybeMoveFromCurrentState(cfg: TournamentBotConfig, gameId: String, color: String): Unit =
fetchGame(cfg, gameId).foreach: game =>
maybeMove(cfg, gameId, color, game.path("turn").asText(), game.path("status").asText(), game.path("fen").asText())
private def consumeGameStream(cfg: TournamentBotConfig, gameId: String, color: String, stream: InputStream): Unit =
val reader = new BufferedReader(new InputStreamReader(stream))
// scalafix:off DisableSyntax.var
var done = false
var line = reader.readLine()
// scalafix:on DisableSyntax.var
while line != null && running && !done do
parse(line).foreach: node =>
node.path("type").asText() match
case "move" => maybeMove(cfg, gameId, color, node.path("turn").asText(), "ongoing", node.path("fen").asText())
case "gameEnd" => log.infof("Game %s ended — status=%s", gameId, node.path("status").asText()); done = true
case _ => ()
line = reader.readLine()
private def maybeMove(
cfg: TournamentBotConfig,
gameId: String,
color: String,
turn: String,
status: String,
fen: String,
): Unit =
if turn == color && status == "ongoing" && fen.nonEmpty then
computeUci(cfg, fen) match
case None => log.warnf("No move found for game %s (fen=%s)", gameId, fen)
case Some(uci) => submitMove(cfg, gameId, uci)
private def computeUci(cfg: TournamentBotConfig, fen: String): Option[String] =
FenParser.parseFen(fen) match
case Left(err) => log.warnf("FEN parse failed: %s (%s)", fen, err.toString); None
case Right(context) => engine(cfg).apply(context).map(toUci)
private def submitMove(cfg: TournamentBotConfig, gameId: String, uci: String): Unit =
Try {
val response = authed(cfg, target(cfg).path("game").path(gameId).path("move").path(uci))
.post(Entity.entity("", MediaType.APPLICATION_JSON))
if response.getStatus == 200 then log.infof("Played %s in game %s", uci, gameId)
else log.warnf("Move %s rejected in game %s — status %d", uci, gameId, response.getStatus)
response.close()
} match
case Failure(ex) => log.errorf(ex, "Error submitting move %s in game %s", uci, gameId)
case Success(_) => ()
private def fetchGame(cfg: TournamentBotConfig, gameId: String): Option[JsonNode] =
Try {
val response = target(cfg).path("game").path(gameId).request(MediaType.APPLICATION_JSON).get()
val node = if response.getStatus == 200 then Some(response.readEntity(classOf[JsonNode])) else None
response.close()
node
}.getOrElse(None)
private def openGameStream(cfg: TournamentBotConfig, gameId: String): InputStream =
Try {
val response = authed(cfg, target(cfg).path("game").path(gameId).path("stream"))
.header("Accept", "application/x-ndjson")
.get()
if response.getStatus == 200 then response.readEntity(classOf[InputStream])
else { log.warnf("Game stream %s returned status %d", gameId, response.getStatus); response.close(); null }
}.getOrElse(null)
private def engine(cfg: TournamentBotConfig): Bot =
botController.getBot(cfg.difficulty).orElse(botController.getBot("medium")).get
private def target(cfg: TournamentBotConfig) =
client.target(cfg.serverUrl).path("api").path("tournament").path(cfg.tournamentId)
private def authed(cfg: TournamentBotConfig, t: jakarta.ws.rs.client.WebTarget) =
t.request(MediaType.APPLICATION_JSON).header("Authorization", s"Bearer ${cfg.token}")
private def parse(line: String): Option[JsonNode] =
val trimmed = line.trim
if trimmed.isEmpty then None else Try(objectMapper.readTree(trimmed)).toOption
private def forEachLine(stream: InputStream)(handle: String => Unit): Unit =
val reader = new BufferedReader(new InputStreamReader(stream))
// scalafix:off DisableSyntax.var
var line: String = reader.readLine()
// scalafix:on DisableSyntax.var
while line != null && running do
Try(handle(line)).failed.foreach(ex => log.warnf(ex, "Error handling stream line"))
line = reader.readLine()
private def toUci(move: Move): String =
val base = s"${move.from}${move.to}"
move.moveType match
case MoveType.Promotion(piece) => base + promotionChar(piece)
case _ => base
private def promotionChar(piece: PromotionPiece): String =
piece match
case PromotionPiece.Knight => "n"
case PromotionPiece.Bishop => "b"
case PromotionPiece.Rook => "r"
case PromotionPiece.Queen => "q"
private def sleep(ms: Long): Unit = Try(Thread.sleep(ms))
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=13 MINOR=14
PATCH=0 PATCH=0
+120
View File
@@ -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<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
tasks.withType<ScalaCompile> {
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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().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
}
@@ -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
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
WQIDAQAB
-----END PUBLIC KEY-----
@@ -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
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
@@ -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")
@@ -0,0 +1,90 @@
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 =
var running = true
while running 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) if isInterrupted(ex) =>
Thread.currentThread().interrupt()
running = false
case Failure(ex) => log.warnf(ex, "Error in result poll loop")
case Success(_) => ()
private def isInterrupted(ex: Throwable): Boolean =
ex.isInstanceOf[InterruptedException] ||
(ex.getCause != null && ex.getCause.isInstanceOf[InterruptedException])
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(_) => ()
@@ -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)
@@ -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))
@@ -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)
@@ -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()
@@ -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)
@@ -0,0 +1,303 @@
package de.nowchess.tournament.service
import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl}
import de.nowchess.tournament.config.RedisConfig
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 io.quarkus.redis.datasource.RedisDataSource
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
@Inject var redis: RedisDataSource = uninitialized
@Inject var redisConfig: RedisConfig = 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),
Some("Authenticated"),
)
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"}""")
publishBotGameStart(white.botName, resp.gameId, "white", white.botId)
publishBotGameStart(black.botName, resp.gameId, "black", black.botId)
private def publishBotGameStart(
botName: String,
gameId: String,
playingAs: String,
botAccountId: String,
): Unit =
val channel = s"${redisConfig.prefix}:bot:$botName:events"
val payload = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":1500,"botAccountId":"$botAccountId"}"""
Try(redis.pubsub(classOf[String]).publish(channel, payload)) match
case Failure(ex) => log.warnf(ex, "Failed to publish gameStart to bot channel %s", channel)
case Success(_) => ()
@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
}
@@ -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)))
}
@@ -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",
)
@@ -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")
@@ -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)
+1
View File
@@ -26,4 +26,5 @@ include(
"modules:ws", "modules:ws",
"modules:store", "modules:store",
"modules:coordinator", "modules:coordinator",
"modules:tournament",
) )