Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c586b9003 | |||
| ae3ef766e8 | |||
| 487711628f | |||
| 008d72d826 | |||
| 576e3fea9b |
@@ -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"
|
||||||
@@ -109,3 +109,21 @@
|
|||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|
||||||
* IO microservice ([#38](https://git.janis-eccarius.de/NowChess/NowChessSystems/issues/38)) ([a386f57](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/a386f57c21d34ead6cc6f92836c52b714597e289))
|
* 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))
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ dependencies {
|
|||||||
strictly(versions["SCALA_LIBRARY"]!!)
|
strictly(versions["SCALA_LIBRARY"]!!)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
|
||||||
|
|
||||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||||
testImplementation("org.junit.jupiter:junit-jupiter")
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package de.nowchess.api.dto
|
package de.nowchess.api.dto
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
||||||
|
|
||||||
case class GameWritebackEventDto(
|
case class GameWritebackEventDto(
|
||||||
gameId: String,
|
gameId: String,
|
||||||
fen: String,
|
fen: String,
|
||||||
@@ -14,11 +16,11 @@ case class GameWritebackEventDto(
|
|||||||
limitSeconds: Option[Int],
|
limitSeconds: Option[Int],
|
||||||
incrementSeconds: Option[Int],
|
incrementSeconds: Option[Int],
|
||||||
daysPerMove: Option[Int],
|
daysPerMove: Option[Int],
|
||||||
whiteRemainingMs: Option[Long],
|
@JsonDeserialize(contentAs = classOf[java.lang.Long]) whiteRemainingMs: Option[Long],
|
||||||
blackRemainingMs: Option[Long],
|
@JsonDeserialize(contentAs = classOf[java.lang.Long]) blackRemainingMs: Option[Long],
|
||||||
incrementMs: Option[Long],
|
@JsonDeserialize(contentAs = classOf[java.lang.Long]) incrementMs: Option[Long],
|
||||||
clockLastTickAt: Option[Long],
|
@JsonDeserialize(contentAs = classOf[java.lang.Long]) clockLastTickAt: Option[Long],
|
||||||
clockMoveDeadline: Option[Long],
|
@JsonDeserialize(contentAs = classOf[java.lang.Long]) clockMoveDeadline: Option[Long],
|
||||||
clockActiveColor: Option[String],
|
clockActiveColor: Option[String],
|
||||||
pendingDrawOffer: Option[String],
|
pendingDrawOffer: Option[String],
|
||||||
result: Option[String] = None,
|
result: Option[String] = None,
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=12
|
MINOR=13
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
+3
-1
@@ -20,6 +20,7 @@ import jakarta.enterprise.inject.Instance
|
|||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
import scala.util.Try
|
import scala.util.Try
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.function.Consumer
|
import java.util.function.Consumer
|
||||||
@@ -77,7 +78,8 @@ class GameRedisSubscriberManager:
|
|||||||
s"${redisConfig.prefix}:game:$gameId:s2c"
|
s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||||
|
|
||||||
def subscribeGame(gameId: String): Unit =
|
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(
|
val obs = new GameRedisPublisher(
|
||||||
gameId,
|
gameId,
|
||||||
registry,
|
registry,
|
||||||
|
|||||||
@@ -391,3 +391,33 @@
|
|||||||
* **redis:** add log message for starting Writeback listener ([b610678](https://git.janis-eccarius.de/NowChess/NowChessSystems/commit/b610678005de645115f48348e66aa9e6f5deb3d5))
|
* **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))
|
* **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))
|
* 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))
|
||||||
|
|||||||
-10
@@ -1,10 +0,0 @@
|
|||||||
package de.nowchess.store.config
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
|
||||||
|
|
||||||
abstract class GameWritebackEventDtoMixin:
|
|
||||||
@JsonDeserialize(contentAs = classOf[java.lang.Long]) val whiteRemainingMs: Option[Long]
|
|
||||||
@JsonDeserialize(contentAs = classOf[java.lang.Long]) val blackRemainingMs: Option[Long]
|
|
||||||
@JsonDeserialize(contentAs = classOf[java.lang.Long]) val incrementMs: Option[Long]
|
|
||||||
@JsonDeserialize(contentAs = classOf[java.lang.Long]) val clockLastTickAt: Option[Long]
|
|
||||||
@JsonDeserialize(contentAs = classOf[java.lang.Long]) val clockMoveDeadline: Option[Long]
|
|
||||||
@@ -3,7 +3,6 @@ package de.nowchess.store.config
|
|||||||
import com.fasterxml.jackson.core.Version
|
import com.fasterxml.jackson.core.Version
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||||
import de.nowchess.api.dto.GameWritebackEventDto
|
|
||||||
import io.quarkus.jackson.ObjectMapperCustomizer
|
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||||
import jakarta.inject.Singleton
|
import jakarta.inject.Singleton
|
||||||
|
|
||||||
@@ -16,4 +15,3 @@ class JacksonConfig extends ObjectMapperCustomizer:
|
|||||||
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
||||||
// scalafix:on DisableSyntax.null
|
// scalafix:on DisableSyntax.null
|
||||||
})
|
})
|
||||||
mapper.addMixIn(classOf[GameWritebackEventDto], classOf[GameWritebackEventDtoMixin])
|
|
||||||
|
|||||||
+65
-12
@@ -2,8 +2,10 @@ package de.nowchess.store.redis
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import de.nowchess.api.dto.GameWritebackEventDto
|
import de.nowchess.api.dto.GameWritebackEventDto
|
||||||
|
import de.nowchess.store.config.RedisConfig
|
||||||
import de.nowchess.store.service.GameWritebackService
|
import de.nowchess.store.service.GameWritebackService
|
||||||
import io.quarkus.redis.datasource.RedisDataSource
|
import io.quarkus.redis.datasource.RedisDataSource
|
||||||
|
import io.quarkus.redis.datasource.stream.{StreamMessage, XGroupCreateArgs, XReadGroupArgs}
|
||||||
import io.quarkus.runtime.Startup
|
import io.quarkus.runtime.Startup
|
||||||
import jakarta.annotation.PostConstruct
|
import jakarta.annotation.PostConstruct
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
@@ -11,8 +13,9 @@ import jakarta.inject.Inject
|
|||||||
import org.eclipse.microprofile.context.ManagedExecutor
|
import org.eclipse.microprofile.context.ManagedExecutor
|
||||||
import org.jboss.logging.Logger
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.jdk.CollectionConverters.*
|
||||||
import scala.util.{Failure, Success, Try}
|
import scala.util.{Failure, Success, Try}
|
||||||
import java.util.function.Consumer
|
import java.util.UUID
|
||||||
|
|
||||||
@Startup
|
@Startup
|
||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
@@ -23,25 +26,75 @@ class GameWritebackStreamListener:
|
|||||||
@Inject var objectMapper: ObjectMapper = uninitialized
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
@Inject var writebackService: GameWritebackService = uninitialized
|
@Inject var writebackService: GameWritebackService = uninitialized
|
||||||
@Inject var executor: ManagedExecutor = uninitialized
|
@Inject var executor: ManagedExecutor = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
// scalafix:on
|
// 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
|
@PostConstruct
|
||||||
def startListening(): Unit =
|
def startListening(): Unit =
|
||||||
val handler: Consumer[String] = json =>
|
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
|
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])) match
|
||||||
case Failure(ex) =>
|
case Failure(ex) =>
|
||||||
log.errorf(ex, "Failed to parse game-writeback event: %s", json)
|
log.errorf(ex, "Unparseable writeback event, sending to DLQ: %s", json)
|
||||||
|
xadd(dlqKey, json, attempt)
|
||||||
|
ack(msg.id())
|
||||||
case Success(event) =>
|
case Success(event) =>
|
||||||
executor.submit(
|
|
||||||
new Runnable:
|
|
||||||
def run(): Unit =
|
|
||||||
Try(writebackService.writeBack(event)) match
|
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) =>
|
case Failure(ex) =>
|
||||||
log.errorf(ex, "Failed to write back game event for gameId=%s", event.gameId)
|
log.errorf(ex, "Writeback failed for gameId=%s after %d attempts, sending to DLQ", event.gameId, maxRetries)
|
||||||
case Success(_) => (),
|
xadd(dlqKey, json, attempt)
|
||||||
)
|
ack(msg.id())
|
||||||
redis.pubsub(classOf[String]).subscribe("game-writeback", handler)
|
|
||||||
log.infof("Started listening to Writebacks")
|
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,3 +1,3 @@
|
|||||||
MAJOR=0
|
MAJOR=0
|
||||||
MINOR=21
|
MINOR=22
|
||||||
PATCH=0
|
PATCH=0
|
||||||
|
|||||||
@@ -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
|
||||||
+31
@@ -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
|
||||||
+31
@@ -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")
|
||||||
+82
@@ -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(_) => ()
|
||||||
+47
@@ -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)
|
||||||
+43
@@ -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))
|
||||||
+33
@@ -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)
|
||||||
+184
@@ -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()
|
||||||
+68
@@ -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)
|
||||||
+284
@@ -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
|
||||||
|
}
|
||||||
+32
@@ -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",
|
||||||
|
)
|
||||||
+233
@@ -0,0 +1,233 @@
|
|||||||
|
package de.nowchess.tournament.resource
|
||||||
|
|
||||||
|
import de.nowchess.tournament.client.{CoreGameClient, CoreGameResponse}
|
||||||
|
import io.quarkus.test.InjectMock
|
||||||
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
|
import io.restassured.RestAssured
|
||||||
|
import io.restassured.http.ContentType
|
||||||
|
import io.restassured.response.ValidatableResponse
|
||||||
|
import io.smallrye.jwt.build.Jwt
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.hamcrest.Matchers.*
|
||||||
|
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||||
|
import org.mockito.{ArgumentMatchers, Mockito}
|
||||||
|
|
||||||
|
@QuarkusTest
|
||||||
|
class TournamentResourceTest:
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
@RestClient
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
def setup(): Unit =
|
||||||
|
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("game-test-123"))
|
||||||
|
|
||||||
|
private def g() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||||
|
|
||||||
|
private def directorToken(userId: String = "director-1"): String =
|
||||||
|
Jwt.issuer("nowchess").subject(userId).expiresIn(3600).sign()
|
||||||
|
|
||||||
|
private def botToken(botId: String, botName: String): String =
|
||||||
|
Jwt.issuer("nowchess").subject(botId).claim("type", "bot").claim("name", botName).expiresIn(3600).sign()
|
||||||
|
|
||||||
|
private def authed(token: String) =
|
||||||
|
g().header("Authorization", s"Bearer $token")
|
||||||
|
|
||||||
|
private def formAuthed(token: String) =
|
||||||
|
RestAssured.`given`().contentType(ContentType.URLENC).header("Authorization", s"Bearer $token")
|
||||||
|
|
||||||
|
private def createTournament(token: String, name: String = "Test Tour", nbRounds: Int = 3): String =
|
||||||
|
formAuthed(token)
|
||||||
|
.formParam("name", name)
|
||||||
|
.formParam("nbRounds", nbRounds)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.formParam("rated", true)
|
||||||
|
.when().post("/api/tournament")
|
||||||
|
.`then`().statusCode(201).extract().path[String]("id")
|
||||||
|
|
||||||
|
private def postAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||||
|
authed(token).when().post(path).`then`().statusCode(expectedStatus)
|
||||||
|
|
||||||
|
private def deleteAndCheck(token: String, path: String, expectedStatus: Int): ValidatableResponse =
|
||||||
|
authed(token).when().delete(path).`then`().statusCode(expectedStatus)
|
||||||
|
|
||||||
|
private def botJoin(tournamentId: String, botId: String, botName: String): ValidatableResponse =
|
||||||
|
val bt = botToken(botId, botName)
|
||||||
|
authed(bt).when().post(s"/api/tournament/$tournamentId/join").`then`().statusCode(200)
|
||||||
|
|
||||||
|
private def startTournament(token: String, tournamentId: String): ValidatableResponse =
|
||||||
|
authed(token).when().post(s"/api/tournament/$tournamentId/start").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def createsTournamentWhenAuthenticated(): Unit =
|
||||||
|
formAuthed(directorToken())
|
||||||
|
.formParam("name", "Test Tour")
|
||||||
|
.formParam("nbRounds", 3)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.formParam("rated", true)
|
||||||
|
.when().post("/api/tournament")
|
||||||
|
.`then`().statusCode(201)
|
||||||
|
.body("fullName", is("Test Tour"))
|
||||||
|
.body("status", is("created"))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns401WhenUnauthenticated(): Unit =
|
||||||
|
RestAssured.`given`().contentType(ContentType.URLENC)
|
||||||
|
.formParam("name", "Test Tour")
|
||||||
|
.formParam("nbRounds", 3)
|
||||||
|
.formParam("clockLimit", 300)
|
||||||
|
.formParam("clockIncrement", 5)
|
||||||
|
.when().post("/api/tournament")
|
||||||
|
.`then`().statusCode(401)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsEmptyListsOnFreshStart(): Unit =
|
||||||
|
RestAssured.`given`().when().get("/api/tournament")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.body("created", notNullValue())
|
||||||
|
.body("started", notNullValue())
|
||||||
|
.body("finished", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsCreatedTournamentInCreatedList(): Unit =
|
||||||
|
val id = createTournament(directorToken("director-list"), "ListTour")
|
||||||
|
RestAssured.`given`().when().get("/api/tournament")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.body("created.id", hasItem(id))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns404ForUnknownId(): Unit =
|
||||||
|
RestAssured.`given`().when().get("/api/tournament/XXXXXX").`then`().statusCode(404)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsTournamentWithStandings(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-get"), "GetTour")
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.body("id", is(id))
|
||||||
|
.body("standing", notNullValue())
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def directorCanTerminateCreatedTournament(): Unit =
|
||||||
|
val token = directorToken("dir-term")
|
||||||
|
val id = createTournament(token, "TermTour")
|
||||||
|
deleteAndCheck(token, s"/api/tournament/$id", 204)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonDirectorGets403OnTerminate(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-403"), "SecureTour")
|
||||||
|
deleteAndCheck(directorToken("other-user-403"), s"/api/tournament/$id", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def cannotTerminateStartedTournament(): Unit =
|
||||||
|
val token = directorToken("dir-started")
|
||||||
|
val id = createTournament(token, "StartedTour")
|
||||||
|
botJoin(id, "sbot-1", "StartBot1")
|
||||||
|
botJoin(id, "sbot-2", "StartBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
deleteAndCheck(token, s"/api/tournament/$id", 409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def botJoinsSuccessfully(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-join"), "JoinTour")
|
||||||
|
authed(botToken("joinbot-1", "JoinBot1"))
|
||||||
|
.when().post(s"/api/tournament/$id/join")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.body("ok", is(true))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonBotTokenReturns403OnJoin(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-nbjoin"), "NbJoinTour")
|
||||||
|
postAndCheck(directorToken("regular-user"), s"/api/tournament/$id/join", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def alreadyJoinedReturns409(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-dbl"), "DblJoinTour")
|
||||||
|
val bt = botToken("dblbot-1", "DblBot1")
|
||||||
|
botJoin(id, "dblbot-1", "DblBot1")
|
||||||
|
authed(bt).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def startedTournamentReturns409OnJoin(): Unit =
|
||||||
|
val token = directorToken("dir-sjoin")
|
||||||
|
val id = createTournament(token, "SjoinTour")
|
||||||
|
botJoin(id, "sjbot-1", "SjBot1")
|
||||||
|
botJoin(id, "sjbot-2", "SjBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
authed(botToken("sjbot-3", "SjBot3")).when().post(s"/api/tournament/$id/join").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def joinedBotCanWithdraw(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-wd"), "WdTour")
|
||||||
|
val bt = botToken("wdbot-1", "WdBot1")
|
||||||
|
botJoin(id, "wdbot-1", "WdBot1")
|
||||||
|
authed(bt).when().post(s"/api/tournament/$id/withdraw")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.body("ok", is(true))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def notJoinedBotReturns409OnWithdraw(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-wdnj"), "WdnjTour")
|
||||||
|
val bt = botToken("wdnjbot-1", "WdnjBot1")
|
||||||
|
authed(bt).when().post(s"/api/tournament/$id/withdraw").`then`().statusCode(409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def directorStartsWith2Bots(): Unit =
|
||||||
|
val token = directorToken("dir-start")
|
||||||
|
val id = createTournament(token, "StartTour2")
|
||||||
|
botJoin(id, "stbot-1", "StBot1")
|
||||||
|
botJoin(id, "stbot-2", "StBot2")
|
||||||
|
postAndCheck(token, s"/api/tournament/$id/start", 200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def nonDirectorReturns403OnStart(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-ndstart"), "NdStartTour")
|
||||||
|
botJoin(id, "ndstbot-1", "NdstBot1")
|
||||||
|
botJoin(id, "ndstbot-2", "NdstBot2")
|
||||||
|
postAndCheck(directorToken("other-ndstart"), s"/api/tournament/$id/start", 403)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def fewerThan2BotsReturns409OnStart(): Unit =
|
||||||
|
val token = directorToken("dir-1bot")
|
||||||
|
val id = createTournament(token, "1BotTour")
|
||||||
|
botJoin(id, "onebot-1", "OneBot1")
|
||||||
|
postAndCheck(token, s"/api/tournament/$id/start", 409)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def resultsReturns200WithNdjsonContentType(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-res"), "ResTour")
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id/results")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.contentType("application/x-ndjson")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsPairingsForRoundAfterStart(): Unit =
|
||||||
|
val token = directorToken("dir-round")
|
||||||
|
val id = createTournament(token, "RoundTour")
|
||||||
|
botJoin(id, "rndbot-1", "RndBot1")
|
||||||
|
botJoin(id, "rndbot-2", "RndBot2")
|
||||||
|
startTournament(token, id)
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id/round/1").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returns404ForUnknownTournamentRound(): Unit =
|
||||||
|
RestAssured.`given`().when().get("/api/tournament/XXXXXX/round/1").`then`().statusCode(404)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsPgnByDefault(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-pgn"), "PgnTour")
|
||||||
|
RestAssured.`given`().when().get(s"/api/tournament/$id/export/games").`then`().statusCode(200)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def returnsNdjsonWhenAcceptApplicationXNdjson(): Unit =
|
||||||
|
val id = createTournament(directorToken("dir-ndjson"), "NdjsonTour")
|
||||||
|
RestAssured.`given`()
|
||||||
|
.header("Accept", "application/x-ndjson")
|
||||||
|
.when().get(s"/api/tournament/$id/export/games")
|
||||||
|
.`then`().statusCode(200)
|
||||||
|
.contentType("application/x-ndjson")
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
package de.nowchess.tournament.service
|
||||||
|
|
||||||
|
import de.nowchess.tournament.domain.{TournamentPairing, TournamentParticipant}
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import org.junit.jupiter.api.Assertions.*
|
||||||
|
|
||||||
|
class SwissPairingServiceTest:
|
||||||
|
|
||||||
|
private def makeParticipant(botId: String, botName: String, points: Double = 0.0, byeCount: Int = 0): TournamentParticipant =
|
||||||
|
val p = new TournamentParticipant()
|
||||||
|
p.botId = botId
|
||||||
|
p.botName = botName
|
||||||
|
p.points = points
|
||||||
|
p.byeCount = byeCount
|
||||||
|
p
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def pairs2PlayersRandomlyAssignsColors(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "BotOne")
|
||||||
|
val p2 = makeParticipant("b2", "BotTwo")
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isEmpty)
|
||||||
|
val (white, black) = pairs.head
|
||||||
|
val ids = Set(white.botId, black.botId)
|
||||||
|
assertEquals(Set("b1", "b2"), ids)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def pairs4PlayersTopVsEachOther(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 1.0)
|
||||||
|
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3, p4), Nil)
|
||||||
|
assertEquals(2, pairs.size)
|
||||||
|
assertTrue(bye.isEmpty)
|
||||||
|
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||||
|
val pair2Ids = Set(pairs(1)._1.botId, pairs(1)._2.botId)
|
||||||
|
assertEquals(Set("b1", "b2"), pair1Ids)
|
||||||
|
assertEquals(Set("b3", "b4"), pair2Ids)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def oddCountLowestRankedGetsBye(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.0)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 0.0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isDefined)
|
||||||
|
assertEquals("b3", bye.get.botId)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def avoidsRematchSwapsWhenPairAlreadyPlayed(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 2.0)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 1.5)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 1.5)
|
||||||
|
val p4 = makeParticipant("b4", "D", points = 0.0)
|
||||||
|
val pastPairing = new TournamentPairing()
|
||||||
|
pastPairing.whiteId = "b1"
|
||||||
|
pastPairing.blackId = "b2"
|
||||||
|
val (pairs, _) = SwissPairingService.computePairings(List(p1, p2, p3, p4), List(pastPairing))
|
||||||
|
val pair1Ids = Set(pairs(0)._1.botId, pairs(0)._2.botId)
|
||||||
|
assertFalse(pair1Ids == Set("b1", "b2"), "b1 and b2 should not be paired again")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
def playerWithFewerByesGetsTheByeFirst(): Unit =
|
||||||
|
val p1 = makeParticipant("b1", "A", points = 1.0, byeCount = 1)
|
||||||
|
val p2 = makeParticipant("b2", "B", points = 0.5, byeCount = 0)
|
||||||
|
val p3 = makeParticipant("b3", "C", points = 0.0, byeCount = 0)
|
||||||
|
val (pairs, bye) = SwissPairingService.computePairings(List(p1, p2, p3), Nil)
|
||||||
|
assertEquals(1, pairs.size)
|
||||||
|
assertTrue(bye.isDefined)
|
||||||
|
assertEquals("b3", bye.get.botId)
|
||||||
@@ -26,4 +26,5 @@ include(
|
|||||||
"modules:ws",
|
"modules:ws",
|
||||||
"modules:store",
|
"modules:store",
|
||||||
"modules:coordinator",
|
"modules:coordinator",
|
||||||
|
"modules:tournament",
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user