feat(bot): implement bot architecture with difficulty levels and game context handling
This commit is contained in:
+10
-4
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **Stack:** raw-http | none | unknown | scala
|
> **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.**
|
> **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 rebalanceMinInterval
|
||||||
- function heartbeatTtl
|
- function heartbeatTtl
|
||||||
- _...11 more_
|
- _...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/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
|
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
|
||||||
- class CoreGrpcClient
|
- class CoreGrpcClient
|
||||||
@@ -574,6 +576,8 @@
|
|||||||
- function isCheckmate
|
- function isCheckmate
|
||||||
- _...6 more_
|
- _...6 more_
|
||||||
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
|
- `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/RedisConfig.scala` — class RedisConfig
|
||||||
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
|
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
|
||||||
- class RedissonProducer
|
- class RedissonProducer
|
||||||
@@ -591,6 +595,8 @@
|
|||||||
- function merge
|
- 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/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
|
||||||
- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
|
- `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/RedisConfig.scala` — class RedisConfig
|
||||||
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
|
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
|
||||||
- class RedissonProducer
|
- 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/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/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/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/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/Board.scala` — imported by **20** files
|
||||||
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.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/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/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/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/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)
|
## 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/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/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/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/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/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
|
- `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
@@ -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/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/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/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/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/Board.scala` — imported by **20** files
|
||||||
- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.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/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/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/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/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)
|
## 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/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/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/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/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/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
|
- `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
|
||||||
|
|||||||
@@ -262,6 +262,8 @@
|
|||||||
- function rebalanceMinInterval
|
- function rebalanceMinInterval
|
||||||
- function heartbeatTtl
|
- function heartbeatTtl
|
||||||
- _...11 more_
|
- _...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/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
|
||||||
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
|
- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
|
||||||
- class CoreGrpcClient
|
- class CoreGrpcClient
|
||||||
@@ -518,6 +520,8 @@
|
|||||||
- function isCheckmate
|
- function isCheckmate
|
||||||
- _...6 more_
|
- _...6 more_
|
||||||
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
|
- `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/RedisConfig.scala` — class RedisConfig
|
||||||
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
|
- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
|
||||||
- class RedissonProducer
|
- class RedissonProducer
|
||||||
@@ -535,6 +539,8 @@
|
|||||||
- function merge
|
- 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/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
|
||||||
- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
|
- `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/RedisConfig.scala` — class RedisConfig
|
||||||
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
|
- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
|
||||||
- class RedissonProducer
|
- class RedissonProducer
|
||||||
|
|||||||
Generated
+18
File diff suppressed because one or more lines are too long
Generated
+2
-1
@@ -12,11 +12,12 @@
|
|||||||
<option value="$PROJECT_DIR$/modules" />
|
<option value="$PROJECT_DIR$/modules" />
|
||||||
<option value="$PROJECT_DIR$/modules/account" />
|
<option value="$PROJECT_DIR$/modules/account" />
|
||||||
<option value="$PROJECT_DIR$/modules/api" />
|
<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/coordinator" />
|
||||||
<option value="$PROJECT_DIR$/modules/core" />
|
<option value="$PROJECT_DIR$/modules/core" />
|
||||||
<option value="$PROJECT_DIR$/modules/io" />
|
<option value="$PROJECT_DIR$/modules/io" />
|
||||||
<option value="$PROJECT_DIR$/modules/json" />
|
<option value="$PROJECT_DIR$/modules/json" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/official-bots" />
|
||||||
<option value="$PROJECT_DIR$/modules/rule" />
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
<option value="$PROJECT_DIR$/modules/store" />
|
<option value="$PROJECT_DIR$/modules/store" />
|
||||||
<option value="$PROJECT_DIR$/modules/ws" />
|
<option value="$PROJECT_DIR$/modules/ws" />
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</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="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<parameters>
|
||||||
|
|||||||
@@ -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:
|
rest-client:
|
||||||
core-service:
|
core-service:
|
||||||
url: http://localhost:8080
|
url: http://localhost:8080
|
||||||
|
bot-platform-service:
|
||||||
|
url: http://localhost:8087
|
||||||
smallrye-openapi:
|
smallrye-openapi:
|
||||||
info-title: NowChess Account Service
|
info-title: NowChess Account Service
|
||||||
path: /openapi
|
path: /openapi
|
||||||
@@ -27,6 +29,8 @@ quarkus:
|
|||||||
rest-client:
|
rest-client:
|
||||||
core-service:
|
core-service:
|
||||||
url: ${CORE_SERVICE_URL}
|
url: ${CORE_SERVICE_URL}
|
||||||
|
bot-platform-service:
|
||||||
|
url: ${BOT_PLATFORM_SERVICE_URL}
|
||||||
datasource:
|
datasource:
|
||||||
db-kind: postgresql
|
db-kind: postgresql
|
||||||
username: ${DB_USER}
|
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[BotAccountDto],
|
||||||
classOf[BotAccountWithTokenDto],
|
classOf[BotAccountWithTokenDto],
|
||||||
classOf[OfficialBotAccountDto],
|
classOf[OfficialBotAccountDto],
|
||||||
classOf[OfficialBotAccountWithTokenDto],
|
|
||||||
classOf[CreateBotAccountRequest],
|
classOf[CreateBotAccountRequest],
|
||||||
classOf[UpdateBotNameRequest],
|
classOf[UpdateBotNameRequest],
|
||||||
classOf[RotatedTokenDto],
|
classOf[RotatedTokenDto],
|
||||||
|
|||||||
@@ -72,9 +72,6 @@ class OfficialBotAccount extends PanacheEntityBase:
|
|||||||
@Column(nullable = false)
|
@Column(nullable = false)
|
||||||
var name: String = uninitialized
|
var name: String = uninitialized
|
||||||
|
|
||||||
@Column(unique = true, nullable = false, length = 256)
|
|
||||||
var token: String = uninitialized
|
|
||||||
|
|
||||||
var rating: Int = 1500
|
var rating: Int = 1500
|
||||||
|
|
||||||
var createdAt: Instant = uninitialized
|
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 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 =
|
def delete(botId: UUID): Unit =
|
||||||
em.find(classOf[BotAccount], botId) match
|
em.find(classOf[BotAccount], botId) match
|
||||||
case bot: BotAccount => em.remove(bot)
|
case bot: BotAccount => em.remove(bot)
|
||||||
case _ => ()
|
|
||||||
|
|
||||||
def findByToken(token: String): Option[BotAccount] =
|
def findByToken(token: String): Option[BotAccount] =
|
||||||
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
|
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
|
||||||
@@ -97,11 +96,4 @@ class OfficialBotAccountRepository:
|
|||||||
def delete(botId: UUID): Unit =
|
def delete(botId: UUID): Unit =
|
||||||
em.find(classOf[OfficialBotAccount], botId) match
|
em.find(classOf[OfficialBotAccount], botId) match
|
||||||
case bot: OfficialBotAccount => em.remove(bot)
|
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
|
@GET
|
||||||
@Path("/official-bots")
|
@Path("/official-bots")
|
||||||
def getOfficialBots(): Response =
|
def getOfficialBots: Response =
|
||||||
val bots = accountService.getOfficialBotAccounts()
|
val bots = accountService.getOfficialBotAccounts()
|
||||||
Response.ok(bots.map(toOfficialBotDto)).build()
|
Response.ok(bots.map(toOfficialBotDto)).build()
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ class AccountResource:
|
|||||||
def createOfficialBot(req: CreateBotAccountRequest): Response =
|
def createOfficialBot(req: CreateBotAccountRequest): Response =
|
||||||
accountService.createOfficialBotAccount(req.name) match
|
accountService.createOfficialBotAccount(req.name) match
|
||||||
case Right(bot) =>
|
case Right(bot) =>
|
||||||
Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
|
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
|
||||||
case Left(error) =>
|
case Left(error) =>
|
||||||
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
|
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
|
||||||
|
|
||||||
@@ -193,15 +193,6 @@ class AccountResource:
|
|||||||
case Left(error) =>
|
case Left(error) =>
|
||||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
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 =
|
private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto =
|
||||||
OfficialBotAccountDto(
|
OfficialBotAccountDto(
|
||||||
id = bot.id.toString,
|
id = bot.id.toString,
|
||||||
@@ -210,11 +201,3 @@ class AccountResource:
|
|||||||
createdAt = bot.createdAt.toString,
|
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,
|
|
||||||
)
|
|
||||||
|
|||||||
+94
@@ -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] =
|
def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
|
||||||
val bot = new OfficialBotAccount()
|
val bot = new OfficialBotAccount()
|
||||||
bot.name = botName
|
bot.name = botName
|
||||||
bot.token = generateOfficialBotToken(bot.id)
|
|
||||||
bot.createdAt = Instant.now()
|
bot.createdAt = Instant.now()
|
||||||
officialBotAccountRepository.persist(bot)
|
officialBotAccountRepository.persist(bot)
|
||||||
Right(bot)
|
Right(bot)
|
||||||
@@ -137,15 +136,6 @@ class AccountService:
|
|||||||
officialBotAccountRepository.delete(botId)
|
officialBotAccountRepository.delete(botId)
|
||||||
Right(())
|
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 =
|
private def generateBotToken(botId: UUID): String =
|
||||||
Jwt
|
Jwt
|
||||||
.issuer("nowchess")
|
.issuer("nowchess")
|
||||||
@@ -174,10 +164,3 @@ class AccountService:
|
|||||||
userAccountRepository.persist(user)
|
userAccountRepository.persist(user)
|
||||||
Right(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
|
package de.nowchess.account.service
|
||||||
|
|
||||||
import de.nowchess.account.client.{
|
import de.nowchess.account.client.{
|
||||||
|
BotPlatformClient,
|
||||||
CoreCreateGameRequest,
|
CoreCreateGameRequest,
|
||||||
CoreGameClient,
|
CoreGameClient,
|
||||||
CoreGameResponse,
|
CoreGameResponse,
|
||||||
@@ -22,6 +23,7 @@ import jakarta.enterprise.context.ApplicationScoped
|
|||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.jboss.logging.Logger
|
||||||
import scala.compiletime.uninitialized
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -31,6 +33,8 @@ import java.util.UUID
|
|||||||
@ApplicationScoped
|
@ApplicationScoped
|
||||||
class ChallengeService:
|
class ChallengeService:
|
||||||
|
|
||||||
|
private val log = Logger.getLogger(classOf[ChallengeService])
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
// scalafix:off DisableSyntax.var
|
||||||
@Inject
|
@Inject
|
||||||
var userAccountRepository: UserAccountRepository = uninitialized
|
var userAccountRepository: UserAccountRepository = uninitialized
|
||||||
@@ -41,6 +45,10 @@ class ChallengeService:
|
|||||||
@Inject
|
@Inject
|
||||||
@RestClient
|
@RestClient
|
||||||
var coreGameClient: CoreGameClient = uninitialized
|
var coreGameClient: CoreGameClient = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@RestClient
|
||||||
|
var botPlatformClient: BotPlatformClient = uninitialized
|
||||||
// scalafix:on
|
// scalafix:on
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -80,6 +88,7 @@ class ChallengeService:
|
|||||||
challenge.status = ChallengeStatus.Accepted
|
challenge.status = ChallengeStatus.Accepted
|
||||||
challenge.gameId = gameId
|
challenge.gameId = gameId
|
||||||
challengeRepository.merge(challenge)
|
challengeRepository.merge(challenge)
|
||||||
|
notifyBotIfNeeded(challenge, gameId)
|
||||||
challenge
|
challenge
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -111,6 +120,16 @@ class ChallengeService:
|
|||||||
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
||||||
ChallengeListDto(in = incoming, out = outgoing)
|
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] =
|
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
|
||||||
try
|
try
|
||||||
val (white, black) = assignColors(challenge)
|
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
|
|
||||||
@@ -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}
|
||||||
+17
@@ -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
|
||||||
+35
@@ -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,
|
||||||
|
)
|
||||||
+30
@@ -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
|
||||||
+67
@@ -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()
|
||||||
+56
@@ -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 => ()
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@ dependencies {
|
|||||||
implementation(project(":modules:json"))
|
implementation(project(":modules:json"))
|
||||||
implementation(project(":modules:rule"))
|
implementation(project(":modules:rule"))
|
||||||
implementation(project(":modules:io"))
|
implementation(project(":modules:io"))
|
||||||
implementation(project(":modules:bot"))
|
implementation(project(":modules:official-bots"))
|
||||||
|
|
||||||
|
|
||||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
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.board.{Board, Color, Piece, PieceType, Square}
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
import de.nowchess.api.game.{
|
import de.nowchess.api.game.{
|
||||||
BotParticipant,
|
|
||||||
ClockState,
|
ClockState,
|
||||||
CorrespondenceClockState,
|
CorrespondenceClockState,
|
||||||
DrawReason,
|
DrawReason,
|
||||||
GameContext,
|
GameContext,
|
||||||
GameResult,
|
GameResult,
|
||||||
Human,
|
|
||||||
LiveClockState,
|
LiveClockState,
|
||||||
Participant,
|
|
||||||
TimeControl,
|
TimeControl,
|
||||||
WinReason,
|
WinReason,
|
||||||
}
|
}
|
||||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
|
||||||
import de.nowchess.chess.controller.Parser
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.api.error.GameError
|
import de.nowchess.api.error.GameError
|
||||||
@@ -25,7 +21,6 @@ import de.nowchess.api.rules.RuleSet
|
|||||||
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
|
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
|
/** 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.
|
* 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(
|
class GameEngine(
|
||||||
val initialContext: GameContext = GameContext.initial,
|
val initialContext: GameContext = GameContext.initial,
|
||||||
val ruleSet: RuleSet,
|
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,
|
val timeControl: TimeControl = TimeControl.Unlimited,
|
||||||
initialClockState: Option[ClockState] = None,
|
initialClockState: Option[ClockState] = None,
|
||||||
initialDrawOffer: Option[Color] = 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.
|
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
|
||||||
clockState.foreach(scheduleExpiryCheck)
|
clockState.foreach(scheduleExpiryCheck)
|
||||||
|
|
||||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
|
||||||
|
|
||||||
// Synchronized accessors for current state
|
// Synchronized accessors for current state
|
||||||
def board: Board = synchronized(currentContext.board)
|
def board: Board = synchronized(currentContext.board)
|
||||||
def turn: Color = synchronized(currentContext.turn)
|
def turn: Color = synchronized(currentContext.turn)
|
||||||
@@ -325,9 +314,6 @@ class GameEngine(
|
|||||||
notifyObservers(DrawEvent(currentContext, reason))
|
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). */
|
/** Inject clock state directly (for testing). */
|
||||||
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
|
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 currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
|
||||||
requestBotMoveIfNeeded()
|
|
||||||
|
|
||||||
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
|
||||||
move.moveType match
|
move.moveType match
|
||||||
@@ -477,47 +462,6 @@ class GameEngine(
|
|||||||
case _ =>
|
case _ =>
|
||||||
context.board.pieceAt(move.to)
|
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 =
|
private def replayContextFromMoves(moves: List[Move]): GameContext =
|
||||||
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
|
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ class GameResource:
|
|||||||
val color = colorOf(entry)
|
val color = colorOf(entry)
|
||||||
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
|
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
|
// scalafix:on DisableSyntax.throw
|
||||||
|
|
||||||
// ── mapping ──────────────────────────────────────────────────────────────
|
// ── mapping ──────────────────────────────────────────────────────────────
|
||||||
@@ -184,6 +189,7 @@ class GameResource:
|
|||||||
@Path("/{gameId}/move/{uci}")
|
@Path("/{gameId}/move/{uci}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||||
|
assertIsBot()
|
||||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
assertGameNotOver(entry)
|
assertGameNotOver(entry)
|
||||||
assertIsCurrentPlayer(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
|
|
||||||
+6
@@ -12,6 +12,7 @@ import de.nowchess.rules.sets.DefaultRules
|
|||||||
import io.quarkus.test.InjectMock
|
import io.quarkus.test.InjectMock
|
||||||
import io.quarkus.test.junit.QuarkusTest
|
import io.quarkus.test.junit.QuarkusTest
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||||
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
|
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
|
||||||
import org.junit.jupiter.api.Assertions.*
|
import org.junit.jupiter.api.Assertions.*
|
||||||
import org.mockito.ArgumentMatchers.any
|
import org.mockito.ArgumentMatchers.any
|
||||||
@@ -34,8 +35,13 @@ class GameResourceIntegrationTest:
|
|||||||
@InjectMock
|
@InjectMock
|
||||||
var ioWrapper: IoGrpcClientWrapper = uninitialized
|
var ioWrapper: IoGrpcClientWrapper = uninitialized
|
||||||
|
|
||||||
|
@InjectMock
|
||||||
|
var jwt: JsonWebToken = uninitialized
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
def setupMocks(): Unit =
|
def setupMocks(): Unit =
|
||||||
|
when(jwt.getClaim[AnyRef]("type")).thenReturn("bot")
|
||||||
|
|
||||||
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
|
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
|
||||||
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
|
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
|
||||||
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
|
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
|
||||||
|
|||||||
@@ -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]
|
||||||
+7
-8
@@ -1,10 +1,9 @@
|
|||||||
package de.nowchess.bot
|
package de.nowchess.bot
|
||||||
|
|
||||||
import de.nowchess.api.bot.Bot
|
|
||||||
import de.nowchess.bot.bots.ClassicalBot
|
import de.nowchess.bot.bots.ClassicalBot
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
|
||||||
object BotController {
|
object BotController:
|
||||||
|
|
||||||
private val bots: Map[String, Bot] = Map(
|
private val bots: Map[String, Bot] = Map(
|
||||||
"easy" -> ClassicalBot(BotDifficulty.Easy),
|
"easy" -> ClassicalBot(BotDifficulty.Easy),
|
||||||
"medium" -> ClassicalBot(BotDifficulty.Medium),
|
"medium" -> ClassicalBot(BotDifficulty.Medium),
|
||||||
@@ -12,10 +11,10 @@ object BotController {
|
|||||||
"expert" -> ClassicalBot(BotDifficulty.Expert),
|
"expert" -> ClassicalBot(BotDifficulty.Expert),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Get a bot by name. */
|
|
||||||
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
|
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
|
||||||
|
def listBots: List[String] = bots.keys.toList.sorted
|
||||||
|
|
||||||
/** List all available bot names. */
|
@ApplicationScoped
|
||||||
def listBots: List[String] = bots.keys.toList.sorted
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-27
@@ -1,43 +1,37 @@
|
|||||||
package de.nowchess.bot.bots
|
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.game.GameContext
|
||||||
import de.nowchess.api.move.Move
|
import de.nowchess.api.move.Move
|
||||||
|
import de.nowchess.api.rules.RuleSet
|
||||||
import de.nowchess.bot.bots.nnue.EvaluationNNUE
|
import de.nowchess.bot.bots.nnue.EvaluationNNUE
|
||||||
import de.nowchess.bot.logic.AlphaBetaSearch
|
import de.nowchess.bot.logic.AlphaBetaSearch
|
||||||
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
|
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
|
||||||
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
|
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
|
||||||
import de.nowchess.api.rules.RuleSet
|
|
||||||
import de.nowchess.rules.sets.DefaultRules
|
import de.nowchess.rules.sets.DefaultRules
|
||||||
|
|
||||||
final class NNUEBot(
|
object NNUEBot:
|
||||||
|
def apply(
|
||||||
difficulty: BotDifficulty,
|
difficulty: BotDifficulty,
|
||||||
rules: RuleSet = DefaultRules,
|
rules: RuleSet = DefaultRules,
|
||||||
book: Option[PolyglotBook] = None,
|
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)
|
private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
|
||||||
|
|
||||||
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)] =
|
|
||||||
EvaluationNNUE.initAccumulator(context)
|
EvaluationNNUE.initAccumulator(context)
|
||||||
val rootHash = ZobristHash.hash(context)
|
val rootHash = ZobristHash.hash(context)
|
||||||
moves.map { move =>
|
moves.map { move =>
|
||||||
@@ -48,7 +42,6 @@ final class NNUEBot(
|
|||||||
(move, score)
|
(move, score)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Allocate more time for complex positions; less when one move clearly dominates. */
|
|
||||||
private def allocateTime(scored: List[(Move, Int)]): Long =
|
private def allocateTime(scored: List[(Move, Int)]): Long =
|
||||||
val moveCount = scored.length
|
val moveCount = scored.length
|
||||||
if moveCount > 30 then 1500L
|
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
Reference in New Issue
Block a user