diff --git a/.codesight/CODESIGHT.md b/.codesight/CODESIGHT.md
index acfeafa..1b80df2 100644
--- a/.codesight/CODESIGHT.md
+++ b/.codesight/CODESIGHT.md
@@ -2,7 +2,7 @@
> **Stack:** raw-http | none | unknown | scala
-> 0 routes + 40 rpc | 0 models | 0 components | 140 lib files | 1 env vars | 1 middleware
+> 0 routes + 40 rpc | 0 models | 0 components | 146 lib files | 1 env vars | 1 middleware
> **Token savings:** this file is ~0 tokens. Without it, AI exploration would cost ~0 tokens. **Saves ~0 tokens per conversation.**
---
@@ -318,6 +318,8 @@
- function rebalanceMinInterval
- function heartbeatTtl
- _...11 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
- class CoreGrpcClient
@@ -574,6 +576,8 @@
- function isCheckmate
- _...6 more_
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
+- `modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala` — class RedisConfig
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
- class RedissonProducer
@@ -591,6 +595,8 @@
- function merge
- `modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
+- `modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala` — class RedisConfig
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
- class RedissonProducer
@@ -627,7 +633,7 @@
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **76** files
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **57** files
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **55** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **46** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **47** files
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **28** files
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **20** files
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **20** files
@@ -642,15 +648,15 @@
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **9** files
- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — imported by **8** files
- `modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala` — imported by **7** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala` — imported by **6** files
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
-- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` — imported by **6** files
## Import Map (who imports what)
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +71 more
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala` +52 more
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +50 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala`, `modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +41 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala`, `modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +42 more
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +23 more
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +15 more
diff --git a/.codesight/graph.md b/.codesight/graph.md
index 0c644f6..e6975ab 100644
--- a/.codesight/graph.md
+++ b/.codesight/graph.md
@@ -5,7 +5,7 @@
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **76** files
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **57** files
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **55** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **46** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **47** files
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **28** files
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **20** files
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **20** files
@@ -20,15 +20,15 @@
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **9** files
- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — imported by **8** files
- `modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala` — imported by **7** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala` — imported by **6** files
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
-- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` — imported by **6** files
## Import Map (who imports what)
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +71 more
- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/move/Move.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala` +52 more
- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/api/src/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +50 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala`, `modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +41 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala`, `modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala` +42 more
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +23 more
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +15 more
diff --git a/.codesight/libs.md b/.codesight/libs.md
index 4ad2ab4..f8a9e41 100644
--- a/.codesight/libs.md
+++ b/.codesight/libs.md
@@ -262,6 +262,8 @@
- function rebalanceMinInterval
- function heartbeatTtl
- _...11 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
- class CoreGrpcClient
@@ -518,6 +520,8 @@
- function isCheckmate
- _...6 more_
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
+- `modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala` — class RedisConfig
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
- class RedissonProducer
@@ -535,6 +539,8 @@
- function merge
- `modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
+- `modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala` — class RedisConfig
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
- class RedissonProducer
diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml
new file mode 100644
index 0000000..89a8a93
--- /dev/null
+++ b/.idea/copilotDiffState.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 49efe00..0171fb6 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -12,11 +12,12 @@
-
+
+
diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 08120e7..808898e 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
diff --git a/docs/board-api-spec.yaml b/docs/board-api-spec.yaml
deleted file mode 100644
index 61bf241..0000000
--- a/docs/board-api-spec.yaml
+++ /dev/null
@@ -1,771 +0,0 @@
-openapi: 3.0.3
-info:
- title: NowChess Board API
- description: |
- REST API for the NowChess application. Designed to feel familiar to users
- of the [lichess API](https://lichess.org/api).
-
- ## Authentication
- Most endpoints require a Bearer token:
- ```
- Authorization: Bearer
- ```
- Authentication is reserved for future implementation — endpoints are currently
- open unless noted otherwise.
-
- ## Move notation
- Moves are expressed in **UCI notation**: `{from}{to}[promotion]`
- - Normal move: `e2e4`
- - Capture: `d5e6`
- - Promotion: `e7e8q` (q=queen, r=rook, b=bishop, n=knight)
- - Castling: `e1g1` (kingside white), `e1c1` (queenside white)
-
- ## Streaming
- Endpoints that support streaming return **NDJSON** (newline-delimited JSON).
- Request them with:
- ```
- Accept: application/x-ndjson
- ```
- Each line of the response is a complete JSON object. Empty lines are
- keep-alive heartbeats.
-
- ## Rate limiting
- Requests that exceed the rate limit receive `429 Too Many Requests`.
- Honour the `Retry-After` response header and wait before retrying.
- version: 1.0.0
- contact:
- name: NowChess
- license:
- name: MIT
-
-servers:
- - url: http://localhost:8080
- description: Local development server
-
-tags:
- - name: game
- description: Create and manage chess games
- - name: move
- description: Make moves and navigate game history
- - name: draw
- description: Draw offers and claims
- - name: import
- description: Load a game from FEN or PGN
- - name: export
- description: Export a game as FEN or PGN
-
-paths:
-
- # ---------------------------------------------------------------------------
- # Game lifecycle
- # ---------------------------------------------------------------------------
-
- /api/board/game:
- post:
- operationId: createGame
- tags: [game]
- summary: Create a new game
- description: |
- Creates a new chess game starting from the initial position.
- Returns the full game state including the generated `gameId`.
- security:
- - bearerAuth: []
- requestBody:
- required: false
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/CreateGameRequest'
- responses:
- '201':
- description: Game created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}:
- get:
- operationId: getGame
- tags: [game]
- summary: Get game state
- description: Returns the full current state of a game.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Current game state
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/stream:
- get:
- operationId: streamGame
- tags: [game]
- summary: Stream game events
- description: |
- Opens a persistent NDJSON stream for a game. The first object sent is
- a `gameFull` event containing the complete game state. Subsequent
- objects are `gameState` events sent whenever the game changes (move
- made, draw offered, game over, etc.).
-
- Empty lines are heartbeats to keep the connection alive.
-
- Connect with:
- ```
- Accept: application/x-ndjson
- ```
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: NDJSON event stream
- content:
- application/x-ndjson:
- schema:
- oneOf:
- - $ref: '#/components/schemas/GameFullEvent'
- - $ref: '#/components/schemas/GameStateEvent'
- - $ref: '#/components/schemas/ErrorEvent'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/resign:
- post:
- operationId: resignGame
- tags: [game]
- summary: Resign the game
- description: The active player resigns. The game ends immediately.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Resignation accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/OkResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Move-making
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/move/{uci}:
- post:
- operationId: makeMove
- tags: [move]
- summary: Make a move
- description: |
- Submit a move in UCI notation. The move must be legal for the side
- currently to move.
-
- For promotion moves include the target piece as the fifth character:
- `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character
- are rejected with `400 INVALID_MOVE`.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: uci
- in: path
- required: true
- description: Move in UCI notation (e.g. `e2e4`, `e7e8q`)
- schema:
- type: string
- pattern: '^[a-h][1-8][a-h][1-8][qrbn]?$'
- example: e2e4
- responses:
- '200':
- description: Move applied — returns updated game state
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/moves:
- get:
- operationId: getLegalMoves
- tags: [move]
- summary: Get legal moves
- description: |
- Returns all legal moves for the side currently to move.
- Optionally filter to moves originating from a single square.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: square
- in: query
- required: false
- description: Filter to moves from this square (e.g. `e2`)
- schema:
- type: string
- pattern: '^[a-h][1-8]$'
- example: e2
- responses:
- '200':
- description: List of legal moves
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/LegalMovesResponse'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/undo:
- post:
- operationId: undoMove
- tags: [move]
- summary: Undo the last move
- description: Reverts the most recent move. Returns the updated game state.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Move undone
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- description: No moves to undo
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/redo:
- post:
- operationId: redoMove
- tags: [move]
- summary: Redo a previously undone move
- description: Re-applies the next move in the undo stack. Returns the updated game state.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Move redone
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- description: No moves to redo
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Draw handling
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/draw/{action}:
- post:
- operationId: drawAction
- tags: [draw]
- summary: Offer, accept, decline, or claim a draw
- description: |
- Perform a draw-related action:
-
- | Action | Description |
- |-----------|-------------|
- | `offer` | Offer a draw to the opponent |
- | `accept` | Accept the opponent's draw offer |
- | `decline` | Decline the opponent's draw offer |
- | `claim` | Claim a draw under the fifty-move rule (only valid when `status` is `fiftyMoveAvailable`) |
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: action
- in: path
- required: true
- schema:
- type: string
- enum: [offer, accept, decline, claim]
- responses:
- '200':
- description: Action accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/OkResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Import
- # ---------------------------------------------------------------------------
-
- /api/board/game/import/fen:
- post:
- operationId: importFen
- tags: [import]
- summary: Load a position from FEN
- description: |
- Creates a new game from a FEN string. The game starts at the position
- described by the FEN; move history prior to that position is not
- available.
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ImportFenRequest'
- responses:
- '201':
- description: Game created from FEN
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/import/pgn:
- post:
- operationId: importPgn
- tags: [import]
- summary: Load a game from PGN
- description: |
- Creates a new game by replaying all moves in a PGN string. The game
- starts at the position after the final move in the PGN; undo is
- available for every replayed move.
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ImportPgnRequest'
- responses:
- '201':
- description: Game created from PGN
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Export
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/export/fen:
- get:
- operationId: exportFen
- tags: [export]
- summary: Export current position as FEN
- description: Returns the FEN string representing the current board position.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: FEN string
- content:
- text/plain:
- schema:
- type: string
- example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/export/pgn:
- get:
- operationId: exportPgn
- tags: [export]
- summary: Export game as PGN
- description: Returns the full PGN for the game including headers and move text.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: PGN text
- content:
- application/x-chess-pgn:
- schema:
- type: string
- example: |
- [Event "NowChess game"]
- [White "Player1"]
- [Black "Player2"]
- [Result "*"]
-
- 1. e4 e5 2. Nf3 *
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
-# =============================================================================
-# Components
-# =============================================================================
-
-components:
-
- securitySchemes:
- bearerAuth:
- type: http
- scheme: bearer
- description: 'Personal access token — `Authorization: Bearer `'
-
- parameters:
- gameId:
- name: gameId
- in: path
- required: true
- description: 8-character alphanumeric game ID (e.g. `Qa7FJNk2`)
- schema:
- type: string
- pattern: '^[A-Za-z0-9]{8}$'
- example: Qa7FJNk2
-
- responses:
- BadRequest:
- description: Invalid input
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- Unauthorized:
- description: Missing or invalid authentication token
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- NotFound:
- description: Game not found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- TooManyRequests:
- description: Rate limit exceeded — see `Retry-After` header
- headers:
- Retry-After:
- description: Seconds to wait before retrying
- schema:
- type: integer
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
-
- schemas:
-
- # -------------------------------------------------------------------------
- # Requests
- # -------------------------------------------------------------------------
-
- CreateGameRequest:
- type: object
- description: Parameters for creating a new game. All fields are optional.
- properties:
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
-
- ImportFenRequest:
- type: object
- required: [fen]
- properties:
- fen:
- type: string
- description: Complete FEN string (6 fields)
- example: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
-
- ImportPgnRequest:
- type: object
- required: [pgn]
- properties:
- pgn:
- type: string
- description: PGN text (headers and move list)
- example: "1. e4 e5 2. Nf3 Nc6 *"
-
- # -------------------------------------------------------------------------
- # Game state
- # -------------------------------------------------------------------------
-
- GameFull:
- type: object
- description: Complete game information including players and current state.
- required: [gameId, white, black, state]
- properties:
- gameId:
- type: string
- description: Unique 8-character game identifier
- example: Qa7FJNk2
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
- state:
- $ref: '#/components/schemas/GameState'
-
- GameState:
- type: object
- description: |
- The current game state. Included in `GameFull` and returned by move
- endpoints and stream events.
- required: [fen, pgn, turn, status, moves, undoAvailable, redoAvailable]
- properties:
- fen:
- type: string
- description: FEN string for the current position
- example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
- pgn:
- type: string
- description: PGN move text for the full game so far
- example: "1. e4"
- turn:
- type: string
- enum: [white, black]
- description: The side to move
- status:
- $ref: '#/components/schemas/GameStatus'
- winner:
- type: string
- enum: [white, black]
- description: Set when `status` is `checkmate` or `resign`
- nullable: true
- moves:
- type: array
- description: All moves played so far, in UCI notation
- items:
- type: string
- example: [e2e4, e7e5, g1f3]
- undoAvailable:
- type: boolean
- description: Whether `POST /undo` is currently valid
- redoAvailable:
- type: boolean
- description: Whether `POST /redo` is currently valid
-
- GameStatus:
- type: string
- description: |
- Current game status:
-
- | Value | Meaning |
- |-------|---------|
- | `started` | Game in progress, no special condition |
- | `check` | Side to move is in check |
- | `checkmate` | Side to move is checkmated — game over |
- | `stalemate` | Side to move has no legal moves, not in check — game over (draw) |
- | `resign` | A player resigned — game over |
- | `draw` | Draw agreed or claimed — game over |
- | `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
- | `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
- | `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
- enum:
- - started
- - check
- - checkmate
- - stalemate
- - resign
- - draw
- - drawOffered
- - fiftyMoveAvailable
- - insufficientMaterial
-
- # -------------------------------------------------------------------------
- # Moves
- # -------------------------------------------------------------------------
-
- LegalMovesResponse:
- type: object
- required: [moves]
- properties:
- moves:
- type: array
- items:
- $ref: '#/components/schemas/LegalMove'
-
- LegalMove:
- type: object
- required: [from, to, uci, moveType]
- properties:
- from:
- type: string
- description: Origin square in algebraic notation
- example: e2
- to:
- type: string
- description: Destination square in algebraic notation
- example: e4
- uci:
- type: string
- description: Full move in UCI notation
- example: e2e4
- moveType:
- $ref: '#/components/schemas/MoveType'
- promotion:
- type: string
- enum: [queen, rook, bishop, knight]
- description: Target piece for promotion moves
- nullable: true
-
- MoveType:
- type: string
- description: Classification of the move
- enum:
- - normal
- - capture
- - castleKingside
- - castleQueenside
- - enPassant
- - promotion
-
- # -------------------------------------------------------------------------
- # Streaming events
- # -------------------------------------------------------------------------
-
- GameFullEvent:
- type: object
- description: |
- First event on a game stream. Contains the complete game snapshot.
- required: [type, game]
- properties:
- type:
- type: string
- enum: [gameFull]
- game:
- $ref: '#/components/schemas/GameFull'
-
- GameStateEvent:
- type: object
- description: |
- Emitted on a game stream whenever the game state changes (move played,
- draw offered, game over, etc.).
- required: [type, state]
- properties:
- type:
- type: string
- enum: [gameState]
- state:
- $ref: '#/components/schemas/GameState'
-
- ErrorEvent:
- type: object
- description: Emitted on a game stream when an error occurs.
- required: [type, error]
- properties:
- type:
- type: string
- enum: [error]
- error:
- $ref: '#/components/schemas/ApiError'
-
- # -------------------------------------------------------------------------
- # Shared types
- # -------------------------------------------------------------------------
-
- PlayerInfo:
- type: object
- required: [id, displayName]
- properties:
- id:
- type: string
- description: Unique player identifier
- example: player1
- displayName:
- type: string
- description: Human-readable display name
- example: Alice
-
- OkResponse:
- type: object
- required: [ok]
- properties:
- ok:
- type: boolean
- enum: [true]
-
- ApiError:
- type: object
- required: [code, message]
- properties:
- code:
- type: string
- description: Machine-readable error code
- example: INVALID_MOVE
- message:
- type: string
- description: Human-readable error description
- example: e2e5 is not a legal move
- field:
- type: string
- description: Request field that caused the error, if applicable
- example: uci
- nullable: true
diff --git a/modules/account/src/main/resources/application.yml b/modules/account/src/main/resources/application.yml
index 0f9d82c..c63e973 100644
--- a/modules/account/src/main/resources/application.yml
+++ b/modules/account/src/main/resources/application.yml
@@ -6,6 +6,8 @@ quarkus:
rest-client:
core-service:
url: http://localhost:8080
+ bot-platform-service:
+ url: http://localhost:8087
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
@@ -27,6 +29,8 @@ quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
+ bot-platform-service:
+ url: ${BOT_PLATFORM_SERVICE_URL}
datasource:
db-kind: postgresql
username: ${DB_USER}
diff --git a/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala b/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala
new file mode 100644
index 0000000..2d0faad
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/client/BotPlatformClient.scala
@@ -0,0 +1,20 @@
+package de.nowchess.account.client
+
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+
+@Path("/api/bot")
+@RegisterRestClient(configKey = "bot-platform-service")
+trait BotPlatformClient:
+
+ @POST
+ @Path("/game/{gameId}/assign")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def assignBot(
+ @PathParam("gameId") gameId: String,
+ @QueryParam("botId") botId: String,
+ @QueryParam("difficulty") difficulty: Int,
+ @QueryParam("playingAs") playingAs: String,
+ @QueryParam("botAccountId") botAccountId: String,
+ ): Unit
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala
index f2eefb1..d9ba14f 100644
--- a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala
@@ -31,7 +31,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[BotAccountDto],
classOf[BotAccountWithTokenDto],
classOf[OfficialBotAccountDto],
- classOf[OfficialBotAccountWithTokenDto],
classOf[CreateBotAccountRequest],
classOf[UpdateBotNameRequest],
classOf[RotatedTokenDto],
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala
index 8731bdb..ebd16b0 100644
--- a/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala
@@ -72,9 +72,6 @@ class OfficialBotAccount extends PanacheEntityBase:
@Column(nullable = false)
var name: String = uninitialized
- @Column(unique = true, nullable = false, length = 256)
- var token: String = uninitialized
-
var rating: Int = 1500
var createdAt: Instant = uninitialized
diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala
index c192672..d95c08e 100644
--- a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala
@@ -46,4 +46,5 @@ case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
-case class OfficialBotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String)
+
+case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala
index 4a42012..6374381 100644
--- a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala
@@ -67,7 +67,6 @@ class BotAccountRepository:
def delete(botId: UUID): Unit =
em.find(classOf[BotAccount], botId) match
case bot: BotAccount => em.remove(bot)
- case _ => ()
def findByToken(token: String): Option[BotAccount] =
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
@@ -97,11 +96,4 @@ class OfficialBotAccountRepository:
def delete(botId: UUID): Unit =
em.find(classOf[OfficialBotAccount], botId) match
case bot: OfficialBotAccount => em.remove(bot)
- case _ => ()
- def findByToken(token: String): Option[OfficialBotAccount] =
- em.createQuery("FROM OfficialBotAccount WHERE token = :token", classOf[OfficialBotAccount])
- .setParameter("token", token)
- .getResultList
- .asScala
- .headOption
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala
index 6c6863d..bf4a3f7 100644
--- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala
@@ -170,7 +170,7 @@ class AccountResource:
@GET
@Path("/official-bots")
- def getOfficialBots(): Response =
+ def getOfficialBots: Response =
val bots = accountService.getOfficialBotAccounts()
Response.ok(bots.map(toOfficialBotDto)).build()
@@ -180,7 +180,7 @@ class AccountResource:
def createOfficialBot(req: CreateBotAccountRequest): Response =
accountService.createOfficialBotAccount(req.name) match
case Right(bot) =>
- Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
+ Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
case Left(error) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
@@ -193,15 +193,6 @@ class AccountResource:
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
- @POST
- @Path("/official-bots/{botId}/rotate-token")
- @RolesAllowed(Array("Admin"))
- def rotateOfficialBotToken(@PathParam("botId") botId: String): Response =
- accountService.rotateOfficialBotToken(UUID.fromString(botId)) match
- case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build()
- case Left(error) =>
- Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
-
private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto =
OfficialBotAccountDto(
id = bot.id.toString,
@@ -210,11 +201,3 @@ class AccountResource:
createdAt = bot.createdAt.toString,
)
- private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountWithTokenDto =
- OfficialBotAccountWithTokenDto(
- id = bot.id.toString,
- name = bot.name,
- rating = bot.rating,
- token = bot.token,
- createdAt = bot.createdAt.toString,
- )
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
new file mode 100644
index 0000000..ebc3346
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
@@ -0,0 +1,94 @@
+package de.nowchess.account.resource
+
+import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
+import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
+import de.nowchess.account.service.AccountService
+import jakarta.annotation.security.RolesAllowed
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+import org.eclipse.microprofile.jwt.JsonWebToken
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.jboss.logging.Logger
+import scala.compiletime.uninitialized
+
+import java.util.UUID
+
+@Path("/api/challenge/official")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class OfficialChallengeResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var accountService: AccountService = uninitialized
+
+ @Inject
+ var jwt: JsonWebToken = uninitialized
+
+ @Inject
+ @RestClient
+ var coreGameClient: CoreGameClient = uninitialized
+
+ @Inject
+ @RestClient
+ var botPlatformClient: BotPlatformClient = uninitialized
+ // scalafix:on
+
+ private val log = Logger.getLogger(classOf[OfficialChallengeResource])
+
+ @POST
+ @Path("/{botName}")
+ def challengeWithDifficulty(
+ @PathParam("botName") botName: String,
+ @QueryParam("difficulty") difficulty: Int,
+ @QueryParam("color") color: String,
+ ): Response =
+ if difficulty < 1000 || difficulty > 2800 then
+ return Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(ErrorDto("difficulty must be between 1000 and 2800"))
+ .build()
+
+ val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
+ case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
+ case other =>
+ return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build()
+
+ val userId = UUID.fromString(jwt.getSubject)
+
+ val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
+ val userOpt = accountService.findById(userId)
+
+ (botOpt, userOpt) match
+ case (None, _) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Official bot '$botName' not found")).build()
+ case (_, None) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
+ case (Some(bot), Some(user)) =>
+ val userIsWhite = playerColor match
+ case "white" => true
+ case "black" => false
+ case _ => scala.util.Random.nextBoolean()
+ val (white, black, botColor) =
+ if userIsWhite then
+ (CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
+ else
+ (CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
+ val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
+ val gameId =
+ try Right(coreGameClient.createGame(req).gameId)
+ catch case _ => Left("Failed to create game")
+ gameId match
+ case Left(err) =>
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
+ case Right(id) =>
+ try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
+ Response
+ .status(Response.Status.CREATED)
+ .entity(OfficialChallengeResponse(id, botName, difficulty))
+ .build()
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala
index 293a08a..21a3489 100644
--- a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala
@@ -121,7 +121,6 @@ class AccountService:
def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
val bot = new OfficialBotAccount()
bot.name = botName
- bot.token = generateOfficialBotToken(bot.id)
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
Right(bot)
@@ -137,15 +136,6 @@ class AccountService:
officialBotAccountRepository.delete(botId)
Right(())
- @Transactional
- def rotateOfficialBotToken(botId: UUID): Either[AccountError, OfficialBotAccount] =
- officialBotAccountRepository.findById(botId) match
- case None => Left(AccountError.BotNotFound)
- case Some(bot) =>
- bot.token = generateOfficialBotToken(botId)
- officialBotAccountRepository.persist(bot)
- Right(bot)
-
private def generateBotToken(botId: UUID): String =
Jwt
.issuer("nowchess")
@@ -174,10 +164,3 @@ class AccountService:
userAccountRepository.persist(user)
Right(user)
- private def generateOfficialBotToken(botId: UUID): String =
- Jwt
- .issuer("nowchess")
- .subject(botId.toString)
- .expiresAt(Long.MaxValue)
- .claim("type", "official-bot")
- .sign()
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
index b2f05b8..57e0774 100644
--- a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
+++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
@@ -1,6 +1,7 @@
package de.nowchess.account.service
import de.nowchess.account.client.{
+ BotPlatformClient,
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
@@ -22,6 +23,7 @@ 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 java.time.Instant
@@ -31,6 +33,8 @@ import java.util.UUID
@ApplicationScoped
class ChallengeService:
+ private val log = Logger.getLogger(classOf[ChallengeService])
+
// scalafix:off DisableSyntax.var
@Inject
var userAccountRepository: UserAccountRepository = uninitialized
@@ -41,6 +45,10 @@ class ChallengeService:
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
+
+ @Inject
+ @RestClient
+ var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on
@Transactional
@@ -80,6 +88,7 @@ class ChallengeService:
challenge.status = ChallengeStatus.Accepted
challenge.gameId = gameId
challengeRepository.merge(challenge)
+ notifyBotIfNeeded(challenge, gameId)
challenge
@Transactional
@@ -111,6 +120,16 @@ class ChallengeService:
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
ChallengeListDto(in = incoming, out = outgoing)
+ private def notifyBotIfNeeded(challenge: Challenge, gameId: String): Unit =
+ val (white, black) = assignColors(challenge)
+ List(challenge.challenger, challenge.destUser).foreach { user =>
+ user.getBotAccounts.headOption.foreach { bot =>
+ val playingAs = if white.id == user.id.toString then "white" else "black"
+ try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
+ }
+ }
+
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
try
val (white, black) = assignColors(challenge)
diff --git a/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala b/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala
deleted file mode 100644
index f9cb965..0000000
--- a/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.nowchess.api.bot
-
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-
-trait Bot {
-
- def name: String
- def nextMove(context: GameContext): Option[Move]
-
-}
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala b/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala
deleted file mode 100644
index 4e5ce1e..0000000
--- a/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.nowchess.api.game
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.player.PlayerInfo
-
-sealed trait Participant
-final case class Human(playerInfo: PlayerInfo) extends Participant
-final case class BotParticipant(bot: Bot) extends Participant
diff --git a/modules/bot-platform/build.gradle.kts b/modules/bot-platform/build.gradle.kts
new file mode 100644
index 0000000..11f867b
--- /dev/null
+++ b/modules/bot-platform/build.gradle.kts
@@ -0,0 +1,115 @@
+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
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedPackages.set(
+ listOf(
+ "de\\.nowchess\\.botplatform\\.registry",
+ "de\\.nowchess\\.botplatform\\.resource",
+ )
+ )
+ excludedFiles.set(scoverageExcluded)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+
+ 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-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
+
+ implementation(project(":modules:api"))
+
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("io.quarkus:quarkus-junit")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation("io.quarkus:quarkus-test-security")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("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.EXCLUDE
+}
diff --git a/modules/bot-platform/src/main/resources/application.yml b/modules/bot-platform/src/main/resources/application.yml
new file mode 100644
index 0000000..81db361
--- /dev/null
+++ b/modules/bot-platform/src/main/resources/application.yml
@@ -0,0 +1,22 @@
+quarkus:
+ http:
+ port: 8087
+ application:
+ name: nowchess-bot-platform
+ smallrye-jwt:
+ enabled: true
+ log:
+ level: INFO
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala
new file mode 100644
index 0000000..76e9879
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.botplatform.config
+
+import com.fasterxml.jackson.core.Version
+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(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala
new file mode 100644
index 0000000..d59dac1
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala
@@ -0,0 +1,18 @@
+package de.nowchess.botplatform.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.host", defaultValue = "localhost")
+ var host: String = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
+ var port: Int = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedissonProducer.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedissonProducer.scala
new file mode 100644
index 0000000..6eb66d6
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedissonProducer.scala
@@ -0,0 +1,35 @@
+package de.nowchess.botplatform.config
+
+import jakarta.annotation.PreDestroy
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Produces
+import jakarta.inject.Inject
+import org.redisson.Redisson
+import org.redisson.api.RedissonClient
+import org.redisson.config.Config
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedissonProducer:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ private var clientOpt: Option[RedissonClient] = None
+ // scalafix:on DisableSyntax.var
+
+ @Produces
+ @ApplicationScoped
+ def produceRedissonClient(): RedissonClient =
+ val config = new Config()
+ config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
+ config.useSingleServer().setConnectionMinimumIdleSize(1)
+ config.useSingleServer().setConnectTimeout(500)
+ val client = Redisson.create(config)
+ clientOpt = Some(client)
+ client
+
+ @PreDestroy
+ def shutdown(): Unit =
+ clientOpt.foreach(_.shutdown())
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala
new file mode 100644
index 0000000..609032e
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotGameInfo.scala
@@ -0,0 +1,8 @@
+package de.nowchess.botplatform.registry
+
+case class BotGameInfo(
+ botId: String,
+ difficulty: Int,
+ playingAs: String,
+ botAccountId: String,
+)
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
new file mode 100644
index 0000000..9a35c24
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
@@ -0,0 +1,30 @@
+package de.nowchess.botplatform.registry
+
+import io.smallrye.mutiny.subscription.MultiEmitter
+import jakarta.enterprise.context.ApplicationScoped
+
+import java.util.concurrent.ConcurrentHashMap
+
+@ApplicationScoped
+class BotRegistry:
+
+ private val connections = ConcurrentHashMap[String, MultiEmitter[?]]()
+
+ def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
+ connections.put(botId, emitter)
+ ()
+
+ def unregister(botId: String): Unit =
+ connections.remove(botId)
+ ()
+
+ def dispatch(botId: String, event: String): Boolean =
+ Option(connections.get(botId)) match
+ case Some(emitter) =>
+ emitter.asInstanceOf[MultiEmitter[String]].emit(event)
+ true
+ case None => false
+
+ def registeredBots: List[String] =
+ import scala.jdk.CollectionConverters.*
+ connections.keys().asScala.toList
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
new file mode 100644
index 0000000..aa45935
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
@@ -0,0 +1,67 @@
+package de.nowchess.botplatform.resource
+
+import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
+import de.nowchess.botplatform.service.GameBotMonitor
+import io.smallrye.mutiny.Multi
+import jakarta.annotation.security.RolesAllowed
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+import org.eclipse.microprofile.jwt.JsonWebToken
+import scala.compiletime.uninitialized
+
+@Path("/api/bot")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+class BotEventResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var registry: BotRegistry = uninitialized
+
+ @Inject
+ var jwt: JsonWebToken = uninitialized
+
+ @Inject
+ var gameMonitor: GameBotMonitor = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ @GET
+ @Path("/stream/events")
+ @Produces(Array(MediaType.SERVER_SENT_EVENTS))
+ def streamEvents(@QueryParam("botId") botId: String): Multi[String] =
+ val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
+ val subject = Option(jwt.getSubject).getOrElse("")
+ if tokenType != "bot" || subject != botId then
+ Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot"))
+ else
+ Multi.createFrom().emitter[String] { emitter =>
+ registry.register(botId, emitter)
+ emitter.onTermination(() => registry.unregister(botId))
+ }
+
+ @GET
+ @Path("/game/stream/{gameId}")
+ @Produces(Array(MediaType.SERVER_SENT_EVENTS))
+ def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
+ Multi.createFrom().emitter[String] { emitter =>
+ registry.register(s"game-$gameId", emitter)
+ emitter.onTermination(() => registry.unregister(s"game-$gameId"))
+ }
+
+ @POST
+ @Path("/game/{gameId}/assign")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def assignBot(
+ @PathParam("gameId") gameId: String,
+ @QueryParam("botId") botId: String,
+ @QueryParam("difficulty") difficulty: Int,
+ @QueryParam("playingAs") playingAs: String,
+ @QueryParam("botAccountId") botAccountId: String,
+ ): Response =
+ val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
+ gameMonitor.watchGame(gameId, info)
+ val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
+ registry.dispatch(botId, event)
+ Response.ok().build()
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala
new file mode 100644
index 0000000..321f79a
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/service/GameBotMonitor.scala
@@ -0,0 +1,56 @@
+package de.nowchess.botplatform.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.botplatform.config.RedisConfig
+import de.nowchess.botplatform.registry.BotGameInfo
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.redisson.api.{RedissonClient, RBlockingQueue}
+import org.redisson.api.listener.MessageListener
+import scala.compiletime.uninitialized
+import java.util.concurrent.ConcurrentHashMap
+
+@ApplicationScoped
+class GameBotMonitor:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val listeners = ConcurrentHashMap[String, Int]()
+
+ def watchGame(gameId: String, info: BotGameInfo): Unit =
+ val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
+ val topic = redisson.getTopic(topicName)
+ val listenerId = topic.addListener(
+ classOf[String],
+ new MessageListener[String]:
+ def onMessage(channel: CharSequence, msg: String): Unit =
+ handleS2cEvent(gameId, msg, info),
+ )
+ listeners.put(gameId, listenerId)
+
+ def unwatchGame(gameId: String): Unit =
+ Option(listeners.remove(gameId)).foreach { listenerId =>
+ val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
+ redisson.getTopic(topicName).removeListener(listenerId)
+ }
+
+ private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
+
+ private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
+ try
+ val node = objectMapper.readTree(msg)
+ val status = Option(node.path("state").path("status").asText()).getOrElse("")
+ if terminalStatuses.contains(status) then
+ unwatchGame(gameId)
+ else
+ val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
+ if turn == info.playingAs then
+ val fen = node.path("state").path("fen").asText()
+ val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
+ val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
+ queue.put(req)
+ catch case _: Exception => ()
diff --git a/modules/bot/build.gradle.kts b/modules/bot/build.gradle.kts
deleted file mode 100644
index 8041789..0000000
--- a/modules/bot/build.gradle.kts
+++ /dev/null
@@ -1,79 +0,0 @@
-plugins {
- id("scala")
- id("org.scoverage")
-}
-
-group = "de.nowchess"
-version = "1.0-SNAPSHOT"
-
-@Suppress("UNCHECKED_CAST")
-val versions = rootProject.extra["VERSIONS"] as Map
-@Suppress("UNCHECKED_CAST")
-val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
-
-repositories {
- mavenCentral()
-}
-
-scala {
- scalaVersion = versions["SCALA3"]!!
-}
-
-scoverage {
- scoverageVersion.set(versions["SCOVERAGE"]!!)
- excludedPackages.set(
- listOf(
- "de\\.nowchess\\.bot\\.bots\\.NNUEBot",
- "de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
- "de\\.nowchess\\.bot\\.util\\.PolyglotBook",
- )
- )
- excludedFiles.set(scoverageExcluded)
-}
-
-tasks.withType {
- scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
-}
-
-dependencies {
-
- compileOnly("org.scala-lang:scala3-compiler_3") {
- version {
- strictly(versions["SCALA3"]!!)
- }
- }
- implementation("org.scala-lang:scala3-library_3") {
- version {
- strictly(versions["SCALA3"]!!)
- }
- }
-
- implementation(project(":modules:api"))
- implementation(project(":modules:io"))
- implementation(project(":modules:rule"))
- implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
-
- testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
- testImplementation("org.junit.jupiter:junit-jupiter")
- testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
- testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
-
- testRuntimeOnly("org.junit.platform:junit-platform-launcher")
-}
-
-tasks.test {
- useJUnitPlatform {
- includeEngines("scalatest")
- testLogging {
- events("skipped", "failed")
- }
- }
- finalizedBy(tasks.reportScoverage)
-}
-tasks.reportScoverage {
- dependsOn(tasks.test)
-}
-
-tasks.jar {
- duplicatesStrategy = DuplicatesStrategy.EXCLUDE
-}
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
deleted file mode 100644
index a52e5b9..0000000
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.nowchess.bot.bots
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-import de.nowchess.bot.bots.classic.EvaluationClassic
-import de.nowchess.bot.logic.AlphaBetaSearch
-import de.nowchess.bot.util.PolyglotBook
-import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
-import de.nowchess.api.rules.RuleSet
-import de.nowchess.rules.sets.DefaultRules
-
-final class ClassicalBot(
- difficulty: BotDifficulty,
- rules: RuleSet = DefaultRules,
- book: Option[PolyglotBook] = None,
-) extends Bot:
-
- private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationClassic)
- private val TIME_BUDGET_MS = 1000L
-
- override val name: String = s"ClassicalBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- val blockedMoves = BotMoveRepetition.blockedMoves(context)
- book
- .flatMap(_.probe(context))
- .filterNot(blockedMoves.contains)
- .orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS, blockedMoves))
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
deleted file mode 100644
index fd95d0d..0000000
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
+++ /dev/null
@@ -1,43 +0,0 @@
-package de.nowchess.bot.bots
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-import de.nowchess.bot.ai.Evaluation
-import de.nowchess.bot.bots.classic.EvaluationClassic
-import de.nowchess.bot.bots.nnue.EvaluationNNUE
-import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
-import de.nowchess.bot.util.PolyglotBook
-import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
-import de.nowchess.api.rules.RuleSet
-import de.nowchess.rules.sets.DefaultRules
-
-final class HybridBot(
- difficulty: BotDifficulty,
- rules: RuleSet = DefaultRules,
- book: Option[PolyglotBook] = None,
- nnueEvaluation: Evaluation = EvaluationNNUE,
- classicalEvaluation: Evaluation = EvaluationClassic,
- vetoReporter: String => Unit = println(_),
-) extends Bot:
-
- private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
-
- override val name: String = s"HybridBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- val blockedMoves = BotMoveRepetition.blockedMoves(context)
- book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse(searchWithVeto(context, blockedMoves))
-
- private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
- search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
- val next = rules.applyMove(context)(move)
- val staticNnue = nnueEvaluation.evaluate(next)
- val classical = classicalEvaluation.evaluate(next)
- val diff = (classical - staticNnue).abs
- if diff > Config.VETO_THRESHOLD then
- vetoReporter(
- f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
- )
- move
- }
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index c221b20..c07c28d 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -51,7 +51,7 @@ dependencies {
implementation(project(":modules:json"))
implementation(project(":modules:rule"))
implementation(project(":modules:io"))
- implementation(project(":modules:bot"))
+ implementation(project(":modules:official-bots"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
index 3aaea33..561de76 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
@@ -3,19 +3,15 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{
- BotParticipant,
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
- Human,
LiveClockState,
- Participant,
TimeControl,
WinReason,
}
-import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.api.error.GameError
@@ -25,7 +21,6 @@ import de.nowchess.api.rules.RuleSet
import java.time.Instant
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
-import scala.concurrent.{ExecutionContext, Future}
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
@@ -33,10 +28,6 @@ import scala.concurrent.{ExecutionContext, Future}
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet,
- val participants: Map[Color, Participant] = Map(
- Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
- Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
- ),
val timeControl: TimeControl = TimeControl.Unlimited,
initialClockState: Option[ClockState] = None,
initialDrawOffer: Option[Color] = None,
@@ -69,8 +60,6 @@ class GameEngine(
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck)
- private implicit val ec: ExecutionContext = ExecutionContext.global
-
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
@@ -325,9 +314,6 @@ class GameEngine(
notifyObservers(DrawEvent(currentContext, reason))
}
- /** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
- def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
-
/** Inject clock state directly (for testing). */
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
@@ -426,7 +412,6 @@ class GameEngine(
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
- requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -477,47 +462,6 @@ class GameEngine(
case _ =>
context.board.pieceAt(move.to)
- /** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
- */
- private def requestBotMoveIfNeeded(): Unit =
- val pendingBotMove = synchronized {
- participants.get(currentContext.turn) match
- case Some(BotParticipant(bot)) => Some((bot, currentContext))
- case _ => None
- }
-
- pendingBotMove.foreach { case (bot, contextAtRequest) =>
- Future {
- bot.nextMove(contextAtRequest) match
- case Some(move) => applyBotMove(move)
- case None => handleBotNoMove()
- }
- }
-
- private def applyBotMove(move: Move): Unit =
- synchronized {
- val color = currentContext.turn
- val from = move.from
- val to = move.to
- currentContext.board.pieceAt(from) match
- case Some(piece) if piece.color == color =>
- val legal = ruleSet.legalMoves(currentContext)(from)
- legal.find(m => m.to == to && m.moveType == move.moveType) match
- case Some(legalMove) => executeMove(legalMove)
- case None =>
- notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
- case _ =>
- notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
- }
-
- private def handleBotNoMove(): Unit =
- synchronized {
- if ruleSet.isCheckmate(currentContext) then
- val winner = currentContext.turn.opposite
- notifyObservers(CheckmateEvent(currentContext, winner))
- else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
- }
-
private def replayContextFromMoves(moves: List[Move]): GameContext =
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
index cc686cc..901f4c3 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
@@ -79,6 +79,11 @@ class GameResource:
val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
+ private def assertIsBot(): Unit =
+ val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
+ if !Set("bot", "official-bot").contains(botType) then
+ throw ForbiddenException("Only bots can make moves")
+
// scalafix:on DisableSyntax.throw
// ── mapping ──────────────────────────────────────────────────────────────
@@ -184,6 +189,7 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
+ assertIsBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
assertIsCurrentPlayer(entry)
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala
deleted file mode 100644
index 946068d..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala
+++ /dev/null
@@ -1,266 +0,0 @@
-package de.nowchess.chess.engine
-
-import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.{BotParticipant, GameContext, Human}
-import de.nowchess.api.move.{Move, MoveType}
-import de.nowchess.api.player.{PlayerId, PlayerInfo}
-import de.nowchess.bot.bots.ClassicalBot
-import de.nowchess.bot.{BotController, BotDifficulty}
-import de.nowchess.chess.observer.*
-import de.nowchess.rules.sets.DefaultRules
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
-
-private class NoMoveBot extends Bot:
- def name: String = "nomove"
- def nextMove(context: GameContext): Option[Move] = None
-
-private class FixedMoveBot(move: Move) extends Bot:
- def name: String = "fixed"
- def nextMove(context: GameContext): Option[Move] = Some(move)
-
-class GameEngineWithBotTest extends AnyFunSuite with Matchers:
-
- test("GameEngine can play against a ClassicalBot"):
- val bot = ClassicalBot(BotDifficulty.Easy)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
-
- // Collect events
- val moveCount = new AtomicInteger(0)
- val checkmateDetected = new AtomicBoolean(false)
- val gameEnded = new AtomicBoolean(false)
-
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent =>
- moveCount.incrementAndGet()
- case _: CheckmateEvent =>
- checkmateDetected.set(true)
- gameEnded.set(true)
- case _: DrawEvent =>
- gameEnded.set(true)
- case _ => ()
-
- engine.subscribe(observer)
-
- // Play a few moves: e2e4, then let the bot respond
- engine.processUserInput("e2e4")
-
- // Wait a bit for the bot to respond asynchronously
- Thread.sleep(5000)
-
- // White should have moved, then Black (bot) should have responded
- moveCount.get() should be >= 2
-
- test("BotController can list and retrieve bots"):
- val bots = BotController.listBots
- bots should contain("easy")
- bots should contain("medium")
- bots should contain("hard")
- bots should contain("expert")
-
- BotController.getBot("easy") should not be None
- BotController.getBot("medium") should not be None
- BotController.getBot("hard") should not be None
- BotController.getBot("expert") should not be None
- BotController.getBot("unknown") should be(None)
-
- test("GameEngine handles bot with different difficulty"):
- val hardBot = BotController.getBot("hard").get
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
- )
- engine.turn should equal(Color.White)
-
- val movesMade = new AtomicInteger(0)
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent => movesMade.incrementAndGet()
- case _ => ()
-
- engine.subscribe(observer)
-
- // White moves
- engine.processUserInput("d2d4")
- Thread.sleep(500) // Wait for bot response
-
- // At least white moved, possibly black also responded
- movesMade.get() should be >= 1
-
- test("GameEngine plays valid bot moves"):
- val bot = ClassicalBot(BotDifficulty.Easy)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
-
- val moveCount = new AtomicInteger(0)
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent => moveCount.incrementAndGet()
- case _ => ()
-
- engine.subscribe(observer)
-
- // Play a normal move
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
-
- // The game should have progressed with at least one move
- moveCount.get() should be >= 1
- // Game should not be ended (checkmate/stalemate)
- engine.context.moves.nonEmpty should be(true)
-
- test("startGame triggers bot when the starting player is a bot"):
- val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val movesMade = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: MoveExecutedEvent => movesMade.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(500)
- movesMade.get() should be >= 1
-
- test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
- val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
- val bot = new FixedMoveBot(illegalMove)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
- val invalidCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: InvalidMoveEvent => invalidCount.incrementAndGet()
- case _ => (),
- )
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
- invalidCount.get() should be >= 1
-
- test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
- val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
- val bot = new FixedMoveBot(invalidMove)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
- val invalidCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: InvalidMoveEvent => invalidCount.incrementAndGet()
- case _ => (),
- )
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
- invalidCount.get() should be >= 1
-
- test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
- // White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
- val board = Board(
- Map(
- Square(File.A, Rank.R1) -> Piece.WhiteKing,
- Square(File.B, Rank.R2) -> Piece.BlackQueen,
- Square(File.B, Rank.R8) -> Piece.BlackRook,
- Square(File.H, Rank.R8) -> Piece.BlackKing,
- ),
- )
- val ctx = GameContext.initial.copy(
- board = board,
- turn = Color.White,
- castlingRights = CastlingRights(false, false, false, false),
- enPassantSquare = None,
- halfMoveClock = 0,
- moves = List.empty,
- )
- val engine = GameEngine(
- ctx,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val checkmateCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: CheckmateEvent => checkmateCount.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(1000)
- checkmateCount.get() should be >= 1
-
- test("handleBotNoMove fires DrawEvent when position is stalemate"):
- // White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
- val board = Board(
- Map(
- Square(File.A, Rank.R1) -> Piece.WhiteKing,
- Square(File.B, Rank.R3) -> Piece.BlackQueen,
- Square(File.H, Rank.R8) -> Piece.BlackKing,
- ),
- )
- val ctx = GameContext.initial.copy(
- board = board,
- turn = Color.White,
- castlingRights = CastlingRights(false, false, false, false),
- enPassantSquare = None,
- halfMoveClock = 0,
- moves = List.empty,
- )
- val engine = GameEngine(
- ctx,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val drawCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: DrawEvent => drawCount.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(1000)
- drawCount.get() should be >= 1
-
- test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val unexpectedEvents = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
- case _: DrawEvent => unexpectedEvents.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(500)
- unexpectedEvents.get() shouldBe 0
diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
index cb56c06..470cee1 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
@@ -12,6 +12,7 @@ import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
+import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -34,8 +35,13 @@ class GameResourceIntegrationTest:
@InjectMock
var ioWrapper: IoGrpcClientWrapper = uninitialized
+ @InjectMock
+ var jwt: JsonWebToken = uninitialized
+
@BeforeEach
def setupMocks(): Unit =
+ when(jwt.getClaim[AnyRef]("type")).thenReturn("bot")
+
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
diff --git a/modules/official-bots/build.gradle.kts b/modules/official-bots/build.gradle.kts
new file mode 100644
index 0000000..aae0cc7
--- /dev/null
+++ b/modules/official-bots/build.gradle.kts
@@ -0,0 +1,118 @@
+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
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedPackages.set(
+ listOf(
+ "de\\.nowchess\\.bot\\.bots\\.NNUEBot",
+ "de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
+ "de\\.nowchess\\.bot\\.util\\.PolyglotBook",
+ "de\\.nowchess\\.bot\\.resource\\..*",
+ "de\\.nowchess\\.bot\\.config\\..*",
+ )
+ )
+ excludedFiles.set(scoverageExcluded)
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+dependencies {
+
+ 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-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+
+ implementation(project(":modules:api"))
+ implementation(project(":modules:io"))
+ implementation(project(":modules:rule"))
+ implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
+ implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
+
+ testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit")
+ testImplementation("io.rest-assured:rest-assured")
+ 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().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)
+}
diff --git a/modules/bot/codekiddy.bin b/modules/official-bots/codekiddy.bin
similarity index 100%
rename from modules/bot/codekiddy.bin
rename to modules/official-bots/codekiddy.bin
diff --git a/modules/bot/python/.gitignore b/modules/official-bots/python/.gitignore
similarity index 100%
rename from modules/bot/python/.gitignore
rename to modules/official-bots/python/.gitignore
diff --git a/modules/bot/python/DATASETS.md b/modules/official-bots/python/DATASETS.md
similarity index 100%
rename from modules/bot/python/DATASETS.md
rename to modules/official-bots/python/DATASETS.md
diff --git a/modules/bot/python/README.md b/modules/official-bots/python/README.md
similarity index 100%
rename from modules/bot/python/README.md
rename to modules/official-bots/python/README.md
diff --git a/modules/bot/python/nnue.py b/modules/official-bots/python/nnue.py
similarity index 100%
rename from modules/bot/python/nnue.py
rename to modules/official-bots/python/nnue.py
diff --git a/modules/bot/python/requirements.txt b/modules/official-bots/python/requirements.txt
similarity index 100%
rename from modules/bot/python/requirements.txt
rename to modules/official-bots/python/requirements.txt
diff --git a/modules/bot/python/run_pipeline.bat b/modules/official-bots/python/run_pipeline.bat
similarity index 100%
rename from modules/bot/python/run_pipeline.bat
rename to modules/official-bots/python/run_pipeline.bat
diff --git a/modules/bot/python/run_pipeline.sh b/modules/official-bots/python/run_pipeline.sh
similarity index 100%
rename from modules/bot/python/run_pipeline.sh
rename to modules/official-bots/python/run_pipeline.sh
diff --git a/modules/bot/python/src/dataset.py b/modules/official-bots/python/src/dataset.py
similarity index 100%
rename from modules/bot/python/src/dataset.py
rename to modules/official-bots/python/src/dataset.py
diff --git a/modules/bot/python/src/export.py b/modules/official-bots/python/src/export.py
similarity index 100%
rename from modules/bot/python/src/export.py
rename to modules/official-bots/python/src/export.py
diff --git a/modules/bot/python/src/generate.py b/modules/official-bots/python/src/generate.py
similarity index 100%
rename from modules/bot/python/src/generate.py
rename to modules/official-bots/python/src/generate.py
diff --git a/modules/bot/python/src/label.py b/modules/official-bots/python/src/label.py
similarity index 100%
rename from modules/bot/python/src/label.py
rename to modules/official-bots/python/src/label.py
diff --git a/modules/bot/python/src/lichess_importer.py b/modules/official-bots/python/src/lichess_importer.py
similarity index 100%
rename from modules/bot/python/src/lichess_importer.py
rename to modules/official-bots/python/src/lichess_importer.py
diff --git a/modules/bot/python/src/tactical_positions_extractor.py b/modules/official-bots/python/src/tactical_positions_extractor.py
similarity index 100%
rename from modules/bot/python/src/tactical_positions_extractor.py
rename to modules/official-bots/python/src/tactical_positions_extractor.py
diff --git a/modules/bot/python/src/train.py b/modules/official-bots/python/src/train.py
similarity index 100%
rename from modules/bot/python/src/train.py
rename to modules/official-bots/python/src/train.py
diff --git a/modules/bot/python/start.ps1 b/modules/official-bots/python/start.ps1
similarity index 100%
rename from modules/bot/python/start.ps1
rename to modules/official-bots/python/start.ps1
diff --git a/modules/bot/python/start.sh b/modules/official-bots/python/start.sh
similarity index 100%
rename from modules/bot/python/start.sh
rename to modules/official-bots/python/start.sh
diff --git a/modules/bot/python/weights/nnue_weights_best_snapshot.pt b/modules/official-bots/python/weights/nnue_weights_best_snapshot.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_best_snapshot.pt
rename to modules/official-bots/python/weights/nnue_weights_best_snapshot.pt
diff --git a/modules/bot/python/weights/nnue_weights_checkpoint.pt b/modules/official-bots/python/weights/nnue_weights_checkpoint.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_checkpoint.pt
rename to modules/official-bots/python/weights/nnue_weights_checkpoint.pt
diff --git a/modules/bot/python/weights/nnue_weights_v1.pt b/modules/official-bots/python/weights/nnue_weights_v1.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v1.pt
rename to modules/official-bots/python/weights/nnue_weights_v1.pt
diff --git a/modules/bot/python/weights/nnue_weights_v10.pt b/modules/official-bots/python/weights/nnue_weights_v10.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v10.pt
rename to modules/official-bots/python/weights/nnue_weights_v10.pt
diff --git a/modules/bot/python/weights/nnue_weights_v10_metadata.json b/modules/official-bots/python/weights/nnue_weights_v10_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v10_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v10_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v1_metadata.json b/modules/official-bots/python/weights/nnue_weights_v1_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v1_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v1_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v2.pt b/modules/official-bots/python/weights/nnue_weights_v2.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v2.pt
rename to modules/official-bots/python/weights/nnue_weights_v2.pt
diff --git a/modules/bot/python/weights/nnue_weights_v2_metadata.json b/modules/official-bots/python/weights/nnue_weights_v2_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v2_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v2_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v3.pt b/modules/official-bots/python/weights/nnue_weights_v3.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v3.pt
rename to modules/official-bots/python/weights/nnue_weights_v3.pt
diff --git a/modules/bot/python/weights/nnue_weights_v3_metadata.json b/modules/official-bots/python/weights/nnue_weights_v3_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v3_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v3_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v4.pt b/modules/official-bots/python/weights/nnue_weights_v4.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v4.pt
rename to modules/official-bots/python/weights/nnue_weights_v4.pt
diff --git a/modules/bot/python/weights/nnue_weights_v4_metadata.json b/modules/official-bots/python/weights/nnue_weights_v4_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v4_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v4_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v5.pt b/modules/official-bots/python/weights/nnue_weights_v5.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v5.pt
rename to modules/official-bots/python/weights/nnue_weights_v5.pt
diff --git a/modules/bot/python/weights/nnue_weights_v5_metadata.json b/modules/official-bots/python/weights/nnue_weights_v5_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v5_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v5_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v6.pt b/modules/official-bots/python/weights/nnue_weights_v6.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v6.pt
rename to modules/official-bots/python/weights/nnue_weights_v6.pt
diff --git a/modules/bot/python/weights/nnue_weights_v6_metadata.json b/modules/official-bots/python/weights/nnue_weights_v6_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v6_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v6_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v7.pt b/modules/official-bots/python/weights/nnue_weights_v7.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v7.pt
rename to modules/official-bots/python/weights/nnue_weights_v7.pt
diff --git a/modules/bot/python/weights/nnue_weights_v7_metadata.json b/modules/official-bots/python/weights/nnue_weights_v7_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v7_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v7_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v8.pt b/modules/official-bots/python/weights/nnue_weights_v8.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v8.pt
rename to modules/official-bots/python/weights/nnue_weights_v8.pt
diff --git a/modules/bot/python/weights/nnue_weights_v8_metadata.json b/modules/official-bots/python/weights/nnue_weights_v8_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v8_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v8_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v9.pt b/modules/official-bots/python/weights/nnue_weights_v9.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v9.pt
rename to modules/official-bots/python/weights/nnue_weights_v9.pt
diff --git a/modules/bot/python/weights/nnue_weights_v9_metadata.json b/modules/official-bots/python/weights/nnue_weights_v9_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v9_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v9_metadata.json
diff --git a/modules/bot/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json b/modules/official-bots/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json
similarity index 100%
rename from modules/bot/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json
rename to modules/official-bots/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json
diff --git a/modules/official-bots/src/main/resources/application.yml b/modules/official-bots/src/main/resources/application.yml
new file mode 100644
index 0000000..6318458
--- /dev/null
+++ b/modules/official-bots/src/main/resources/application.yml
@@ -0,0 +1,22 @@
+quarkus:
+ http:
+ port: 8088
+ application:
+ name: nowchess-official-bots
+ smallrye-jwt:
+ enabled: true
+ log:
+ level: INFO
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/bot/src/main/resources/nnue_weights.nbai b/modules/official-bots/src/main/resources/nnue_weights.nbai
similarity index 100%
rename from modules/bot/src/main/resources/nnue_weights.nbai
rename to modules/official-bots/src/main/resources/nnue_weights.nbai
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala
new file mode 100644
index 0000000..f47f213
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala
@@ -0,0 +1,6 @@
+package de.nowchess.bot
+
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.move.Move
+
+type Bot = GameContext => Option[Move]
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotController.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
similarity index 55%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotController.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
index 8b520d1..3e21103 100644
--- a/modules/bot/src/main/scala/de/nowchess/bot/BotController.scala
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
@@ -1,10 +1,9 @@
package de.nowchess.bot
-import de.nowchess.api.bot.Bot
import de.nowchess.bot.bots.ClassicalBot
+import jakarta.enterprise.context.ApplicationScoped
-object BotController {
-
+object BotController:
private val bots: Map[String, Bot] = Map(
"easy" -> ClassicalBot(BotDifficulty.Easy),
"medium" -> ClassicalBot(BotDifficulty.Medium),
@@ -12,10 +11,10 @@ object BotController {
"expert" -> ClassicalBot(BotDifficulty.Expert),
)
- /** Get a bot by name. */
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
+ def listBots: List[String] = bots.keys.toList.sorted
- /** List all available bot names. */
- def listBots: List[String] = bots.keys.toList.sorted
-
-}
+@ApplicationScoped
+class BotController:
+ def getBot(name: String): Option[Bot] = BotController.getBot(name)
+ def listBots: List[String] = BotController.listBots
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotDifficulty.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotDifficulty.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/Config.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/Config.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/Config.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/Config.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
new file mode 100644
index 0000000..c5e638a
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
@@ -0,0 +1,25 @@
+package de.nowchess.bot.bots
+
+import de.nowchess.bot.Bot
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.rules.RuleSet
+import de.nowchess.bot.bots.classic.EvaluationClassic
+import de.nowchess.bot.logic.AlphaBetaSearch
+import de.nowchess.bot.util.PolyglotBook
+import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
+import de.nowchess.rules.sets.DefaultRules
+
+object ClassicalBot:
+ def apply(
+ difficulty: BotDifficulty,
+ rules: RuleSet = DefaultRules,
+ book: Option[PolyglotBook] = None,
+ ): Bot =
+ val search = AlphaBetaSearch(rules, weights = EvaluationClassic)
+ val timeBudgetMs = 1000L
+ context =>
+ val blockedMoves = BotMoveRepetition.blockedMoves(context)
+ book
+ .flatMap(_.probe(context))
+ .filterNot(blockedMoves.contains)
+ .orElse(search.bestMoveWithTime(context, timeBudgetMs, blockedMoves))
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
new file mode 100644
index 0000000..0184847
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
@@ -0,0 +1,39 @@
+package de.nowchess.bot.bots
+
+import de.nowchess.bot.Bot
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.move.Move
+import de.nowchess.api.rules.RuleSet
+import de.nowchess.bot.ai.Evaluation
+import de.nowchess.bot.bots.classic.EvaluationClassic
+import de.nowchess.bot.bots.nnue.EvaluationNNUE
+import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
+import de.nowchess.bot.util.PolyglotBook
+import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
+import de.nowchess.rules.sets.DefaultRules
+
+object HybridBot:
+ def apply(
+ difficulty: BotDifficulty,
+ rules: RuleSet = DefaultRules,
+ book: Option[PolyglotBook] = None,
+ nnueEvaluation: Evaluation = EvaluationNNUE,
+ classicalEvaluation: Evaluation = EvaluationClassic,
+ vetoReporter: String => Unit = println(_),
+ ): Bot =
+ val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
+ context =>
+ val blockedMoves = BotMoveRepetition.blockedMoves(context)
+ book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
+ search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
+ val next = rules.applyMove(context)(move)
+ val staticNnue = nnueEvaluation.evaluate(next)
+ val classical = classicalEvaluation.evaluate(next)
+ val diff = (classical - staticNnue).abs
+ if diff > Config.VETO_THRESHOLD then
+ vetoReporter(
+ f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
+ )
+ move
+ }
+ }
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
similarity index 52%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
index bfa2c6f..d61a46e 100644
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
@@ -1,43 +1,37 @@
package de.nowchess.bot.bots
-import de.nowchess.api.bot.Bot
+import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
+import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
-import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
-final class NNUEBot(
+object NNUEBot:
+ def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
-) extends Bot:
+ ): Bot =
+ val search = AlphaBetaSearch(rules, weights = EvaluationNNUE)
+ context =>
+ val blockedMoves = BotMoveRepetition.blockedMoves(context)
+ book
+ .flatMap(_.probe(context))
+ .filterNot(blockedMoves.contains)
+ .orElse {
+ val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
+ if moves.isEmpty then None
+ else
+ val scored = batchEvaluateRoot(rules, context, moves)
+ val bestMove = scored.maxBy(_._2)._1
+ search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
+ }
- private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
-
- override val name: String = s"NNUEBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- val blockedMoves = BotMoveRepetition.blockedMoves(context)
- book
- .flatMap(_.probe(context))
- .filterNot(blockedMoves.contains)
- .orElse {
- val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
- if moves.isEmpty then None
- else
- val scored = batchEvaluateRoot(context, moves)
- val bestMove = scored.maxBy(_._2)._1
- search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
- }
-
- /** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score
- * from the root player's perspective.
- */
- private def batchEvaluateRoot(context: GameContext, moves: List[Move]): List[(Move, Int)] =
+ private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
EvaluationNNUE.initAccumulator(context)
val rootHash = ZobristHash.hash(context)
moves.map { move =>
@@ -48,7 +42,6 @@ final class NNUEBot(
(move, score)
}
- /** Allocate more time for complex positions; less when one move clearly dominates. */
private def allocateTime(scored: List[(Move, Int)]): Long =
val moveCount = scored.length
if moveCount > 30 then 1500L
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala
new file mode 100644
index 0000000..b5b2f7f
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.bot.config
+
+import com.fasterxml.jackson.core.Version
+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(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala
new file mode 100644
index 0000000..ceabe14
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala
@@ -0,0 +1,18 @@
+package de.nowchess.bot.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.host", defaultValue = "localhost")
+ var host: String = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
+ var port: Int = uninitialized
+
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedissonProducer.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedissonProducer.scala
new file mode 100644
index 0000000..ac1663e
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedissonProducer.scala
@@ -0,0 +1,35 @@
+package de.nowchess.bot.config
+
+import jakarta.annotation.PreDestroy
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Produces
+import jakarta.inject.Inject
+import org.redisson.Redisson
+import org.redisson.api.RedissonClient
+import org.redisson.config.Config
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedissonProducer:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ private var clientOpt: Option[RedissonClient] = None
+ // scalafix:on DisableSyntax.var
+
+ @Produces
+ @ApplicationScoped
+ def produceRedissonClient(): RedissonClient =
+ val config = new Config()
+ config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
+ config.useSingleServer().setConnectionMinimumIdleSize(1)
+ config.useSingleServer().setConnectTimeout(500)
+ val client = Redisson.create(config)
+ clientOpt = Some(client)
+ client
+
+ @PreDestroy
+ def shutdown(): Unit =
+ clientOpt.foreach(_.shutdown())
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala
new file mode 100644
index 0000000..fd68c9d
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala
@@ -0,0 +1,31 @@
+package de.nowchess.bot.resource
+
+import de.nowchess.bot.service.DifficultyMapper
+import jakarta.annotation.security.RolesAllowed
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+
+@Path("/api/challenge/official")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+@Produces(Array(MediaType.APPLICATION_JSON))
+@Consumes(Array(MediaType.APPLICATION_JSON))
+class OfficialBotChallengeResource:
+
+ @POST
+ @Path("/{botId}")
+ def challengeWithDifficulty(
+ @PathParam("botId") botId: String,
+ @QueryParam("difficulty") difficulty: Int
+ ): Response =
+ DifficultyMapper.fromElo(difficulty) match
+ case None =>
+ Response.status(Response.Status.BAD_REQUEST)
+ .entity(s"""{"error":"difficulty must be between 1000 and 2800"}""")
+ .build()
+ case Some(botDifficulty) =>
+ // TODO: wire to account service challenge creation + bot routing
+ Response.status(Response.Status.CREATED)
+ .entity(s"""{"botId":"$botId","difficulty":$difficulty,"status":"pending"}""")
+ .build()
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala
new file mode 100644
index 0000000..e28db4d
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala
@@ -0,0 +1,12 @@
+package de.nowchess.bot.service
+
+import de.nowchess.bot.BotDifficulty
+
+object DifficultyMapper:
+ def fromElo(elo: Int): Option[BotDifficulty] =
+ elo match
+ case e if e >= 1000 && e <= 1400 => Some(BotDifficulty.Easy)
+ case e if e >= 1401 && e <= 1800 => Some(BotDifficulty.Medium)
+ case e if e >= 1801 && e <= 2300 => Some(BotDifficulty.Hard)
+ case e if e >= 2301 && e <= 2800 => Some(BotDifficulty.Expert)
+ case _ => None
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala
new file mode 100644
index 0000000..b179004
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/MoveRequestParser.scala
@@ -0,0 +1,26 @@
+package de.nowchess.bot.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+
+case class MoveRequest(
+ gameId: String,
+ fen: String,
+ turn: String,
+ playingAs: String,
+ difficulty: Int,
+ botAccountId: String,
+)
+
+object MoveRequestParser:
+ def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
+ scala.util.Try {
+ val node = mapper.readTree(json)
+ MoveRequest(
+ gameId = node.get("gameId").asText(),
+ fen = node.get("fen").asText(),
+ turn = node.get("turn").asText(),
+ playingAs = node.get("playingAs").asText(),
+ difficulty = node.get("difficulty").asInt(1400),
+ botAccountId = node.get("botAccountId").asText(),
+ )
+ }.toOption
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
new file mode 100644
index 0000000..0d1a348
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
@@ -0,0 +1,70 @@
+package de.nowchess.bot.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.bot.Bot
+import de.nowchess.bot.BotController
+import de.nowchess.bot.BotDifficulty
+import de.nowchess.bot.config.RedisConfig
+import de.nowchess.io.fen.FenParser
+import io.quarkus.runtime.StartupEvent
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.event.Observes
+import jakarta.inject.Inject
+import org.redisson.api.RedissonClient
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class OfficialBotService:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redisson: RedissonClient = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var botController: BotController = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ def onStart(@Observes event: StartupEvent): Unit =
+ Thread.ofVirtual().start(() => runWorker())
+ ()
+
+ private def runWorker(): Unit =
+ val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
+ while true do
+ try
+ val json = queue.take()
+ MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
+ catch case _: InterruptedException => Thread.currentThread().interrupt()
+
+ private def processRequest(req: MoveRequest): Unit =
+ val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
+ val botName = difficulty match
+ case BotDifficulty.Easy => "easy"
+ case BotDifficulty.Medium => "medium"
+ case BotDifficulty.Hard => "hard"
+ case BotDifficulty.Expert => "expert"
+ botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
+
+ private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
+ FenParser.parseFen(req.fen).toOption.foreach { context =>
+ bot(context).foreach { move =>
+ val uci = toUci(move)
+ val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
+ val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
+ redisson.getTopic(c2sTopic).publish(moveMsg)
+ ()
+ }
+ }
+
+ private def toUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(piece) => base + promotionChar(piece)
+ case _ => base
+
+ private def promotionChar(piece: PromotionPiece): String =
+ piece match
+ case PromotionPiece.Knight => "n"
+ case PromotionPiece.Bishop => "b"
+ case PromotionPiece.Rook => "r"
+ case PromotionPiece.Queen => "q"
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotControllerTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotControllerTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/EvaluationTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/EvaluationTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 37e1ae2..d709bcc 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -19,7 +19,8 @@ include(
"modules:json",
"modules:io",
"modules:rule",
- "modules:bot",
+ "modules:bot-platform",
+ "modules:official-bots",
"modules:account",
"modules:ws",
"modules:store",