feat(bot): implement bot architecture with difficulty levels and game context handling

This commit is contained in:
2026-04-28 00:59:32 +02:00
parent 6b59e68e04
commit c10a4d7e64
121 changed files with 1010 additions and 1358 deletions
+10 -4
View File
@@ -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
+3 -3
View File
@@ -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
+6
View File
@@ -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
+18
View File
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -12,11 +12,12 @@
<option value="$PROJECT_DIR$/modules" />
<option value="$PROJECT_DIR$/modules/account" />
<option value="$PROJECT_DIR$/modules/api" />
<option value="$PROJECT_DIR$/modules/bot" />
<option value="$PROJECT_DIR$/modules/bot-platform" />
<option value="$PROJECT_DIR$/modules/coordinator" />
<option value="$PROJECT_DIR$/modules/core" />
<option value="$PROJECT_DIR$/modules/io" />
<option value="$PROJECT_DIR$/modules/json" />
<option value="$PROJECT_DIR$/modules/official-bots" />
<option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/store" />
<option value="$PROJECT_DIR$/modules/ws" />
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
</profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot-platform.integrationTest,NowChessSystems.modules.bot-platform.main,NowChessSystems.modules.bot-platform.native-test,NowChessSystems.modules.bot-platform.quarkus-generated-sources,NowChessSystems.modules.bot-platform.quarkus-test-generated-sources,NowChessSystems.modules.bot-platform.scoverage,NowChessSystems.modules.bot-platform.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.official-bots.integrationTest,NowChessSystems.modules.official-bots.main,NowChessSystems.modules.official-bots.native-test,NowChessSystems.modules.official-bots.quarkus-generated-sources,NowChessSystems.modules.official-bots.quarkus-test-generated-sources,NowChessSystems.modules.official-bots.scoverage,NowChessSystems.modules.official-bots.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
<option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" />
<parameters>
-771
View File
@@ -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 <token>
```
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 <token>`'
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
@@ -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}
@@ -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
@@ -31,7 +31,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[BotAccountDto],
classOf[BotAccountWithTokenDto],
classOf[OfficialBotAccountDto],
classOf[OfficialBotAccountWithTokenDto],
classOf[CreateBotAccountRequest],
classOf[UpdateBotNameRequest],
classOf[RotatedTokenDto],
@@ -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
@@ -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)
@@ -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
@@ -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,
)
@@ -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()
@@ -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()
@@ -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)
@@ -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]
}
@@ -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
+115
View File
@@ -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<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
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<ScalaCompile> {
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<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().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
}
@@ -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}
@@ -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
})
@@ -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
@@ -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())
@@ -0,0 +1,8 @@
package de.nowchess.botplatform.registry
case class BotGameInfo(
botId: String,
difficulty: Int,
playingAs: String,
botAccountId: String,
)
@@ -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
@@ -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()
@@ -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 => ()
-79
View File
@@ -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<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
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<ScalaCompile> {
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
}
@@ -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))
@@ -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
}
+1 -1
View File
@@ -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}"))
@@ -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))
@@ -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)
@@ -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
@@ -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),
+118
View File
@@ -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<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
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<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
tasks.withType<JavaCompile> {
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<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest", "junit-jupiter")
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
@@ -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}
@@ -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]
@@ -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)
/** 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
@@ -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))
@@ -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
}
}
@@ -1,26 +1,23 @@
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:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
override val name: String = s"NNUEBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
): Bot =
val search = AlphaBetaSearch(rules, weights = EvaluationNNUE)
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
@@ -29,15 +26,12 @@ final class NNUEBot(
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(context, moves)
val scored = batchEvaluateRoot(rules, 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
@@ -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
})
@@ -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
@@ -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())

Some files were not shown because too many files have changed in this diff Show More