Compare commits

...

17 Commits

Author SHA1 Message Date
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
Janis ae3ef766e8 feat(redis): implement game writeback stream processing with error handling and retries
Build & Test (NowChessSystems) TeamCity build failed
2026-05-22 12:37:39 +02:00
TeamCity 487711628f ci: bump version with Build-106 2026-05-22 10:00:20 +00:00
Janis 008d72d826 fix(dependencies): correct Jackson databind dependency group ID
Build & Test (NowChessSystems) TeamCity build finished
2026-05-22 11:38:34 +02:00
Janis 576e3fea9b feat(dto): update GameWritebackEventDto for JSON deserialization and remove unused mixin
Build & Test (NowChessSystems) TeamCity build failed
2026-05-22 11:35:55 +02:00
TeamCity 1f66b7bde7 ci: bump version with Build-105 2026-05-22 08:50:38 +00:00
Janis 381161f003 feat(config): add GameWritebackEventDtoMixin for JSON deserialization
Build & Test (NowChessSystems) TeamCity build finished
2026-05-22 10:23:30 +02:00
TeamCity d0150fdfa4 ci: bump version with Build-104 2026-05-21 09:33:30 +00:00
Janis 29072efbfb fix(account): configure JDBC connection pool size to prevent exhaustion under load
Build & Test (NowChessSystems) TeamCity build finished
Default Agroal pool (max-size=20) exhausted under concurrent logins due to
bcrypt latency per request. Fixes NCS-87.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 11:06:33 +02:00
TeamCity dcc5ce1195 ci: bump version with Build-103 2026-05-19 13:52:19 +00:00
Janis 354db11972 fix(tests): update token path to accessToken in ChallengeResourceTest
Build & Test (NowChessSystems) TeamCity build finished
2026-05-19 15:27:53 +02:00
Janis 9296db88b7 feat(account): implement token pair handling for login and refresh endpoints
Build & Test (NowChessSystems) TeamCity build failed
2026-05-19 14:41:21 +02:00
Janis ec22a9585e refactor(account): update time control structure and remove deprecated fields 2026-05-19 14:41:20 +02:00
TeamCity 411eed2453 ci: bump version with Build-102 2026-05-19 11:40:58 +00:00
Janis af6b0ed8b7 feat(redis): use ManagedExecutor for asynchronous writeback processing
Build & Test (NowChessSystems) TeamCity build finished
2026-05-19 13:16:07 +02:00
TeamCity bcd8257db2 ci: bump version with Build-101 2026-05-19 09:06:09 +00:00
Janis d61fe97b4c feat(redis): add @Startup annotation to GameWritebackStreamListener
Build & Test (NowChessSystems) TeamCity build finished
2026-05-19 10:44:51 +02:00
48 changed files with 2630 additions and 61 deletions
+11 -5
View File
@@ -10,11 +10,6 @@ post {
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}
headers {
Content-Type: application/json
Authorization: Bearer {{token}}
@@ -22,9 +17,20 @@ headers {
body:json {
{
"color": "random",
"timeControl": {
"limitSeconds": 600,
"incrementSeconds": 5
}
}
}
vars:pre-request {
username: bdc
token: eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJub3djaGVzcyIsInN1YiI6ImY3MGQxNDIwLTdhZmEtNGVjMy05Mzg1LWViYWU0N2U3NGY4OCIsInVzZXJuYW1lIjoicGxheWVyMSIsImlhdCI6MTc3OTE5MjkwOCwiZXhwIjoxNzc5MTkzMjA4LCJqdGkiOiJkMDhlZmRhMi01ZjliLTQyNjgtOGM3MC1iNDA2OTViNDhiMTUifQ.q68R2bUdRQ5QwEIfcP0d2g_Wac94qd4K6BzP-PC94x-tDpT3leUY8ZpqY6YzoNv-ywp5sm47-WC539DyUqdzDgPVLLSmOMjRxG-HpaNdXwsXlp8_C7KGlgkK_XSMa3Gq6S4AfUbaXbRhPg5NZz2MRosu0BY2ed0ISvmEfX5XEdBRlKgD14BIKAsEFv3tHtarFS1RGpcSoHV2oeIk_m-VHUC78N-ciNHmBH6mZna_fVHgMocOIrEsTZuUms0Zacmebvh2tAcf36xux1Bm2awJff19zReD-A2o9jucrKcM3Im5BJ6JtuWAsHEae9KLmuol6S2pldvNOmUn3egFUYz0yQ
}
settings {
encodeUrl: true
timeout: 0
}
+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"
+65
View File
@@ -283,3 +283,68 @@
* 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))
## (2026-05-19)
### 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
* **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))
* 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))
## (2026-05-21)
### 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))
* 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))
@@ -76,6 +76,9 @@ nowchess:
password: ${DB_PASSWORD}
jdbc:
url: ${DB_URL}
min-size: 10
max-size: 50
acquisition-timeout: 30
hibernate-orm:
schema-management:
strategy: update
@@ -25,7 +25,8 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[DeclineReason],
classOf[TimeControl],
classOf[LoginRequest],
classOf[TokenResponse],
classOf[RefreshRequest],
classOf[TokenPairResponse],
classOf[PlayerInfo],
classOf[PublicAccountDto],
classOf[BotAccountDto],
@@ -6,7 +6,6 @@ import scala.compiletime.uninitialized
import java.time.Instant
import java.util.UUID
import scala.Conversion
@Entity
@Table(name = "challenges")
@@ -34,13 +33,14 @@ class Challenge extends PanacheEntityBase:
@Column(nullable = true, columnDefinition = "varchar(255)")
var declineReason: DeclineReason = uninitialized
var timeControlType: String = uninitialized
@Column(nullable = true)
var limitSeconds: java.lang.Integer = uninitialized
@Column(nullable = true)
var timeControlLimit: java.lang.Integer = uninitialized
var incrementSeconds: java.lang.Integer = uninitialized
@Column(nullable = true)
var timeControlIncrement: java.lang.Integer = uninitialized
var daysPerMove: java.lang.Integer = uninitialized
var createdAt: Instant = uninitialized
@@ -52,5 +52,6 @@ class Challenge extends PanacheEntityBase:
def gameIdOpt: Option[String] = Option(gameId)
def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
def limitSecondsOpt: Option[Int] = Option(limitSeconds).map(_.intValue())
def incrementSecondsOpt: Option[Int] = Option(incrementSeconds).map(_.intValue())
def daysPerMoveOpt: Option[Int] = Option(daysPerMove).map(_.intValue())
@@ -4,13 +4,15 @@ case class RegisterRequest(username: String, email: String, password: String)
case class LoginRequest(username: String, password: String)
case class TokenResponse(token: String)
case class RefreshRequest(refreshToken: String)
case class TokenPairResponse(accessToken: String, refreshToken: String)
case class PlayerInfo(id: String, name: String, rating: Int)
case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String)
case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int])
case class TimeControlDto(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
case class ChallengeRequest(color: String, timeControl: TimeControlDto)
@@ -4,6 +4,7 @@ enum AccountError:
case UsernameTaken(username: String)
case EmailAlreadyRegistered(email: String)
case InvalidCredentials
case InvalidRefreshToken
case UserNotFound
case BotNotFound
case BotLimitExceeded
@@ -15,6 +16,7 @@ enum AccountError:
case UsernameTaken(u) => s"Username '$u' is already taken"
case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
case InvalidCredentials => "Invalid credentials"
case InvalidRefreshToken => "Invalid or expired refresh token"
case UserNotFound => "User not found"
case BotNotFound => "Bot account not found"
case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded"
@@ -40,8 +40,19 @@ class AccountResource:
@Path("/login")
def login(req: LoginRequest): Response =
accountService.login(req) match
case Right(token) =>
Response.ok(TokenResponse(token)).build()
case Right((accessToken, refreshToken)) =>
Response.ok(TokenPairResponse(accessToken, refreshToken)).build()
case Left(AccountError.UserBanned) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
case Left(error) =>
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
@POST
@Path("/refresh")
def refresh(req: RefreshRequest): Response =
accountService.refresh(req.refreshToken) match
case Right((accessToken, refreshToken)) =>
Response.ok(TokenPairResponse(accessToken, refreshToken)).build()
case Left(AccountError.UserBanned) =>
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
case Left(error) =>
@@ -6,6 +6,7 @@ import de.nowchess.account.error.AccountError
import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
import io.micrometer.core.instrument.MeterRegistry
import io.quarkus.elytron.security.common.BcryptUtil
import io.smallrye.jwt.auth.principal.JWTParser
import io.smallrye.jwt.build.Jwt
import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
@@ -34,6 +35,9 @@ class AccountService:
@Inject
var meterRegistry: MeterRegistry = uninitialized
@Inject
var jwtParser: JWTParser = uninitialized
// scalafix:on
@PostConstruct
@@ -66,7 +70,7 @@ class AccountService:
log.infof("User %s registered successfully", req.username)
Right(account)
def login(req: LoginRequest): Either[AccountError, String] =
def login(req: LoginRequest): Either[AccountError, (String, String)] =
val result = authenticateUser(req)
result match
case Right(_) => meterRegistry.counter("nowchess.auth.logins", "result", "success").increment()
@@ -75,7 +79,7 @@ class AccountService:
meterRegistry.counter("nowchess.auth.login.failures", "reason", loginFailureReason(error)).increment()
result
private def authenticateUser(req: LoginRequest): Either[AccountError, String] =
private def authenticateUser(req: LoginRequest): Either[AccountError, (String, String)] =
userAccountRepository.findByUsername(req.username) match
case None =>
log.warnf("Login failed for unknown user %s", req.username)
@@ -89,16 +93,39 @@ class AccountService:
Left(AccountError.UserBanned)
else
log.infof("User %s logged in successfully", req.username)
Right(
Jwt
.issuer("nowchess")
.subject(account.id.toString)
.claim("username", account.username)
.sign(),
)
Right((generateAccessToken(account), generateRefreshToken(account.id)))
def refresh(refreshToken: String): Either[AccountError, (String, String)] =
try
val parsed = jwtParser.parse(refreshToken)
if parsed.getClaim[String]("type") != "refresh" then Left(AccountError.InvalidRefreshToken)
else
val userId = UUID.fromString(parsed.getSubject)
userAccountRepository.findById(userId) match
case None => Left(AccountError.UserNotFound)
case Some(u) if u.banned => Left(AccountError.UserBanned)
case Some(u) => Right((generateAccessToken(u), generateRefreshToken(u.id)))
catch case _: Throwable => Left(AccountError.InvalidRefreshToken)
private def generateAccessToken(account: UserAccount): String =
Jwt
.issuer("nowchess")
.subject(account.id.toString)
.claim("username", account.username)
.expiresIn(3600)
.sign()
private def generateRefreshToken(userId: UUID): String =
Jwt
.issuer("nowchess")
.subject(userId.toString)
.claim("type", "refresh")
.expiresIn(30L * 24 * 3600)
.sign()
private def loginFailureReason(error: AccountError): String = error match
case AccountError.InvalidCredentials => "invalid_credentials"
case AccountError.InvalidRefreshToken => "invalid_refresh_token"
case AccountError.UserBanned => "user_banned"
case AccountError.UsernameTaken(_) => "username_taken"
case AccountError.EmailAlreadyRegistered(_) => "email_registered"
@@ -88,9 +88,9 @@ class ChallengeService:
challenge.destUser = destUser
challenge.color = color
challenge.status = ChallengeStatus.Created
challenge.timeControlType = req.timeControl.`type`
challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
challenge.limitSeconds = req.timeControl.limitSeconds.map(java.lang.Integer.valueOf).orNull
challenge.incrementSeconds = req.timeControl.incrementSeconds.map(java.lang.Integer.valueOf).orNull
challenge.daysPerMove = req.timeControl.daysPerMove.map(java.lang.Integer.valueOf).orNull
challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge)
@@ -200,10 +200,9 @@ class ChallengeService:
if ThreadLocalRandom.current().nextBoolean() then (challenger, destUser) else (destUser, challenger)
private def buildTimeControl(challenge: Challenge): Option[CoreTimeControl] =
challenge.timeControlType match
case "unlimited" => None
case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
if challenge.limitSecondsOpt.isEmpty && challenge.incrementSecondsOpt.isEmpty && challenge.daysPerMoveOpt.isEmpty
then None
else Some(CoreTimeControl(challenge.limitSecondsOpt, challenge.incrementSecondsOpt, challenge.daysPerMoveOpt))
private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
raw.toLowerCase match
@@ -227,7 +226,7 @@ class ChallengeService:
destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
variant = "standard",
color = c.color.toString.toLowerCase,
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
timeControl = TimeControlDto(c.limitSecondsOpt, c.incrementSecondsOpt, c.daysPerMoveOpt),
status = c.status.toString.toLowerCase,
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
gameId = c.gameIdOpt,
@@ -32,7 +32,24 @@ class AccountResourceTest:
.`then`()
.statusCode(200)
.extract()
.path[String]("token")
.path[String]("accessToken")
private def registerAndLoginPair(username: String): (String, String) =
givenRequest()
.body(registerBody(username))
.when()
.post("/api/account")
.`then`()
.statusCode(200)
val resp = givenRequest()
.body(loginBody(username))
.when()
.post("/api/account/login")
.`then`()
.statusCode(200)
.extract()
.response()
(resp.path[String]("accessToken"), resp.path[String]("refreshToken"))
@Test
def registerReturns200(): Unit =
@@ -57,7 +74,7 @@ class AccountResourceTest:
.body("error", containsString("bob"))
@Test
def loginReturns200WithToken(): Unit =
def loginReturns200WithTokenPair(): Unit =
givenRequest().body(registerBody("charlie")).when().post("/api/account")
givenRequest()
.body(loginBody("charlie"))
@@ -65,7 +82,8 @@ class AccountResourceTest:
.post("/api/account/login")
.`then`()
.statusCode(200)
.body("token", notNullValue())
.body("accessToken", notNullValue())
.body("refreshToken", notNullValue())
@Test
def loginUnauthorizedOnWrongPassword(): Unit =
@@ -105,3 +123,34 @@ class AccountResourceTest:
.get("/api/account/doesnotexist")
.`then`()
.statusCode(404)
@Test
def refreshReturnsNewTokenPair(): Unit =
val (_, refreshToken) = registerAndLoginPair("refresh_user")
givenRequest()
.body(s"""{"refreshToken":"$refreshToken"}""")
.when()
.post("/api/account/refresh")
.`then`()
.statusCode(200)
.body("accessToken", notNullValue())
.body("refreshToken", notNullValue())
@Test
def refreshWithInvalidTokenReturns401(): Unit =
givenRequest()
.body("""{"refreshToken":"invalid.token.value"}""")
.when()
.post("/api/account/refresh")
.`then`()
.statusCode(401)
@Test
def refreshWithAccessTokenReturns401(): Unit =
val accessToken = registerAndLogin("refresh_bad_type")
givenRequest()
.body(s"""{"refreshToken":"$accessToken"}""")
.when()
.post("/api/account/refresh")
.`then`()
.statusCode(401)
@@ -41,10 +41,10 @@ class ChallengeResourceTest:
.`then`()
.statusCode(200)
.extract()
.path[String]("token")
.path[String]("accessToken")
private val clockBody =
"""{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}"""
"""{"color":"random","timeControl":{"limitSeconds":300,"incrementSeconds":5}}"""
private def authed(token: String) =
givenRequest().header("Authorization", s"Bearer $token")
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=16
MINOR=18
PATCH=0
+18
View File
@@ -109,3 +109,21 @@
### Bug Fixes
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
## (2026-05-22)
### Features
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
* NCS-13 Implement Threefold Repetition ([#31](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/31)) ([767d305](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/767d3051a76c266050b6335774d66e2db2273c16))
* NCS-14 implemented insufficient moves rule ([#30](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/30)) ([b0399a4](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b0399a4e489950083066c9538df9a84dcc7a4613))
* NCS-21 Write Scripts to automate certain tasks ([#15](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/15)) ([8051871](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/80518719d536a087d339fe02530825dc07f8b388))
* NCS-25 Add linters to keep quality up ([#27](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/27)) ([fd4e67d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/fd4e67d4f782a7e955822d90cb909d0a81676fb2))
* NCS-37 Quarkus integration ([#35](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/35)) ([f088c4e](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/f088c4e9ffcc498d3d1b6f01e8f50042d5830d55))
* NCS-41 Bot Platform ([#33](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/33)) ([8744bee](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8744bee2dd20966dae90a09c21a43d5b06f59e00))
* **rule:** Rules as a microservice ([#39](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/39)) ([093134d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/093134d36c6844ba02a36a28d5d044f09291cd1d))
* true-microservices ([#40](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/40)) ([5909242](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/590924254e8a2754de661a57a03e43f89ceb6299))
### Bug Fixes
* **dependencies:** correct Jackson databind dependency group ID ([008d72d](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/008d72d826707c04205bac7de25170fae5fed861))
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
+1
View File
@@ -49,6 +49,7 @@ dependencies {
strictly(versions["SCALA_LIBRARY"]!!)
}
}
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -1,5 +1,7 @@
package de.nowchess.api.dto
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
case class GameWritebackEventDto(
gameId: String,
fen: String,
@@ -14,11 +16,11 @@ case class GameWritebackEventDto(
limitSeconds: Option[Int],
incrementSeconds: Option[Int],
daysPerMove: Option[Int],
whiteRemainingMs: Option[Long],
blackRemainingMs: Option[Long],
incrementMs: Option[Long],
clockLastTickAt: Option[Long],
clockMoveDeadline: Option[Long],
@JsonDeserialize(contentAs = classOf[java.lang.Long]) whiteRemainingMs: Option[Long],
@JsonDeserialize(contentAs = classOf[java.lang.Long]) blackRemainingMs: Option[Long],
@JsonDeserialize(contentAs = classOf[java.lang.Long]) incrementMs: Option[Long],
@JsonDeserialize(contentAs = classOf[java.lang.Long]) clockLastTickAt: Option[Long],
@JsonDeserialize(contentAs = classOf[java.lang.Long]) clockMoveDeadline: Option[Long],
clockActiveColor: Option[String],
pendingDrawOffer: Option[String],
result: Option[String] = None,
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=12
MINOR=13
PATCH=0
@@ -20,6 +20,7 @@ import jakarta.enterprise.inject.Instance
import jakarta.inject.Inject
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
import java.util.function.Consumer
@@ -77,7 +78,8 @@ class GameRedisSubscriberManager:
s"${redisConfig.prefix}:game:$gameId:s2c"
def subscribeGame(gameId: String): Unit =
val writebackFn: String => Unit = json => redis.pubsub(classOf[String]).publish("game-writeback", json)
val writebackFn: String => Unit = json =>
redis.stream(classOf[String]).xadd(s"${redisConfig.prefix}:game-writeback", Map("data" -> json).asJava)
val obs = new GameRedisPublisher(
gameId,
registry,
+114
View File
@@ -307,3 +307,117 @@
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
## (2026-05-19)
### 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))
* **config:** update application.yml to nest HTTP port configuration ([3efebd5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3efebd5ed0493159c51f7246d18d59bac58cf875))
* 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))
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
* 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))
* **redis:** add @Startup annotation to GameWritebackStreamListener ([d61fe97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d61fe97b4c8e2db5e34b4a14d995297cc09f9435))
* 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
* ensure full hierarchy registration for reflection in NativeReflectionConfig ([ebba729](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ebba729af3265df1619dfbe46fd1945b2a7e30b7))
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
## (2026-05-19)
### 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))
* **config:** update application.yml to nest HTTP port configuration ([3efebd5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3efebd5ed0493159c51f7246d18d59bac58cf875))
* 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))
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
* 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))
* **redis:** add @Startup annotation to GameWritebackStreamListener ([d61fe97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d61fe97b4c8e2db5e34b4a14d995297cc09f9435))
* **redis:** use ManagedExecutor for asynchronous writeback processing ([af6b0ed](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/af6b0ed8b73724fcc8f20dfccbe6fe8f84fd792d))
* 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
* ensure full hierarchy registration for reflection in NativeReflectionConfig ([ebba729](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ebba729af3265df1619dfbe46fd1945b2a7e30b7))
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
## (2026-05-22)
### 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:** add GameWritebackEventDtoMixin for JSON deserialization ([381161f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/381161f00345612a1789e08243746083dff884c5))
* **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))
* **config:** update application.yml to nest HTTP port configuration ([3efebd5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3efebd5ed0493159c51f7246d18d59bac58cf875))
* 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))
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
* 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))
* **redis:** add @Startup annotation to GameWritebackStreamListener ([d61fe97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d61fe97b4c8e2db5e34b4a14d995297cc09f9435))
* **redis:** use ManagedExecutor for asynchronous writeback processing ([af6b0ed](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/af6b0ed8b73724fcc8f20dfccbe6fe8f84fd792d))
* 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
* ensure full hierarchy registration for reflection in NativeReflectionConfig ([ebba729](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ebba729af3265df1619dfbe46fd1945b2a7e30b7))
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
## (2026-05-22)
### 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:** add GameWritebackEventDtoMixin for JSON deserialization ([381161f](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/381161f00345612a1789e08243746083dff884c5))
* **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))
* **config:** update application.yml to nest HTTP port configuration ([3efebd5](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3efebd5ed0493159c51f7246d18d59bac58cf875))
* 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))
* **dto:** update GameWritebackEventDto for JSON deserialization and remove unused mixin ([576e3fe](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/576e3fea9bf1082549ea53efd3288474c42be93d))
* implement clock expiry scanning and handling for game records ([#53](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/53)) ([8f9eb12](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/8f9eb12f663efabe4dc72b94394438652ad0ef02))
* 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))
* **redis:** add @Startup annotation to GameWritebackStreamListener ([d61fe97](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/d61fe97b4c8e2db5e34b4a14d995297cc09f9435))
* **redis:** use ManagedExecutor for asynchronous writeback processing ([af6b0ed](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/af6b0ed8b73724fcc8f20dfccbe6fe8f84fd792d))
* 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
* ensure full hierarchy registration for reflection in NativeReflectionConfig ([ebba729](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/ebba729af3265df1619dfbe46fd1945b2a7e30b7))
* NCS-85 Database Writeback fails without Logs ([#52](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/52)) ([7323908](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/73239088d985f01aa6b1067ed9097a845e471d4f))
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
* **redis:** update Redis configuration with max pool size and waiting parameters ([5baf6a7](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/5baf6a7cdbea484fc49c02e2b5a1c3919b7fa2c4))
* remove unused HTTP root-path configurations from application.yml ([3ed3e59](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/3ed3e59ee456d54cd3d65ece4f36623e256b9736))
@@ -2,16 +2,22 @@ package de.nowchess.store.redis
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.store.config.RedisConfig
import de.nowchess.store.service.GameWritebackService
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.function.Consumer
import java.util.UUID
@Startup
@ApplicationScoped
class GameWritebackStreamListener:
@Inject
@@ -19,21 +25,76 @@ class GameWritebackStreamListener:
var redis: RedisDataSource = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var writebackService: GameWritebackService = uninitialized
@Inject var executor: ManagedExecutor = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[GameWritebackStreamListener])
private val log = Logger.getLogger(classOf[GameWritebackStreamListener])
private val groupName = "store-writeback"
private def streamKey = s"${redisConfig.prefix}:game-writeback"
private def dlqKey = s"${redisConfig.prefix}:game-writeback-dlq"
private val maxRetries = 3
private val consumerId = UUID.randomUUID().toString
@PostConstruct
def startListening(): Unit =
val handler: Consumer[String] = json =>
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
case Failure(ex) =>
log.errorf(ex, "Failed to parse game-writeback event: %s", json)
case Success(event) =>
Try(writebackService.writeBack(event)) match
case Failure(ex) =>
log.errorf(ex, "Failed to write back game event for gameId=%s", event.gameId)
case Success(_) => ()
redis.pubsub(classOf[String]).subscribe("game-writeback", handler)
log.infof("Started listening to Writebacks")
()
createGroupIfAbsent()
executor.submit(new Runnable:
def run(): Unit = pollLoop()
)
log.infof("Started listening to game-writeback stream (consumer=%s)", consumerId)
private def createGroupIfAbsent(): Unit =
Try(redis.stream(classOf[String]).xgroupCreate(streamKey, groupName, "0", new XGroupCreateArgs().mkstream())) match
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
case Failure(ex) => log.warnf(ex, "Failed to create consumer group")
case Success(_) => ()
private def pollLoop(): Unit =
while true do
Try {
val messages = redis.stream(classOf[String]).xreadgroup(
groupName,
consumerId,
streamKey,
">",
new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)),
)
if messages != null then messages.forEach(msg => handleMessage(msg))
} match
case Failure(ex) => log.warnf(ex, "Error in writeback poll loop")
case Success(_) => ()
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
val payload = msg.payload()
val json = payload.get("data")
val attempt = Option(payload.get("attempt")).flatMap(_.toIntOption).getOrElse(0)
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
case Failure(ex) =>
log.errorf(ex, "Unparseable writeback event, sending to DLQ: %s", json)
xadd(dlqKey, json, attempt)
ack(msg.id())
case Success(event) =>
Try(writebackService.writeBack(event)) match
case Success(_) =>
ack(msg.id())
case Failure(ex) if attempt + 1 < maxRetries =>
log.warnf(ex, "Writeback failed for gameId=%s attempt=%d, retrying", event.gameId, attempt)
xadd(streamKey, json, attempt + 1)
ack(msg.id())
case Failure(ex) =>
log.errorf(ex, "Writeback failed for gameId=%s after %d attempts, sending to DLQ", event.gameId, maxRetries)
xadd(dlqKey, json, attempt)
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(_) => ()
private def xadd(key: String, json: String, attempt: Int): Unit =
Try(redis.stream(classOf[String]).xadd(key, Map("data" -> json, "attempt" -> attempt.toString).asJava)) match
case Failure(ex) => log.errorf(ex, "Failed to publish to stream %s", key)
case Success(_) => ()
+1 -1
View File
@@ -1,3 +1,3 @@
MAJOR=0
MINOR=18
MINOR=22
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,82 @@
package de.nowchess.tournament.redis
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.GameWritebackEventDto
import de.nowchess.tournament.config.RedisConfig
import de.nowchess.tournament.service.TournamentService
import io.quarkus.redis.datasource.RedisDataSource
import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs}
import io.quarkus.runtime.Startup
import jakarta.annotation.PostConstruct
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.context.ManagedExecutor
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
import scala.util.{Failure, Success, Try}
import java.util.UUID
@Startup
@ApplicationScoped
class GameResultStreamListener:
// scalafix:off DisableSyntax.var
@Inject var redis: RedisDataSource = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var tournamentService: TournamentService = uninitialized
@Inject var executor: ManagedExecutor = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[GameResultStreamListener])
private val groupName = "tournament-result"
private val consumerId = UUID.randomUUID().toString
private def streamKey = s"${redisConfig.prefix}:game-writeback"
@PostConstruct
def startListening(): Unit =
createGroupIfAbsent()
executor.submit(new Runnable:
def run(): Unit = pollLoop()
)
log.infof("Tournament result listener started (consumer=%s)", consumerId)
private def createGroupIfAbsent(): Unit =
Try(redis.stream(classOf[String]).xgroupCreate(streamKey, groupName, "0", new XGroupCreateArgs().mkstream())) match
case Failure(ex) if Option(ex.getMessage).exists(_.contains("BUSYGROUP")) => ()
case Failure(ex) => log.warnf(ex, "Failed to create consumer group")
case Success(_) => ()
private def pollLoop(): Unit =
while true do
Try {
val messages = redis.stream(classOf[String]).xreadgroup(
groupName,
consumerId,
streamKey,
">",
new XReadGroupArgs().count(10).block(java.time.Duration.ofSeconds(2)),
)
if messages != null then messages.forEach(msg => handleMessage(msg))
} match
case Failure(ex) => log.warnf(ex, "Error in result poll loop")
case Success(_) => ()
private def handleMessage(msg: StreamMessage[String, String, String]): Unit =
val json = msg.payload().get("data")
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
case Failure(ex) =>
log.errorf(ex, "Unparseable game result event: %s", json)
ack(msg.id())
case Success(event) =>
if event.result.isDefined then
Try(tournamentService.handleGameResult(event.gameId, event.result.get, event.pgn)) match
case Failure(ex) => log.errorf(ex, "Failed to handle game result for %s", event.gameId)
case Success(_) => ()
ack(msg.id())
private def ack(id: String): Unit =
Try(redis.stream(classOf[String]).xack(streamKey, groupName, id)) match
case Failure(ex) => log.warnf(ex, "Failed to ack message %s", id)
case Success(_) => ()
@@ -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,284 @@
package de.nowchess.tournament.service
import de.nowchess.tournament.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo, CoreTimeControl}
import de.nowchess.tournament.domain.{Tournament, TournamentPairing, TournamentParticipant}
import de.nowchess.tournament.dto.{BotRef, Clock, CreateTournamentForm, PairingDto, ResultDto, Standing, TournamentDto, Variant}
import de.nowchess.tournament.error.TournamentError
import de.nowchess.tournament.repository.{PairingRepository, ParticipantRepository, TournamentRepository}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import scala.util.{Failure, Success, Try}
import java.time.Instant
@ApplicationScoped
class TournamentService:
private val log = Logger.getLogger(classOf[TournamentService])
// scalafix:off DisableSyntax.var
@Inject var tournamentRepository: TournamentRepository = uninitialized
@Inject var participantRepository: ParticipantRepository = uninitialized
@Inject var pairingRepository: PairingRepository = uninitialized
@Inject var streamManager: TournamentStreamManager = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
// scalafix:on
@Transactional
def create(createdBy: String, form: CreateTournamentForm): Tournament =
val t = new Tournament()
t.id = scala.util.Random.alphanumeric.take(6).mkString
t.fullName = form.name
t.nbRounds = form.nbRounds
t.clockLimit = form.clockLimit
t.clockIncrement = form.clockIncrement
t.rated = form.rated
t.status = "created"
t.currentRound = 0
t.createdBy = createdBy
tournamentRepository.persist(t)
t
def get(id: String): Option[Tournament] =
tournamentRepository.findOptById(id)
def list(): (List[Tournament], List[Tournament], List[Tournament]) =
(
tournamentRepository.findByStatus("created"),
tournamentRepository.findByStatus("started"),
tournamentRepository.findByStatus("finished"),
)
@Transactional
def terminate(id: String, userId: String): Either[TournamentError, Unit] =
for
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
yield
tournamentRepository.delete(t)
@Transactional
def join(id: String, botId: String, botName: String): Either[TournamentError, Unit] =
for
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
_ <- Either.cond(
participantRepository.findByTournamentIdAndBotId(id, botId).isEmpty,
(),
TournamentError.AlreadyJoined,
)
yield
val p = new TournamentParticipant()
p.tournamentId = id
p.botId = botId
p.botName = botName
participantRepository.persist(p)
@Transactional
def withdraw(id: String, botId: String): Either[TournamentError, Unit] =
for
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
p <- participantRepository.findByTournamentIdAndBotId(id, botId).toRight(TournamentError.NotJoined)
yield participantRepository.delete(p)
@Transactional
def start(id: String, userId: String): Either[TournamentError, Tournament] =
for
t <- tournamentRepository.findOptById(id).toRight(TournamentError.NotFound(id))
_ <- Either.cond(t.createdBy == userId, (), TournamentError.NotDirector)
_ <- Either.cond(t.status == "created", (), TournamentError.WrongStatus("created"))
participants <- validateMinParticipants(id)
yield
t.status = "started"
t.currentRound = 1
t.startsAt = Instant.now()
tournamentRepository.persist(t)
streamManager.publish(t.id, """{"type":"tournamentStarted"}""")
startRound(t, 1, participants)
t
private def validateMinParticipants(id: String): Either[TournamentError, List[TournamentParticipant]] =
val ps = participantRepository.findByTournamentId(id)
Either.cond(ps.size >= 2, ps, TournamentError.NotEnoughParticipants)
private def startRound(t: Tournament, round: Int, participants: List[TournamentParticipant]): Unit =
val pastPairings = pairingRepository.findByTournamentId(t.id)
val (pairs, byeOpt) = SwissPairingService.computePairings(participants, pastPairings)
byeOpt.foreach(bye => createByePairing(t.id, round, bye))
pairs.foreach { case (white, black) => createRealPairing(t.id, round, white, black, t) }
streamManager.publish(t.id, s"""{"type":"roundStarted","round":$round}""")
private def createByePairing(tournamentId: String, round: Int, bye: TournamentParticipant): Unit =
val pairing = new TournamentPairing()
pairing.tournamentId = tournamentId
pairing.round = round
pairing.blackId = bye.botId
pairing.blackName = bye.botName
pairing.winner = "bye"
pairingRepository.persist(pairing)
bye.points += 0.5
bye.byeCount += 1
participantRepository.persist(bye)
private def createRealPairing(
tournamentId: String,
round: Int,
white: TournamentParticipant,
black: TournamentParticipant,
t: Tournament,
): Unit =
val tc = CoreTimeControl(Some(t.clockLimit), Some(t.clockIncrement), None)
val req = CoreCreateGameRequest(
Some(CorePlayerInfo(white.botId, white.botName)),
Some(CorePlayerInfo(black.botId, black.botName)),
Some(tc),
if t.rated then Some("Rated") else Some("Casual"),
)
Try(coreGameClient.createGame(req)) match
case Failure(ex) => log.errorf(ex, "Failed to create game for round %d in tournament %s", round, tournamentId)
case Success(resp) =>
val pairing = new TournamentPairing()
pairing.tournamentId = tournamentId
pairing.round = round
pairing.whiteId = white.botId
pairing.whiteName = white.botName
pairing.blackId = black.botId
pairing.blackName = black.botName
pairing.gameId = resp.gameId
pairingRepository.persist(pairing)
streamManager.publishToBot(tournamentId, white.botId, s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"white"}""")
streamManager.publishToBot(tournamentId, black.botId, s"""{"type":"gameStart","round":$round,"gameId":"${resp.gameId}","color":"black"}""")
@Transactional
def handleGameResult(gameId: String, result: String, pgn: String): Unit =
pairingRepository.findByGameId(gameId).foreach { pairing =>
val (winnerStr, wPts, bPts) = parseResult(result)
pairing.winner = winnerStr
pairing.moveList = pgn
pairingRepository.persist(pairing)
updateParticipantStats(pairing, wPts, bPts)
checkRoundCompletion(pairing.tournamentId)
}
private def parseResult(result: String): (String, Double, Double) = result match
case "1-0" => ("white", 1.0, 0.0)
case "0-1" => ("black", 0.0, 1.0)
case "1/2-1/2" => ("draw", 0.5, 0.5)
case _ => ("draw", 0.5, 0.5)
private def updateParticipantStats(pairing: TournamentPairing, wPts: Double, bPts: Double): Unit =
Option(pairing.whiteId).foreach { wId =>
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, wId).foreach { p =>
p.points += wPts
p.nbGames += 1
if wPts == 1.0 then p.wins += 1 else if wPts == 0.5 then p.draws += 1 else p.losses += 1
participantRepository.persist(p)
}
}
participantRepository.findByTournamentIdAndBotId(pairing.tournamentId, pairing.blackId).foreach { p =>
p.points += bPts
p.nbGames += 1
if bPts == 1.0 then p.wins += 1 else if bPts == 0.5 then p.draws += 1 else p.losses += 1
participantRepository.persist(p)
}
private def checkRoundCompletion(tournamentId: String): Unit =
tournamentRepository.findOptById(tournamentId).foreach { t =>
val roundPairings = pairingRepository.findByTournamentIdAndRound(tournamentId, t.currentRound)
val allDone = roundPairings.nonEmpty && roundPairings.forall(p => Option(p.winner).isDefined)
if allDone then onRoundComplete(t)
}
private def onRoundComplete(t: Tournament): Unit =
streamManager.publish(t.id, s"""{"type":"roundFinished","round":${t.currentRound}}""")
recomputeBuchholz(t.id)
if t.currentRound >= t.nbRounds then finishTournament(t)
else
t.currentRound += 1
tournamentRepository.persist(t)
val participants = participantRepository.findByTournamentId(t.id)
startRound(t, t.currentRound, participants)
private def finishTournament(t: Tournament): Unit =
val participants = sortedStandings(participantRepository.findByTournamentId(t.id))
participants.headOption.foreach { winner =>
t.winnerId = winner.botId
t.winnerName = winner.botName
}
t.status = "finished"
tournamentRepository.persist(t)
val winnerInfo = participants.headOption
.map(w => s"""{"id":"${w.botId}","name":"${w.botName}"}""")
.getOrElse("null")
streamManager.publish(t.id, s"""{"type":"tournamentFinished","winner":$winnerInfo}""")
private def recomputeBuchholz(tournamentId: String): Unit =
val participants = participantRepository.findByTournamentId(tournamentId)
val pairings = pairingRepository.findByTournamentId(tournamentId)
val pointsById = participants.map(p => p.botId -> p.points).toMap
participants.foreach { p =>
val opponentIds = pairings.flatMap(pair =>
if pair.whiteId == p.botId then Some(pair.blackId)
else if pair.blackId == p.botId && Option(pair.whiteId).isDefined then Some(pair.whiteId)
else None,
)
p.tieBreak = opponentIds.flatMap(id => pointsById.get(id)).sum
participantRepository.persist(p)
}
def getStandings(tournamentId: String): List[ResultDto] =
val participants = sortedStandings(participantRepository.findByTournamentId(tournamentId))
participants.zipWithIndex.map { case (p, idx) =>
ResultDto(idx + 1, p.points, p.tieBreak, BotRef(p.botId, p.botName), p.nbGames, p.wins, p.draws, p.losses)
}
def getPairings(tournamentId: String, round: Int): List[PairingDto] =
pairingRepository.findByTournamentIdAndRound(tournamentId, round).map(toPairingDto)
def getAllPairings(tournamentId: String): List[TournamentPairing] =
pairingRepository.findByTournamentId(tournamentId)
def getResults(tournamentId: String): List[ResultDto] = getStandings(tournamentId)
def toDto(t: Tournament, standings: List[ResultDto] = Nil): TournamentDto =
val participants = participantRepository.findByTournamentId(t.id)
TournamentDto(
id = t.id,
fullName = t.fullName,
clock = Clock(t.clockLimit, t.clockIncrement),
variant = Variant("standard", "Standard"),
rated = t.rated,
nbPlayers = participants.size,
nbRounds = t.nbRounds,
createdBy = t.createdBy,
startsAt = Option(t.startsAt).map(_.toString),
status = t.status,
round = t.currentRound,
standing = Standing(1, standings),
winner = if t.winnerId != null then Some(BotRef(t.winnerId, t.winnerName)) else None,
)
private def toPairingDto(p: TournamentPairing): PairingDto =
PairingDto(
id = p.id.toString,
round = p.round,
white = Option(p.whiteId).map(id => BotRef(id, p.whiteName)),
black = BotRef(p.blackId, p.blackName),
gameId = Option(p.gameId),
winner = Option(p.winner),
)
private def sortedStandings(participants: List[TournamentParticipant]): List[TournamentParticipant] =
participants.sortWith { (a, b) =>
if a.points != b.points then a.points > b.points
else if a.tieBreak != b.tieBreak then a.tieBreak > b.tieBreak
else a.botName < b.botName
}
@@ -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:store",
"modules:coordinator",
"modules:tournament",
)