diff --git a/.codesight/CODESIGHT.md b/.codesight/CODESIGHT.md
index e84c141..1b80df2 100644
--- a/.codesight/CODESIGHT.md
+++ b/.codesight/CODESIGHT.md
@@ -2,11 +2,58 @@
> **Stack:** raw-http | none | unknown | scala
-> 0 routes | 0 models | 0 components | 63 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.**
---
+# Routes
+
+## gRPC
+
+- `/CoordinatorService/BatchResubscribeGames` (BatchResubscribeRequest) → BatchResubscribeResponse
+- `/CoordinatorService/UnsubscribeGames` (UnsubscribeGamesRequest) → UnsubscribeGamesResponse
+- `/CoordinatorService/EvictGames` (EvictGamesRequest) → EvictGamesResponse
+- `/CoordinatorService/DrainInstance` (DrainInstanceRequest) → DrainInstanceResponse
+- `/CoordinatorService/BatchResubscribeGames` (BatchResubscribeRequest) → BatchResubscribeResponse
+- `/CoordinatorService/UnsubscribeGames` (UnsubscribeGamesRequest) → UnsubscribeGamesResponse
+- `/CoordinatorService/EvictGames` (EvictGamesRequest) → EvictGamesResponse
+- `/CoordinatorService/DrainInstance` (DrainInstanceRequest) → DrainInstanceResponse
+- `/IoService/ImportFen` (ProtoImportFenRequest) → ProtoGameContext
+- `/IoService/ImportPgn` (ProtoImportPgnRequest) → ProtoGameContext
+- `/IoService/ExportCombined` (ProtoGameContext) → ProtoCombinedExport
+- `/IoService/ExportFen` (ProtoGameContext) → ProtoStringResult
+- `/IoService/ExportPgn` (ProtoGameContext) → ProtoStringResult
+- `/RuleService/CandidateMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/LegalMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/AllLegalMoves` (ProtoGameContext) → ProtoMoveList
+- `/RuleService/IsCheck` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsCheckmate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsStalemate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsInsufficientMaterial` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsFiftyMoveRule` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsThreefoldRepetition` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/ApplyMove` (ProtoMoveRequest) → ProtoGameContext
+- `/RuleService/PostMoveStatus` (ProtoGameContext) → ProtoPostMoveStatus
+- `/IoService/ImportFen` (ProtoImportFenRequest) → ProtoGameContext
+- `/IoService/ImportPgn` (ProtoImportPgnRequest) → ProtoGameContext
+- `/IoService/ExportCombined` (ProtoGameContext) → ProtoCombinedExport
+- `/IoService/ExportFen` (ProtoGameContext) → ProtoStringResult
+- `/IoService/ExportPgn` (ProtoGameContext) → ProtoStringResult
+- `/RuleService/CandidateMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/LegalMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/AllLegalMoves` (ProtoGameContext) → ProtoMoveList
+- `/RuleService/IsCheck` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsCheckmate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsStalemate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsInsufficientMaterial` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsFiftyMoveRule` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsThreefoldRepetition` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/ApplyMove` (ProtoMoveRequest) → ProtoGameContext
+- `/RuleService/PostMoveStatus` (ProtoGameContext) → ProtoPostMoveStatus
+
+---
+
# Libraries
- `jacoco-reporter/scoverage_coverage_gaps.py`
@@ -25,6 +72,74 @@
- function main: () -> None
- class TestCase
- _...2 more_
+- `modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala` — class CoreGameClient, function createGame
+- `modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala`
+ - class Challenge
+ - function gameIdOpt
+ - function declineReasonOpt
+ - function timeControlLimitOpt
+ - function timeControlIncrementOpt
+- `modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala` — class ChallengeColorConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala` — class ChallengeStatusConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala` — class DeclineReasonConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala` — class TimeControl
+- `modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala`
+ - class UserAccount
+ - function getBotAccounts
+ - class BotAccount
+ - class OfficialBotAccount
+- `modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala` — function message
+- `modules/account/src/main/scala/de/nowchess/account/error/ChallengeError.scala` — function message
+- `modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala` — class AlreadyLoggedInFilter
+- `modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala`
+ - class UserAccountRepository
+ - function findByUsername
+ - function findById
+ - function persist
+ - function findByEmail
+ - function findAll
+ - _...12 more_
+- `modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala`
+ - class ChallengeRepository
+ - function findActiveByChallengerId
+ - function findActiveByDestUserId
+ - function findDuplicateChallenge
+ - function findById
+ - function persist
+ - _...1 more_
+- `modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala`
+ - class AccountResource
+ - function register
+ - function login
+ - function me
+ - function publicProfile
+ - function banUser
+ - _...10 more_
+- `modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala`
+ - class ChallengeResource
+ - function create
+ - function list
+ - function accept
+ - function decline
+ - function cancel
+- `modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala`
+ - class AccountService
+ - function register
+ - function login
+ - function findByUsername
+ - function findById
+ - function createBotAccount
+ - _...11 more_
+- `modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala`
+ - class ChallengeService
+ - function create
+ - function accept
+ - function decline
+ - function cancel
+ - function listForUser
+ - _...1 more_
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala`
- class Board
- function apply
@@ -54,6 +169,15 @@
- `modules/api/src/main/scala/de/nowchess/api/dto/ErrorEventDto.scala` — class ErrorEventDto, function apply
- `modules/api/src/main/scala/de/nowchess/api/dto/GameFullEventDto.scala` — class GameFullEventDto, function apply
- `modules/api/src/main/scala/de/nowchess/api/dto/GameStateEventDto.scala` — class GameStateEventDto, function apply
+- `modules/api/src/main/scala/de/nowchess/api/error/GameError.scala` — function message
+- `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`
+ - function activeColor
+ - function afterMove
+ - function remainingMs
+ - function remainingMs
+ - function afterMove
+ - function remainingMs
+ - _...3 more_
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`
- function kingSquare
- function withBoard
@@ -62,11 +186,21 @@
- function withEnPassantSquare
- function withHalfMoveClock
- _...4 more_
+- `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
+- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — class GameContextImport, function importGameContext
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — class PlayerId, function apply
- `modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala`
- class ApiResponse
- function error
- function totalPages
+- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`
+ - class RuleSet
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...6 more_
- `modules/bot/python/nnue.py`
- function get_weights_dir: ()
- function get_data_dir: ()
@@ -171,30 +305,136 @@
- class ZobristHash
- function hash
- function nextHash
-- `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`
- - class Command
- - function execute
- - function undo
- - function description
- - class MoveResult
-- `modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala`
- - class CommandInvoker
- - function execute
- - function undo
- - function redo
- - function history
- - function getCurrentIndex
- - _...3 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala` — class CoordinatorApp
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/BeansProducer.scala`
+ - class BeansProducer
+ - function redissonClient
+ - function kubernetesClient
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala`
+ - class CoordinatorConfig
+ - function maxGamesPerCore
+ - function maxDeviationPercent
+ - function rebalanceInterval
+ - function rebalanceMinInterval
+ - function heartbeatTtl
+ - _...11 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
+ - class CoreGrpcClient
+ - function shutdown
+ - function batchResubscribeGames
+ - function unsubscribeGames
+ - function evictGames
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala`
+ - class CoordinatorResource
+ - function listInstances
+ - function getMetrics
+ - function triggerRebalance
+ - function triggerFailover
+ - function triggerScaleUp
+ - _...1 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala`
+ - class AutoScaler
+ - function checkAndScale
+ - function scaleUp
+ - function scaleDown
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala`
+ - class CacheEvictionManager
+ - function setRedisPrefix
+ - function evictStaleGames
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala`
+ - class FailoverService
+ - function setRedisPrefix
+ - function onInstanceStreamDropped
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala`
+ - class HealthMonitor
+ - function setRedisPrefix
+ - function checkInstanceHealth
+ - function watchK8sPods
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala`
+ - class InstanceRegistry
+ - function setRedisPrefix
+ - function getInstance
+ - function getAllInstances
+ - function updateInstanceFromRedis
+ - function markInstanceDead
+ - _...1 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala`
+ - class LoadBalancer
+ - function setRedisPrefix
+ - function shouldRebalance
+ - function rebalance
+- `modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala`
+ - class RuleSetRestAdapter
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...5 more_
+- `modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala`
+ - class IoServiceClient
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+ - function exportCombined
+- `modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala`
+ - class RuleServiceClient
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...6 more_
+- `modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala` — class StoreServiceClient, function getGame
- `modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala` — class RedisConfig
+- `modules/core/src/main/scala/de/nowchess/chess/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function produceRedissonClient
+ - function shutdown
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — class Parser, function parseMove
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
- class GameEngine
- function board
- function turn
- function context
- - function canUndo
- - function canRedo
- - _...11 more_
+ - function pendingDrawOfferBy
+ - function currentClockState
+ - _...22 more_
+- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala`
+ - class ApiException
+ - class GameNotFoundException
+ - class BadRequestException
+- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiExceptionMapper.scala` — class ApiExceptionMapper, function toResponse
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala` — class CoordinatorServiceHandler
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`
+ - class CoreProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala`
+ - class IoGrpcClientWrapper
+ - function exportCombined
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/RuleSetGrpcAdapter.scala`
+ - class RuleSetGrpcAdapter
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...5 more_
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
- function context
- class Observer
@@ -203,28 +443,52 @@
- function subscribe
- function unsubscribe
- _...1 more_
+- `modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala` — class C2sMessage
+- `modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala` — class GameRedisPublisher, function onGameEvent
+- `modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala`
+ - class GameRedisSubscriberManager
+ - function subscribeGame
+ - function onMessage
+ - function unsubscribeGame
+ - function batchResubscribeGames
+ - function unsubscribeGames
+ - _...3 more_
- `modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistry.scala`
- class GameRegistry
- function store
- function get
- function update
- function generateId
-- `modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala`
- - class GameRegistryImpl
+- `modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala`
+ - class RedisGameRegistry
+ - function generateId
- function store
- function get
- function update
- - function generateId
+- `modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala`
+ - class GameDtoMapper
+ - function statusOf
+ - function moveToUci
+ - function toPlayerDto
+ - function toClockDto
+ - function toGameStateDto
+ - _...1 more_
- `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala`
+ - class GameResource
- function onGameEvent
- function createGame
- function getGame
- - function streamGame
- - function onGameEvent
- function resignGame
+ - function makeMove
- _...9 more_
-- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
-- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — class GameContextImport, function importGameContext
+- `modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala`
+ - class InstanceHeartbeatService
+ - function onStart
+ - function onShutdown
+ - function setRedisPrefix
+ - function setSubscriptionCount
+ - function setLocalCacheSize
+ - _...2 more_
- `modules/io/src/main/scala/de/nowchess/io/GameFileService.scala`
- class GameFileService
- function saveGameToFile
@@ -253,6 +517,15 @@
- function parseBoard
- function importGameContext
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — function buildSquares
+- `modules/io/src/main/scala/de/nowchess/io/grpc/IoGrpcService.scala` — class IoGrpcService
+- `modules/io/src/main/scala/de/nowchess/io/grpc/IoProtoMapper.scala`
+ - class IoProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
- `modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala` — class JsonExporter, function exportGameContext
- `modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala` — class JsonParser, function importGameContext
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala`
@@ -265,20 +538,76 @@
- function importGameContext
- function parsePgn
- function parseAlgebraicMove
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala`
- - class RuleSet
+- `modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala`
+ - class IoResource
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+ - function exportCombined
+- `modules/json/src/main/scala/de/nowchess/json/ChessJacksonModule.scala` — class ChessJacksonModule
+- `modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala` — class GameResultDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/GameResultSerializer.scala` — class GameResultSerializer
+- `modules/json/src/main/scala/de/nowchess/json/MoveTypeDeserializer.scala` — class MoveTypeDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/MoveTypeSerializer.scala` — class MoveTypeSerializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareDeserializer.scala` — class SquareDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareKeyDeserializer.scala` — class SquareKeyDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareKeySerializer.scala` — class SquareKeySerializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareSerializer.scala` — class SquareSerializer
+- `modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/rule/src/main/scala/de/nowchess/rules/grpc/ProtoMapper.scala`
+ - class ProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
+- `modules/rule/src/main/scala/de/nowchess/rules/grpc/RuleGrpcService.scala` — class RuleGrpcService
+- `modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala`
+ - class RuleSetResource
- function candidateMoves
- function legalMoves
- function allLegalMoves
- function isCheck
- function isCheckmate
- - _...5 more_
-- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala`
- - class DefaultRules
- - function positionOf
- - function loop
- - function toMoves
- - function loop
+ - _...6 more_
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
+- `modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala` — class RedisConfig
+- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function redissonClient
+ - function close
+- `modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala` — class GameRecord
+- `modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala`
+ - class GameWritebackStreamListener
+ - function startListening
+ - function onMessage
+- `modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala`
+ - class GameRecordRepository
+ - function findByGameId
+ - function persist
+ - function merge
+- `modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
+- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
+- `modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala` — class RedisConfig
+- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function produceRedissonClient
+ - function shutdown
+- `modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala`
+ - class GameWebSocketResource
+ - function onOpen
+ - function onMessage
+ - function onTextMessage
+ - function onClose
---
@@ -301,39 +630,39 @@
## Most Imported Files (change these carefully)
-- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **64** files
-- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **44** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **40** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **35** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **19** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **18** files
-- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **17** files
-- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **17** files
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **11** files
-- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
-- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **9** files
-- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **7** files
-- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — imported by **7** 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/move/Move.scala` — imported by **55** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **47** files
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **28** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **18** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **18** files
+- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala` — imported by **14** files
+- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **13** files
+- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **11** files
+- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **9** files
+- `modules/api/src/main/scala/de/nowchess/api/error/GameError.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/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala` — imported by **7** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala` — imported by **6** files
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
-- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` — imported by **6** files
-- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **5** files
-- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — imported by **5** files
-- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **4** files
-- `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala` — imported by **4** files
-- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` — imported by **4** files
## Import Map (who imports what)
-- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala` +59 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/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +39 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/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` +35 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.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/GameContextTest.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` +30 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` +14 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Piece.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/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +13 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` +12 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` +12 more
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.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` +6 more
-- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala` +5 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/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` +42 more
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +23 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Piece.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/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` ← `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` +13 more
+- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` ← `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala` +13 more
---
diff --git a/.codesight/graph.md b/.codesight/graph.md
index 57ba7eb..e6975ab 100644
--- a/.codesight/graph.md
+++ b/.codesight/graph.md
@@ -2,36 +2,36 @@
## Most Imported Files (change these carefully)
-- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **64** files
-- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **44** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **40** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **35** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **19** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **18** files
-- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **17** files
-- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **17** files
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala` — imported by **11** files
-- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
-- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **9** files
-- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **7** files
-- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — imported by **7** 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/move/Move.scala` — imported by **55** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **47** files
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **28** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **20** files
+- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **18** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **18** files
+- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala` — imported by **14** files
+- `modules/api/src/main/scala/de/nowchess/api/board/CastlingRights.scala` — imported by **13** files
+- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **11** files
+- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **9** files
+- `modules/api/src/main/scala/de/nowchess/api/error/GameError.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/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala` — imported by **7** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala` — imported by **6** files
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
-- `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala` — imported by **6** files
-- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **5** files
-- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — imported by **5** files
-- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **4** files
-- `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala` — imported by **4** files
-- `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` — imported by **4** files
## Import Map (who imports what)
-- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala`, `modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala` +59 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/test/scala/de/nowchess/api/board/BoardTest.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +39 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/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/api/src/test/scala/de/nowchess/api/move/MoveTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala` +35 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.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/GameContextTest.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` +30 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` +14 more
-- `modules/api/src/main/scala/de/nowchess/api/board/Piece.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/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +13 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` +12 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` +12 more
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.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` +6 more
-- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` ← `modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala` +5 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/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` +42 more
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` ← `modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +23 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/board/PieceType.scala` ← `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala`, `modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala`, `modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/board/Piece.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/main/scala/de/nowchess/bot/util/PolyglotHash.scala`, `modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala`, `modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala` +15 more
+- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` ← `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`, `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` +13 more
+- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` ← `modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`, `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala` +13 more
diff --git a/.codesight/libs.md b/.codesight/libs.md
index c7da5a2..f8a9e41 100644
--- a/.codesight/libs.md
+++ b/.codesight/libs.md
@@ -16,6 +16,74 @@
- function main: () -> None
- class TestCase
- _...2 more_
+- `modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala` — class CoreGameClient, function createGame
+- `modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala`
+ - class Challenge
+ - function gameIdOpt
+ - function declineReasonOpt
+ - function timeControlLimitOpt
+ - function timeControlIncrementOpt
+- `modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala` — class ChallengeColorConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala` — class ChallengeStatusConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala` — class DeclineReasonConverter
+- `modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala` — class TimeControl
+- `modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala`
+ - class UserAccount
+ - function getBotAccounts
+ - class BotAccount
+ - class OfficialBotAccount
+- `modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala` — function message
+- `modules/account/src/main/scala/de/nowchess/account/error/ChallengeError.scala` — function message
+- `modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala` — class AlreadyLoggedInFilter
+- `modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala`
+ - class UserAccountRepository
+ - function findByUsername
+ - function findById
+ - function persist
+ - function findByEmail
+ - function findAll
+ - _...12 more_
+- `modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala`
+ - class ChallengeRepository
+ - function findActiveByChallengerId
+ - function findActiveByDestUserId
+ - function findDuplicateChallenge
+ - function findById
+ - function persist
+ - _...1 more_
+- `modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala`
+ - class AccountResource
+ - function register
+ - function login
+ - function me
+ - function publicProfile
+ - function banUser
+ - _...10 more_
+- `modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala`
+ - class ChallengeResource
+ - function create
+ - function list
+ - function accept
+ - function decline
+ - function cancel
+- `modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala`
+ - class AccountService
+ - function register
+ - function login
+ - function findByUsername
+ - function findById
+ - function createBotAccount
+ - _...11 more_
+- `modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala`
+ - class ChallengeService
+ - function create
+ - function accept
+ - function decline
+ - function cancel
+ - function listForUser
+ - _...1 more_
- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala`
- class Board
- function apply
@@ -45,6 +113,15 @@
- `modules/api/src/main/scala/de/nowchess/api/dto/ErrorEventDto.scala` — class ErrorEventDto, function apply
- `modules/api/src/main/scala/de/nowchess/api/dto/GameFullEventDto.scala` — class GameFullEventDto, function apply
- `modules/api/src/main/scala/de/nowchess/api/dto/GameStateEventDto.scala` — class GameStateEventDto, function apply
+- `modules/api/src/main/scala/de/nowchess/api/error/GameError.scala` — function message
+- `modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala`
+ - function activeColor
+ - function afterMove
+ - function remainingMs
+ - function remainingMs
+ - function afterMove
+ - function remainingMs
+ - _...3 more_
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala`
- function kingSquare
- function withBoard
@@ -53,11 +130,21 @@
- function withEnPassantSquare
- function withHalfMoveClock
- _...4 more_
+- `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
+- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — class GameContextImport, function importGameContext
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — class PlayerId, function apply
- `modules/api/src/main/scala/de/nowchess/api/response/ApiResponse.scala`
- class ApiResponse
- function error
- function totalPages
+- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`
+ - class RuleSet
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...6 more_
- `modules/bot/python/nnue.py`
- function get_weights_dir: ()
- function get_data_dir: ()
@@ -162,30 +249,136 @@
- class ZobristHash
- function hash
- function nextHash
-- `modules/core/src/main/scala/de/nowchess/chess/command/Command.scala`
- - class Command
- - function execute
- - function undo
- - function description
- - class MoveResult
-- `modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala`
- - class CommandInvoker
- - function execute
- - function undo
- - function redo
- - function history
- - function getCurrentIndex
- - _...3 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala` — class CoordinatorApp
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/BeansProducer.scala`
+ - class BeansProducer
+ - function redissonClient
+ - function kubernetesClient
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala`
+ - class CoordinatorConfig
+ - function maxGamesPerCore
+ - function maxDeviationPercent
+ - function rebalanceInterval
+ - function rebalanceMinInterval
+ - function heartbeatTtl
+ - _...11 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala` — class CoordinatorGrpcServer
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala`
+ - class CoreGrpcClient
+ - function shutdown
+ - function batchResubscribeGames
+ - function unsubscribeGames
+ - function evictGames
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala`
+ - class CoordinatorResource
+ - function listInstances
+ - function getMetrics
+ - function triggerRebalance
+ - function triggerFailover
+ - function triggerScaleUp
+ - _...1 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala`
+ - class AutoScaler
+ - function checkAndScale
+ - function scaleUp
+ - function scaleDown
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala`
+ - class CacheEvictionManager
+ - function setRedisPrefix
+ - function evictStaleGames
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala`
+ - class FailoverService
+ - function setRedisPrefix
+ - function onInstanceStreamDropped
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala`
+ - class HealthMonitor
+ - function setRedisPrefix
+ - function checkInstanceHealth
+ - function watchK8sPods
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala`
+ - class InstanceRegistry
+ - function setRedisPrefix
+ - function getInstance
+ - function getAllInstances
+ - function updateInstanceFromRedis
+ - function markInstanceDead
+ - _...1 more_
+- `modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala`
+ - class LoadBalancer
+ - function setRedisPrefix
+ - function shouldRebalance
+ - function rebalance
+- `modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala`
+ - class RuleSetRestAdapter
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...5 more_
+- `modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala`
+ - class IoServiceClient
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+ - function exportCombined
+- `modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala`
+ - class RuleServiceClient
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...6 more_
+- `modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala` — class StoreServiceClient, function getGame
- `modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala` — class RedisConfig
+- `modules/core/src/main/scala/de/nowchess/chess/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function produceRedissonClient
+ - function shutdown
- `modules/core/src/main/scala/de/nowchess/chess/controller/Parser.scala` — class Parser, function parseMove
- `modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala`
- class GameEngine
- function board
- function turn
- function context
- - function canUndo
- - function canRedo
- - _...11 more_
+ - function pendingDrawOfferBy
+ - function currentClockState
+ - _...22 more_
+- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala`
+ - class ApiException
+ - class GameNotFoundException
+ - class BadRequestException
+- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiExceptionMapper.scala` — class ApiExceptionMapper, function toResponse
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala` — class CoordinatorServiceHandler
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala`
+ - class CoreProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala`
+ - class IoGrpcClientWrapper
+ - function exportCombined
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+- `modules/core/src/main/scala/de/nowchess/chess/grpc/RuleSetGrpcAdapter.scala`
+ - class RuleSetGrpcAdapter
+ - function candidateMoves
+ - function legalMoves
+ - function allLegalMoves
+ - function isCheck
+ - function isCheckmate
+ - _...5 more_
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala`
- function context
- class Observer
@@ -194,28 +387,52 @@
- function subscribe
- function unsubscribe
- _...1 more_
+- `modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala` — class C2sMessage
+- `modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala` — class GameRedisPublisher, function onGameEvent
+- `modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala`
+ - class GameRedisSubscriberManager
+ - function subscribeGame
+ - function onMessage
+ - function unsubscribeGame
+ - function batchResubscribeGames
+ - function unsubscribeGames
+ - _...3 more_
- `modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistry.scala`
- class GameRegistry
- function store
- function get
- function update
- function generateId
-- `modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala`
- - class GameRegistryImpl
+- `modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala`
+ - class RedisGameRegistry
+ - function generateId
- function store
- function get
- function update
- - function generateId
+- `modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala`
+ - class GameDtoMapper
+ - function statusOf
+ - function moveToUci
+ - function toPlayerDto
+ - function toClockDto
+ - function toGameStateDto
+ - _...1 more_
- `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala`
+ - class GameResource
- function onGameEvent
- function createGame
- function getGame
- - function streamGame
- - function onGameEvent
- function resignGame
+ - function makeMove
- _...9 more_
-- `modules/io/src/main/scala/de/nowchess/io/GameContextExport.scala` — class GameContextExport, function exportGameContext
-- `modules/io/src/main/scala/de/nowchess/io/GameContextImport.scala` — class GameContextImport, function importGameContext
+- `modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala`
+ - class InstanceHeartbeatService
+ - function onStart
+ - function onShutdown
+ - function setRedisPrefix
+ - function setSubscriptionCount
+ - function setLocalCacheSize
+ - _...2 more_
- `modules/io/src/main/scala/de/nowchess/io/GameFileService.scala`
- class GameFileService
- function saveGameToFile
@@ -244,6 +461,15 @@
- function parseBoard
- function importGameContext
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParserSupport.scala` — function buildSquares
+- `modules/io/src/main/scala/de/nowchess/io/grpc/IoGrpcService.scala` — class IoGrpcService
+- `modules/io/src/main/scala/de/nowchess/io/grpc/IoProtoMapper.scala`
+ - class IoProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
- `modules/io/src/main/scala/de/nowchess/io/json/JsonExporter.scala` — class JsonExporter, function exportGameContext
- `modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala` — class JsonParser, function importGameContext
- `modules/io/src/main/scala/de/nowchess/io/pgn/PgnExporter.scala`
@@ -256,17 +482,73 @@
- function importGameContext
- function parsePgn
- function parseAlgebraicMove
-- `modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala`
- - class RuleSet
+- `modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/io/src/main/scala/de/nowchess/io/service/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala`
+ - class IoResource
+ - function importFen
+ - function importPgn
+ - function exportFen
+ - function exportPgn
+ - function exportCombined
+- `modules/json/src/main/scala/de/nowchess/json/ChessJacksonModule.scala` — class ChessJacksonModule
+- `modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala` — class GameResultDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/GameResultSerializer.scala` — class GameResultSerializer
+- `modules/json/src/main/scala/de/nowchess/json/MoveTypeDeserializer.scala` — class MoveTypeDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/MoveTypeSerializer.scala` — class MoveTypeSerializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareDeserializer.scala` — class SquareDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareKeyDeserializer.scala` — class SquareKeyDeserializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareKeySerializer.scala` — class SquareKeySerializer
+- `modules/json/src/main/scala/de/nowchess/json/SquareSerializer.scala` — class SquareSerializer
+- `modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/rule/src/main/scala/de/nowchess/rules/grpc/ProtoMapper.scala`
+ - class ProtoMapper
+ - function toProtoColor
+ - function fromProtoColor
+ - function toProtoPieceType
+ - function fromProtoPieceType
+ - function toProtoMoveKind
+ - _...9 more_
+- `modules/rule/src/main/scala/de/nowchess/rules/grpc/RuleGrpcService.scala` — class RuleGrpcService
+- `modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala`
+ - class RuleSetResource
- function candidateMoves
- function legalMoves
- function allLegalMoves
- function isCheck
- function isCheckmate
- - _...5 more_
-- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala`
- - class DefaultRules
- - function positionOf
- - function loop
- - function toMoves
- - function loop
+ - _...6 more_
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — class DefaultRules, function positionOf
+- `modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala` — class RedisConfig
+- `modules/store/src/main/scala/de/nowchess/store/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function redissonClient
+ - function close
+- `modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala` — class GameRecord
+- `modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala`
+ - class GameWritebackStreamListener
+ - function startListening
+ - function onMessage
+- `modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala`
+ - class GameRecordRepository
+ - function findByGameId
+ - function persist
+ - function merge
+- `modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala` — class StoreGameResource, function getGame
+- `modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala` — class GameWritebackService, function writeBack
+- `modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala` — class JacksonConfig, function customize
+- `modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala` — class NativeReflectionConfig
+- `modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala` — class RedisConfig
+- `modules/ws/src/main/scala/de/nowchess/ws/config/RedissonProducer.scala`
+ - class RedissonProducer
+ - function produceRedissonClient
+ - function shutdown
+- `modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala`
+ - class GameWebSocketResource
+ - function onOpen
+ - function onMessage
+ - function onTextMessage
+ - function onClose
diff --git a/.codesight/routes.md b/.codesight/routes.md
new file mode 100644
index 0000000..cd5f7e4
--- /dev/null
+++ b/.codesight/routes.md
@@ -0,0 +1,44 @@
+# Routes
+
+## gRPC
+
+- `/CoordinatorService/BatchResubscribeGames` (BatchResubscribeRequest) → BatchResubscribeResponse
+- `/CoordinatorService/UnsubscribeGames` (UnsubscribeGamesRequest) → UnsubscribeGamesResponse
+- `/CoordinatorService/EvictGames` (EvictGamesRequest) → EvictGamesResponse
+- `/CoordinatorService/DrainInstance` (DrainInstanceRequest) → DrainInstanceResponse
+- `/CoordinatorService/BatchResubscribeGames` (BatchResubscribeRequest) → BatchResubscribeResponse
+- `/CoordinatorService/UnsubscribeGames` (UnsubscribeGamesRequest) → UnsubscribeGamesResponse
+- `/CoordinatorService/EvictGames` (EvictGamesRequest) → EvictGamesResponse
+- `/CoordinatorService/DrainInstance` (DrainInstanceRequest) → DrainInstanceResponse
+- `/IoService/ImportFen` (ProtoImportFenRequest) → ProtoGameContext
+- `/IoService/ImportPgn` (ProtoImportPgnRequest) → ProtoGameContext
+- `/IoService/ExportCombined` (ProtoGameContext) → ProtoCombinedExport
+- `/IoService/ExportFen` (ProtoGameContext) → ProtoStringResult
+- `/IoService/ExportPgn` (ProtoGameContext) → ProtoStringResult
+- `/RuleService/CandidateMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/LegalMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/AllLegalMoves` (ProtoGameContext) → ProtoMoveList
+- `/RuleService/IsCheck` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsCheckmate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsStalemate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsInsufficientMaterial` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsFiftyMoveRule` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsThreefoldRepetition` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/ApplyMove` (ProtoMoveRequest) → ProtoGameContext
+- `/RuleService/PostMoveStatus` (ProtoGameContext) → ProtoPostMoveStatus
+- `/IoService/ImportFen` (ProtoImportFenRequest) → ProtoGameContext
+- `/IoService/ImportPgn` (ProtoImportPgnRequest) → ProtoGameContext
+- `/IoService/ExportCombined` (ProtoGameContext) → ProtoCombinedExport
+- `/IoService/ExportFen` (ProtoGameContext) → ProtoStringResult
+- `/IoService/ExportPgn` (ProtoGameContext) → ProtoStringResult
+- `/RuleService/CandidateMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/LegalMoves` (ProtoSquareRequest) → ProtoMoveList
+- `/RuleService/AllLegalMoves` (ProtoGameContext) → ProtoMoveList
+- `/RuleService/IsCheck` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsCheckmate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsStalemate` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsInsufficientMaterial` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsFiftyMoveRule` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/IsThreefoldRepetition` (ProtoGameContext) → ProtoBoolResult
+- `/RuleService/ApplyMove` (ProtoMoveRequest) → ProtoGameContext
+- `/RuleService/PostMoveStatus` (ProtoGameContext) → ProtoPostMoveStatus
diff --git a/.codesight/wiki/index.md b/.codesight/wiki/index.md
index 22ba8d0..afb5e0d 100644
--- a/.codesight/wiki/index.md
+++ b/.codesight/wiki/index.md
@@ -1,6 +1,6 @@
# NowChessSystems — Wiki
-_Generated 2026-04-12 — re-run `npx codesight --wiki` if the codebase has changed._
+_Generated 2026-04-23 — re-run `npx codesight --wiki` if the codebase has changed._
Structural map compiled from source code via AST. No LLM — deterministic, 200ms.
@@ -15,7 +15,7 @@ Structural map compiled from source code via AST. No LLM — deterministic, 200m
- Routes: **0**
- Models: **0**
- Components: **0**
-- Env vars: **0** required, **0** with defaults
+- Env vars: **1** required, **0** with defaults
## How to Use
@@ -41,4 +41,4 @@ These exist in your codebase but are **not** reflected in wiki articles:
When in doubt, search the source. The wiki is a starting point, not a complete inventory.
---
-_Last compiled: 2026-04-12 · 2 articles · [codesight](https://github.com/Houseofmvps/codesight)_
\ No newline at end of file
+_Last compiled: 2026-04-23 · 2 articles · [codesight](https://github.com/Houseofmvps/codesight)_
\ No newline at end of file
diff --git a/.codesight/wiki/log.md b/.codesight/wiki/log.md
index 6c83809..d94578e 100644
--- a/.codesight/wiki/log.md
+++ b/.codesight/wiki/log.md
@@ -3,3 +3,5 @@
History of `npx codesight --wiki` runs. Capped at 20 entries.
## [2026-04-12 14:34:19] scan | 0 routes, 0 models, 0 components → 2 articles
+
+## [2026-04-23 11:41:43] scan | 0 routes, 0 models, 0 components → 2 articles
diff --git a/.codesight/wiki/overview.md b/.codesight/wiki/overview.md
index 770cf96..190a96a 100644
--- a/.codesight/wiki/overview.md
+++ b/.codesight/wiki/overview.md
@@ -4,16 +4,24 @@
**NowChessSystems** is a scala project built with raw-http.
+## Scale
+
+1 middleware layers · 1 environment variables
+
## High-Impact Files
Changes to these files have the widest blast radius across the codebase:
-- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **28** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **21** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **19** files
-- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **14** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Board.scala` — imported by **13** files
-- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **10** files
+- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` — imported by **74** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Square.scala` — imported by **66** files
+- `modules/api/src/main/scala/de/nowchess/api/move/Move.scala` — imported by **52** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Color.scala` — imported by **42** files
+- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **27** files
+- `modules/api/src/main/scala/de/nowchess/api/board/Piece.scala` — imported by **21** files
+
+## Required Environment Variables
+
+- `STOCKFISH_PATH` — `modules/bot/python/nnue.py`
---
-_Back to [index.md](./index.md) · Generated 2026-04-12_
\ No newline at end of file
+_Back to [index.md](./index.md) · Generated 2026-04-23_
\ No newline at end of file
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
new file mode 100644
index 0000000..8065503
--- /dev/null
+++ b/.devcontainer/Dockerfile
@@ -0,0 +1,12 @@
+FROM mcr.microsoft.com/devcontainers/java:21-bookworm
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ postgresql-client \
+ redis-tools \
+ stockfish \
+ && rm -rf /var/lib/apt/lists/*
+
+RUN mkdir -p /home/vscode/.gradle/wrapper/dists \
+ && chown -R vscode:vscode /home/vscode/.gradle
+
diff --git a/.devcontainer/README.md b/.devcontainer/README.md
new file mode 100644
index 0000000..60f7a5c
--- /dev/null
+++ b/.devcontainer/README.md
@@ -0,0 +1,18 @@
+# Devcontainer
+
+Dieses Setup startet den NowChess-Workspace zusammen mit Redis und PostgreSQL.
+
+## Enthaltene Services
+- `workspace` – Scala/Gradle-Entwicklungscontainer
+- `redis` – Redis 7.4
+- `postgres` – PostgreSQL 16
+
+## Wichtige Ports
+- App-Services: `8080`, `8081`, `8082`, `8083`, `8084`, `8085`, `8086`, `9086`
+- Redis auf dem Host: `16379`
+- PostgreSQL auf dem Host: `15432`
+
+## Einstieg
+- VS Code: Ordner in einem Dev Container öffnen
+- IntelliJ: Dev Container / Docker-Compose-Workspace öffnen und den `workspace`-Dienst nutzen
+
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..1de19ea
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,39 @@
+{
+ "name": "NowChessSystems",
+ "dockerComposeFile": ["docker-compose.yml"],
+ "service": "workspace",
+ "workspaceFolder": "/workspaces/NowChessSystems",
+ "shutdownAction": "stopCompose",
+ "overrideCommand": false,
+ "remoteUser": "vscode",
+ "forwardPorts": [8080, 8081, 8082, 8083, 8084, 8085, 8086, 9086],
+ "portsAttributes": {
+ "8080": {"label": "NowChess Core", "onAutoForward": "notify"},
+ "8081": {"label": "NowChess Io", "onAutoForward": "notify"},
+ "8082": {"label": "NowChess Rule", "onAutoForward": "notify"},
+ "8083": {"label": "NowChess Account", "onAutoForward": "notify"},
+ "8084": {"label": "NowChess WebSocket", "onAutoForward": "notify"},
+ "8085": {"label": "NowChess Store", "onAutoForward": "notify"},
+ "8086": {"label": "NowChess Coordinator HTTP", "onAutoForward": "notify"},
+ "9086": {"label": "NowChess Coordinator gRPC", "onAutoForward": "notify"}
+ },
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "java.configuration.updateBuildConfiguration": "automatic",
+ "java.import.gradle.wrapper.enabled": true,
+ "files.watcherExclude": {
+ "**/build/**": true,
+ "**/.gradle/**": true
+ }
+ },
+ "extensions": [
+ "scala-lang.scala",
+ "redhat.java",
+ "vscjava.vscode-java-pack"
+ ]
+ }
+ },
+ "postCreateCommand": "bash -lc './gradlew --no-daemon help >/dev/null'"
+}
+
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 0000000..3cad48f
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,65 @@
+services:
+ workspace:
+ build:
+ context: ..
+ dockerfile: .devcontainer/Dockerfile
+ command: sleep infinity
+ init: true
+ volumes:
+ - gradle-cache:/home/vscode/.gradle
+ environment:
+ REDIS_HOST: redis
+ REDIS_PORT: 6379
+ REDIS_PREFIX: nowchess
+ DB_URL: jdbc:postgresql://postgres:5432/nowchess
+ DB_USER: nowchess
+ DB_PASSWORD: nowchess
+ CORE_SERVICE_URL: http://localhost:8080
+ INTERNAL_SECRET: dev-internal-secret
+ STOCKFISH_PATH: /usr/games/stockfish
+ NOWCHESS_COORDINATOR_ENABLED: "true"
+ dns:
+ - 8.8.8.8
+ - 1.1.1.1
+ depends_on:
+ redis:
+ condition: service_healthy
+ postgres:
+ condition: service_healthy
+
+ redis:
+ image: docker.io/redis:7.4-alpine
+ command: ["redis-server", "--appendonly", "yes", "--save", "60", "1"]
+ ports:
+ - "16379:6379"
+ volumes:
+ - redis-data:/data
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ start_period: 5s
+
+ postgres:
+ image: docker.io/postgres:16-alpine
+ environment:
+ POSTGRES_DB: nowchess
+ POSTGRES_USER: nowchess
+ POSTGRES_PASSWORD: nowchess
+ ports:
+ - "15432:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
+ interval: 5s
+ timeout: 3s
+ retries: 20
+ start_period: 10s
+
+volumes:
+ gradle-cache:
+ redis-data:
+ postgres-data:
+
diff --git a/.github/workflows/native-image.yml b/.github/workflows/native-image.yml
index 9ace760..8c77b50 100644
--- a/.github/workflows/native-image.yml
+++ b/.github/workflows/native-image.yml
@@ -31,8 +31,15 @@ jobs:
strategy:
matrix:
module:
+ - account
+ - bot-platform
+ - coordinator
- core
- io
+ - official-bots
+ - rule
+ - store
+ - ws
steps:
- uses: actions/checkout@v4
diff --git a/.gitignore b/.gitignore
index 50e5622..8113cf5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,9 @@ bin/
graphify-out/
.graphify_*.json
+### Keys ###
+**/keys/private.pem
+
### Mac OS ###
.DS_Store
/jacoco-reporter/.venv/
diff --git a/.idea/copilotDiffState.xml b/.idea/copilotDiffState.xml
new file mode 100644
index 0000000..89a8a93
--- /dev/null
+++ b/.idea/copilotDiffState.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/gradle.xml b/.idea/gradle.xml
index 08b68c3..475e81c 100644
--- a/.idea/gradle.xml
+++ b/.idea/gradle.xml
@@ -5,16 +5,23 @@
-
+
diff --git a/CLAUDE.md b/CLAUDE.md
index dccc202..c98fd79 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,7 +22,6 @@ Use consistently.
| `rule` | Game rules | api |
| `bot` | Bots and AI | api,rule,io |
| `io` | Export formats | api, core |
-| `ui` | Entrypoint & UI | core, io |
## Style
diff --git a/CLAUDE.original.md b/CLAUDE.original.md
index 8439c5e..1f46413 100644
--- a/CLAUDE.original.md
+++ b/CLAUDE.original.md
@@ -22,7 +22,6 @@ Try to stick to these commands for consistency.
| `rule` | Game rules | api |
| `bot` | Bots and AI | api,rule,io |
| `io` | Export formats | api, core |
-| `ui` | Entrypoint & UI | core, io |
## Style
diff --git a/add-a-new-microservice-ethereal-marshmallow.md b/add-a-new-microservice-ethereal-marshmallow.md
new file mode 100644
index 0000000..7ff5c61
--- /dev/null
+++ b/add-a-new-microservice-ethereal-marshmallow.md
@@ -0,0 +1,334 @@
+# Plan: Add Coordinator Microservice
+
+## Context
+NowChess scales `core` horizontally via shared Redis but lacks:
+- **Instance visibility**: no way to list running cores or their load
+- **Load balancing**: games land randomly on cores; no rebalancing
+- **Failover**: dead cores orphan subscriptions; bullet chess requires <1s recovery
+- **Auto-scaling**: manual ops to add/remove cores
+- **Cache management**: no eviction of stale games from core memory
+
+Bullet chess games run on move timings of <3s. 30s failover = game lost on clock. Target: **<300ms failover**.
+
+---
+
+## Architecture: Sub-1s Failover
+
+### Why Not Polling/TTL
+- TTL expiry: minimum 10-30s detection
+- HTTP polling 3x failure: 30s minimum
+- **gRPC streaming TCP drop: 50-200ms** — use this as primary
+
+### Primary: gRPC Bidirectional Streaming
+- Core opens a **persistent bidirectional stream** (`CoreHeartbeatStream`) to coordinator on startup
+- Core sends heartbeat frames every **200ms**
+- Core crash = TCP RST/FIN → coordinator stream error in **~50-200ms**
+- Stream also carries metadata updates (subscription count changes) in real-time
+
+### Fallback: Redis Heartbeat + K8s Watch
+- Redis heartbeat key `{prefix}:instances:{instanceId}` with **5s TTL**, refreshed every **2s**
+- K8s pod watch via Kubernetes Java client (event-driven; handles pod eviction/OOMKill)
+- Fallback covers: network partition (TCP stays up but core is zombie), coordinator restart gap
+
+---
+
+## Design
+
+### 1. Module: `modules/coordinator`
+**Language**: Scala 3.5.1, Quarkus REST + gRPC
+**Ports**: HTTP 8086, gRPC 9086
+**Dependencies**: Redisson, Kubernetes Java client, Quarkus gRPC
+**Persistence**: None (all state in Redis)
+
+---
+
+### 2. Instance Registry
+
+**Redis schema**:
+```
+{prefix}:instances:{instanceId}
+ - TTL: 5s (refreshed by core every 2s via background task)
+ - Value: JSON
+ {
+ "instanceId": "core-abc123",
+ "hostname": "core-pod-3",
+ "httpPort": 8080,
+ "grpcPort": 9080,
+ "subscriptionCount": 147,
+ "localCacheSize": 147,
+ "lastHeartbeat": "2026-04-26T10:15:30.123Z"
+ }
+
+{prefix}:instance:{instanceId}:games
+ - Type: Redis Set (no TTL — managed explicitly)
+ - Members: all gameIds currently subscribed on this instance
+```
+
+**Core changes** (new `InstanceHeartbeatService` bean in `modules/core`):
+- `@PostConstruct`: generate stable `instanceId` (hostname + random suffix); open gRPC stream to coordinator; publish Redis heartbeat; register in `{prefix}:instances:{instanceId}`
+- Every 200ms: send heartbeat frame on gRPC stream (carries `subscriptionCount`)
+- Every 2s: refresh Redis heartbeat bucket TTL
+- `subscribeGame(gameId)`: `SADD {prefix}:instance:{instanceId}:games gameId`
+- `unsubscribeGame(gameId)` / `evictGame(gameId)`: `SREM {prefix}:instance:{instanceId}:games gameId`
+- `@PreDestroy`: delete Redis key + games set; close gRPC stream (clean shutdown)
+
+---
+
+### 3. Health Monitoring (3 signals, primary fast)
+
+| Signal | Mechanism | Detection time | Role |
+|--------|-----------|---------------|------|
+| **gRPC stream drop** | TCP RST/FIN on bidirectional stream | 50–200ms | Primary |
+| **Redis heartbeat expiry** | `{prefix}:instances:{instanceId}` TTL=5s | 5–7s | Fallback |
+| **K8s pod watch** | `CoreV1Api.listNamespacedPod` watch stream | ~instant (pod events) | Fallback |
+
+**Dead decision**:
+- gRPC stream drops → **immediate failover** (no confirmation needed; games must recover fast)
+- Redis heartbeat expires (gRPC still up) → verify with single HTTP `/q/health` call → if fail: failover
+- K8s pod NotReady (gRPC still up) → failover
+
+---
+
+### 4. Failover Protocol (<300ms target)
+
+```
+T+0ms Core JVM crashes / network drops
+T+50ms Coordinator: gRPC stream error received
+T+52ms SMEMBERS {prefix}:instance:{instanceId}:games → list of orphaned gameIds
+T+55ms Distribute gameIds across healthy cores (least-loaded first)
+T+60ms BatchResubscribeGames gRPC call(s) fire to healthy core(s)
+T+150ms Healthy cores resubscribed; Redis s2c topics live again
+T+200ms WebSocket clients reconnect; receive GameFullEventDto on CONNECTED
+```
+
+**Failover steps** (coordinator `FailoverService`):
+1. On stream drop for `instanceId`:
+ a. Mark instance DEAD in local map
+ b. `SMEMBERS {prefix}:instance:{instanceId}:games`
+ c. Group gameIds into batches per target core (round-robin by load)
+ d. For each target core: call `BatchResubscribeGames(gameIds)`
+ e. Each target core: calls `subscribeGame(gameId)` for each (loads from Redis if not in local cache)
+ f. `DEL {prefix}:instance:{instanceId}:games` (cleanup)
+2. Log failover event with count of games migrated + latency
+
+---
+
+### 5. Load Rebalancing
+
+**Thresholds** (both must be evaluated):
+1. **Absolute**: any core > 500 games → rebalance
+2. **Relative**: max load > mean × 1.2 AND max - min > 50 games → rebalance
+
+**Algorithm** (runs every 30s, min 60s between actual rebalances):
+1. Read all `{prefix}:instances:*` keys → load map
+2. Identify overloaded cores (exceed either threshold)
+3. For each overloaded core: pick `excess = load - targetLoad` games
+4. Assign excess games to underloaded cores
+5. Call `UnsubscribeGames(gameIds)` on overloaded core
+6. Call `BatchResubscribeGames(gameIds)` on target core
+7. Overloaded core: `SREM` each game from its set
+8. Target core: `SADD` each game to its set on subscribe
+
+---
+
+### 6. Auto-Scaling
+
+**Metric**: avg `subscriptionCount` across all cores
+
+**Actions**:
+- avg > `scale-up-threshold` (80% of max): patch `nowchess-core` Argo Rollout `spec.replicas += 1`
+- avg < `scale-down-threshold` (30% of max) AND `replicas > min-replicas`: drain one core then scale down
+- Backoff: min 2-minute interval between scale events
+
+**Argo Rollouts API**:
+- CRD: `argoproj.io/v1alpha1`, Kind: `Rollout`, resource: `rollouts`
+- Scale via Fabric8 `GenericKubernetesResource` patch on `spec.replicas`
+- No StatefulSet — Argo Rollout owns pod lifecycle (canary/blue-green strategies respected)
+- Pod watch filter: label selector `app=nowchess-core` (Rollout sets this; `rollouts-pod-template-hash` is Argo's equivalent of `pod-template-hash`)
+
+**Drain before scale-down**:
+1. Pick least-loaded core
+2. Migrate all its games to other cores via `BatchResubscribeGames`
+3. Call `DrainInstance(instanceId)` on that core (sets it to reject new subscriptions)
+4. After drain confirmed: patch Rollout `spec.replicas -= 1`
+
+---
+
+### 7. Cache Eviction
+
+**Trigger**: coordinator scans `{prefix}:game:entry:*` every 10 minutes
+**Policy**: if `now - lastUpdated > 45min` AND `gameId` in any instance's games set → call `EvictGame`
+**Effect**: core removes game from `localEngines` and `unsubscribeGame`, `SREM` from instance set
+
+---
+
+### 8. Proto: `coordinator_service.proto`
+
+```proto
+syntax = "proto3";
+package de.nowchess.coordinator;
+
+service CoordinatorService {
+ // Core → Coordinator: bidirectional stream for liveness
+ rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
+
+ // Coordinator → Core: batch resubscribe after failover or rebalance
+ rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
+
+ // Coordinator → Core: unsubscribe games (rebalance source)
+ rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
+
+ // Coordinator → Core: evict idle games from local cache
+ rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
+
+ // Coordinator → Core: drain instance before scale-down
+ rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
+}
+
+message HeartbeatFrame {
+ string instanceId = 1;
+ string hostname = 2;
+ int32 httpPort = 3;
+ int32 grpcPort = 4;
+ int32 subscriptionCount = 5;
+ int32 localCacheSize = 6;
+ int64 timestampMillis = 7;
+}
+
+message CoordinatorCommand {
+ // Future: coordinator can push commands back (e.g., "start draining")
+ string type = 1;
+ string payload = 2;
+}
+
+message BatchResubscribeRequest {
+ repeated string gameIds = 1;
+}
+
+message BatchResubscribeResponse {
+ int32 subscribedCount = 1;
+ repeated string failedGameIds = 2;
+}
+
+message UnsubscribeGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message UnsubscribeGamesResponse {
+ int32 unsubscribedCount = 1;
+}
+
+message EvictGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message EvictGamesResponse {
+ int32 evictedCount = 1;
+}
+
+message DrainInstanceRequest {}
+
+message DrainInstanceResponse {
+ int32 gamesMigrated = 0;
+}
+```
+
+---
+
+### 9. Coordinator REST API (internal)
+
+- `GET /api/coordinator/instances` — all cores with load, health state
+- `GET /api/coordinator/metrics` — load distribution, rebalance history
+- `POST /api/coordinator/rebalance` — manual rebalance trigger
+- `POST /api/coordinator/failover/{instanceId}` — manual failover
+- `POST /api/coordinator/scale-up` / `scale-down` — manual scaling
+
+---
+
+### 10. Configuration
+
+**`modules/coordinator/src/main/resources/application.yml`**:
+```yaml
+quarkus.application.name: nowchess-coordinator
+quarkus.http.port: 8086
+quarkus.grpc.server.port: 9086
+
+nowchess.coordinator.max-games-per-core: 500
+nowchess.coordinator.max-deviation-percent: 20
+nowchess.coordinator.rebalance-interval: 30s
+nowchess.coordinator.rebalance-min-interval: 60s
+nowchess.coordinator.heartbeat-ttl: 5s
+nowchess.coordinator.stream-heartbeat-interval: 200ms
+nowchess.coordinator.cache-eviction-interval: 10m
+nowchess.coordinator.game-idle-threshold: 45m
+nowchess.coordinator.auto-scale-enabled: false
+nowchess.coordinator.scale-up-threshold: 0.8
+nowchess.coordinator.scale-down-threshold: 0.3
+nowchess.coordinator.scale-min-replicas: 2
+nowchess.coordinator.scale-max-replicas: 10
+nowchess.coordinator.k8s-namespace: default
+nowchess.coordinator.k8s-rollout-name: nowchess-core
+nowchess.coordinator.k8s-rollout-label-selector: app=nowchess-core
+
+quarkus.kubernetes-client.trust-certs: true
+```
+
+**Core `application.yml` additions**:
+```yaml
+nowchess.coordinator.host: localhost
+nowchess.coordinator.grpc-port: 9086
+nowchess.coordinator.stream-heartbeat-interval: 200ms
+nowchess.coordinator.redis-heartbeat-interval: 2s
+nowchess.coordinator.instance-id: ${HOSTNAME:local}-${quarkus.uuid}
+```
+
+---
+
+### 11. Files to Create / Modify
+
+**New — `modules/coordinator/`**:
+```
+build.gradle.kts
+src/main/proto/coordinator_service.proto
+src/main/resources/application.yml
+src/main/scala/de/nowchess/coordinator/
+ resource/CoordinatorResource.scala # REST endpoints
+ service/InstanceRegistry.scala # Redis instance list + in-memory map
+ service/HealthMonitor.scala # gRPC stream watcher + Redis TTL + k8s watch
+ service/FailoverService.scala # dead core → BatchResubscribe
+ service/LoadBalancer.scala # rebalance logic
+ service/AutoScaler.scala # k8s StatefulSet scaling
+ service/CacheEvictionManager.scala # idle game eviction
+ grpc/CoordinatorGrpcServer.scala # CoordinatorService gRPC impl (for HeartbeatStream)
+```
+
+**Modify — `modules/core/`**:
+- `build.gradle.kts` — add `coordinator_service.proto` stub, keep grpc dep
+- `src/main/proto/coordinator_service.proto` — copy (or symlink) proto for stub generation
+- `src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala` — `SADD`/`SREM` on subscribe/unsubscribe + implement `BatchResubscribeGames`, `UnsubscribeGames`, `EvictGames`, `DrainInstance` gRPC handlers
+- `src/main/scala/de/nowchess/chess/` — new `InstanceHeartbeatService.scala` (startup, gRPC stream, Redis TTL refresh)
+- `src/main/resources/application.yml` — coordinator connection config
+
+**Modify — root**:
+- `settings.gradle.kts` — add `include("modules/coordinator")`
+
+---
+
+## Verification
+
+1. `./compile` — coordinator and core compile cleanly
+2. **Stream detection**: start core + coordinator; kill core JVM (`kill -9`); coordinator logs failover within 300ms
+3. **Game continuity**: active game on killed core; WebSocket client reconnects and receives game state
+4. **Rebalance**: create 600 games on core-1 (2-core setup); coordinator rebalances ~100 to core-2
+5. **Fallback**: disconnect gRPC stream manually but keep core alive; Redis TTL fallback triggers within 7s
+6. **Cache eviction**: create idle game; coordinator calls `EvictGames` after 45min idle
+7. **REST metrics**: `curl localhost:8086/api/coordinator/metrics` returns per-core load + health
+8. **Restart recovery**: restart coordinator; gRPC streams re-establish from cores; state rebuilt from Redis
+
+---
+
+## Dependencies (new)
+
+- `io.fabric8:kubernetes-client:6.13.0` (Fabric8 k8s client — handles Argo `Rollout` CRD via `GenericKubernetesResource`; no Argo Java SDK needed)
+- Redisson — already in core, reuse via shared config
+- Quarkus gRPC — already in core, reuse
diff --git a/bruno/board/game/01 Create Game.bru b/bruno/board/game/01 Create Game.bru
index 85139dc..66540b1 100644
--- a/bruno/board/game/01 Create Game.bru
+++ b/bruno/board/game/01 Create Game.bru
@@ -18,6 +18,10 @@ headers {
body:json {
{
"white": {"id": "p1", "displayName": "Alice"},
- "black": {"id": "p2", "displayName": "Bob"}
+ "black": {"id": "p2", "displayName": "Bob"},
+ "timeControl": {
+ "limitSeconds": 300,
+ "incrementSeconds": 3
+ }
}
}
diff --git a/bruno/board/game/02 Get Game.bru b/bruno/board/game/02 Get Game.bru
index b9f0e65..a8e7dfc 100644
--- a/bruno/board/game/02 Get Game.bru
+++ b/bruno/board/game/02 Get Game.bru
@@ -4,9 +4,12 @@ meta {
seq: 2
}
-http {
- method: GET
+get {
url: {{baseUrl}}/api/board/game/{{gameId}}
body: none
auth: none
}
+
+vars:pre-request {
+ gameId: j0nPtcjl
+}
diff --git a/bruno/board/game/03 Stream Game.bru b/bruno/board/game/03 Stream Game.bru
index e85e594..cbc6a17 100644
--- a/bruno/board/game/03 Stream Game.bru
+++ b/bruno/board/game/03 Stream Game.bru
@@ -1,19 +1,34 @@
meta {
name: Stream Game
- type: http
+ type: ws
seq: 3
}
-get {
- url: {{baseUrl}}/api/board/game/{{gameId}}/stream
- body: none
+ws {
+ url: {{wsBaseUrl}}/api/board/game/{{gameId}}/ws
+ body: ws
auth: none
}
-headers {
- Accept: application/x-ndjson
+body:ws {
+ name: move
+ content: '''
+ {
+ "type": "MOVE",
+ "uci": "b1c3"
+ }
+ '''
+}
+
+body:ws {
+ name: ping
+ content: '''
+ {
+ "type": "PING"
+ }
+ '''
}
vars:pre-request {
- gameId: tjOgyEcS
+ gameId: j0nPtcjl
}
diff --git a/bruno/board/import/01 Import FEN.bru b/bruno/board/import/01 Import FEN.bru
index ddd86d4..bd0ddc1 100644
--- a/bruno/board/import/01 Import FEN.bru
+++ b/bruno/board/import/01 Import FEN.bru
@@ -19,6 +19,10 @@ body:json {
{
"fen": "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1",
"white": {"id": "p1", "displayName": "Alice"},
- "black": {"id": "p2", "displayName": "Bob"}
+ "black": {"id": "p2", "displayName": "Bob"},
+ "timeControl": {
+ "limitSeconds": 300,
+ "incrementSeconds": 3
+ }
}
}
diff --git a/bruno/board/move/01 Make Move.bru b/bruno/board/move/01 Make Move.bru
index 8600477..7d67129 100644
--- a/bruno/board/move/01 Make Move.bru
+++ b/bruno/board/move/01 Make Move.bru
@@ -11,5 +11,5 @@ post {
}
vars:pre-request {
- gameId: tjOgyEcS
+ gameId: Yg200tOF
}
diff --git a/bruno/board/move/02 Get Legal Moves.bru b/bruno/board/move/02 Get Legal Moves.bru
index 1226024..ccb14e9 100644
--- a/bruno/board/move/02 Get Legal Moves.bru
+++ b/bruno/board/move/02 Get Legal Moves.bru
@@ -15,5 +15,5 @@ params:query {
}
vars:pre-request {
- gameId: tjOgyEcS
+ gameId: COy3oigz
}
diff --git a/bruno/environments/local.bru b/bruno/environments/local.bru
index d0f7638..7ec255f 100644
--- a/bruno/environments/local.bru
+++ b/bruno/environments/local.bru
@@ -1,4 +1,5 @@
vars {
baseUrl: http://localhost:8080
+ wsBaseUrl: ws://localhost:8084
ioBaseUrl: http://localhost:8081
}
diff --git a/build.gradle.kts b/build.gradle.kts
index 5127534..0a7e09f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -12,7 +12,7 @@ version = "1.0-SNAPSHOT"
// converted to scoverage regexes via globToScoverageRegex for instrumentation-time exclusion.
val coverageExclusions = listOf(
// UI renders JavaFX components; headless test environments cannot exercise rendering paths
- "modules/ui/**",
+ "modules/api/**",
// FastParse macro-generated combinators produce synthetic branches that scoverage marks as uncovered
"modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse*",
// NNUE inference pipeline — coverage requires a trained model file not present in CI
@@ -42,7 +42,17 @@ val coverageExclusions = listOf(
// JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration
"**/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala",
//RuleSetRestAdapter - Quarkus integration of rule into core, only testable with Quarkus tests
- "**/core/src/main/de/nowchess/chess/adapter/RuleSetRestAdapter.scala"
+ "**/core/src/main/de/nowchess/chess/adapter/RuleSetRestAdapter.scala",
+ // AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage
+ "**/account/src/main/scala/de/nowchess/account/resource/**",
+ // JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
+ "**/account/src/main/scala/de/nowchess/account/config/**",
+ // WebSocket service — infrastructure CDI beans (RedisConfig)
+ "**/ws/src/main/scala/de/nowchess/ws/config/**",
+ // GameWebSocketResource in core — replaced by ws module
+ "**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
+ // Coordinator infrastructure — gRPC, microservice orchestration
+ "**/coordinator/src/main/scala/**",
)
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
diff --git a/docs/board-api-spec.yaml b/docs/board-api-spec.yaml
deleted file mode 100644
index 61bf241..0000000
--- a/docs/board-api-spec.yaml
+++ /dev/null
@@ -1,771 +0,0 @@
-openapi: 3.0.3
-info:
- title: NowChess Board API
- description: |
- REST API for the NowChess application. Designed to feel familiar to users
- of the [lichess API](https://lichess.org/api).
-
- ## Authentication
- Most endpoints require a Bearer token:
- ```
- Authorization: Bearer
- ```
- Authentication is reserved for future implementation — endpoints are currently
- open unless noted otherwise.
-
- ## Move notation
- Moves are expressed in **UCI notation**: `{from}{to}[promotion]`
- - Normal move: `e2e4`
- - Capture: `d5e6`
- - Promotion: `e7e8q` (q=queen, r=rook, b=bishop, n=knight)
- - Castling: `e1g1` (kingside white), `e1c1` (queenside white)
-
- ## Streaming
- Endpoints that support streaming return **NDJSON** (newline-delimited JSON).
- Request them with:
- ```
- Accept: application/x-ndjson
- ```
- Each line of the response is a complete JSON object. Empty lines are
- keep-alive heartbeats.
-
- ## Rate limiting
- Requests that exceed the rate limit receive `429 Too Many Requests`.
- Honour the `Retry-After` response header and wait before retrying.
- version: 1.0.0
- contact:
- name: NowChess
- license:
- name: MIT
-
-servers:
- - url: http://localhost:8080
- description: Local development server
-
-tags:
- - name: game
- description: Create and manage chess games
- - name: move
- description: Make moves and navigate game history
- - name: draw
- description: Draw offers and claims
- - name: import
- description: Load a game from FEN or PGN
- - name: export
- description: Export a game as FEN or PGN
-
-paths:
-
- # ---------------------------------------------------------------------------
- # Game lifecycle
- # ---------------------------------------------------------------------------
-
- /api/board/game:
- post:
- operationId: createGame
- tags: [game]
- summary: Create a new game
- description: |
- Creates a new chess game starting from the initial position.
- Returns the full game state including the generated `gameId`.
- security:
- - bearerAuth: []
- requestBody:
- required: false
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/CreateGameRequest'
- responses:
- '201':
- description: Game created
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '401':
- $ref: '#/components/responses/Unauthorized'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}:
- get:
- operationId: getGame
- tags: [game]
- summary: Get game state
- description: Returns the full current state of a game.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Current game state
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/stream:
- get:
- operationId: streamGame
- tags: [game]
- summary: Stream game events
- description: |
- Opens a persistent NDJSON stream for a game. The first object sent is
- a `gameFull` event containing the complete game state. Subsequent
- objects are `gameState` events sent whenever the game changes (move
- made, draw offered, game over, etc.).
-
- Empty lines are heartbeats to keep the connection alive.
-
- Connect with:
- ```
- Accept: application/x-ndjson
- ```
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: NDJSON event stream
- content:
- application/x-ndjson:
- schema:
- oneOf:
- - $ref: '#/components/schemas/GameFullEvent'
- - $ref: '#/components/schemas/GameStateEvent'
- - $ref: '#/components/schemas/ErrorEvent'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/resign:
- post:
- operationId: resignGame
- tags: [game]
- summary: Resign the game
- description: The active player resigns. The game ends immediately.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Resignation accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/OkResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Move-making
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/move/{uci}:
- post:
- operationId: makeMove
- tags: [move]
- summary: Make a move
- description: |
- Submit a move in UCI notation. The move must be legal for the side
- currently to move.
-
- For promotion moves include the target piece as the fifth character:
- `e7e8q`, `a2a1r`, etc. Promotion moves without the fifth character
- are rejected with `400 INVALID_MOVE`.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: uci
- in: path
- required: true
- description: Move in UCI notation (e.g. `e2e4`, `e7e8q`)
- schema:
- type: string
- pattern: '^[a-h][1-8][a-h][1-8][qrbn]?$'
- example: e2e4
- responses:
- '200':
- description: Move applied — returns updated game state
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/moves:
- get:
- operationId: getLegalMoves
- tags: [move]
- summary: Get legal moves
- description: |
- Returns all legal moves for the side currently to move.
- Optionally filter to moves originating from a single square.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: square
- in: query
- required: false
- description: Filter to moves from this square (e.g. `e2`)
- schema:
- type: string
- pattern: '^[a-h][1-8]$'
- example: e2
- responses:
- '200':
- description: List of legal moves
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/LegalMovesResponse'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/undo:
- post:
- operationId: undoMove
- tags: [move]
- summary: Undo the last move
- description: Reverts the most recent move. Returns the updated game state.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Move undone
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- description: No moves to undo
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/redo:
- post:
- operationId: redoMove
- tags: [move]
- summary: Redo a previously undone move
- description: Re-applies the next move in the undo stack. Returns the updated game state.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: Move redone
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameState'
- '400':
- description: No moves to redo
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Draw handling
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/draw/{action}:
- post:
- operationId: drawAction
- tags: [draw]
- summary: Offer, accept, decline, or claim a draw
- description: |
- Perform a draw-related action:
-
- | Action | Description |
- |-----------|-------------|
- | `offer` | Offer a draw to the opponent |
- | `accept` | Accept the opponent's draw offer |
- | `decline` | Decline the opponent's draw offer |
- | `claim` | Claim a draw under the fifty-move rule (only valid when `status` is `fiftyMoveAvailable`) |
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- - name: action
- in: path
- required: true
- schema:
- type: string
- enum: [offer, accept, decline, claim]
- responses:
- '200':
- description: Action accepted
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/OkResponse'
- '400':
- $ref: '#/components/responses/BadRequest'
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Import
- # ---------------------------------------------------------------------------
-
- /api/board/game/import/fen:
- post:
- operationId: importFen
- tags: [import]
- summary: Load a position from FEN
- description: |
- Creates a new game from a FEN string. The game starts at the position
- described by the FEN; move history prior to that position is not
- available.
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ImportFenRequest'
- responses:
- '201':
- description: Game created from FEN
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/import/pgn:
- post:
- operationId: importPgn
- tags: [import]
- summary: Load a game from PGN
- description: |
- Creates a new game by replaying all moves in a PGN string. The game
- starts at the position after the final move in the PGN; undo is
- available for every replayed move.
- security:
- - bearerAuth: []
- requestBody:
- required: true
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ImportPgnRequest'
- responses:
- '201':
- description: Game created from PGN
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/GameFull'
- '400':
- $ref: '#/components/responses/BadRequest'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- # ---------------------------------------------------------------------------
- # Export
- # ---------------------------------------------------------------------------
-
- /api/board/game/{gameId}/export/fen:
- get:
- operationId: exportFen
- tags: [export]
- summary: Export current position as FEN
- description: Returns the FEN string representing the current board position.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: FEN string
- content:
- text/plain:
- schema:
- type: string
- example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
- /api/board/game/{gameId}/export/pgn:
- get:
- operationId: exportPgn
- tags: [export]
- summary: Export game as PGN
- description: Returns the full PGN for the game including headers and move text.
- security:
- - bearerAuth: []
- parameters:
- - $ref: '#/components/parameters/gameId'
- responses:
- '200':
- description: PGN text
- content:
- application/x-chess-pgn:
- schema:
- type: string
- example: |
- [Event "NowChess game"]
- [White "Player1"]
- [Black "Player2"]
- [Result "*"]
-
- 1. e4 e5 2. Nf3 *
- '404':
- $ref: '#/components/responses/NotFound'
- '429':
- $ref: '#/components/responses/TooManyRequests'
-
-# =============================================================================
-# Components
-# =============================================================================
-
-components:
-
- securitySchemes:
- bearerAuth:
- type: http
- scheme: bearer
- description: 'Personal access token — `Authorization: Bearer `'
-
- parameters:
- gameId:
- name: gameId
- in: path
- required: true
- description: 8-character alphanumeric game ID (e.g. `Qa7FJNk2`)
- schema:
- type: string
- pattern: '^[A-Za-z0-9]{8}$'
- example: Qa7FJNk2
-
- responses:
- BadRequest:
- description: Invalid input
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- Unauthorized:
- description: Missing or invalid authentication token
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- NotFound:
- description: Game not found
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
- TooManyRequests:
- description: Rate limit exceeded — see `Retry-After` header
- headers:
- Retry-After:
- description: Seconds to wait before retrying
- schema:
- type: integer
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ApiError'
-
- schemas:
-
- # -------------------------------------------------------------------------
- # Requests
- # -------------------------------------------------------------------------
-
- CreateGameRequest:
- type: object
- description: Parameters for creating a new game. All fields are optional.
- properties:
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
-
- ImportFenRequest:
- type: object
- required: [fen]
- properties:
- fen:
- type: string
- description: Complete FEN string (6 fields)
- example: rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
-
- ImportPgnRequest:
- type: object
- required: [pgn]
- properties:
- pgn:
- type: string
- description: PGN text (headers and move list)
- example: "1. e4 e5 2. Nf3 Nc6 *"
-
- # -------------------------------------------------------------------------
- # Game state
- # -------------------------------------------------------------------------
-
- GameFull:
- type: object
- description: Complete game information including players and current state.
- required: [gameId, white, black, state]
- properties:
- gameId:
- type: string
- description: Unique 8-character game identifier
- example: Qa7FJNk2
- white:
- $ref: '#/components/schemas/PlayerInfo'
- black:
- $ref: '#/components/schemas/PlayerInfo'
- state:
- $ref: '#/components/schemas/GameState'
-
- GameState:
- type: object
- description: |
- The current game state. Included in `GameFull` and returned by move
- endpoints and stream events.
- required: [fen, pgn, turn, status, moves, undoAvailable, redoAvailable]
- properties:
- fen:
- type: string
- description: FEN string for the current position
- example: rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1
- pgn:
- type: string
- description: PGN move text for the full game so far
- example: "1. e4"
- turn:
- type: string
- enum: [white, black]
- description: The side to move
- status:
- $ref: '#/components/schemas/GameStatus'
- winner:
- type: string
- enum: [white, black]
- description: Set when `status` is `checkmate` or `resign`
- nullable: true
- moves:
- type: array
- description: All moves played so far, in UCI notation
- items:
- type: string
- example: [e2e4, e7e5, g1f3]
- undoAvailable:
- type: boolean
- description: Whether `POST /undo` is currently valid
- redoAvailable:
- type: boolean
- description: Whether `POST /redo` is currently valid
-
- GameStatus:
- type: string
- description: |
- Current game status:
-
- | Value | Meaning |
- |-------|---------|
- | `started` | Game in progress, no special condition |
- | `check` | Side to move is in check |
- | `checkmate` | Side to move is checkmated — game over |
- | `stalemate` | Side to move has no legal moves, not in check — game over (draw) |
- | `resign` | A player resigned — game over |
- | `draw` | Draw agreed or claimed — game over |
- | `drawOffered` | Waiting for the opponent to accept or decline a draw offer |
- | `fiftyMoveAvailable` | Fifty-move rule threshold reached; active player may claim draw |
- | `insufficientMaterial` | Neither side has enough pieces to deliver checkmate — game over (draw) |
- enum:
- - started
- - check
- - checkmate
- - stalemate
- - resign
- - draw
- - drawOffered
- - fiftyMoveAvailable
- - insufficientMaterial
-
- # -------------------------------------------------------------------------
- # Moves
- # -------------------------------------------------------------------------
-
- LegalMovesResponse:
- type: object
- required: [moves]
- properties:
- moves:
- type: array
- items:
- $ref: '#/components/schemas/LegalMove'
-
- LegalMove:
- type: object
- required: [from, to, uci, moveType]
- properties:
- from:
- type: string
- description: Origin square in algebraic notation
- example: e2
- to:
- type: string
- description: Destination square in algebraic notation
- example: e4
- uci:
- type: string
- description: Full move in UCI notation
- example: e2e4
- moveType:
- $ref: '#/components/schemas/MoveType'
- promotion:
- type: string
- enum: [queen, rook, bishop, knight]
- description: Target piece for promotion moves
- nullable: true
-
- MoveType:
- type: string
- description: Classification of the move
- enum:
- - normal
- - capture
- - castleKingside
- - castleQueenside
- - enPassant
- - promotion
-
- # -------------------------------------------------------------------------
- # Streaming events
- # -------------------------------------------------------------------------
-
- GameFullEvent:
- type: object
- description: |
- First event on a game stream. Contains the complete game snapshot.
- required: [type, game]
- properties:
- type:
- type: string
- enum: [gameFull]
- game:
- $ref: '#/components/schemas/GameFull'
-
- GameStateEvent:
- type: object
- description: |
- Emitted on a game stream whenever the game state changes (move played,
- draw offered, game over, etc.).
- required: [type, state]
- properties:
- type:
- type: string
- enum: [gameState]
- state:
- $ref: '#/components/schemas/GameState'
-
- ErrorEvent:
- type: object
- description: Emitted on a game stream when an error occurs.
- required: [type, error]
- properties:
- type:
- type: string
- enum: [error]
- error:
- $ref: '#/components/schemas/ApiError'
-
- # -------------------------------------------------------------------------
- # Shared types
- # -------------------------------------------------------------------------
-
- PlayerInfo:
- type: object
- required: [id, displayName]
- properties:
- id:
- type: string
- description: Unique player identifier
- example: player1
- displayName:
- type: string
- description: Human-readable display name
- example: Alice
-
- OkResponse:
- type: object
- required: [ok]
- properties:
- ok:
- type: boolean
- enum: [true]
-
- ApiError:
- type: object
- required: [code, message]
- properties:
- code:
- type: string
- description: Machine-readable error code
- example: INVALID_MOVE
- message:
- type: string
- description: Human-readable error description
- example: e2e5 is not a legal move
- field:
- type: string
- description: Request field that caused the error, if applicable
- example: uci
- nullable: true
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index b1e8239..55daf46 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
#Sat Mar 21 14:37:06 CET 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.0-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/jacoco-reporter/application.yml b/jacoco-reporter/application.yml
new file mode 100644
index 0000000..e69de29
diff --git a/modules/account/build.gradle.kts b/modules/account/build.gradle.kts
new file mode 100644
index 0000000..7589e77
--- /dev/null
+++ b/modules/account/build.gradle.kts
@@ -0,0 +1,117 @@
+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
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+
+ runtimeOnly("io.quarkus:quarkus-jdbc-h2")
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(project(":modules:security"))
+
+ implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-rest-client-jackson")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-hibernate-orm-panache")
+ implementation("io.quarkus:quarkus-jdbc-postgresql")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-jwt-build")
+ implementation("io.quarkus:quarkus-elytron-security-common")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-micrometer")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("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-jdbc-h2")
+ testImplementation("io.quarkus:quarkus-test-security")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging {
+ events("passed", "skipped", "failed")
+ showStandardStreams = true
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage {
+ dependsOn(tasks.test)
+}
+
+tasks.jar {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+}
diff --git a/modules/account/src/main/resources/META-INF.native-image.de.nowchess.account/reachability-metadata.json b/modules/account/src/main/resources/META-INF.native-image.de.nowchess.account/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/account/src/main/resources/META-INF.native-image.de.nowchess.account/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/account/src/main/resources/application.yml b/modules/account/src/main/resources/application.yml
new file mode 100644
index 0000000..898d3af
--- /dev/null
+++ b/modules/account/src/main/resources/application.yml
@@ -0,0 +1,64 @@
+quarkus:
+ http:
+ port: 8083
+ application:
+ name: nowchess-account
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ rest-client:
+ core-service:
+ url: http://localhost:8080
+ smallrye-openapi:
+ info-title: NowChess Account Service
+ path: /openapi
+ swagger-ui:
+ always-include: true
+ path: /swagger-ui
+ datasource:
+ db-kind: h2
+ username: sa
+ password: ""
+ jdbc:
+ url: jdbc:h2:mem:nowchess;DB_CLOSE_DELAY=-1
+ hibernate-orm:
+ schema-management:
+ strategy: drop-and-create
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+ internal:
+ secret: 123abc
+
+"%deployed":
+ quarkus:
+ rest-client:
+ core-service:
+ url: ${CORE_SERVICE_URL}
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
+ datasource:
+ db-kind: postgresql
+ username: ${DB_USER}
+ password: ${DB_PASSWORD}
+ jdbc:
+ url: ${DB_URL}
+ hibernate-orm:
+ schema-management:
+ strategy: update
+ mp:
+ jwt:
+ verify:
+ publickey:
+ location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
+ issuer: nowchess
+ smallrye:
+ jwt:
+ sign:
+ key:
+ location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
\ No newline at end of file
diff --git a/modules/account/src/main/resources/keys/public.pem b/modules/account/src/main/resources/keys/public.pem
new file mode 100644
index 0000000..6b6b842
--- /dev/null
+++ b/modules/account/src/main/resources/keys/public.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
+g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
+Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
+634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
+YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
+7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
+WQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala
new file mode 100644
index 0000000..648b83b
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala
@@ -0,0 +1,27 @@
+package de.nowchess.account.client
+
+import de.nowchess.security.InternalSecretClientFilter
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+
+case class CorePlayerInfo(id: String, displayName: String)
+case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
+case class CoreCreateGameRequest(
+ white: Option[CorePlayerInfo],
+ black: Option[CorePlayerInfo],
+ timeControl: Option[CoreTimeControl],
+ mode: Option[String],
+)
+case class CoreGameResponse(gameId: String)
+
+@Path("/api/board/game")
+@RegisterRestClient(configKey = "core-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
+trait CoreGameClient:
+
+ @POST
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def createGame(req: CoreCreateGameRequest): CoreGameResponse
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala
new file mode 100644
index 0000000..ad00ee6
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.account.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..18814b9
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala
@@ -0,0 +1,50 @@
+package de.nowchess.account.config
+
+import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
+import de.nowchess.account.domain.{
+ BotAccount,
+ Challenge,
+ ChallengeColor,
+ ChallengeStatus,
+ DeclineReason,
+ OfficialBotAccount,
+ TimeControl,
+ UserAccount,
+}
+import de.nowchess.account.dto.*
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(
+ classOf[UserAccount],
+ classOf[BotAccount],
+ classOf[OfficialBotAccount],
+ classOf[Challenge],
+ classOf[ChallengeColor],
+ classOf[ChallengeStatus],
+ classOf[DeclineReason],
+ classOf[TimeControl],
+ classOf[LoginRequest],
+ classOf[TokenResponse],
+ classOf[PlayerInfo],
+ classOf[PublicAccountDto],
+ classOf[BotAccountDto],
+ classOf[BotAccountWithTokenDto],
+ classOf[OfficialBotAccountDto],
+ classOf[CreateBotAccountRequest],
+ classOf[UpdateBotNameRequest],
+ classOf[RotatedTokenDto],
+ classOf[TimeControlDto],
+ classOf[ChallengeRequest],
+ classOf[ChallengeDto],
+ classOf[DeclineRequest],
+ classOf[ChallengeListDto],
+ classOf[ErrorDto],
+ classOf[CorePlayerInfo],
+ classOf[CoreTimeControl],
+ classOf[CoreCreateGameRequest],
+ classOf[CoreGameResponse],
+ classOf[OfficialChallengeResponse],
+ ),
+)
+class NativeReflectionConfig
diff --git a/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala
new file mode 100644
index 0000000..948bbf0
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+package de.nowchess.account.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ // scalafix:off DisableSyntax.var
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala
new file mode 100644
index 0000000..1f5775c
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala
@@ -0,0 +1,56 @@
+package de.nowchess.account.domain
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase
+import jakarta.persistence.*
+import scala.compiletime.uninitialized
+
+import java.time.Instant
+import java.util.UUID
+import scala.Conversion
+
+@Entity
+@Table(name = "challenges")
+class Challenge extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ var id: UUID = uninitialized
+
+ @ManyToOne
+ var challenger: UserAccount = uninitialized
+
+ @ManyToOne
+ var destUser: UserAccount = uninitialized
+
+ @Convert(converter = classOf[ChallengeColorConverter])
+ @Column(columnDefinition = "varchar(255)")
+ var color: ChallengeColor = uninitialized
+
+ @Convert(converter = classOf[ChallengeStatusConverter])
+ @Column(columnDefinition = "varchar(255)")
+ var status: ChallengeStatus = uninitialized
+
+ @Convert(converter = classOf[DeclineReasonConverter])
+ @Column(nullable = true, columnDefinition = "varchar(255)")
+ var declineReason: DeclineReason = uninitialized
+
+ var timeControlType: String = uninitialized
+
+ @Column(nullable = true)
+ var timeControlLimit: java.lang.Integer = uninitialized
+
+ @Column(nullable = true)
+ var timeControlIncrement: java.lang.Integer = uninitialized
+
+ var createdAt: Instant = uninitialized
+
+ var expiresAt: Instant = uninitialized
+
+ @Column(nullable = true)
+ var gameId: String = uninitialized
+ // scalafix:on
+
+ def gameIdOpt: Option[String] = Option(gameId)
+ def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
+ def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
+ def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala
new file mode 100644
index 0000000..9f5fb13
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColor.scala
@@ -0,0 +1,4 @@
+package de.nowchess.account.domain
+
+enum ChallengeColor:
+ case White, Black, Random
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala
new file mode 100644
index 0000000..d12a1ed
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeColorConverter.scala
@@ -0,0 +1,12 @@
+package de.nowchess.account.domain
+
+import jakarta.persistence.AttributeConverter
+import jakarta.persistence.Converter
+
+@Converter(autoApply = true)
+class ChallengeColorConverter extends AttributeConverter[ChallengeColor, String]:
+ override def convertToDatabaseColumn(attribute: ChallengeColor): String =
+ Option(attribute).map(_.toString).orNull
+
+ override def convertToEntityAttribute(dbData: String): ChallengeColor =
+ Option(dbData).map(ChallengeColor.valueOf).orNull
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala
new file mode 100644
index 0000000..4883cf8
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatus.scala
@@ -0,0 +1,4 @@
+package de.nowchess.account.domain
+
+enum ChallengeStatus:
+ case Created, Canceled, Declined, Accepted
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala
new file mode 100644
index 0000000..e310605
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/ChallengeStatusConverter.scala
@@ -0,0 +1,12 @@
+package de.nowchess.account.domain
+
+import jakarta.persistence.AttributeConverter
+import jakarta.persistence.Converter
+
+@Converter(autoApply = true)
+class ChallengeStatusConverter extends AttributeConverter[ChallengeStatus, String]:
+ override def convertToDatabaseColumn(attribute: ChallengeStatus): String =
+ Option(attribute).map(_.toString).orNull
+
+ override def convertToEntityAttribute(dbData: String): ChallengeStatus =
+ Option(dbData).map(ChallengeStatus.valueOf).orNull
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala
new file mode 100644
index 0000000..8f7c7fd
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReason.scala
@@ -0,0 +1,4 @@
+package de.nowchess.account.domain
+
+enum DeclineReason:
+ case Generic, Later, TooFast, TooSlow, TimeControl, Rated, Casual, Standard, Variant, NoBot, OnlyBot
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala
new file mode 100644
index 0000000..ff128ee
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/DeclineReasonConverter.scala
@@ -0,0 +1,12 @@
+package de.nowchess.account.domain
+
+import jakarta.persistence.AttributeConverter
+import jakarta.persistence.Converter
+
+@Converter(autoApply = true)
+class DeclineReasonConverter extends AttributeConverter[DeclineReason, String]:
+ override def convertToDatabaseColumn(attribute: DeclineReason): String =
+ Option(attribute).map(_.toString).orNull
+
+ override def convertToEntityAttribute(dbData: String): DeclineReason =
+ Option(dbData).map(DeclineReason.valueOf).orNull
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala
new file mode 100644
index 0000000..5bc2fd0
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala
@@ -0,0 +1,7 @@
+package de.nowchess.account.domain
+
+sealed trait TimeControl
+
+object TimeControl:
+ case class Clock(limit: Int, increment: Int) extends TimeControl
+ case object Unlimited extends TimeControl
diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala
new file mode 100644
index 0000000..ebd16b0
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/domain/UserAccount.scala
@@ -0,0 +1,78 @@
+package de.nowchess.account.domain
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase
+import jakarta.persistence.*
+import scala.compiletime.uninitialized
+import scala.jdk.CollectionConverters.*
+
+import java.time.Instant
+import java.util.UUID
+
+@Entity
+@Table(name = "user_accounts")
+class UserAccount extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ var id: UUID = uninitialized
+
+ @Column(unique = true, nullable = false)
+ var username: String = uninitialized
+
+ @Column(unique = true, nullable = false)
+ var email: String = uninitialized
+
+ var passwordHash: String = uninitialized
+
+ var rating: Int = 1500
+
+ var createdAt: Instant = uninitialized
+
+ var banned: Boolean = false
+
+ @OneToMany(mappedBy = "owner", cascade = Array(CascadeType.ALL), orphanRemoval = true)
+ var botAccounts: java.util.List[BotAccount] = uninitialized
+ // scalafix:on
+
+ def getBotAccounts: List[BotAccount] = Option(botAccounts).map(_.asScala.toList).getOrElse(Nil)
+
+@Entity
+@Table(name = "bot_accounts")
+class BotAccount extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ var id: UUID = uninitialized
+
+ @Column(nullable = false)
+ var name: String = uninitialized
+
+ @ManyToOne(optional = false)
+ @JoinColumn(name = "owner_id", nullable = false)
+ var owner: UserAccount = uninitialized
+
+ @Column(unique = true, nullable = false, length = 256)
+ var token: String = uninitialized
+
+ var rating: Int = 1500
+
+ var createdAt: Instant = uninitialized
+
+ var banned: Boolean = false
+ // scalafix:on
+
+@Entity
+@Table(name = "official_bot_accounts")
+class OfficialBotAccount extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ var id: UUID = uninitialized
+
+ @Column(nullable = false)
+ var name: String = uninitialized
+
+ var rating: Int = 1500
+
+ var createdAt: Instant = uninitialized
+ // scalafix:on
diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala
new file mode 100644
index 0000000..6876803
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala
@@ -0,0 +1,49 @@
+package de.nowchess.account.dto
+
+case class RegisterRequest(username: String, email: String, password: String)
+
+case class LoginRequest(username: String, password: String)
+
+case class TokenResponse(token: String)
+
+case class PlayerInfo(id: String, name: String, rating: Int)
+
+case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String)
+
+case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int])
+
+case class ChallengeRequest(color: String, timeControl: TimeControlDto)
+
+case class ChallengeDto(
+ id: String,
+ challenger: PlayerInfo,
+ destUser: PlayerInfo,
+ variant: String,
+ color: String,
+ timeControl: TimeControlDto,
+ status: String,
+ declineReason: Option[String],
+ gameId: Option[String],
+ createdAt: String,
+ expiresAt: String,
+)
+
+case class DeclineRequest(reason: Option[String])
+
+case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto])
+
+case class ErrorDto(error: String)
+
+case class CreateBotAccountRequest(name: String)
+
+case class UpdateBotNameRequest(name: String)
+
+case class BotAccountDto(id: String, name: String, rating: Int, createdAt: String)
+
+case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String)
+
+case class RotatedTokenDto(token: String)
+
+case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
+
+case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
diff --git a/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala
new file mode 100644
index 0000000..e6c8218
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/error/AccountError.scala
@@ -0,0 +1,23 @@
+package de.nowchess.account.error
+
+enum AccountError:
+ case UsernameTaken(username: String)
+ case EmailAlreadyRegistered(email: String)
+ case InvalidCredentials
+ case UserNotFound
+ case BotNotFound
+ case BotLimitExceeded
+ case NotAuthorized
+ case UserBanned
+ case BotBanned
+
+ def message: String = this match
+ case UsernameTaken(u) => s"Username '$u' is already taken"
+ case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
+ case InvalidCredentials => "Invalid credentials"
+ case UserNotFound => "User not found"
+ case BotNotFound => "Bot account not found"
+ case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded"
+ case NotAuthorized => "Not authorized to perform this action"
+ case UserBanned => "User account is banned"
+ case BotBanned => "Bot account is banned"
diff --git a/modules/account/src/main/scala/de/nowchess/account/error/ChallengeError.scala b/modules/account/src/main/scala/de/nowchess/account/error/ChallengeError.scala
new file mode 100644
index 0000000..206d5c8
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/error/ChallengeError.scala
@@ -0,0 +1,25 @@
+package de.nowchess.account.error
+
+enum ChallengeError:
+ case UserNotFound(username: String)
+ case ChallengerNotFound
+ case CannotChallengeSelf
+ case DuplicateChallenge
+ case InvalidColor(color: String)
+ case InvalidDeclineReason(reason: String)
+ case ChallengeNotFound
+ case ChallengeNotActive
+ case NotAuthorized
+ case GameCreationFailed
+
+ def message: String = this match
+ case UserNotFound(u) => s"User '$u' not found"
+ case ChallengerNotFound => "Challenger not found"
+ case CannotChallengeSelf => "Cannot challenge yourself"
+ case DuplicateChallenge => "Active challenge to this user already exists"
+ case InvalidColor(c) => s"Unknown color: $c"
+ case InvalidDeclineReason(r) => s"Unknown decline reason: $r"
+ case ChallengeNotFound => "Challenge not found"
+ case ChallengeNotActive => "Challenge is not active"
+ case NotAuthorized => "Not authorized"
+ case GameCreationFailed => "Failed to create game"
diff --git a/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala
new file mode 100644
index 0000000..8eb433d
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/filter/AlreadyLoggedInFilter.scala
@@ -0,0 +1,43 @@
+package de.nowchess.account.filter
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
+import jakarta.ws.rs.core.Response
+import jakarta.ws.rs.ext.Provider
+import org.eclipse.microprofile.jwt.JsonWebToken
+import scala.compiletime.uninitialized
+
+@Provider
+@ApplicationScoped
+class AlreadyLoggedInFilter extends ContainerRequestFilter:
+
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var jwt: JsonWebToken = uninitialized
+ // scalafix:on
+
+ override def filter(context: ContainerRequestContext): Unit =
+ val path = context.getUriInfo.getPath
+ val method = context.getMethod
+
+ if isProtectedEndpoint(path, method) && isAuthenticated then
+ context.abortWith(
+ Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity("""{"error":"Already logged in"}""")
+ .build(),
+ )
+
+ private def isAuthenticated: Boolean =
+ // scalafix:off DisableSyntax.null
+ try jwt.getName != null
+ catch
+ case _ => false
+ // scalafix:on DisableSyntax.null
+
+ private def isProtectedEndpoint(path: String, method: String): Boolean =
+ (path.contains("/api/account") || path.contains("/account")) &&
+ ((path.endsWith("/api/account") && method == "POST") ||
+ (path.endsWith("/account") && method == "POST") ||
+ (path.contains("/login") && method == "POST"))
diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala
new file mode 100644
index 0000000..3d6803f
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala
@@ -0,0 +1,98 @@
+package de.nowchess.account.repository
+
+import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.persistence.EntityManager
+
+import java.util.UUID
+import scala.jdk.CollectionConverters.*
+
+@ApplicationScoped
+class UserAccountRepository:
+
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = scala.compiletime.uninitialized
+ // scalafix:on
+
+ def findByUsername(username: String): Option[UserAccount] =
+ em.createQuery("FROM UserAccount WHERE username = :username", classOf[UserAccount])
+ .setParameter("username", username)
+ .getResultList
+ .stream()
+ .findFirst()
+ .map(Option(_))
+ .orElse(None)
+
+ def findById(id: UUID): Option[UserAccount] =
+ Option(em.find(classOf[UserAccount], id))
+
+ def persist(account: UserAccount): UserAccount =
+ em.persist(account)
+ account
+
+ def findByEmail(email: String): Option[UserAccount] =
+ em.createQuery("FROM UserAccount WHERE email = :email", classOf[UserAccount])
+ .setParameter("email", email)
+ .getResultList
+ .asScala
+ .headOption
+
+ def findAll(): List[UserAccount] =
+ em.createQuery("FROM UserAccount", classOf[UserAccount]).getResultList.asScala.toList
+
+@ApplicationScoped
+class BotAccountRepository:
+
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = scala.compiletime.uninitialized
+ // scalafix:on
+
+ def findById(id: UUID): Option[BotAccount] =
+ Option(em.find(classOf[BotAccount], id))
+
+ def findByOwner(ownerId: UUID): List[BotAccount] =
+ em.createQuery("FROM BotAccount WHERE owner.id = :ownerId", classOf[BotAccount])
+ .setParameter("ownerId", ownerId)
+ .getResultList
+ .asScala
+ .toList
+
+ def persist(bot: BotAccount): BotAccount =
+ em.persist(bot)
+ bot
+
+ def delete(botId: UUID): Unit =
+ em.find(classOf[BotAccount], botId) match
+ case bot: BotAccount => em.remove(bot)
+
+ def findByToken(token: String): Option[BotAccount] =
+ em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
+ .setParameter("token", token)
+ .getResultList
+ .asScala
+ .headOption
+
+@ApplicationScoped
+class OfficialBotAccountRepository:
+
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = scala.compiletime.uninitialized
+ // scalafix:on
+
+ def findById(id: UUID): Option[OfficialBotAccount] =
+ Option(em.find(classOf[OfficialBotAccount], id))
+
+ def findAll(): List[OfficialBotAccount] =
+ em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
+
+ def persist(bot: OfficialBotAccount): OfficialBotAccount =
+ em.persist(bot)
+ bot
+
+ def delete(botId: UUID): Unit =
+ em.find(classOf[OfficialBotAccount], botId) match
+ case bot: OfficialBotAccount => em.remove(bot)
diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala
new file mode 100644
index 0000000..191c47c
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/repository/ChallengeRepository.scala
@@ -0,0 +1,62 @@
+package de.nowchess.account.repository
+
+import de.nowchess.account.domain.{Challenge, ChallengeStatus}
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.persistence.EntityManager
+
+import java.time.Instant
+import java.util.UUID
+import scala.jdk.CollectionConverters.*
+
+@ApplicationScoped
+class ChallengeRepository:
+
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = scala.compiletime.uninitialized
+ // scalafix:on
+
+ def findActiveByChallengerId(challengerId: UUID): List[Challenge] =
+ em.createQuery(
+ "FROM Challenge WHERE challenger.id = :cid AND status = :status AND expiresAt > :now",
+ classOf[Challenge],
+ ).setParameter("cid", challengerId)
+ .setParameter("status", ChallengeStatus.Created)
+ .setParameter("now", Instant.now())
+ .getResultList
+ .asScala
+ .toList
+
+ def findActiveByDestUserId(destUserId: UUID): List[Challenge] =
+ em.createQuery(
+ "FROM Challenge WHERE destUser.id = :uid AND status = :status AND expiresAt > :now",
+ classOf[Challenge],
+ ).setParameter("uid", destUserId)
+ .setParameter("status", ChallengeStatus.Created)
+ .setParameter("now", Instant.now())
+ .getResultList
+ .asScala
+ .toList
+
+ def findDuplicateChallenge(challengerId: UUID, destUserId: UUID): Option[Challenge] =
+ em.createQuery(
+ "FROM Challenge WHERE challenger.id = :cid AND destUser.id = :uid AND status = :status AND expiresAt > :now",
+ classOf[Challenge],
+ ).setParameter("cid", challengerId)
+ .setParameter("uid", destUserId)
+ .setParameter("status", ChallengeStatus.Created)
+ .setParameter("now", Instant.now())
+ .getResultList
+ .asScala
+ .headOption
+
+ def findById(id: UUID): Option[Challenge] =
+ Option(em.find(classOf[Challenge], id))
+
+ def persist(challenge: Challenge): Challenge =
+ em.persist(challenge)
+ challenge
+
+ def merge(challenge: Challenge): Challenge =
+ em.merge(challenge)
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala
new file mode 100644
index 0000000..c24d634
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala
@@ -0,0 +1,202 @@
+package de.nowchess.account.resource
+
+import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
+import de.nowchess.account.dto.*
+import de.nowchess.account.error.AccountError
+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 scala.compiletime.uninitialized
+
+import java.util.UUID
+
+@Path("/api/account")
+@ApplicationScoped
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class AccountResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var accountService: AccountService = uninitialized
+
+ @Inject
+ var jwt: JsonWebToken = uninitialized
+ // scalafix:on
+
+ @POST
+ def register(req: RegisterRequest): Response =
+ accountService.register(req) match
+ case Right(account) =>
+ Response.ok(toPublicDto(account)).build()
+ case Left(error) =>
+ Response.status(Response.Status.CONFLICT).entity(ErrorDto(error.message)).build()
+
+ @POST
+ @Path("/login")
+ def login(req: LoginRequest): Response =
+ accountService.login(req) match
+ case Right(token) =>
+ Response.ok(TokenResponse(token)).build()
+ case Left(AccountError.UserBanned) =>
+ Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
+ case Left(error) =>
+ Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
+
+ @GET
+ @Path("/me")
+ @RolesAllowed(Array("**"))
+ def me(): Response =
+ val id = UUID.fromString(jwt.getSubject)
+ accountService.findById(id) match
+ case Some(account) => Response.ok(toPublicDto(account)).build()
+ case None => Response.status(Response.Status.NOT_FOUND).build()
+
+ @GET
+ @Path("/{username}")
+ def publicProfile(@PathParam("username") username: String): Response =
+ accountService.findByUsername(username) match
+ case Some(account) => Response.ok(toPublicDto(account)).build()
+ case None => Response.status(Response.Status.NOT_FOUND).build()
+
+ @POST
+ @Path("/{userId}/ban")
+ @RolesAllowed(Array("Admin"))
+ def banUser(@PathParam("userId") userId: String): Response =
+ accountService.banUser(UUID.fromString(userId)) match
+ case Right(user) => Response.ok(toPublicDto(user)).build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ @POST
+ @Path("/{userId}/unban")
+ @RolesAllowed(Array("Admin"))
+ def unbanUser(@PathParam("userId") userId: String): Response =
+ accountService.unbanUser(UUID.fromString(userId)) match
+ case Right(user) => Response.ok(toPublicDto(user)).build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ @POST
+ @Path("/bots")
+ @RolesAllowed(Array("**"))
+ def createBotAccount(req: CreateBotAccountRequest): Response =
+ val ownerId = UUID.fromString(jwt.getSubject)
+ accountService.createBotAccount(ownerId, req.name) match
+ case Right(bot) =>
+ Response.status(Response.Status.CREATED).entity(toBotDtoWithToken(bot)).build()
+ case Left(error) =>
+ val status = error match
+ case AccountError.BotLimitExceeded => Response.Status.BAD_REQUEST
+ case _ => Response.Status.INTERNAL_SERVER_ERROR
+ Response.status(status).entity(ErrorDto(error.message)).build()
+
+ @GET
+ @Path("/bots")
+ @RolesAllowed(Array("**"))
+ def listBotAccounts(): Response =
+ val ownerId = UUID.fromString(jwt.getSubject)
+ val bots = accountService.getBotAccounts(ownerId)
+ Response.ok(bots.map(toBotDto)).build()
+
+ @PUT
+ @Path("/bots/{botId}")
+ @RolesAllowed(Array("**"))
+ def updateBotName(@PathParam("botId") botId: String, req: UpdateBotNameRequest): Response =
+ val ownerId = UUID.fromString(jwt.getSubject)
+ accountService.updateBotName(UUID.fromString(botId), ownerId, req.name) match
+ case Right(bot) => Response.ok(toBotDto(bot)).build()
+ case Left(AccountError.NotAuthorized) =>
+ Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ @POST
+ @Path("/bots/{botId}/rotate-token")
+ @RolesAllowed(Array("**"))
+ def rotateBotToken(@PathParam("botId") botId: String): Response =
+ val ownerId = UUID.fromString(jwt.getSubject)
+ accountService.rotateBotToken(UUID.fromString(botId), ownerId) match
+ case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build()
+ case Left(AccountError.NotAuthorized) =>
+ Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ @DELETE
+ @Path("/bots/{botId}")
+ @RolesAllowed(Array("**"))
+ def deleteBotAccount(@PathParam("botId") botId: String): Response =
+ val ownerId = UUID.fromString(jwt.getSubject)
+ val botUuid = UUID.fromString(botId)
+ accountService.getBotAccountWithOwnerCheck(botUuid, ownerId) match
+ case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(AccountError.BotNotFound.message)).build()
+ case Some(None) =>
+ Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
+ case Some(Some(_)) =>
+ accountService.deleteBotAccount(botUuid) match
+ case Right(_) => Response.noContent().build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ private def toPublicDto(account: UserAccount): PublicAccountDto =
+ PublicAccountDto(
+ id = account.id.toString,
+ username = account.username,
+ rating = account.rating,
+ createdAt = account.createdAt.toString,
+ )
+
+ private def toBotDto(bot: BotAccount): BotAccountDto =
+ BotAccountDto(
+ id = bot.id.toString,
+ name = bot.name,
+ rating = bot.rating,
+ createdAt = bot.createdAt.toString,
+ )
+
+ private def toBotDtoWithToken(bot: BotAccount): BotAccountWithTokenDto =
+ BotAccountWithTokenDto(
+ id = bot.id.toString,
+ name = bot.name,
+ rating = bot.rating,
+ token = bot.token,
+ createdAt = bot.createdAt.toString,
+ )
+
+ @GET
+ @Path("/official-bots")
+ def getOfficialBots: Response =
+ val bots = accountService.getOfficialBotAccounts()
+ Response.ok(bots.map(toOfficialBotDto)).build()
+
+ @POST
+ @Path("/official-bots")
+ @RolesAllowed(Array("Admin"))
+ def createOfficialBot(req: CreateBotAccountRequest): Response =
+ accountService.createOfficialBotAccount(req.name) match
+ case Right(bot) =>
+ Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
+ case Left(error) =>
+ Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
+
+ @DELETE
+ @Path("/official-bots/{botId}")
+ @RolesAllowed(Array("Admin"))
+ def deleteOfficialBot(@PathParam("botId") botId: String): Response =
+ accountService.deleteOfficialBotAccount(UUID.fromString(botId)) match
+ case Right(_) => Response.noContent().build()
+ case Left(error) =>
+ Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
+
+ private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto =
+ OfficialBotAccountDto(
+ id = bot.id.toString,
+ name = bot.name,
+ rating = bot.rating,
+ createdAt = bot.createdAt.toString,
+ )
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala
new file mode 100644
index 0000000..6d77005
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala
@@ -0,0 +1,88 @@
+package de.nowchess.account.resource
+
+import de.nowchess.account.dto.*
+import de.nowchess.account.error.ChallengeError
+import de.nowchess.account.service.ChallengeService
+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
+
+import java.util.UUID
+
+@Path("/api/challenge")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+@Consumes(Array(MediaType.APPLICATION_JSON))
+@Produces(Array(MediaType.APPLICATION_JSON))
+class ChallengeResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var challengeService: ChallengeService = uninitialized
+
+ @Inject
+ var jwt: JsonWebToken = uninitialized
+ // scalafix:on
+
+ @POST
+ @Path("/{username}")
+ def create(@PathParam("username") username: String, req: ChallengeRequest): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.create(userId, username, req) match
+ case Right(challenge) =>
+ Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
+ case Left(error) =>
+ val status = error match
+ case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND
+ case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST
+ case _ => Response.Status.CONFLICT
+ Response.status(status).entity(ErrorDto(error.message)).build()
+
+ @GET
+ def list(): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ Response.ok(challengeService.listForUser(userId)).build()
+
+ @GET
+ @Path("/{id}")
+ def get(@PathParam("id") id: UUID): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.findById(id, userId) match
+ case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
+ case Left(error) => errorResponse(error)
+
+ @POST
+ @Path("/{id}/accept")
+ def accept(@PathParam("id") id: UUID): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.accept(id, userId) match
+ case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
+ case Left(error) => errorResponse(error)
+
+ @POST
+ @Path("/{id}/decline")
+ def decline(@PathParam("id") id: UUID, req: DeclineRequest): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.decline(id, userId, req) match
+ case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
+ case Left(error) => errorResponse(error)
+
+ @POST
+ @Path("/{id}/cancel")
+ def cancel(@PathParam("id") id: UUID): Response =
+ val userId = UUID.fromString(jwt.getSubject)
+ challengeService.cancel(id, userId) match
+ case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
+ case Left(error) => errorResponse(error)
+
+ private def errorResponse(error: ChallengeError): Response =
+ val status = error match
+ case ChallengeError.ChallengeNotFound => Response.Status.NOT_FOUND
+ case ChallengeError.NotAuthorized => Response.Status.FORBIDDEN
+ case ChallengeError.GameCreationFailed => Response.Status.INTERNAL_SERVER_ERROR
+ case _ => Response.Status.BAD_REQUEST
+ Response.status(status).entity(ErrorDto(error.message)).build()
diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
new file mode 100644
index 0000000..635dbe7
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/resource/OfficialChallengeResource.scala
@@ -0,0 +1,91 @@
+package de.nowchess.account.resource
+
+import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
+import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
+import de.nowchess.account.service.{AccountService, EventPublisher}
+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
+import java.util.concurrent.ThreadLocalRandom
+
+@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 var botEventPublisher: EventPublisher = uninitialized
+
+ @Inject
+ @RestClient
+ var coreGameClient: CoreGameClient = 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
+ Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(ErrorDto("difficulty must be between 1000 and 2800"))
+ .build()
+ else
+ val normalizedColor = Option(color).map(_.toLowerCase).getOrElse("random")
+ normalizedColor match
+ case "white" | "black" | "random" =>
+ 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 = normalizedColor match
+ case "white" => true
+ case "black" => false
+ case _ => ThreadLocalRandom.current().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 botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
+ Response
+ .status(Response.Status.CREATED)
+ .entity(OfficialChallengeResponse(id, botName, difficulty))
+ .build()
+ case other =>
+ Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
+ .build()
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala
new file mode 100644
index 0000000..71d5bb7
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala
@@ -0,0 +1,165 @@
+package de.nowchess.account.service
+
+import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
+import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
+import de.nowchess.account.error.AccountError
+import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
+import io.quarkus.elytron.security.common.BcryptUtil
+import io.smallrye.jwt.build.Jwt
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.transaction.Transactional
+import scala.compiletime.uninitialized
+
+import java.time.Instant
+import java.util.UUID
+
+@ApplicationScoped
+class AccountService:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var userAccountRepository: UserAccountRepository = uninitialized
+
+ @Inject
+ var botAccountRepository: BotAccountRepository = uninitialized
+
+ @Inject
+ var officialBotAccountRepository: OfficialBotAccountRepository = uninitialized
+ // scalafix:on
+
+ @Transactional
+ def register(req: RegisterRequest): Either[AccountError, UserAccount] =
+ if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
+ else if userAccountRepository.findByEmail(req.email).isDefined then
+ Left(AccountError.EmailAlreadyRegistered(req.email))
+ else
+ val account = new UserAccount()
+ account.username = req.username
+ account.email = req.email
+ account.passwordHash = BcryptUtil.bcryptHash(req.password)
+ account.createdAt = Instant.now()
+ userAccountRepository.persist(account)
+ Right(account)
+
+ def login(req: LoginRequest): Either[AccountError, String] =
+ userAccountRepository.findByUsername(req.username) match
+ case None => Left(AccountError.InvalidCredentials)
+ case Some(account) =>
+ if !BcryptUtil.matches(req.password, account.passwordHash) then Left(AccountError.InvalidCredentials)
+ else if account.banned then Left(AccountError.UserBanned)
+ else
+ Right(
+ Jwt
+ .issuer("nowchess")
+ .subject(account.id.toString)
+ .claim("username", account.username)
+ .sign(),
+ )
+
+ def findByUsername(username: String): Option[UserAccount] =
+ userAccountRepository.findByUsername(username)
+
+ def findById(id: UUID): Option[UserAccount] =
+ userAccountRepository.findById(id)
+
+ @Transactional
+ def createBotAccount(ownerId: UUID, botName: String): Either[AccountError, BotAccount] =
+ userAccountRepository.findById(ownerId) match
+ case None => Left(AccountError.UserNotFound)
+ case Some(owner) =>
+ val botAccounts = botAccountRepository.findByOwner(ownerId)
+ if botAccounts.length >= 5 then Left(AccountError.BotLimitExceeded)
+ else
+ val bot = new BotAccount()
+ bot.name = botName
+ bot.owner = owner
+ bot.token = generateBotToken(bot.id)
+ bot.createdAt = Instant.now()
+ botAccountRepository.persist(bot)
+ Right(bot)
+
+ def getBotAccounts(ownerId: UUID): List[BotAccount] =
+ botAccountRepository.findByOwner(ownerId)
+
+ def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] =
+ botAccountRepository.findById(botId) match
+ case None => Some(None)
+ case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId))
+
+ @Transactional
+ def deleteBotAccount(botId: UUID): Either[AccountError, Unit] =
+ botAccountRepository.findById(botId) match
+ case None => Left(AccountError.BotNotFound)
+ case Some(_) =>
+ botAccountRepository.delete(botId)
+ Right(())
+
+ @Transactional
+ def updateBotName(botId: UUID, ownerId: UUID, newName: String): Either[AccountError, BotAccount] =
+ botAccountRepository.findById(botId) match
+ case None => Left(AccountError.BotNotFound)
+ case Some(bot) =>
+ if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
+ else
+ bot.name = newName
+ botAccountRepository.persist(bot)
+ Right(bot)
+
+ @Transactional
+ def rotateBotToken(botId: UUID, ownerId: UUID): Either[AccountError, BotAccount] =
+ botAccountRepository.findById(botId) match
+ case None => Left(AccountError.BotNotFound)
+ case Some(bot) =>
+ if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
+ else
+ bot.token = generateBotToken(botId)
+ botAccountRepository.persist(bot)
+ Right(bot)
+
+ @Transactional
+ def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
+ val bot = new OfficialBotAccount()
+ bot.name = botName
+ bot.createdAt = Instant.now()
+ officialBotAccountRepository.persist(bot)
+ Right(bot)
+
+ def getOfficialBotAccounts(): List[OfficialBotAccount] =
+ officialBotAccountRepository.findAll()
+
+ @Transactional
+ def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
+ officialBotAccountRepository.findById(botId) match
+ case None => Left(AccountError.BotNotFound)
+ case Some(_) =>
+ officialBotAccountRepository.delete(botId)
+ Right(())
+
+ private def generateBotToken(botId: UUID): String =
+ Jwt
+ .issuer("nowchess")
+ .subject(botId.toString)
+ .expiresAt(Long.MaxValue)
+ .claim("type", "bot")
+ .sign()
+
+ @Transactional
+ def banUser(userId: UUID): Either[AccountError, UserAccount] =
+ userAccountRepository.findById(userId) match
+ case None => Left(AccountError.UserNotFound)
+ case Some(user) =>
+ user.banned = true
+ user.botAccounts.forEach(_.banned = true)
+ userAccountRepository.persist(user)
+ Right(user)
+
+ @Transactional
+ def unbanUser(userId: UUID): Either[AccountError, UserAccount] =
+ userAccountRepository.findById(userId) match
+ case None => Left(AccountError.UserNotFound)
+ case Some(user) =>
+ user.banned = false
+ user.botAccounts.forEach(_.banned = false)
+ userAccountRepository.persist(user)
+ Right(user)
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
new file mode 100644
index 0000000..532b4c8
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala
@@ -0,0 +1,197 @@
+package de.nowchess.account.service
+
+import de.nowchess.account.client.{
+ CoreCreateGameRequest,
+ CoreGameClient,
+ CoreGameResponse,
+ CorePlayerInfo,
+ CoreTimeControl,
+}
+import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
+import de.nowchess.account.dto.{
+ ChallengeDto,
+ ChallengeListDto,
+ ChallengeRequest,
+ DeclineRequest,
+ PlayerInfo,
+ TimeControlDto,
+}
+import de.nowchess.account.error.ChallengeError
+import de.nowchess.account.repository.{ChallengeRepository, UserAccountRepository}
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.transaction.Transactional
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.jboss.logging.Logger
+import scala.compiletime.uninitialized
+
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+import java.util.UUID
+import java.util.concurrent.ThreadLocalRandom
+
+@ApplicationScoped
+class ChallengeService:
+
+ private val log = Logger.getLogger(classOf[ChallengeService])
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var userAccountRepository: UserAccountRepository = uninitialized
+
+ @Inject
+ var challengeRepository: ChallengeRepository = uninitialized
+
+ @Inject
+ @RestClient
+ var coreGameClient: CoreGameClient = uninitialized
+
+ @Inject
+ var eventPublisher: EventPublisher = uninitialized
+ // scalafix:on
+
+ @Transactional
+ def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
+ for
+ destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
+ challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
+ _ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
+ _ <- Either.cond(
+ challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
+ (),
+ ChallengeError.DuplicateChallenge,
+ )
+ color <- parseColor(req.color)
+ yield
+ val challenge = new Challenge()
+ challenge.challenger = challenger
+ challenge.destUser = destUser
+ challenge.color = color
+ challenge.status = ChallengeStatus.Created
+ challenge.timeControlType = req.timeControl.`type`
+ challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
+ challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
+ challenge.createdAt = Instant.now()
+ challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
+ challengeRepository.persist(challenge)
+ try eventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
+ challenge
+
+ @Transactional
+ def accept(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
+ for
+ challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
+ _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
+ _ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
+ gameId <- createGame(challenge)
+ yield
+ challenge.status = ChallengeStatus.Accepted
+ challenge.gameId = gameId
+ challengeRepository.merge(challenge)
+ notifyBotIfNeeded(challenge, gameId)
+ try eventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
+ challenge
+
+ @Transactional
+ def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[ChallengeError, Challenge] =
+ for
+ challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
+ _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
+ _ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
+ reason <- parseDeclineReason(req.reason)
+ yield
+ challenge.status = ChallengeStatus.Declined
+ challenge.declineReason = reason.orNull
+ challengeRepository.merge(challenge)
+ challenge
+
+ @Transactional
+ def cancel(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
+ for
+ challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
+ _ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
+ _ <- Either.cond(challenge.challenger.id == userId, (), ChallengeError.NotAuthorized)
+ yield
+ challenge.status = ChallengeStatus.Canceled
+ challengeRepository.merge(challenge)
+ challenge
+
+ def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
+ for
+ challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
+ _ <- Either.cond(
+ challenge.challenger.id == userId || challenge.destUser.id == userId,
+ (),
+ ChallengeError.NotAuthorized,
+ )
+ yield challenge
+
+ def listForUser(userId: UUID): ChallengeListDto =
+ val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
+ val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
+ ChallengeListDto(in = incoming, out = outgoing)
+
+ private def notifyBotIfNeeded(challenge: Challenge, gameId: String): Unit =
+ val (white, black) = assignColors(challenge)
+ List(challenge.challenger, challenge.destUser).foreach { user =>
+ user.getBotAccounts.headOption.foreach { bot =>
+ val playingAs = if white.id == user.id.toString then "white" else "black"
+ try eventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
+ catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
+ }
+ }
+
+ private def createGame(challenge: Challenge): Either[ChallengeError, String] =
+ try
+ val (white, black) = assignColors(challenge)
+ val tc = buildTimeControl(challenge)
+ val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
+ Right(coreGameClient.createGame(req).gameId)
+ catch case _ => Left(ChallengeError.GameCreationFailed)
+
+ private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
+ val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username)
+ val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username)
+ challenge.color match
+ case ChallengeColor.White => (challenger, destUser)
+ case ChallengeColor.Black => (destUser, challenger)
+ case ChallengeColor.Random =>
+ if ThreadLocalRandom.current().nextBoolean() then (challenger, destUser) else (destUser, challenger)
+
+ private def buildTimeControl(challenge: Challenge): Option[CoreTimeControl] =
+ challenge.timeControlType match
+ case "unlimited" => None
+ case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
+ case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
+
+ private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
+ raw.toLowerCase match
+ case "white" => Right(ChallengeColor.White)
+ case "black" => Right(ChallengeColor.Black)
+ case "random" => Right(ChallengeColor.Random)
+ case _ => Left(ChallengeError.InvalidColor(raw))
+
+ private def parseDeclineReason(raw: Option[String]): Either[ChallengeError, Option[DeclineReason]] =
+ raw match
+ case None => Right(None)
+ case Some(r) =>
+ DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match
+ case Some(reason) => Right(Some(reason))
+ case None => Left(ChallengeError.InvalidDeclineReason(r))
+
+ def toDto(c: Challenge): ChallengeDto =
+ ChallengeDto(
+ id = c.id.toString,
+ challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating),
+ destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
+ variant = "standard",
+ color = c.color.toString.toLowerCase,
+ timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
+ status = c.status.toString.toLowerCase,
+ declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
+ gameId = c.gameIdOpt,
+ createdAt = c.createdAt.toString,
+ expiresAt = c.expiresAt.toString,
+ )
diff --git a/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala b/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala
new file mode 100644
index 0000000..fc508a7
--- /dev/null
+++ b/modules/account/src/main/scala/de/nowchess/account/service/EventPublisher.scala
@@ -0,0 +1,31 @@
+package de.nowchess.account.service
+
+import de.nowchess.account.config.RedisConfig
+import io.quarkus.redis.datasource.RedisDataSource
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class EventPublisher:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redis: RedisDataSource = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
+ val event =
+ s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
+ redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
+ ()
+
+ def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
+ val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
+ redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$destUserId:events", event)
+ ()
+
+ def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
+ val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
+ redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$challengerId:events", event)
+ ()
diff --git a/modules/account/src/test/resources/application.yml b/modules/account/src/test/resources/application.yml
new file mode 100644
index 0000000..82fe950
--- /dev/null
+++ b/modules/account/src/test/resources/application.yml
@@ -0,0 +1,36 @@
+quarkus:
+ http:
+ port: 8083
+ application:
+ name: nowchess-account
+ smallrye-openapi:
+ info-title: NowChess Account Service
+ path: /openapi
+ swagger-ui:
+ always-include: true
+ path: /swagger-ui
+ datasource:
+ db-kind: h2
+ username: sa
+ password: ""
+ jdbc:
+ url: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
+ hibernate-orm:
+ schema-management:
+ strategy: drop-and-create
+mp:
+ jwt:
+ verify:
+ publickey:
+ location: keys/test-public.pem
+ issuer: nowchess
+smallrye:
+ jwt:
+ sign:
+ key:
+ location: keys/test-private.pem
+nowchess:
+ internal:
+ secret: test-secret
+ auth:
+ enabled: false
diff --git a/modules/account/src/test/resources/keys/test-private.pem b/modules/account/src/test/resources/keys/test-private.pem
new file mode 100644
index 0000000..ad772e8
--- /dev/null
+++ b/modules/account/src/test/resources/keys/test-private.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
+wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
+PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
+oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
+jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
+GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
+FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
+ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
+wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
+UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
+3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
+B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
+Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
+GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
+cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
+aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
+ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
+DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
+0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
+TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
+8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
+G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
+clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
+wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
+kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
+5i4ReFwm3/IWDn9kLmHT6Fg=
+-----END PRIVATE KEY-----
diff --git a/modules/account/src/test/resources/keys/test-public.pem b/modules/account/src/test/resources/keys/test-public.pem
new file mode 100644
index 0000000..a5b79c0
--- /dev/null
+++ b/modules/account/src/test/resources/keys/test-public.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
+VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
+MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
+WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
+QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
+FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
+BwIDAQAB
+-----END PUBLIC KEY-----
diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala
new file mode 100644
index 0000000..7ea3f1c
--- /dev/null
+++ b/modules/account/src/test/scala/de/nowchess/account/resource/AccountResourceTest.scala
@@ -0,0 +1,107 @@
+package de.nowchess.account.resource
+
+import io.quarkus.test.junit.QuarkusTest
+import io.restassured.RestAssured
+import io.restassured.http.ContentType
+import org.hamcrest.Matchers.*
+import org.junit.jupiter.api.Test
+
+@QuarkusTest
+class AccountResourceTest:
+
+ private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
+
+ private def registerBody(username: String, email: String = "", password: String = "secret") =
+ val resolvedEmail = if email.isEmpty then s"$username@example.com" else email
+ s"""{"username":"$username","email":"$resolvedEmail","password":"$password"}"""
+
+ private def loginBody(username: String, password: String = "secret") =
+ s"""{"username":"$username","password":"$password"}"""
+
+ private def registerAndLogin(username: String): String =
+ givenRequest()
+ .body(registerBody(username))
+ .when()
+ .post("/api/account")
+ .`then`()
+ .statusCode(200)
+ givenRequest()
+ .body(loginBody(username))
+ .when()
+ .post("/api/account/login")
+ .`then`()
+ .statusCode(200)
+ .extract()
+ .path[String]("token")
+
+ @Test
+ def registerReturns200(): Unit =
+ givenRequest()
+ .body(registerBody("alice"))
+ .when()
+ .post("/api/account")
+ .`then`()
+ .statusCode(200)
+ .body("username", is("alice"))
+ .body("rating", is(1500))
+
+ @Test
+ def registerConflictOnDuplicateUsername(): Unit =
+ givenRequest().body(registerBody("bob")).when().post("/api/account")
+ givenRequest()
+ .body(registerBody("bob"))
+ .when()
+ .post("/api/account")
+ .`then`()
+ .statusCode(409)
+ .body("error", containsString("bob"))
+
+ @Test
+ def loginReturns200WithToken(): Unit =
+ givenRequest().body(registerBody("charlie")).when().post("/api/account")
+ givenRequest()
+ .body(loginBody("charlie"))
+ .when()
+ .post("/api/account/login")
+ .`then`()
+ .statusCode(200)
+ .body("token", notNullValue())
+
+ @Test
+ def loginUnauthorizedOnWrongPassword(): Unit =
+ givenRequest().body(registerBody("dave")).when().post("/api/account")
+ givenRequest()
+ .body(loginBody("dave", "wrongpassword"))
+ .when()
+ .post("/api/account/login")
+ .`then`()
+ .statusCode(401)
+
+ @Test
+ def getMeReturns200(): Unit =
+ val token = registerAndLogin("eve")
+ givenRequest()
+ .header("Authorization", s"Bearer $token")
+ .when()
+ .get("/api/account/me")
+ .`then`()
+ .statusCode(200)
+ .body("username", is("eve"))
+
+ @Test
+ def getPublicProfileReturns200(): Unit =
+ givenRequest().body(registerBody("frank")).when().post("/api/account")
+ givenRequest()
+ .when()
+ .get("/api/account/frank")
+ .`then`()
+ .statusCode(200)
+ .body("username", is("frank"))
+
+ @Test
+ def getPublicProfileNotFound(): Unit =
+ givenRequest()
+ .when()
+ .get("/api/account/doesnotexist")
+ .`then`()
+ .statusCode(404)
diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala
new file mode 100644
index 0000000..b793d6b
--- /dev/null
+++ b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala
@@ -0,0 +1,179 @@
+package de.nowchess.account.resource
+
+import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
+import io.quarkus.test.InjectMock
+import io.quarkus.test.junit.QuarkusTest
+import io.restassured.RestAssured
+import io.restassured.http.ContentType
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.hamcrest.Matchers.*
+import org.junit.jupiter.api.{BeforeEach, Test}
+import org.mockito.{ArgumentMatchers, Mockito}
+
+@QuarkusTest
+class ChallengeResourceTest:
+
+ @InjectMock
+ @RestClient
+ // scalafix:off DisableSyntax.var
+ var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
+ // scalafix:on
+
+ @BeforeEach
+ def setup(): Unit =
+ Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
+
+ private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
+
+ private def registerBody(username: String, suffix: String = "") =
+ val email = s"$username$suffix@test.com"
+ s"""{"username":"$username$suffix","email":"$email","password":"secret"}"""
+
+ private def loginBody(username: String, suffix: String = "") =
+ s"""{"username":"$username$suffix","password":"secret"}"""
+
+ private def registerAndLogin(username: String, suffix: String = ""): String =
+ givenRequest().body(registerBody(username, suffix)).when().post("/api/account")
+ givenRequest()
+ .body(loginBody(username, suffix))
+ .when()
+ .post("/api/account/login")
+ .`then`()
+ .statusCode(200)
+ .extract()
+ .path[String]("token")
+
+ private val clockBody =
+ """{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}"""
+
+ private def authed(token: String) =
+ givenRequest().header("Authorization", s"Bearer $token")
+
+ @Test
+ def createChallengeReturns201(): Unit =
+ val t1 = registerAndLogin("user1c")
+ registerAndLogin("user2c")
+ authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/user2c")
+ .`then`()
+ .statusCode(201)
+ .body("status", is("created"))
+ .body("color", is("random"))
+
+ @Test
+ def createChallengeConflictOnDuplicate(): Unit =
+ val t1 = registerAndLogin("user1dup")
+ registerAndLogin("user2dup")
+ authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/user2dup")
+ authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/user2dup")
+ .`then`()
+ .statusCode(409)
+
+ @Test
+ def createChallengeSelfForbidden(): Unit =
+ val token = registerAndLogin("selfuser")
+ authed(token)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/selfuser")
+ .`then`()
+ .statusCode(400)
+
+ @Test
+ def acceptChallengeReturns200(): Unit =
+ val t1 = registerAndLogin("accUser1")
+ val t2 = registerAndLogin("accUser2")
+ val challengeId = authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/accUser2")
+ .`then`()
+ .statusCode(201)
+ .extract()
+ .path[String]("id")
+ authed(t2)
+ .when()
+ .post(s"/api/challenge/$challengeId/accept")
+ .`then`()
+ .statusCode(200)
+ .body("status", is("accepted"))
+ .body("gameId", is("test-game-id"))
+
+ @Test
+ def declineChallengeReturns200(): Unit =
+ val t1 = registerAndLogin("decUser1")
+ val t2 = registerAndLogin("decUser2")
+ val challengeId = authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/decUser2")
+ .`then`()
+ .statusCode(201)
+ .extract()
+ .path[String]("id")
+ authed(t2)
+ .contentType(ContentType.JSON)
+ .body("""{"reason":"later"}""")
+ .when()
+ .post(s"/api/challenge/$challengeId/decline")
+ .`then`()
+ .statusCode(200)
+ .body("status", is("declined"))
+ .body("declineReason", is("later"))
+
+ @Test
+ def cancelChallengeReturns200(): Unit =
+ val t1 = registerAndLogin("canUser1")
+ registerAndLogin("canUser2")
+ val challengeId = authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/canUser2")
+ .`then`()
+ .statusCode(201)
+ .extract()
+ .path[String]("id")
+ authed(t1)
+ .when()
+ .post(s"/api/challenge/$challengeId/cancel")
+ .`then`()
+ .statusCode(200)
+ .body("status", is("canceled"))
+
+ @Test
+ def listChallengesReturnsInAndOut(): Unit =
+ val t1 = registerAndLogin("listUser1")
+ registerAndLogin("listUser2")
+ registerAndLogin("listUser3")
+ authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/listUser2")
+ .`then`()
+ .statusCode(201)
+ authed(t1)
+ .contentType(ContentType.JSON)
+ .body(clockBody)
+ .when()
+ .post("/api/challenge/listUser3")
+ .`then`()
+ .statusCode(201)
+ authed(t1)
+ .when()
+ .get("/api/challenge")
+ .`then`()
+ .statusCode(200)
+ .body("out.size()", is(2))
+ .body("in.size()", is(0))
diff --git a/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala b/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala
deleted file mode 100644
index f9cb965..0000000
--- a/modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.nowchess.api.bot
-
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-
-trait Bot {
-
- def name: String
- def nextMove(context: GameContext): Option[Move]
-
-}
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ClockDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ClockDto.scala
new file mode 100644
index 0000000..d1219ab
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ClockDto.scala
@@ -0,0 +1,9 @@
+package de.nowchess.api.dto
+
+/** Snapshot of remaining clock time for both players in milliseconds. -1 indicates the value is not applicable (e.g.
+ * inactive player in correspondence chess).
+ */
+final case class ClockDto(
+ whiteRemainingMs: Long,
+ blackRemainingMs: Long,
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala
index 7f18de4..f8fc019 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/CreateGameRequestDto.scala
@@ -1,6 +1,10 @@
package de.nowchess.api.dto
+import de.nowchess.api.game.GameMode
+
final case class CreateGameRequestDto(
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
+ timeControl: Option[TimeControlDto],
+ mode: Option[GameMode] = None,
)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
index 5556d3c..42688e1 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameStateDto.scala
@@ -9,4 +9,6 @@ final case class GameStateDto(
moves: List[String],
undoAvailable: Boolean,
redoAvailable: Boolean,
+ clock: Option[ClockDto],
+ takebackRequestedBy: Option[String] = None,
)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/GameWritebackEventDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/GameWritebackEventDto.scala
new file mode 100644
index 0000000..f1feb9f
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/GameWritebackEventDto.scala
@@ -0,0 +1,28 @@
+package de.nowchess.api.dto
+
+case class GameWritebackEventDto(
+ gameId: String,
+ fen: String,
+ pgn: String,
+ moveCount: Int,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ resigned: Boolean,
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+ whiteRemainingMs: Option[Long],
+ blackRemainingMs: Option[Long],
+ incrementMs: Option[Long],
+ clockLastTickAt: Option[Long],
+ clockMoveDeadline: Option[Long],
+ clockActiveColor: Option[String],
+ pendingDrawOffer: Option[String],
+ result: Option[String] = None,
+ terminationReason: Option[String] = None,
+ redoStack: List[String] = Nil,
+ pendingTakebackRequest: Option[String] = None,
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala
index 19f35b6..2b15e70 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/ImportFenRequestDto.scala
@@ -4,4 +4,5 @@ final case class ImportFenRequestDto(
fen: String,
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
+ timeControl: Option[TimeControlDto],
)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
index ee00f74..0abf842 100644
--- a/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/PlayerInfoDto.scala
@@ -1,3 +1,5 @@
package de.nowchess.api.dto
-final case class PlayerInfoDto(id: String, displayName: String)
+import de.nowchess.api.player.PlayerType
+
+final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
diff --git a/modules/api/src/main/scala/de/nowchess/api/dto/TimeControlDto.scala b/modules/api/src/main/scala/de/nowchess/api/dto/TimeControlDto.scala
new file mode 100644
index 0000000..9c2b70e
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/dto/TimeControlDto.scala
@@ -0,0 +1,7 @@
+package de.nowchess.api.dto
+
+final case class TimeControlDto(
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/error/GameError.scala b/modules/api/src/main/scala/de/nowchess/api/error/GameError.scala
new file mode 100644
index 0000000..023f48e
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/error/GameError.scala
@@ -0,0 +1,13 @@
+package de.nowchess.api.error
+
+enum GameError:
+ case ParseError(details: String)
+ case FileReadError(details: String)
+ case FileWriteError(details: String)
+ case IllegalMove
+
+ def message: String = this match
+ case ParseError(d) => d
+ case FileReadError(d) => d
+ case FileWriteError(d) => d
+ case IllegalMove => "Illegal move"
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala b/modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala
new file mode 100644
index 0000000..202e2bb
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/game/ClockState.scala
@@ -0,0 +1,55 @@
+package de.nowchess.api.game
+
+import de.nowchess.api.board.Color
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+sealed trait ClockState:
+ def activeColor: Color
+ def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState]
+ def remainingMs(color: Color, now: Instant): Long
+
+final case class LiveClockState(
+ whiteRemainingMs: Long,
+ blackRemainingMs: Long,
+ incrementMs: Long,
+ lastTickAt: Instant,
+ activeColor: Color,
+) extends ClockState:
+ def remainingMs(color: Color, now: Instant): Long =
+ val stored = if color == Color.White then whiteRemainingMs else blackRemainingMs
+ if color == activeColor then math.max(0L, stored - (now.toEpochMilli - lastTickAt.toEpochMilli))
+ else stored
+
+ def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
+ val elapsed = at.toEpochMilli - lastTickAt.toEpochMilli
+ val newRemaining =
+ (if movedColor == Color.White then whiteRemainingMs else blackRemainingMs) - elapsed + incrementMs
+ if newRemaining <= 0 then Left(movedColor)
+ else
+ val (w, b) =
+ if movedColor == Color.White then (newRemaining, blackRemainingMs)
+ else (whiteRemainingMs, newRemaining)
+ Right(copy(whiteRemainingMs = w, blackRemainingMs = b, lastTickAt = at, activeColor = movedColor.opposite))
+
+final case class CorrespondenceClockState(
+ moveDeadline: Instant,
+ daysPerMove: Int,
+ activeColor: Color,
+) extends ClockState:
+ def remainingMs(color: Color, now: Instant): Long =
+ math.max(0L, moveDeadline.toEpochMilli - now.toEpochMilli)
+
+ def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
+ if at.isAfter(moveDeadline) then Left(movedColor)
+ else Right(copy(moveDeadline = at.plus(daysPerMove.toLong, ChronoUnit.DAYS), activeColor = movedColor.opposite))
+
+object ClockState:
+ def fromTimeControl(tc: TimeControl, activeColor: Color, now: Instant): Option[ClockState] =
+ tc match
+ case TimeControl.Clock(limit, inc) =>
+ val ms = limit * 1000L
+ Some(LiveClockState(ms, ms, inc * 1000L, now, activeColor))
+ case TimeControl.Correspondence(days) =>
+ Some(CorrespondenceClockState(now.plus(days.toLong, ChronoUnit.DAYS), days, activeColor))
+ case TimeControl.Unlimited => None
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala
new file mode 100644
index 0000000..5504c36
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/game/GameMode.scala
@@ -0,0 +1,4 @@
+package de.nowchess.api.game
+
+enum GameMode:
+ case Open, Authenticated
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala b/modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala
index 9a40bd6..d91fbad 100644
--- a/modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala
@@ -4,5 +4,10 @@ import de.nowchess.api.board.Color
/** Outcome of a finished game. */
enum GameResult:
- case Win(color: Color)
+ case Win(color: Color, winReason: WinReason)
case Draw(reason: DrawReason)
+
+enum WinReason:
+ case Checkmate
+ case Resignation
+ case TimeControl
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala b/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala
deleted file mode 100644
index 4e5ce1e..0000000
--- a/modules/api/src/main/scala/de/nowchess/api/game/Participant.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.nowchess.api.game
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.player.PlayerInfo
-
-sealed trait Participant
-final case class Human(playerInfo: PlayerInfo) extends Participant
-final case class BotParticipant(bot: Bot) extends Participant
diff --git a/modules/api/src/main/scala/de/nowchess/api/game/TimeControl.scala b/modules/api/src/main/scala/de/nowchess/api/game/TimeControl.scala
new file mode 100644
index 0000000..642463e
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/game/TimeControl.scala
@@ -0,0 +1,6 @@
+package de.nowchess.api.game
+
+enum TimeControl:
+ case Clock(limitSeconds: Int, incrementSeconds: Int)
+ case Correspondence(daysPerMove: Int)
+ case Unlimited
diff --git a/modules/api/src/main/scala/de/nowchess/api/grpc/ProtoMapperBase.scala b/modules/api/src/main/scala/de/nowchess/api/grpc/ProtoMapperBase.scala
new file mode 100644
index 0000000..d775051
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/grpc/ProtoMapperBase.scala
@@ -0,0 +1,107 @@
+package de.nowchess.api.grpc
+
+import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
+import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
+
+import scala.jdk.CollectionConverters.*
+
+trait ProtoMapperBase[PC, PPT, PMK, PM, PSP, PBoard, PCR, PRK, PGC]:
+ def toProtoColor(c: Color): PC
+ def fromProtoColor(c: PC): Color
+ def toProtoPieceType(pt: PieceType): PPT
+ def fromProtoPieceType(pt: PPT): PieceType
+ def toProtoMoveKind(mt: MoveType): PMK
+ def fromProtoMoveKind(k: PMK): MoveType
+
+ def toProtoMove(m: DomainMove): PM
+ def fromProtoMove(m: PM): Option[DomainMove]
+
+ def toProtoSquarePiece(sq: Square, piece: Piece): PSP
+ def fromProtoSquarePiece(sp: PSP): Option[(Square, Piece)]
+
+ def toProtoBoard(board: Board): java.util.List[PSP]
+ def fromProtoBoard(pieces: java.util.List[PSP]): Board
+
+ def toProtoResultKind(r: Option[GameResult]): PRK
+ def fromProtoResultKind(k: PRK): Option[GameResult]
+
+ def toProtoCastlingRights(cr: DomainCastlingRights): PCR
+ def fromProtoCastlingRights(pcr: PCR): DomainCastlingRights
+
+ def toProtoGameContext(ctx: GameContext): PGC
+ def fromProtoGameContext(p: PGC): GameContext
+
+object ProtoMapperBase:
+ def colorConversions[PC](white: PC, black: PC): (Color => PC, PC => Color) =
+ (
+ (c: Color) =>
+ c match
+ case Color.White => white
+ case Color.Black => black,
+ (pc: PC) =>
+ if pc == white then Color.White
+ else Color.Black,
+ )
+
+ def pieceTypeConversions[PPT](
+ pawn: PPT,
+ knight: PPT,
+ bishop: PPT,
+ rook: PPT,
+ queen: PPT,
+ king: PPT,
+ ): (PieceType => PPT, PPT => PieceType) =
+ (
+ (pt: PieceType) =>
+ pt match
+ case PieceType.Pawn => pawn
+ case PieceType.Knight => knight
+ case PieceType.Bishop => bishop
+ case PieceType.Rook => rook
+ case PieceType.Queen => queen
+ case PieceType.King => king,
+ (ppt: PPT) =>
+ if ppt == pawn then PieceType.Pawn
+ else if ppt == knight then PieceType.Knight
+ else if ppt == bishop then PieceType.Bishop
+ else if ppt == rook then PieceType.Rook
+ else if ppt == queen then PieceType.Queen
+ else PieceType.King,
+ )
+
+ def moveKindConversions[PMK](
+ quiet: PMK,
+ capture: PMK,
+ castleKingside: PMK,
+ castleQueenside: PMK,
+ enPassant: PMK,
+ promoQueen: PMK,
+ promoRook: PMK,
+ promoBishop: PMK,
+ promoKnight: PMK,
+ ): (MoveType => PMK, PMK => MoveType) =
+ (
+ (mt: MoveType) =>
+ mt match
+ case MoveType.Normal(false) => quiet
+ case MoveType.Normal(true) => capture
+ case MoveType.CastleKingside => castleKingside
+ case MoveType.CastleQueenside => castleQueenside
+ case MoveType.EnPassant => enPassant
+ case MoveType.Promotion(PromotionPiece.Queen) => promoQueen
+ case MoveType.Promotion(PromotionPiece.Rook) => promoRook
+ case MoveType.Promotion(PromotionPiece.Bishop) => promoBishop
+ case MoveType.Promotion(PromotionPiece.Knight) => promoKnight,
+ (pmk: PMK) =>
+ if pmk == quiet then MoveType.Normal(false)
+ else if pmk == capture then MoveType.Normal(true)
+ else if pmk == castleKingside then MoveType.CastleKingside
+ else if pmk == castleQueenside then MoveType.CastleQueenside
+ else if pmk == enPassant then MoveType.EnPassant
+ else if pmk == promoQueen then MoveType.Promotion(PromotionPiece.Queen)
+ else if pmk == promoRook then MoveType.Promotion(PromotionPiece.Rook)
+ else if pmk == promoBishop then MoveType.Promotion(PromotionPiece.Bishop)
+ else if pmk == promoKnight then MoveType.Promotion(PromotionPiece.Knight)
+ else MoveType.Normal(false),
+ )
diff --git a/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala b/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala
index 22c149e..948bbbe 100644
--- a/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala
@@ -1,6 +1,7 @@
package de.nowchess.api.io
+import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
trait GameContextImport:
- def importGameContext(input: String): Either[String, GameContext]
+ def importGameContext(input: String): Either[GameError, GameContext]
diff --git a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
index c6b8053..44de279 100644
--- a/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala
@@ -23,4 +23,10 @@ object PlayerId:
final case class PlayerInfo(
id: PlayerId,
displayName: String,
+ playerType: PlayerType = PlayerType.Human,
)
+
+enum PlayerType:
+ case Human
+ case OfficialBot
+ case Bot
diff --git a/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala b/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala
new file mode 100644
index 0000000..c1ba30c
--- /dev/null
+++ b/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala
@@ -0,0 +1,9 @@
+package de.nowchess.api.rules
+
+final case class PostMoveStatus(
+ isCheckmate: Boolean,
+ isStalemate: Boolean,
+ isInsufficientMaterial: Boolean,
+ isCheck: Boolean,
+ isThreefoldRepetition: Boolean,
+)
diff --git a/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala
index 535b655..52bd3cf 100644
--- a/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala
+++ b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala
@@ -39,3 +39,15 @@ trait RuleSet:
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
*/
def applyMove(context: GameContext)(move: Move): GameContext
+
+ /** Batch status check after a move is applied. Replaces individual isCheckmate/isStalemate/isInsufficientMaterial/
+ * isCheck/isThreefoldRepetition calls with a single round-trip. Override for remote implementations.
+ */
+ def postMoveStatus(context: GameContext): PostMoveStatus =
+ PostMoveStatus(
+ isCheckmate = isCheckmate(context),
+ isStalemate = isStalemate(context),
+ isInsufficientMaterial = isInsufficientMaterial(context),
+ isCheck = isCheck(context),
+ isThreefoldRepetition = isThreefoldRepetition(context),
+ )
diff --git a/modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala b/modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala
new file mode 100644
index 0000000..9946062
--- /dev/null
+++ b/modules/api/src/test/scala/de/nowchess/api/game/ClockStateTest.scala
@@ -0,0 +1,109 @@
+package de.nowchess.api.game
+
+import de.nowchess.api.board.Color
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+class ClockStateTest extends AnyFunSuite with Matchers:
+
+ private val t0 = Instant.parse("2024-01-01T00:00:00Z")
+ private val t1s = t0.plusSeconds(1)
+ private val t5s = t0.plusSeconds(5)
+
+ // ── LiveClockState ────────────────────────────────────────────────────────
+
+ test("LiveClockState.afterMove deducts elapsed and adds increment on valid move"):
+ val cs = LiveClockState(300_000L, 300_000L, 3_000L, t0, Color.White)
+ cs.afterMove(Color.White, t5s) match
+ case Right(updated: LiveClockState) =>
+ updated.whiteRemainingMs shouldBe (300_000L - 5_000L + 3_000L)
+ updated.blackRemainingMs shouldBe 300_000L
+ updated.activeColor shouldBe Color.Black
+ updated.lastTickAt shouldBe t5s
+ case other => fail(s"Expected Right(LiveClockState), got $other")
+
+ test("LiveClockState.afterMove returns Left when time exhausted"):
+ val cs = LiveClockState(2_000L, 300_000L, 0L, t0, Color.White)
+ cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
+
+ test("LiveClockState.afterMove returns Left when time exactly zero"):
+ val cs = LiveClockState(5_000L, 300_000L, 0L, t0, Color.White)
+ cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
+
+ test("LiveClockState.remainingMs for active color deducts live elapsed"):
+ val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.White)
+ val now = t5s
+ cs.remainingMs(Color.White, now) shouldBe (300_000L - 5_000L)
+
+ test("LiveClockState.remainingMs for inactive color returns stored value"):
+ val cs = LiveClockState(200_000L, 300_000L, 0L, t0, Color.White)
+ cs.remainingMs(Color.Black, t5s) shouldBe 300_000L
+
+ test("LiveClockState.remainingMs clamps to zero when overdue"):
+ val cs = LiveClockState(1_000L, 300_000L, 0L, t0, Color.White)
+ cs.remainingMs(Color.White, t5s) shouldBe 0L
+
+ test("LiveClockState.afterMove advances activeColor to opponent"):
+ val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.Black)
+ cs.afterMove(Color.Black, t1s) match
+ case Right(updated: LiveClockState) => updated.activeColor shouldBe Color.White
+ case other => fail(s"Expected Right, got $other")
+
+ // ── CorrespondenceClockState ──────────────────────────────────────────────
+
+ test("CorrespondenceClockState.afterMove advances deadline on valid move"):
+ val deadline = t0.plus(3L, ChronoUnit.DAYS)
+ val cs = CorrespondenceClockState(deadline, 3, Color.White)
+ cs.afterMove(Color.White, t1s) match
+ case Right(updated: CorrespondenceClockState) =>
+ updated.moveDeadline shouldBe t1s.plus(3L, ChronoUnit.DAYS)
+ updated.activeColor shouldBe Color.Black
+ case other => fail(s"Expected Right(CorrespondenceClockState), got $other")
+
+ test("CorrespondenceClockState.afterMove returns Left when move is past deadline"):
+ val deadline = t0.plus(1L, ChronoUnit.DAYS)
+ val cs = CorrespondenceClockState(deadline, 3, Color.White)
+ val lateMove = t0.plus(2L, ChronoUnit.DAYS)
+ cs.afterMove(Color.White, lateMove) shouldBe Left(Color.White)
+
+ test("CorrespondenceClockState.remainingMs returns time until deadline"):
+ val deadline = t0.plus(3L, ChronoUnit.DAYS)
+ val cs = CorrespondenceClockState(deadline, 3, Color.White)
+ val expected = deadline.toEpochMilli - t1s.toEpochMilli
+ cs.remainingMs(Color.White, t1s) shouldBe expected
+
+ test("CorrespondenceClockState.remainingMs clamps to zero when overdue"):
+ val deadline = t0.plus(1L, ChronoUnit.DAYS)
+ val cs = CorrespondenceClockState(deadline, 3, Color.White)
+ val overdue = t0.plus(2L, ChronoUnit.DAYS)
+ cs.remainingMs(Color.White, overdue) shouldBe 0L
+
+ // ── ClockState.fromTimeControl ────────────────────────────────────────────
+
+ test("fromTimeControl with Clock returns LiveClockState with correct initial values"):
+ ClockState.fromTimeControl(TimeControl.Clock(300, 3), Color.White, t0) match
+ case Some(cs: LiveClockState) =>
+ cs.whiteRemainingMs shouldBe 300_000L
+ cs.blackRemainingMs shouldBe 300_000L
+ cs.incrementMs shouldBe 3_000L
+ cs.activeColor shouldBe Color.White
+ cs.lastTickAt shouldBe t0
+ case other => fail(s"Expected Some(LiveClockState), got $other")
+
+ test("fromTimeControl with Correspondence returns CorrespondenceClockState"):
+ ClockState.fromTimeControl(TimeControl.Correspondence(3), Color.White, t0) match
+ case Some(cs: CorrespondenceClockState) =>
+ cs.moveDeadline shouldBe t0.plus(3L, ChronoUnit.DAYS)
+ cs.daysPerMove shouldBe 3
+ cs.activeColor shouldBe Color.White
+ case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
+
+ test("fromTimeControl with Unlimited returns None"):
+ ClockState.fromTimeControl(TimeControl.Unlimited, Color.White, t0) shouldBe None
+
+ test("fromTimeControl with Black as starting color sets activeColor correctly"):
+ ClockState.fromTimeControl(TimeControl.Clock(300, 0), Color.Black, t0) match
+ case Some(cs: LiveClockState) => cs.activeColor shouldBe Color.Black
+ case other => fail(s"Expected Some(LiveClockState), got $other")
diff --git a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala
index 3161ced..a64e92c 100644
--- a/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala
+++ b/modules/api/src/test/scala/de/nowchess/api/game/GameContextTest.scala
@@ -1,6 +1,7 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
+import de.nowchess.api.game.WinReason.Checkmate
import de.nowchess.api.move.Move
import de.nowchess.api.game.{DrawReason, GameResult}
import org.scalatest.funsuite.AnyFunSuite
@@ -61,7 +62,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
GameContext.initial.withMove(move).moves shouldBe List(move)
test("withResult sets Win result"):
- val win = Some(GameResult.Win(Color.White))
+ val win = Some(GameResult.Win(Color.White, Checkmate))
GameContext.initial.withResult(win).result shouldBe win
test("withResult sets Draw result"):
@@ -69,7 +70,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
GameContext.initial.withResult(draw).result shouldBe draw
test("withResult clears result"):
- val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black)))
+ val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black, Checkmate)))
ctx.withResult(None).result shouldBe None
test("kingSquare returns white king position"):
diff --git a/modules/bot-platform/build.gradle.kts b/modules/bot-platform/build.gradle.kts
new file mode 100644
index 0000000..e2963e1
--- /dev/null
+++ b/modules/bot-platform/build.gradle.kts
@@ -0,0 +1,115 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedPackages.set(
+ listOf(
+ "de\\.nowchess\\.botplatform\\.registry",
+ "de\\.nowchess\\.botplatform\\.resource",
+ )
+ )
+ excludedFiles.set(scoverageExcluded)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-rest-client-jackson")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+
+ implementation(project(":modules:api"))
+
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("io.quarkus:quarkus-junit")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation("io.quarkus:quarkus-test-security")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("junit-jupiter")
+ testLogging {
+ events("passed", "skipped", "failed")
+ showStandardStreams = true
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage {
+ dependsOn(tasks.test)
+}
+
+tasks.jar {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
diff --git a/modules/bot-platform/src/main/resources/application.yml b/modules/bot-platform/src/main/resources/application.yml
new file mode 100644
index 0000000..8367882
--- /dev/null
+++ b/modules/bot-platform/src/main/resources/application.yml
@@ -0,0 +1,24 @@
+quarkus:
+ http:
+ port: 8087
+ application:
+ name: nowchess-bot-platform
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ smallrye-jwt:
+ enabled: true
+ log:
+ level: INFO
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala
new file mode 100644
index 0000000..76e9879
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.botplatform.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala
new file mode 100644
index 0000000..e5a023e
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+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.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
new file mode 100644
index 0000000..909ee3f
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/registry/BotRegistry.scala
@@ -0,0 +1,41 @@
+package de.nowchess.botplatform.registry
+
+import de.nowchess.botplatform.config.RedisConfig
+import io.quarkus.redis.datasource.RedisDataSource
+import io.quarkus.redis.datasource.pubsub.PubSubCommands
+import io.smallrye.mutiny.subscription.MultiEmitter
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+
+@ApplicationScoped
+class BotRegistry:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redis: RedisDataSource = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val connections = ConcurrentHashMap[String, (MultiEmitter[? >: String], PubSubCommands.RedisSubscriber)]()
+
+ def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
+ val channel = s"${redisConfig.prefix}:bot:$botId:events"
+ val handler: Consumer[String] = msg => emitter.emit(msg)
+ val subscriber = redis.pubsub(classOf[String]).subscribe(channel, handler)
+ connections.put(botId, (emitter, subscriber))
+ ()
+
+ def unregister(botId: String): Unit =
+ Option(connections.remove(botId)).foreach { (_, subscriber) =>
+ subscriber.unsubscribe(s"${redisConfig.prefix}:bot:$botId:events")
+ }
+
+ def dispatch(botId: String, event: String): Unit =
+ redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
+ ()
+
+ def registeredBots: List[String] =
+ import scala.jdk.CollectionConverters.*
+ connections.keys().asScala.toList
diff --git a/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
new file mode 100644
index 0000000..0264832
--- /dev/null
+++ b/modules/bot-platform/src/main/scala/de/nowchess/botplatform/resource/BotEventResource.scala
@@ -0,0 +1,63 @@
+package de.nowchess.botplatform.resource
+
+import de.nowchess.botplatform.config.RedisConfig
+import de.nowchess.botplatform.registry.BotRegistry
+import io.quarkus.redis.datasource.RedisDataSource
+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
+import java.util.function.Consumer
+
+@Path("/api/bot")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+class BotEventResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var registry: BotRegistry = uninitialized
+ @Inject var jwt: JsonWebToken = uninitialized
+ @Inject var redis: RedisDataSource = uninitialized
+ @Inject var redisConfig: RedisConfig = 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 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 =>
+ val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
+ val handler: Consumer[String] = msg => emitter.emit(msg)
+ val subscriber = redis.pubsub(classOf[String]).subscribe(topicName, handler)
+ emitter.onTermination(() => subscriber.unsubscribe(topicName))
+ }
+
+ @POST
+ @Path("/game/{gameId}/move/{uci}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def makeMove(
+ @PathParam("gameId") gameId: String,
+ @PathParam("uci") uci: String,
+ ): Response =
+ val playerId = Option(jwt.getSubject).getOrElse("")
+ val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
+ redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
+ Response.ok().build()
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
deleted file mode 100644
index a52e5b9..0000000
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.nowchess.bot.bots
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-import de.nowchess.bot.bots.classic.EvaluationClassic
-import de.nowchess.bot.logic.AlphaBetaSearch
-import de.nowchess.bot.util.PolyglotBook
-import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
-import de.nowchess.api.rules.RuleSet
-import de.nowchess.rules.sets.DefaultRules
-
-final class ClassicalBot(
- difficulty: BotDifficulty,
- rules: RuleSet = DefaultRules,
- book: Option[PolyglotBook] = None,
-) extends Bot:
-
- private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationClassic)
- private val TIME_BUDGET_MS = 1000L
-
- override val name: String = s"ClassicalBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- val blockedMoves = BotMoveRepetition.blockedMoves(context)
- book
- .flatMap(_.probe(context))
- .filterNot(blockedMoves.contains)
- .orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS, blockedMoves))
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
deleted file mode 100644
index fd95d0d..0000000
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
+++ /dev/null
@@ -1,43 +0,0 @@
-package de.nowchess.bot.bots
-
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.GameContext
-import de.nowchess.api.move.Move
-import de.nowchess.bot.ai.Evaluation
-import de.nowchess.bot.bots.classic.EvaluationClassic
-import de.nowchess.bot.bots.nnue.EvaluationNNUE
-import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
-import de.nowchess.bot.util.PolyglotBook
-import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
-import de.nowchess.api.rules.RuleSet
-import de.nowchess.rules.sets.DefaultRules
-
-final class HybridBot(
- difficulty: BotDifficulty,
- rules: RuleSet = DefaultRules,
- book: Option[PolyglotBook] = None,
- nnueEvaluation: Evaluation = EvaluationNNUE,
- classicalEvaluation: Evaluation = EvaluationClassic,
- vetoReporter: String => Unit = println(_),
-) extends Bot:
-
- private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
-
- override val name: String = s"HybridBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- val blockedMoves = BotMoveRepetition.blockedMoves(context)
- book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse(searchWithVeto(context, blockedMoves))
-
- private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
- search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
- val next = rules.applyMove(context)(move)
- val staticNnue = nnueEvaluation.evaluate(next)
- val classical = classicalEvaluation.evaluate(next)
- val diff = (classical - staticNnue).abs
- if diff > Config.VETO_THRESHOLD then
- vetoReporter(
- f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
- )
- move
- }
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
deleted file mode 100644
index bfa2c6f..0000000
--- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
+++ /dev/null
@@ -1,60 +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.nnue.EvaluationNNUE
-import de.nowchess.bot.logic.AlphaBetaSearch
-import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
-import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
-import de.nowchess.api.rules.RuleSet
-import de.nowchess.rules.sets.DefaultRules
-
-final class NNUEBot(
- difficulty: BotDifficulty,
- rules: RuleSet = DefaultRules,
- book: Option[PolyglotBook] = None,
-) extends Bot:
-
- private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
-
- override val name: String = s"NNUEBot(${difficulty.toString})"
-
- override def nextMove(context: GameContext): Option[Move] =
- 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)
- val rootHash = ZobristHash.hash(context)
- moves.map { move =>
- val child = rules.applyMove(context)(move)
- val childHash = ZobristHash.nextHash(context, rootHash, move, child)
- EvaluationNNUE.pushAccumulator(1, move, context, child)
- val score = -EvaluationNNUE.evaluateAccumulator(1, child, childHash)
- (move, score)
- }
-
- /** Allocate more time for complex positions; less when one move clearly dominates. */
- private def allocateTime(scored: List[(Move, Int)]): Long =
- val moveCount = scored.length
- if moveCount > 30 then 1500L
- else if moveCount < 5 then 500L
- else
- val scores = scored.map(_._2)
- val best = scores.max
- val second = scores.filter(_ < best).maxOption.getOrElse(best)
- if best - second > 200 then 600L else 1000L
diff --git a/modules/coordinator/build.gradle.kts b/modules/coordinator/build.gradle.kts
new file mode 100644
index 0000000..8da781b
--- /dev/null
+++ b/modules/coordinator/build.gradle.kts
@@ -0,0 +1,114 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedFiles.set(scoverageExcluded)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
+
+tasks.withType(ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ exclude("**/service/*.scala")
+ exclude("**/resource/*.scala")
+ exclude("**/config/*.scala")
+ }
+ }
+}
+
+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(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-grpc")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("io.quarkus:quarkus-rest-client")
+ implementation("io.quarkus:quarkus-rest-client-jackson")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+ implementation("io.fabric8:kubernetes-client:6.13.0")
+
+ 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-junit5")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+tasks.withType().configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging { events("passed", "skipped", "failed") }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage { dependsOn(tasks.test) }
+tasks.jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
diff --git a/modules/coordinator/src/main/proto/coordinator_service.proto b/modules/coordinator/src/main/proto/coordinator_service.proto
new file mode 100644
index 0000000..8c00215
--- /dev/null
+++ b/modules/coordinator/src/main/proto/coordinator_service.proto
@@ -0,0 +1,60 @@
+syntax = "proto3";
+option java_package = "de.nowchess.coordinator.proto";
+option java_multiple_files = true;
+option java_outer_classname = "CoordinatorServiceProto";
+
+service CoordinatorService {
+ rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
+ rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
+ rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
+ rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
+ rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
+}
+
+message HeartbeatFrame {
+ string instanceId = 1;
+ string hostname = 2;
+ int32 httpPort = 3;
+ int32 grpcPort = 4;
+ int32 subscriptionCount = 5;
+ int32 localCacheSize = 6;
+ int64 timestampMillis = 7;
+}
+
+message CoordinatorCommand {
+ string type = 1;
+ string payload = 2;
+}
+
+message BatchResubscribeRequest {
+ repeated string gameIds = 1;
+}
+
+message BatchResubscribeResponse {
+ int32 subscribedCount = 1;
+ repeated string failedGameIds = 2;
+}
+
+message UnsubscribeGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message UnsubscribeGamesResponse {
+ int32 unsubscribedCount = 1;
+}
+
+message EvictGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message EvictGamesResponse {
+ int32 evictedCount = 1;
+}
+
+message DrainInstanceRequest {
+ string instanceId = 1;
+}
+
+message DrainInstanceResponse {
+ int32 gamesMigrated = 1;
+}
diff --git a/modules/coordinator/src/main/resources/META-INF.native-image.de.nowchess.coordinator/reachability-metadata.json b/modules/coordinator/src/main/resources/META-INF.native-image.de.nowchess.coordinator/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/coordinator/src/main/resources/META-INF.native-image.de.nowchess.coordinator/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/coordinator/src/main/resources/application.yml b/modules/coordinator/src/main/resources/application.yml
new file mode 100644
index 0000000..83d3d87
--- /dev/null
+++ b/modules/coordinator/src/main/resources/application.yml
@@ -0,0 +1,52 @@
+quarkus:
+ application:
+ name: nowchess-coordinator
+ http:
+ port: 8086
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ grpc:
+ server:
+ port: 9086
+ rest-client:
+ connection-timeout: 5000
+ read-timeout: 10000
+ smallrye-openapi:
+ info-title: NowChess Coordinator Service
+ info-version: 1.0.0
+ info-description: Coordination endpoints for instance health, balancing, failover, and scaling
+ path: /openapi
+ swagger-ui:
+ always-include: true
+ path: /swagger-ui
+
+nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
+
+ coordinator:
+ max-games-per-core: 500
+ max-deviation-percent: 20
+ rebalance-interval: 30s
+ rebalance-min-interval: 60s
+ heartbeat-ttl: 5s
+ stream-heartbeat-interval: PT0.2S
+ cache-eviction-interval: 10m
+ game-idle-threshold: 45m
+ auto-scale-enabled: false
+ scale-up-threshold: 0.8
+ scale-down-threshold: 0.3
+ scale-min-replicas: 2
+ scale-max-replicas: 10
+ k8s-namespace: default
+ k8s-rollout-name: nowchess-core
+ k8s-rollout-label-selector: "app=nowchess-core"
+
+---
+# dev profile
+"%dev":
+ quarkus:
+ log:
+ level: DEBUG
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala
new file mode 100644
index 0000000..dddef0f
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/CoordinatorApp.scala
@@ -0,0 +1,7 @@
+package de.nowchess.coordinator
+
+import jakarta.ws.rs.core.Application
+import jakarta.enterprise.context.ApplicationScoped
+
+@ApplicationScoped
+class CoordinatorApp extends Application
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/BeansProducer.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/BeansProducer.scala
new file mode 100644
index 0000000..88fdc01
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/BeansProducer.scala
@@ -0,0 +1,14 @@
+package de.nowchess.coordinator.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Produces
+import io.fabric8.kubernetes.client.KubernetesClientBuilder
+import io.fabric8.kubernetes.client.KubernetesClient
+
+@ApplicationScoped
+class BeansProducer:
+
+ @Produces
+ @ApplicationScoped
+ def kubernetesClient: KubernetesClient =
+ KubernetesClientBuilder().build()
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala
new file mode 100644
index 0000000..cff6e41
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/CoordinatorConfig.scala
@@ -0,0 +1,55 @@
+package de.nowchess.coordinator.config
+
+import io.smallrye.config.ConfigMapping
+import io.smallrye.config.WithName
+import java.time.Duration
+
+@ConfigMapping(prefix = "nowchess.coordinator")
+trait CoordinatorConfig:
+ @WithName("max-games-per-core")
+ def maxGamesPerCore: Int
+
+ @WithName("max-deviation-percent")
+ def maxDeviationPercent: Int
+
+ @WithName("rebalance-interval")
+ def rebalanceInterval: Duration
+
+ @WithName("rebalance-min-interval")
+ def rebalanceMinInterval: Duration
+
+ @WithName("heartbeat-ttl")
+ def heartbeatTtl: Duration
+
+ @WithName("stream-heartbeat-interval")
+ def streamHeartbeatInterval: Duration
+
+ @WithName("cache-eviction-interval")
+ def cacheEvictionInterval: Duration
+
+ @WithName("game-idle-threshold")
+ def gameIdleThreshold: Duration
+
+ @WithName("auto-scale-enabled")
+ def autoScaleEnabled: Boolean
+
+ @WithName("scale-up-threshold")
+ def scaleUpThreshold: Double
+
+ @WithName("scale-down-threshold")
+ def scaleDownThreshold: Double
+
+ @WithName("scale-min-replicas")
+ def scaleMinReplicas: Int
+
+ @WithName("scale-max-replicas")
+ def scaleMaxReplicas: Int
+
+ @WithName("k8s-namespace")
+ def k8sNamespace: String
+
+ @WithName("k8s-rollout-name")
+ def k8sRolloutName: String
+
+ @WithName("k8s-rollout-label-selector")
+ def k8sRolloutLabelSelector: String
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala
new file mode 100644
index 0000000..7fecbea
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.coordinator.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..f49df79
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/config/NativeReflectionConfig.scala
@@ -0,0 +1,13 @@
+package de.nowchess.coordinator.config
+
+import de.nowchess.coordinator.dto.InstanceMetadata
+import de.nowchess.coordinator.resource.MetricsDto
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(
+ classOf[InstanceMetadata],
+ classOf[MetricsDto],
+ ),
+)
+class NativeReflectionConfig
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/dto/InstanceMetadata.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/dto/InstanceMetadata.scala
new file mode 100644
index 0000000..ad35296
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/dto/InstanceMetadata.scala
@@ -0,0 +1,23 @@
+package de.nowchess.coordinator.dto
+
+import com.fasterxml.jackson.annotation.JsonProperty
+import java.time.Instant
+
+case class InstanceMetadata(
+ @JsonProperty("instanceId")
+ instanceId: String,
+ @JsonProperty("hostname")
+ hostname: String,
+ @JsonProperty("httpPort")
+ httpPort: Int,
+ @JsonProperty("grpcPort")
+ grpcPort: Int,
+ @JsonProperty("subscriptionCount")
+ subscriptionCount: Int,
+ @JsonProperty("localCacheSize")
+ localCacheSize: Int,
+ @JsonProperty("lastHeartbeat")
+ lastHeartbeat: String,
+ @JsonProperty("state")
+ state: String = "HEALTHY",
+)
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala
new file mode 100644
index 0000000..754241c
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoordinatorGrpcServer.scala
@@ -0,0 +1,101 @@
+package de.nowchess.coordinator.grpc
+
+import jakarta.inject.Inject
+import jakarta.inject.Singleton
+import io.quarkus.grpc.GrpcService
+import scala.compiletime.uninitialized
+import de.nowchess.coordinator.service.{FailoverService, InstanceRegistry}
+import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
+import io.grpc.stub.StreamObserver
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.jboss.logging.Logger
+
+@GrpcService
+@Singleton
+class CoordinatorGrpcServer extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var failoverService: FailoverService = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val mapper = ObjectMapper()
+ private val log = Logger.getLogger(classOf[CoordinatorGrpcServer])
+
+ override def heartbeatStream(
+ responseObserver: StreamObserver[CoordinatorCommand],
+ ): StreamObserver[HeartbeatFrame] =
+ new StreamObserver[HeartbeatFrame]:
+ // scalafix:off DisableSyntax.var
+ private var lastInstanceId = ""
+ // scalafix:on DisableSyntax.var
+
+ override def onNext(frame: HeartbeatFrame): Unit =
+ lastInstanceId = frame.getInstanceId
+ try
+ instanceRegistry.updateInstanceFromRedis(frame.getInstanceId)
+ log.debugf(
+ "Received heartbeat from %s with %d subscriptions",
+ frame.getInstanceId,
+ frame.getSubscriptionCount,
+ )
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to process heartbeat from %s", frame.getInstanceId)
+
+ override def onError(t: Throwable): Unit =
+ log.warnf(t, "Heartbeat stream error for instance %s", lastInstanceId)
+ if lastInstanceId.nonEmpty then failoverService.onInstanceStreamDropped(lastInstanceId)
+
+ override def onCompleted: Unit =
+ log.infof("Heartbeat stream completed for instance %s", lastInstanceId)
+
+ override def batchResubscribeGames(
+ request: BatchResubscribeRequest,
+ responseObserver: StreamObserver[BatchResubscribeResponse],
+ ): Unit =
+ log.infof("Batch resubscribe request for %d games", request.getGameIdsList.size())
+ val response = BatchResubscribeResponse
+ .newBuilder()
+ .setSubscribedCount(request.getGameIdsList.size())
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def unsubscribeGames(
+ request: UnsubscribeGamesRequest,
+ responseObserver: StreamObserver[UnsubscribeGamesResponse],
+ ): Unit =
+ log.infof("Unsubscribe request for %d games", request.getGameIdsList.size())
+ val response = UnsubscribeGamesResponse
+ .newBuilder()
+ .setUnsubscribedCount(request.getGameIdsList.size())
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def evictGames(
+ request: EvictGamesRequest,
+ responseObserver: StreamObserver[EvictGamesResponse],
+ ): Unit =
+ log.infof("Evict request for %d games", request.getGameIdsList.size())
+ val response = EvictGamesResponse
+ .newBuilder()
+ .setEvictedCount(request.getGameIdsList.size())
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def drainInstance(
+ request: DrainInstanceRequest,
+ responseObserver: StreamObserver[DrainInstanceResponse],
+ ): Unit =
+ val instanceId = request.getInstanceId
+ log.infof("Drain request for instance %s", instanceId)
+ val gamesBefore = instanceRegistry.getInstance(instanceId).map(_.subscriptionCount).getOrElse(0)
+ failoverService.onInstanceStreamDropped(instanceId)
+ val response = DrainInstanceResponse.newBuilder().setGamesMigrated(gamesBefore).build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala
new file mode 100644
index 0000000..1be6081
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/grpc/CoreGrpcClient.scala
@@ -0,0 +1,63 @@
+package de.nowchess.coordinator.grpc
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.annotation.PreDestroy
+import org.jboss.logging.Logger
+import io.grpc.ManagedChannel
+import io.grpc.ManagedChannelBuilder
+import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
+import scala.jdk.CollectionConverters.*
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.TimeUnit
+
+@ApplicationScoped
+class CoreGrpcClient:
+ private val log = Logger.getLogger(classOf[CoreGrpcClient])
+ private val channels = ConcurrentHashMap[String, ManagedChannel]()
+
+ private def getChannel(host: String, port: Int): ManagedChannel =
+ channels.computeIfAbsent(s"$host:$port", _ => ManagedChannelBuilder.forAddress(host, port).usePlaintext().build())
+
+ private def evictStaleChannel(host: String, port: Int): Unit =
+ Option(channels.remove(s"$host:$port")).foreach(_.shutdownNow())
+
+ @PreDestroy
+ def shutdown(): Unit =
+ channels.values.asScala.foreach { ch =>
+ ch.shutdown()
+ if !ch.awaitTermination(5, TimeUnit.SECONDS) then ch.shutdownNow()
+ }
+ channels.clear()
+
+ def batchResubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
+ try
+ val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
+ val request = BatchResubscribeRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
+ stub.batchResubscribeGames(request).getSubscribedCount
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "batchResubscribeGames RPC failed for %s:%d", host, port)
+ evictStaleChannel(host, port)
+ 0
+
+ def unsubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
+ try
+ val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
+ val request = UnsubscribeGamesRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
+ stub.unsubscribeGames(request).getUnsubscribedCount
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "unsubscribeGames RPC failed for %s:%d", host, port)
+ evictStaleChannel(host, port)
+ 0
+
+ def evictGames(host: String, port: Int, gameIds: List[String]): Int =
+ try
+ val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
+ val request = EvictGamesRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
+ stub.evictGames(request).getEvictedCount
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "evictGames RPC failed for %s:%d", host, port)
+ evictStaleChannel(host, port)
+ 0
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala
new file mode 100644
index 0000000..f02a7ef
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/resource/CoordinatorResource.scala
@@ -0,0 +1,101 @@
+package de.nowchess.coordinator.resource
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import scala.compiletime.uninitialized
+import scala.jdk.CollectionConverters.*
+import de.nowchess.coordinator.service.{AutoScaler, FailoverService, InstanceRegistry, LoadBalancer}
+import de.nowchess.coordinator.dto.InstanceMetadata
+import org.jboss.logging.Logger
+
+@Path("/api/coordinator")
+@ApplicationScoped
+class CoordinatorResource:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var loadBalancer: LoadBalancer = uninitialized
+
+ @Inject
+ private var autoScaler: AutoScaler = uninitialized
+
+ @Inject
+ private var failoverService: FailoverService = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val log = Logger.getLogger(classOf[CoordinatorResource])
+
+ @GET
+ @Path("/instances")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def listInstances: java.util.List[InstanceMetadata] =
+ instanceRegistry.getAllInstances.asJava
+
+ @GET
+ @Path("/metrics")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getMetrics: MetricsDto =
+ val instances = instanceRegistry.getAllInstances
+ val loads = instances.map(_.subscriptionCount)
+ val totalGames = loads.sum
+ val avgLoad = if instances.nonEmpty then loads.sum.toDouble / instances.size else 0.0
+ val maxLoad = if loads.nonEmpty then loads.max else 0
+ val minLoad = if loads.nonEmpty then loads.min else 0
+
+ MetricsDto(
+ totalInstances = instances.size,
+ healthyInstances = instances.count(_.state == "HEALTHY"),
+ deadInstances = instances.count(_.state == "DEAD"),
+ totalGames = totalGames,
+ avgGamesPerCore = avgLoad,
+ maxGamesPerCore = maxLoad,
+ minGamesPerCore = minLoad,
+ instances = instances,
+ )
+
+ @POST
+ @Path("/rebalance")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def triggerRebalance: scala.collection.Map[String, String] =
+ log.info("Manual rebalance triggered")
+ loadBalancer.rebalance
+ Map("status" -> "rebalance_started")
+
+ @POST
+ @Path("/failover/{instanceId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def triggerFailover(@PathParam("instanceId") instanceId: String): scala.collection.Map[String, String] =
+ log.infof("Manual failover triggered for instance %s", instanceId)
+ failoverService.onInstanceStreamDropped(instanceId)
+ Map("status" -> "failover_started", "instanceId" -> instanceId)
+
+ @POST
+ @Path("/scale-up")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def triggerScaleUp: scala.collection.Map[String, String] =
+ log.info("Manual scale up triggered")
+ autoScaler.scaleUp()
+ Map("status" -> "scale_up_started")
+
+ @POST
+ @Path("/scale-down")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def triggerScaleDown: scala.collection.Map[String, String] =
+ log.info("Manual scale down triggered")
+ autoScaler.scaleDown()
+ Map("status" -> "scale_down_started")
+
+case class MetricsDto(
+ totalInstances: Int,
+ healthyInstances: Int,
+ deadInstances: Int,
+ totalGames: Int,
+ avgGamesPerCore: Double,
+ maxGamesPerCore: Int,
+ minGamesPerCore: Int,
+ instances: List[InstanceMetadata],
+)
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala
new file mode 100644
index 0000000..5a074c8
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/AutoScaler.scala
@@ -0,0 +1,135 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Instance
+import jakarta.inject.Inject
+import de.nowchess.coordinator.config.CoordinatorConfig
+import io.fabric8.kubernetes.api.model.GenericKubernetesResource
+import io.fabric8.kubernetes.client.KubernetesClient
+import org.jboss.logging.Logger
+
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class AutoScaler:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var kubeClientInstance: Instance[KubernetesClient] = uninitialized
+
+ @Inject
+ private var config: CoordinatorConfig = uninitialized
+
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val log = Logger.getLogger(classOf[AutoScaler])
+ private val lastScaleTime = new java.util.concurrent.atomic.AtomicLong(0L)
+
+ private def kubeClientOpt: Option[KubernetesClient] =
+ if kubeClientInstance.isUnsatisfied then None
+ else Some(kubeClientInstance.get())
+
+ // scalafix:off DisableSyntax.asInstanceOf
+ // scalafix:off DisableSyntax.isInstanceOf
+ private def rolloutSpec(rollout: GenericKubernetesResource): Option[java.util.Map[String, AnyRef]] =
+ Option(rollout.get("spec")).collect {
+ case m if m.isInstanceOf[java.util.Map[?, ?]] => m.asInstanceOf[java.util.Map[String, AnyRef]]
+ }
+ // scalafix:on DisableSyntax.asInstanceOf
+ // scalafix:on DisableSyntax.isInstanceOf
+
+ def checkAndScale: Unit =
+ if config.autoScaleEnabled then
+ val now = System.currentTimeMillis()
+ val last = lastScaleTime.get()
+ if now - last >= 120000 && lastScaleTime.compareAndSet(last, now) then
+ val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
+ if instances.nonEmpty then
+ val avgLoad = instances.map(_.subscriptionCount).sum.toDouble / instances.size
+
+ if avgLoad > config.scaleUpThreshold * config.maxGamesPerCore then scaleUp()
+ else if avgLoad < config.scaleDownThreshold * config.maxGamesPerCore && instances.size > config.scaleMinReplicas
+ then scaleDown()
+
+ def scaleUp(): Unit =
+ log.info("Scaling up Argo Rollout")
+ kubeClientOpt match
+ case None =>
+ log.warn("Kubernetes client not available, cannot scale")
+ case Some(kube) =>
+ try
+ Option(
+ kube
+ .resources(classOf[GenericKubernetesResource])
+ .inNamespace(config.k8sNamespace)
+ .withName(config.k8sRolloutName)
+ .get(),
+ ).foreach { rollout =>
+ rolloutSpec(rollout).foreach { spec =>
+ spec.get("replicas") match
+ case replicas: Integer =>
+ val currentReplicas = replicas.intValue()
+ val maxReplicas = config.scaleMaxReplicas
+
+ if currentReplicas < maxReplicas then
+ spec.put("replicas", String.valueOf(currentReplicas + 1))
+ kube
+ .resources(classOf[GenericKubernetesResource])
+ .inNamespace(config.k8sNamespace)
+ .withName(config.k8sRolloutName)
+ .update()
+ log.infof(
+ "Scaled up %s from %d to %d replicas",
+ config.k8sRolloutName,
+ currentReplicas,
+ currentReplicas + 1,
+ )
+ else log.infof("Already at max replicas %d for %s", maxReplicas, config.k8sRolloutName)
+ case _ => ()
+ }
+ }
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to scale up %s", config.k8sRolloutName)
+
+ def scaleDown(): Unit =
+ log.info("Scaling down Argo Rollout")
+ kubeClientOpt match
+ case None =>
+ log.warn("Kubernetes client not available, cannot scale")
+ case Some(kube) =>
+ try
+ Option(
+ kube
+ .resources(classOf[GenericKubernetesResource])
+ .inNamespace(config.k8sNamespace)
+ .withName(config.k8sRolloutName)
+ .get(),
+ ).foreach { rollout =>
+ rolloutSpec(rollout).foreach { spec =>
+ spec.get("replicas") match
+ case replicas: Integer =>
+ val currentReplicas = replicas.intValue()
+ val minReplicas = config.scaleMinReplicas
+
+ if currentReplicas > minReplicas then
+ spec.put("replicas", String.valueOf(currentReplicas - 1))
+ kube
+ .resources(classOf[GenericKubernetesResource])
+ .inNamespace(config.k8sNamespace)
+ .withName(config.k8sRolloutName)
+ .update()
+ log.infof(
+ "Scaled down %s from %d to %d replicas",
+ config.k8sRolloutName,
+ currentReplicas,
+ currentReplicas - 1,
+ )
+ else log.infof("Already at min replicas %d for %s", minReplicas, config.k8sRolloutName)
+ case _ => ()
+ }
+ }
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to scale down %s", config.k8sRolloutName)
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala
new file mode 100644
index 0000000..40a62e9
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/CacheEvictionManager.scala
@@ -0,0 +1,93 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import io.quarkus.redis.datasource.RedisDataSource
+import de.nowchess.coordinator.config.CoordinatorConfig
+import com.fasterxml.jackson.databind.ObjectMapper
+import scala.jdk.CollectionConverters.*
+import org.jboss.logging.Logger
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.time.Instant
+import de.nowchess.coordinator.grpc.CoreGrpcClient
+
+@ApplicationScoped
+class CacheEvictionManager:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+
+ @Inject
+ private var config: CoordinatorConfig = uninitialized
+
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var coreGrpcClient: CoreGrpcClient = uninitialized
+
+ @Inject
+ private var objectMapper: ObjectMapper = uninitialized
+
+ private val log = Logger.getLogger(classOf[CacheEvictionManager])
+ private var redisPrefix = "nowchess"
+ // scalafix:on DisableSyntax.var
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def evictStaleGames: Unit =
+ log.info("Starting cache eviction scan")
+
+ val pattern = s"$redisPrefix:game:entry:*"
+ val keys = redis.key(classOf[String]).keys(pattern)
+ val now = System.currentTimeMillis()
+ val idleThresholdMs = config.gameIdleThreshold.toMillis
+
+ val evictedCount = keys.asScala.foldLeft(0) { (count, key) =>
+ try
+ Option(redis.value(classOf[String]).get(key)).fold(count) { value =>
+ val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
+ val lastUpdated = extractLastUpdatedTimestamp(value)
+
+ if lastUpdated > 0 && (now - lastUpdated) > idleThresholdMs then
+ findInstanceWithGame(gameId).fold(count) { instance =>
+ try
+ coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId))
+ redis.key(classOf[String]).del(key)
+ log.infof("Evicted idle game %s from %s", gameId, instance.instanceId)
+ count + 1
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to evict game %s", gameId)
+ count
+ }
+ else count
+ }
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Error processing game key %s", key)
+ count
+ }
+
+ log.infof("Cache eviction scan completed, evicted %d games", evictedCount)
+
+ private def extractLastUpdatedTimestamp(json: String): Long =
+ Try {
+ val parsed = objectMapper.readTree(json)
+ Option(parsed.get("lastHeartbeat"))
+ .filter(_.isTextual)
+ .fold(0L)(lh => Instant.parse(lh.asText()).toEpochMilli)
+ }.getOrElse(0L)
+
+ private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] =
+ try
+ instanceRegistry.getAllInstances.find { instance =>
+ val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
+ redis.set(classOf[String]).sismember(setKey, gameId)
+ }
+ catch
+ case ex: Exception =>
+ log.debugf(ex, "Failed to find instance for game %s", gameId)
+ None
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala
new file mode 100644
index 0000000..fb31858
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/FailoverService.scala
@@ -0,0 +1,103 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import io.quarkus.redis.datasource.RedisDataSource
+import scala.jdk.CollectionConverters.*
+import scala.compiletime.uninitialized
+import org.jboss.logging.Logger
+import de.nowchess.coordinator.dto.InstanceMetadata
+import de.nowchess.coordinator.grpc.CoreGrpcClient
+
+@ApplicationScoped
+class FailoverService:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var coreGrpcClient: CoreGrpcClient = uninitialized
+
+ private val log = Logger.getLogger(classOf[FailoverService])
+ private var redisPrefix = "nowchess"
+ // scalafix:on DisableSyntax.var
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def onInstanceStreamDropped(instanceId: String): Unit =
+ log.infof("Instance %s stream dropped, triggering failover", instanceId)
+
+ val startTime = System.currentTimeMillis()
+ instanceRegistry.markInstanceDead(instanceId)
+
+ val gameIds = getOrphanedGames(instanceId)
+ log.infof("Found %d orphaned games for instance %s", gameIds.size, instanceId)
+
+ if gameIds.nonEmpty then
+ val healthyInstances = instanceRegistry.getAllInstances
+ .filter(_.state == "HEALTHY")
+ .sortBy(_.subscriptionCount)
+
+ if healthyInstances.nonEmpty then
+ distributeGames(gameIds, healthyInstances, instanceId)
+
+ val elapsed = System.currentTimeMillis() - startTime
+ log.infof("Failover completed in %dms for instance %s", elapsed, instanceId)
+ else log.warnf("No healthy instances available for failover of %s", instanceId)
+
+ cleanupDeadInstance(instanceId)
+
+ private def getOrphanedGames(instanceId: String): List[String] =
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.set(classOf[String]).smembers(setKey).asScala.toList
+
+ private def distributeGames(
+ gameIds: List[String],
+ healthyInstances: List[InstanceMetadata],
+ deadInstanceId: String,
+ ): Unit =
+ if gameIds.nonEmpty && healthyInstances.nonEmpty then
+ val batchSize = math.max(1, gameIds.size / healthyInstances.size)
+ val batches = gameIds.grouped(batchSize).toList
+
+ batches.zipWithIndex.foreach { case (batch, idx) =>
+ if !tryMigrateBatch(batch, idx, healthyInstances, deadInstanceId) then
+ log.errorf(
+ "Failed to migrate batch of %d games from %s to any healthy instance",
+ batch.size,
+ deadInstanceId,
+ )
+ }
+
+ @scala.annotation.tailrec
+ private def tryMigrateBatch(
+ batch: List[String],
+ batchIdx: Int,
+ instances: List[InstanceMetadata],
+ deadId: String,
+ attempt: Int = 0,
+ ): Boolean =
+ if attempt >= instances.size then false
+ else
+ val target = instances((batchIdx + attempt) % instances.size)
+ val success =
+ try
+ val subscribed = coreGrpcClient.batchResubscribeGames(target.hostname, target.grpcPort, batch)
+ if subscribed > 0 then
+ log.infof("Migrated %d games from %s to %s", subscribed, deadId, target.instanceId)
+ true
+ else false
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to migrate batch to %s, trying next", target.instanceId)
+ false
+ if success then true else tryMigrateBatch(batch, batchIdx, instances, deadId, attempt + 1)
+
+ private def cleanupDeadInstance(instanceId: String): Unit =
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.key(classOf[String]).del(setKey)
+ log.infof("Cleaned up games set for instance %s", instanceId)
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala
new file mode 100644
index 0000000..4a031a7
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/HealthMonitor.scala
@@ -0,0 +1,123 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Instance
+import jakarta.inject.Inject
+import de.nowchess.coordinator.config.CoordinatorConfig
+import io.fabric8.kubernetes.client.KubernetesClient
+import io.fabric8.kubernetes.api.model.Pod
+import io.quarkus.redis.datasource.RedisDataSource
+import scala.jdk.CollectionConverters.*
+import org.jboss.logging.Logger
+import scala.compiletime.uninitialized
+import java.time.Instant
+
+@ApplicationScoped
+class HealthMonitor:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var kubeClientInstance: Instance[KubernetesClient] = uninitialized
+
+ @Inject
+ private var config: CoordinatorConfig = uninitialized
+
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+
+ private val log = Logger.getLogger(classOf[HealthMonitor])
+ private var redisPrefix = "nowchess"
+ // scalafix:on DisableSyntax.var
+
+ private def kubeClientOpt: Option[KubernetesClient] =
+ if kubeClientInstance.isUnsatisfied then None
+ else Some(kubeClientInstance.get())
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def checkInstanceHealth: Unit =
+ val instances = instanceRegistry.getAllInstances
+ instances.foreach { inst =>
+ val isHealthy = checkHealth(inst.instanceId)
+ if !isHealthy && inst.state == "HEALTHY" then
+ log.warnf("Instance %s marked unhealthy", inst.instanceId)
+ instanceRegistry.markInstanceDead(inst.instanceId)
+ }
+
+ private def checkHealth(instanceId: String): Boolean =
+ val redisHealthy = checkRedisHeartbeat(instanceId)
+ val k8sHealthy = checkK8sPodStatus(instanceId)
+ redisHealthy && k8sHealthy
+
+ private def checkRedisHeartbeat(instanceId: String): Boolean =
+ try
+ val key = s"$redisPrefix:instances:$instanceId"
+ redis.key(classOf[String]).pttl(key) > 0
+ catch
+ case ex: Exception =>
+ log.debugf(ex, "Redis heartbeat check failed for %s", instanceId)
+ false
+
+ private def checkK8sPodStatus(instanceId: String): Boolean =
+ kubeClientOpt.fold(true) { kube =>
+ try
+ val pods = kube
+ .pods()
+ .inNamespace(config.k8sNamespace)
+ .withLabel(config.k8sRolloutLabelSelector)
+ .list()
+ .getItems
+ .asScala
+
+ pods.exists { pod =>
+ val podName = pod.getMetadata.getName
+ podName.contains(instanceId) && isPodReady(pod)
+ }
+ catch
+ case ex: Exception =>
+ log.debugf(ex, "K8s pod status check failed for %s", instanceId)
+ true
+ }
+
+ def watchK8sPods: Unit =
+ kubeClientOpt match
+ case None =>
+ log.debug("Kubernetes client not available for pod watch")
+ case Some(kube) =>
+ try
+ val pods = kube
+ .pods()
+ .inNamespace(config.k8sNamespace)
+ .withLabel(config.k8sRolloutLabelSelector)
+ .list()
+ .getItems
+ .asScala
+
+ val instances = instanceRegistry.getAllInstances
+ instances.foreach { inst =>
+ val matchingPod = pods.find { pod =>
+ pod.getMetadata.getName.contains(inst.instanceId)
+ }
+
+ matchingPod match
+ case Some(pod) =>
+ val isReady = isPodReady(pod)
+ if !isReady && inst.state == "HEALTHY" then
+ log.warnf("Pod %s not ready, marking instance %s dead", pod.getMetadata.getName, inst.instanceId)
+ instanceRegistry.markInstanceDead(inst.instanceId)
+ case None =>
+ if inst.state == "HEALTHY" then
+ log.warnf("No pod found for instance %s, marking dead", inst.instanceId)
+ instanceRegistry.markInstanceDead(inst.instanceId)
+ }
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to watch k8s pods")
+
+ private def isPodReady(pod: Pod): Boolean =
+ Option(pod.getStatus)
+ .flatMap(s => Option(s.getConditions))
+ .exists(_.asScala.exists(cond => cond.getType == "Ready" && cond.getStatus == "True"))
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala
new file mode 100644
index 0000000..69e9db2
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/InstanceRegistry.scala
@@ -0,0 +1,47 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import io.quarkus.redis.datasource.RedisDataSource
+import scala.jdk.CollectionConverters.*
+import scala.compiletime.uninitialized
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.coordinator.dto.InstanceMetadata
+import java.util.concurrent.ConcurrentHashMap
+
+@ApplicationScoped
+class InstanceRegistry:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+ private var redisPrefix = "nowchess"
+ // scalafix:on DisableSyntax.var
+
+ private val mapper = ObjectMapper()
+ private val instances = ConcurrentHashMap[String, InstanceMetadata]()
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def getInstance(instanceId: String): Option[InstanceMetadata] =
+ Option(instances.get(instanceId))
+
+ def getAllInstances: List[InstanceMetadata] =
+ instances.values.asScala.toList
+
+ def updateInstanceFromRedis(instanceId: String): Unit =
+ val key = s"$redisPrefix:instances:$instanceId"
+ Option(redis.value(classOf[String]).get(key)).foreach { value =>
+ try
+ val metadata = mapper.readValue(value, classOf[InstanceMetadata])
+ instances.put(instanceId, metadata)
+ catch case _: Exception => ()
+ }
+
+ def markInstanceDead(instanceId: String): Unit =
+ instances.computeIfPresent(instanceId, (_, inst) => inst.copy(state = "DEAD"))
+ ()
+
+ def removeInstance(instanceId: String): Unit =
+ instances.remove(instanceId)
+ ()
diff --git a/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala
new file mode 100644
index 0000000..4c37df5
--- /dev/null
+++ b/modules/coordinator/src/main/scala/de/nowchess/coordinator/service/LoadBalancer.scala
@@ -0,0 +1,127 @@
+package de.nowchess.coordinator.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import de.nowchess.coordinator.config.CoordinatorConfig
+import io.quarkus.redis.datasource.RedisDataSource
+import org.jboss.logging.Logger
+import scala.compiletime.uninitialized
+import scala.concurrent.duration.*
+import scala.jdk.CollectionConverters.*
+import de.nowchess.coordinator.grpc.CoreGrpcClient
+
+@ApplicationScoped
+class LoadBalancer:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var config: CoordinatorConfig = uninitialized
+
+ @Inject
+ private var instanceRegistry: InstanceRegistry = uninitialized
+
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+
+ @Inject
+ private var coreGrpcClient: CoreGrpcClient = uninitialized
+
+ private val log = Logger.getLogger(classOf[LoadBalancer])
+ private val lastRebalanceTime = new java.util.concurrent.atomic.AtomicLong(0L)
+ private var redisPrefix = "nowchess"
+ // scalafix:on DisableSyntax.var
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def shouldRebalance: Boolean =
+ val now = System.currentTimeMillis()
+ val minInterval = config.rebalanceMinInterval.toMillis
+ if now - lastRebalanceTime.get() < minInterval then false
+ else
+ val instances = instanceRegistry.getAllInstances
+ if instances.isEmpty then false
+ else
+ val loads = instances.map(_.subscriptionCount)
+ val maxLoad = loads.max
+ val minLoad = loads.min
+ val avgLoad = loads.sum.toDouble / loads.size
+
+ val exceededMax = maxLoad > config.maxGamesPerCore
+ val deviationPercent = 100.0 * (maxLoad - avgLoad) / avgLoad
+ val exceededDeviation =
+ maxLoad > avgLoad && deviationPercent > config.maxDeviationPercent && (maxLoad - minLoad) > 50
+
+ exceededMax || exceededDeviation
+
+ def rebalance: Unit =
+ log.info("Starting rebalance")
+ val startTime = System.currentTimeMillis()
+ lastRebalanceTime.set(startTime)
+
+ try
+ val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
+
+ if instances.size < 2 then log.info("Not enough healthy instances for rebalance")
+ else
+ val loads = instances.map(_.subscriptionCount)
+ val avgLoad = loads.sum.toDouble / loads.size
+
+ val overloaded = instances
+ .filter(_.subscriptionCount > config.maxGamesPerCore)
+ .sortBy[Int](_.subscriptionCount)
+ .reverse
+ val underloaded = instances
+ .filter(_.subscriptionCount < avgLoad * 0.8)
+ .sortBy(_.subscriptionCount)
+
+ if underloaded.isEmpty then log.info("No underloaded instances available for rebalance")
+ else
+ val allBatches = overloaded.flatMap { over =>
+ val excess = math.max(0, over.subscriptionCount - avgLoad.toInt)
+ val gamesToMove = getGamesToMove(over.instanceId, excess)
+ if gamesToMove.isEmpty then List.empty
+ else
+ val batchSize = math.max(1, (gamesToMove.size + underloaded.size - 1) / underloaded.size)
+ gamesToMove.grouped(batchSize).toList.map((over, _))
+ }
+
+ allBatches.zipWithIndex.foreach { case ((over, batch), idx) =>
+ val target = underloaded(idx % underloaded.size)
+ try
+ coreGrpcClient.unsubscribeGames(over.hostname, over.grpcPort, batch)
+ val subscribed = coreGrpcClient.batchResubscribeGames(target.hostname, target.grpcPort, batch)
+ if subscribed > 0 then
+ updateRedisGameSets(over.instanceId, target.instanceId, batch)
+ log.infof("Moved %d games from %s to %s", subscribed, over.instanceId, target.instanceId)
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to move games from %s to %s", over.instanceId, target.instanceId)
+ }
+
+ val elapsed = System.currentTimeMillis() - startTime
+ log.infof("Rebalance completed in %dms", elapsed)
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Rebalance failed")
+
+ private def getGamesToMove(instanceId: String, count: Int): List[String] =
+ try
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.set(classOf[String]).smembers(setKey).asScala.toList.take(count)
+ catch
+ case ex: Exception =>
+ log.debugf(ex, "Failed to get games for %s", instanceId)
+ List()
+
+ private def updateRedisGameSets(fromInstanceId: String, toInstanceId: String, gameIds: List[String]): Unit =
+ try
+ val fromKey = s"$redisPrefix:instance:$fromInstanceId:games"
+ val toKey = s"$redisPrefix:instance:$toInstanceId:games"
+
+ gameIds.foreach { gameId =>
+ redis.set(classOf[String]).srem(fromKey, gameId)
+ redis.set(classOf[String]).sadd(toKey, gameId)
+ }
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to update Redis game sets")
diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts
index 8596d8e..dda74de 100644
--- a/modules/core/build.gradle.kts
+++ b/modules/core/build.gradle.kts
@@ -48,7 +48,11 @@ dependencies {
}
implementation(project(":modules:api"))
- implementation(project(":modules:bot"))
+ implementation(project(":modules:json"))
+ implementation(project(":modules:rule"))
+ implementation(project(":modules:io"))
+ implementation(project(":modules:official-bots"))
+ implementation(project(":modules:security"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
@@ -56,6 +60,7 @@ dependencies {
implementation("io.quarkus:quarkus-hibernate-orm")
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("io.quarkus:quarkus-rest-client")
+ implementation("io.quarkus:quarkus-grpc")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
@@ -63,9 +68,10 @@ dependencies {
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-websockets-next")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
-
+ implementation("io.quarkus:quarkus-redis-client")
testImplementation(project(":modules:io"))
testImplementation(project(":modules:rule"))
@@ -119,3 +125,25 @@ tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ exclude("**/coordinator/*.scala")
+ exclude("**/registry/RedisGameRegistry.scala")
+ exclude("**/service/InstanceHeartbeatService.scala")
+ exclude("**/resource/GameDtoMapper.scala")
+ exclude("**/resource/GameResource.scala")
+ exclude("**/redis/GameRedis*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
+
+tasks.compileScala {
+ dependsOn(tasks.named("compileJava"))
+}
+
diff --git a/modules/core/src/main/proto/chess_types.proto b/modules/core/src/main/proto/chess_types.proto
new file mode 100644
index 0000000..3d0ee87
--- /dev/null
+++ b/modules/core/src/main/proto/chess_types.proto
@@ -0,0 +1,87 @@
+syntax = "proto3";
+option java_package = "de.nowchess.core.proto";
+option java_multiple_files = true;
+option java_outer_classname = "ChessTypesProto";
+
+enum ProtoColor {
+ WHITE = 0;
+ BLACK = 1;
+}
+
+enum ProtoPieceType {
+ PAWN = 0;
+ KNIGHT = 1;
+ BISHOP = 2;
+ ROOK = 3;
+ QUEEN = 4;
+ KING = 5;
+}
+
+enum ProtoMoveKind {
+ QUIET = 0;
+ CAPTURE = 1;
+ CASTLE_KINGSIDE = 2;
+ CASTLE_QUEENSIDE = 3;
+ EN_PASSANT = 4;
+ PROMO_QUEEN = 5;
+ PROMO_ROOK = 6;
+ PROMO_BISHOP = 7;
+ PROMO_KNIGHT = 8;
+}
+
+enum ProtoGameResultKind {
+ ONGOING = 0;
+ WIN_CHECKMATE_W = 1;
+ WIN_CHECKMATE_B = 2;
+ WIN_RESIGN_W = 3;
+ WIN_RESIGN_B = 4;
+ WIN_TIME_W = 5;
+ WIN_TIME_B = 6;
+ DRAW_STALEMATE = 7;
+ DRAW_INSUFFICIENT = 8;
+ DRAW_FIFTY_MOVE = 9;
+ DRAW_THREEFOLD = 10;
+ DRAW_AGREEMENT = 11;
+}
+
+message ProtoPiece {
+ ProtoColor color = 1;
+ ProtoPieceType piece_type = 2;
+}
+
+message ProtoSquarePiece {
+ string square = 1;
+ ProtoPiece piece = 2;
+}
+
+message ProtoMove {
+ string from = 1;
+ string to = 2;
+ ProtoMoveKind move_kind = 3;
+}
+
+message ProtoCastlingRights {
+ bool white_king_side = 1;
+ bool white_queen_side = 2;
+ bool black_king_side = 3;
+ bool black_queen_side = 4;
+}
+
+message ProtoGameContext {
+ repeated ProtoSquarePiece board = 1;
+ ProtoColor turn = 2;
+ ProtoCastlingRights castling_rights = 3;
+ string en_passant_square = 4;
+ int32 half_move_clock = 5;
+ repeated ProtoMove moves = 6;
+ ProtoGameResultKind result = 7;
+ repeated ProtoSquarePiece initial_board = 8;
+}
+
+message ProtoPostMoveStatus {
+ bool is_checkmate = 1;
+ bool is_stalemate = 2;
+ bool is_insufficient_material = 3;
+ bool is_check = 4;
+ bool is_threefold_repetition = 5;
+}
diff --git a/modules/core/src/main/proto/coordinator_service.proto b/modules/core/src/main/proto/coordinator_service.proto
new file mode 100644
index 0000000..2f49119
--- /dev/null
+++ b/modules/core/src/main/proto/coordinator_service.proto
@@ -0,0 +1,58 @@
+syntax = "proto3";
+option java_package = "de.nowchess.coordinator.proto";
+option java_multiple_files = true;
+option java_outer_classname = "CoordinatorServiceProto";
+
+service CoordinatorService {
+ rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
+ rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
+ rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
+ rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
+ rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
+}
+
+message HeartbeatFrame {
+ string instanceId = 1;
+ string hostname = 2;
+ int32 httpPort = 3;
+ int32 grpcPort = 4;
+ int32 subscriptionCount = 5;
+ int32 localCacheSize = 6;
+ int64 timestampMillis = 7;
+}
+
+message CoordinatorCommand {
+ string type = 1;
+ string payload = 2;
+}
+
+message BatchResubscribeRequest {
+ repeated string gameIds = 1;
+}
+
+message BatchResubscribeResponse {
+ int32 subscribedCount = 1;
+ repeated string failedGameIds = 2;
+}
+
+message UnsubscribeGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message UnsubscribeGamesResponse {
+ int32 unsubscribedCount = 1;
+}
+
+message EvictGamesRequest {
+ repeated string gameIds = 1;
+}
+
+message EvictGamesResponse {
+ int32 evictedCount = 1;
+}
+
+message DrainInstanceRequest {}
+
+message DrainInstanceResponse {
+ int32 gamesMigrated = 1;
+}
diff --git a/modules/core/src/main/proto/io_service.proto b/modules/core/src/main/proto/io_service.proto
new file mode 100644
index 0000000..11d2ede
--- /dev/null
+++ b/modules/core/src/main/proto/io_service.proto
@@ -0,0 +1,31 @@
+syntax = "proto3";
+option java_package = "de.nowchess.core.proto";
+option java_multiple_files = true;
+option java_outer_classname = "IoServiceProto";
+
+import "chess_types.proto";
+
+message ProtoImportFenRequest {
+ string fen = 1;
+}
+
+message ProtoImportPgnRequest {
+ string pgn = 1;
+}
+
+message ProtoCombinedExport {
+ string fen = 1;
+ string pgn = 2;
+}
+
+message ProtoStringResult {
+ string value = 1;
+}
+
+service IoService {
+ rpc ImportFen (ProtoImportFenRequest) returns (ProtoGameContext);
+ rpc ImportPgn (ProtoImportPgnRequest) returns (ProtoGameContext);
+ rpc ExportCombined (ProtoGameContext) returns (ProtoCombinedExport);
+ rpc ExportFen (ProtoGameContext) returns (ProtoStringResult);
+ rpc ExportPgn (ProtoGameContext) returns (ProtoStringResult);
+}
diff --git a/modules/core/src/main/proto/rule_service.proto b/modules/core/src/main/proto/rule_service.proto
new file mode 100644
index 0000000..7ad54b3
--- /dev/null
+++ b/modules/core/src/main/proto/rule_service.proto
@@ -0,0 +1,38 @@
+syntax = "proto3";
+option java_package = "de.nowchess.core.proto";
+option java_multiple_files = true;
+option java_outer_classname = "RuleServiceProto";
+
+import "chess_types.proto";
+
+message ProtoSquareRequest {
+ ProtoGameContext context = 1;
+ string square = 2;
+}
+
+message ProtoMoveRequest {
+ ProtoGameContext context = 1;
+ ProtoMove move = 2;
+}
+
+message ProtoMoveList {
+ repeated ProtoMove moves = 1;
+}
+
+message ProtoBoolResult {
+ bool value = 1;
+}
+
+service RuleService {
+ rpc CandidateMoves (ProtoSquareRequest) returns (ProtoMoveList);
+ rpc LegalMoves (ProtoSquareRequest) returns (ProtoMoveList);
+ rpc AllLegalMoves (ProtoGameContext) returns (ProtoMoveList);
+ rpc IsCheck (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsCheckmate (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsStalemate (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsInsufficientMaterial (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsFiftyMoveRule (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsThreefoldRepetition (ProtoGameContext) returns (ProtoBoolResult);
+ rpc ApplyMove (ProtoMoveRequest) returns (ProtoGameContext);
+ rpc PostMoveStatus (ProtoGameContext) returns (ProtoPostMoveStatus);
+}
diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml
index bbc7bff..fe9f259 100644
--- a/modules/core/src/main/resources/application.yml
+++ b/modules/core/src/main/resources/application.yml
@@ -3,8 +3,111 @@ quarkus:
port: 8080
application:
name: nowchess-core
- rest-client:
- io-service:
- url: http://localhost:8081
- rule-service:
- url: http://localhost:8082
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ grpc:
+ clients:
+ rule-grpc:
+ host: localhost
+ port: 8082
+ io-grpc:
+ host: localhost
+ port: 8081
+ coordinator-grpc:
+ host: localhost
+ port: 9086
+ server:
+ use-separate-server: false
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+ internal:
+ secret: 123abc
+
+ coordinator:
+ enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
+ host: localhost
+ grpc-port: 9086
+ stream-heartbeat-interval: 200ms
+ redis-heartbeat-interval: 2s
+ instance-id: ${HOSTNAME:local}-${quarkus.uuid}
+
+"%dev":
+ mp:
+ jwt:
+ verify:
+ publickey:
+ location: keys/public.pem
+ issuer: nowchess
+ quarkus:
+ http:
+ cors:
+ ~: true
+ origins: http://localhost:4200
+ methods: GET,POST,PUT,DELETE,OPTIONS
+ headers: Content-Type,Accept,Authorization
+ grpc:
+ clients:
+ rule-grpc:
+ host: localhost
+ port: 8082
+ io-grpc:
+ host: localhost
+ port: 8081
+ rest-client:
+ io-service:
+ url: http://localhost:8081
+ rule-service:
+ url: http://localhost:8082
+ store-service:
+ url: http://localhost:8085
+
+"%deployed":
+ mp:
+ jwt:
+ verify:
+ publickey:
+ location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
+ issuer: nowchess
+ quarkus:
+ http:
+ cors:
+ ~: true
+ origins: ${CORS_ORIGINS}
+ methods: GET,POST,PUT,DELETE,OPTIONS
+ headers: Content-Type,Accept,Authorization
+ grpc:
+ clients:
+ rule-grpc:
+ host: ${RULE_SERVICE_HOST}
+ port: ${RULE_SERVICE_GRPC_PORT:9082}
+ io-grpc:
+ host: ${IO_SERVICE_HOST}
+ port: ${IO_SERVICE_GRPC_PORT:9081}
+ coordinator-grpc:
+ host: ${COORDINATOR_SERVICE_HOST:localhost}
+ port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
+ rest-client:
+ io-service:
+ url: ${IO_SERVICE_URL}
+ rule-service:
+ url: ${RULE_SERVICE_URL}
+ store-service:
+ url: ${STORE_SERVICE_URL}
+ nowchess:
+ redis:
+ host: ${REDIS_HOST}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
+
+ coordinator:
+ enabled: ${NOWCHESS_COORDINATOR_ENABLED:true}
+ host: ${COORDINATOR_SERVICE_HOST:localhost}
+ grpc-port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
+ stream-heartbeat-interval: 200ms
+ redis-heartbeat-interval: 2s
+ instance-id: ${HOSTNAME:local}-${quarkus.uuid}
diff --git a/modules/core/src/main/resources/keys/public.pem b/modules/core/src/main/resources/keys/public.pem
new file mode 100644
index 0000000..6b6b842
--- /dev/null
+++ b/modules/core/src/main/resources/keys/public.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
+g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
+Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
+634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
+YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
+7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
+WQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala
index a281095..fa81949 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala
@@ -4,7 +4,7 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
-import de.nowchess.api.rules.RuleSet
+import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient
@@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet:
def applyMove(ctx: GameContext)(move: Move): GameContext =
client.applyMove(RuleMoveRequest(ctx, move))
+
+ override def postMoveStatus(ctx: GameContext): PostMoveStatus =
+ client.postMoveStatus(ctx)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala b/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala
new file mode 100644
index 0000000..3fc4e2c
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/GameRecordDto.scala
@@ -0,0 +1,24 @@
+package de.nowchess.chess.client
+
+case class GameRecordDto(
+ gameId: String,
+ fen: String,
+ pgn: String,
+ moveCount: Int,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ resigned: Boolean,
+ limitSeconds: java.lang.Integer,
+ incrementSeconds: java.lang.Integer,
+ daysPerMove: java.lang.Integer,
+ whiteRemainingMs: java.lang.Long,
+ blackRemainingMs: java.lang.Long,
+ incrementMs: java.lang.Long,
+ clockLastTickAt: java.lang.Long,
+ clockMoveDeadline: java.lang.Long,
+ clockActiveColor: String,
+ pendingDrawOffer: String,
+)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
index bc41b1b..45f2ce3 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala
@@ -2,12 +2,17 @@ package de.nowchess.chess.client
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
import de.nowchess.api.game.GameContext
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+case class CombinedExportResponse(fen: String, pgn: String)
+
@Path("/io")
@RegisterRestClient(configKey = "io-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
trait IoServiceClient:
@POST
@@ -33,3 +38,9 @@ trait IoServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array("application/x-chess-pgn"))
def exportPgn(ctx: GameContext): String
+
+ @POST
+ @Path("/export/combined")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def exportCombined(ctx: GameContext): CombinedExportResponse
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
index b213ef4..7c6c425 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala
@@ -2,8 +2,11 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
+import de.nowchess.api.rules.PostMoveStatus
+import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class RuleSquareRequest(context: GameContext, square: String)
@@ -11,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
@Path("/api/rules")
@RegisterRestClient(configKey = "rule-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
trait RuleServiceClient:
@POST
@@ -72,3 +76,9 @@ trait RuleServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: RuleMoveRequest): GameContext
+
+ @POST
+ @Path("/post-move-status")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def postMoveStatus(ctx: GameContext): PostMoveStatus
diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
new file mode 100644
index 0000000..fca9aa9
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/client/StoreServiceClient.scala
@@ -0,0 +1,16 @@
+package de.nowchess.chess.client
+
+import de.nowchess.security.InternalSecretClientFilter
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.MediaType
+import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
+import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
+
+@RegisterRestClient(configKey = "store-service")
+@RegisterProvider(classOf[InternalSecretClientFilter])
+@Path("/game")
+trait StoreServiceClient:
+ @GET
+ @Path("/{gameId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getGame(@PathParam("gameId") gameId: String): GameRecordDto
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala b/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
deleted file mode 100644
index 68c3c9b..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/command/Command.scala
+++ /dev/null
@@ -1,60 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.board.{Piece, Square}
-import de.nowchess.api.game.GameContext
-
-/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
- * transitions.
- */
-trait Command:
- /** Execute the command and return true if successful, false otherwise. */
- def execute(): Boolean
-
- /** Undo the command and return true if successful, false otherwise. */
- def undo(): Boolean
-
- /** A human-readable description of this command. */
- def description: String
-
-/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
- */
-case class MoveCommand(
- from: Square,
- to: Square,
- moveResult: Option[MoveResult] = None,
- previousContext: Option[GameContext] = None,
- notation: String = "",
-) extends Command:
-
- override def execute(): Boolean =
- moveResult.isDefined
-
- override def undo(): Boolean =
- previousContext.isDefined
-
- override def description: String = s"Move from $from to $to"
-
-// Sealed hierarchy of move outcomes (for tracking state changes)
-sealed trait MoveResult
-object MoveResult:
- case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
- case object InvalidFormat extends MoveResult
- case object InvalidMove extends MoveResult
-
-/** Command to quit the game. */
-case class QuitCommand() extends Command:
- override def execute(): Boolean = true
- override def undo(): Boolean = false
- override def description: String = "Quit game"
-
-/** Command to reset the board to initial position. */
-case class ResetCommand(
- previousContext: Option[GameContext] = None,
-) extends Command:
-
- override def execute(): Boolean = true
-
- override def undo(): Boolean =
- previousContext.isDefined
-
- override def description: String = "Reset board"
diff --git a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala b/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
deleted file mode 100644
index 78760ef..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/command/CommandInvoker.scala
+++ /dev/null
@@ -1,67 +0,0 @@
-package de.nowchess.chess.command
-
-/** Manages command execution and history for undo/redo support. */
-class CommandInvoker:
- private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
- @SuppressWarnings(Array("DisableSyntax.var"))
- private var currentIndex = -1
-
- /** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
- */
- def execute(command: Command): Boolean = synchronized {
- if command.execute() then
- // Remove any commands after current index (redo stack is discarded)
- while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
- executedCommands += command
- currentIndex += 1
- true
- else false
- }
-
- /** Undo the last executed command if possible. */
- def undo(): Boolean = synchronized {
- if currentIndex >= 0 && currentIndex < executedCommands.size then
- val command = executedCommands(currentIndex)
- if command.undo() then
- currentIndex -= 1
- true
- else false
- else false
- }
-
- /** Redo the next command in history if available. */
- def redo(): Boolean = synchronized {
- if currentIndex + 1 < executedCommands.size then
- val command = executedCommands(currentIndex + 1)
- if command.execute() then
- currentIndex += 1
- true
- else false
- else false
- }
-
- /** Get the history of all executed commands. */
- def history: List[Command] = synchronized {
- executedCommands.toList
- }
-
- /** Get the current position in command history. */
- def getCurrentIndex: Int = synchronized {
- currentIndex
- }
-
- /** Clear all command history. */
- def clear(): Unit = synchronized {
- executedCommands.clear()
- currentIndex = -1
- }
-
- /** Check if undo is available. */
- def canUndo: Boolean = synchronized {
- currentIndex >= 0
- }
-
- /** Check if redo is available. */
- def canRedo: Boolean = synchronized {
- currentIndex + 1 < executedCommands.size
- }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
index 87b8ba7..4c0311f 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala
@@ -2,11 +2,8 @@ package de.nowchess.chess.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
-import de.nowchess.api.board.Square
-import de.nowchess.api.move.MoveType
-import de.nowchess.chess.json.{MoveTypeDeserializer, MoveTypeSerializer, SquareDeserializer, SquareSerializer}
+import de.nowchess.json.ChessJacksonModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -19,11 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
- val mod = new SimpleModule()
- mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
- mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- mod.addSerializer(classOf[Square], new SquareSerializer())
- mod.addDeserializer(classOf[Square], new SquareDeserializer())
- mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
- mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
- mapper.registerModule(mod)
+ mapper.registerModule(new ChessJacksonModule())
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
index 42e4cd4..5fa2ea4 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala
@@ -2,13 +2,14 @@ package de.nowchess.chess.config
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.dto.*
-import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
+import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[ApiErrorDto],
+ classOf[ClockDto],
classOf[CreateGameRequestDto],
classOf[ErrorEventDto],
classOf[GameFullDto],
@@ -21,6 +22,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[LegalMovesResponseDto],
classOf[OkResponseDto],
classOf[PlayerInfoDto],
+ classOf[TimeControlDto],
classOf[GameContext],
classOf[Color],
classOf[Piece],
@@ -34,6 +36,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[PromotionPiece],
classOf[GameResult],
classOf[DrawReason],
+ classOf[GameMode],
),
)
class NativeReflectionConfig
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala
new file mode 100644
index 0000000..e453e59
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+package de.nowchess.chess.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ // scalafix:off DisableSyntax.var
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala
deleted file mode 100644
index 35e9604..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeyDeserializer.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.nowchess.chess.config
-
-import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
-import de.nowchess.api.board.Square
-
-class SquareKeyDeserializer extends KeyDeserializer:
- override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
- Square.fromAlgebraic(key).orNull
diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala
deleted file mode 100644
index c0ca0d0..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/config/SquareKeySerializer.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.nowchess.chess.config
-
-import com.fasterxml.jackson.core.JsonGenerator
-import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
-import de.nowchess.api.board.Square
-
-class SquareKeySerializer extends JsonSerializer[Square]:
- override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
- gen.writeFieldName(value.toString)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
index 21903ca..561de76 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala
@@ -2,15 +2,25 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
-import de.nowchess.api.game.{BotParticipant, DrawReason, GameContext, GameResult, Human, Participant}
-import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.api.game.{
+ ClockState,
+ CorrespondenceClockState,
+ DrawReason,
+ GameContext,
+ GameResult,
+ LiveClockState,
+ TimeControl,
+ WinReason,
+}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
-import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
+import de.nowchess.api.error.GameError
+import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
import de.nowchess.api.io.{GameContextExport, GameContextImport}
import de.nowchess.api.rules.RuleSet
-import scala.concurrent.{ExecutionContext, Future}
+import java.time.Instant
+import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
/** 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.
@@ -18,10 +28,11 @@ import scala.concurrent.{ExecutionContext, Future}
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet,
- val participants: Map[Color, Participant] = Map(
- Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
- Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
- ),
+ val timeControl: TimeControl = TimeControl.Unlimited,
+ initialClockState: Option[ClockState] = None,
+ initialDrawOffer: Option[Color] = None,
+ initialRedoStack: List[Move] = Nil,
+ initialTakebackRequest: Option[Color] = None,
) extends Observable:
// Ensure that initialBoard is set correctly for threefold repetition detection
private val contextWithInitialBoard =
@@ -31,25 +42,42 @@ class GameEngine(
@SuppressWarnings(Array("DisableSyntax.var"))
private var currentContext: GameContext = contextWithInitialBoard
@SuppressWarnings(Array("DisableSyntax.var"))
- private var pendingDrawOffer: Option[Color] = None
- private val invoker = new CommandInvoker()
+ private var pendingDrawOffer: Option[Color] = initialDrawOffer
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var clockState: Option[ClockState] =
+ initialClockState.orElse(ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now()))
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var scheduledCheck: Option[ScheduledFuture[?]] = None
+ // One shared scheduler per engine; shut down with the game.
+ private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var redoStack: List[Move] = initialRedoStack
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var isRedoing: Boolean = false
+ @SuppressWarnings(Array("DisableSyntax.var"))
+ private var pendingTakebackRequest: Option[Color] = initialTakebackRequest
- private implicit val ec: ExecutionContext = ExecutionContext.global
+ // Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
+ clockState.foreach(scheduleExpiryCheck)
// Synchronized accessors for current state
- def board: Board = synchronized(currentContext.board)
- def turn: Color = synchronized(currentContext.turn)
- def context: GameContext = synchronized(currentContext)
- def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
+ def board: Board = synchronized(currentContext.board)
+ def turn: Color = synchronized(currentContext.turn)
+ def context: GameContext = synchronized(currentContext)
+ def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
+ def currentClockState: Option[ClockState] = synchronized(clockState)
/** Check if undo is available. */
- def canUndo: Boolean = synchronized(invoker.canUndo)
+ def canUndo: Boolean = synchronized(currentContext.moves.nonEmpty)
/** Check if redo is available. */
- def canRedo: Boolean = synchronized(invoker.canRedo)
+ def canRedo: Boolean = synchronized(redoStack.nonEmpty)
- /** Get the command history for inspection (testing/debugging). */
- def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
+ /** Get redo stack moves for inspection. */
+ def redoStackMoves: List[Move] = synchronized(redoStack)
+
+ /** Get pending takeback request (if any). */
+ def pendingTakebackRequestBy: Option[Color] = synchronized(pendingTakebackRequest)
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
* GameEvent.
@@ -126,13 +154,17 @@ class GameEngine(
def redo(): Unit = synchronized(performRedo())
/** Resign from the game. The opponent wins. */
+ def resign(): Unit = synchronized(resign(currentContext.turn))
+
def resign(color: Color): Unit = synchronized {
if currentContext.result.isDefined then
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else
- currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite)))
+ currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
pendingDrawOffer = None
- invoker.clear()
+ pendingTakebackRequest = None
+ stopClock()
+ redoStack = Nil
notifyObservers(ResignEvent(currentContext, color))
}
@@ -162,7 +194,9 @@ class GameEngine(
case Some(_) =>
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
pendingDrawOffer = None
- invoker.clear()
+ pendingTakebackRequest = None
+ stopClock()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
}
@@ -187,11 +221,13 @@ class GameEngine(
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
else if currentContext.halfMoveClock >= 100 then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
- invoker.clear()
+ stopClock()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
else if ruleSet.isThreefoldRepetition(currentContext) then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
- invoker.clear()
+ stopClock()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
}
@@ -199,40 +235,44 @@ class GameEngine(
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
*/
- def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
+ def loadGame(importer: GameContextImport, input: String): Either[GameError, Unit] = synchronized {
importer.importGameContext(input) match
case Left(err) => Left(err)
case Right(ctx) =>
replayGame(ctx).map { _ =>
pendingDrawOffer = None
+ pendingTakebackRequest = None
+ redoStack = Nil
+ stopClock()
+ clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(PgnLoadedEvent(currentContext))
}
}
- private def replayGame(ctx: GameContext): Either[String, Unit] =
+ private def replayGame(ctx: GameContext): Either[GameError, Unit] =
val savedContext = currentContext
currentContext = GameContext.initial
- invoker.clear()
+ redoStack = Nil
if ctx.moves.isEmpty then
currentContext = ctx.copy(initialBoard = ctx.board)
Right(())
else replayMoves(ctx.moves, savedContext)
- private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
- val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
+ private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[GameError, Unit] =
+ val result = moves.foldLeft[Either[GameError, Unit]](Right(())) { (acc, move) =>
acc.flatMap(_ => applyReplayMove(move))
}
result.left.foreach(_ => currentContext = savedContext)
result
- private def applyReplayMove(move: Move): Either[String, Unit] =
+ private def applyReplayMove(move: Move): Either[GameError, Unit] =
val legal = ruleSet.legalMoves(currentContext)(move.from)
val candidate = move.moveType match
case MoveType.Promotion(pp) => legal.find(m => m.to == move.to && m.moveType == MoveType.Promotion(pp))
case _ => legal.find(_.to == move.to)
candidate match
- case None => Left("Illegal move.")
+ case None => Left(GameError.IllegalMove)
case Some(lm) => executeMove(lm); Right(())
/** Export the current game context using the provided exporter. */
@@ -247,7 +287,10 @@ class GameEngine(
else newContext
currentContext = contextWithInitialBoard
pendingDrawOffer = None
- invoker.clear()
+ pendingTakebackRequest = None
+ redoStack = Nil
+ stopClock()
+ clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(BoardResetEvent(currentContext))
}
@@ -255,46 +298,89 @@ class GameEngine(
def reset(): Unit = synchronized {
currentContext = GameContext.initial
pendingDrawOffer = None
- invoker.clear()
+ pendingTakebackRequest = None
+ redoStack = Nil
+ stopClock()
+ clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
notifyObservers(BoardResetEvent(currentContext))
}
- /** Resign the game on behalf of the side to move. */
- def resign(): Unit = synchronized {
- if currentContext.result.isEmpty then
- val winner = currentContext.turn.opposite
- currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
- invoker.clear()
- }
-
/** Apply a draw result directly (for agreement, fifty-move claim, etc.). */
def applyDraw(reason: DrawReason): Unit = synchronized {
if currentContext.result.isEmpty then
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
- invoker.clear()
+ stopClock()
+ redoStack = Nil
notifyObservers(DrawEvent(currentContext, reason))
}
- /** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
- def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
+ /** Inject clock state directly (for testing). */
+ private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
+
+ // ──── Clock helpers ────
+
+ private def advanceClock(movedColor: Color): Unit =
+ clockState.foreach { cs =>
+ cs.afterMove(movedColor, Instant.now()) match
+ case Left(flagged) => clockState = None; cancelScheduled(); handleTimeFlag(flagged)
+ case Right(updated) => clockState = Some(updated); scheduleExpiryCheck(updated)
+ }
+
+ private def handleTimeFlag(flagged: Color): Unit =
+ val result =
+ if ruleSet.isInsufficientMaterial(currentContext) then GameResult.Draw(DrawReason.InsufficientMaterial)
+ else GameResult.Win(flagged.opposite, WinReason.TimeControl)
+ currentContext = currentContext.withResult(Some(result))
+ pendingDrawOffer = None
+ pendingTakebackRequest = None
+ redoStack = Nil
+ notifyObservers(TimeFlagEvent(currentContext, flagged))
+
+ private def scheduleExpiryCheck(cs: ClockState): Unit =
+ cancelScheduled()
+ cs match
+ case live: LiveClockState =>
+ val delayMs = math.max(0L, live.remainingMs(live.activeColor, Instant.now()))
+ val future = scheduler.schedule(
+ new Runnable { def run(): Unit = checkClockExpiry() },
+ delayMs,
+ TimeUnit.MILLISECONDS,
+ )
+ scheduledCheck = Some(future)
+ case _ => ()
+
+ private def cancelScheduled(): Unit =
+ scheduledCheck.foreach(_.cancel(false))
+ scheduledCheck = None
+
+ private def stopClock(): Unit =
+ cancelScheduled()
+ clockState = None
+
+ private def checkClockExpiry(): Unit = synchronized {
+ if currentContext.result.isEmpty then
+ clockState.foreach { cs =>
+ if cs.remainingMs(cs.activeColor, Instant.now()) <= 0 then
+ clockState = None
+ handleTimeFlag(cs.activeColor)
+ }
+ }
// ──── Private helpers ────
private def executeMove(move: Move): Unit =
+ if !isRedoing then
+ redoStack = Nil
+ pendingTakebackRequest = None
+
val contextBefore = currentContext
val nextContext = ruleSet.applyMove(currentContext)(move)
val captured = computeCaptured(currentContext, move)
-
- val cmd = MoveCommand(
- from = move.from,
- to = move.to,
- moveResult = Some(MoveResult.Successful(nextContext, captured)),
- previousContext = Some(contextBefore),
- notation = translateMoveToNotation(move, contextBefore.board),
- )
- invoker.execute(cmd)
+ val notation = translateMoveToNotation(move, contextBefore.board)
currentContext = nextContext
+ advanceClock(contextBefore.turn)
+
notifyObservers(
MoveExecutedEvent(
currentContext,
@@ -304,25 +390,28 @@ class GameEngine(
),
)
- if ruleSet.isCheckmate(currentContext) then
- val winner = currentContext.turn.opposite
- currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
- notifyObservers(CheckmateEvent(currentContext, winner))
- invoker.clear()
- else if ruleSet.isStalemate(currentContext) then
- currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
- notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
- invoker.clear()
- else if ruleSet.isInsufficientMaterial(currentContext) then
- currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
- notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
- invoker.clear()
- else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
+ val status = ruleSet.postMoveStatus(currentContext)
+ if currentContext.result.isEmpty then
+ if status.isCheckmate then
+ val winner = currentContext.turn.opposite
+ currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
+ cancelScheduled()
+ notifyObservers(CheckmateEvent(currentContext, winner))
+ redoStack = Nil
+ else if status.isStalemate then
+ currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
+ cancelScheduled()
+ notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
+ redoStack = Nil
+ else if status.isInsufficientMaterial then
+ currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
+ cancelScheduled()
+ notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
+ redoStack = Nil
+ else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
- if ruleSet.isThreefoldRepetition(currentContext) then
- notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
- else requestBotMoveIfNeeded()
+ if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -373,73 +462,67 @@ class GameEngine(
case _ =>
context.board.pieceAt(move.to)
- /** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
- */
- private def requestBotMoveIfNeeded(): Unit =
- val pendingBotMove = synchronized {
- participants.get(currentContext.turn) match
- case Some(BotParticipant(bot)) => Some((bot, currentContext))
- case _ => None
- }
-
- pendingBotMove.foreach { case (bot, contextAtRequest) =>
- Future {
- bot.nextMove(contextAtRequest) match
- case Some(move) => applyBotMove(move)
- case None => handleBotNoMove()
- }
- }
-
- private def applyBotMove(move: Move): Unit =
- synchronized {
- val color = currentContext.turn
- val from = move.from
- val to = move.to
- currentContext.board.pieceAt(from) match
- case Some(piece) if piece.color == color =>
- val legal = ruleSet.legalMoves(currentContext)(from)
- legal.find(m => m.to == to && m.moveType == move.moveType) match
- case Some(legalMove) => executeMove(legalMove)
- case None =>
- notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
- case _ =>
- notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
- }
-
- private def handleBotNoMove(): Unit =
- synchronized {
- if ruleSet.isCheckmate(currentContext) then
- val winner = currentContext.turn.opposite
- notifyObservers(CheckmateEvent(currentContext, winner))
- else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
- }
+ private def replayContextFromMoves(moves: List[Move]): GameContext =
+ moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
private def performUndo(): Unit =
- if invoker.canUndo then
- val cmd = invoker.history(invoker.getCurrentIndex)
- (cmd: @unchecked) match
- case moveCmd: MoveCommand =>
- moveCmd.previousContext.foreach(currentContext = _)
- invoker.undo()
- notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
- else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ if currentContext.moves.isEmpty then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ else
+ val lastMove = currentContext.moves.last
+ val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
+ val notation = translateMoveToNotation(lastMove, prevCtx.board)
+ redoStack = lastMove :: redoStack
+ currentContext = prevCtx
+ notifyObservers(MoveUndoneEvent(currentContext, notation))
private def performRedo(): Unit =
- if invoker.canRedo then
- val cmd = invoker.history(invoker.getCurrentIndex + 1)
- (cmd: @unchecked) match
- case moveCmd: MoveCommand =>
- for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
- currentContext = nextCtx
- invoker.redo()
- val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
- notifyObservers(
- MoveRedoneEvent(
- currentContext,
- moveCmd.notation,
- moveCmd.from.toString,
- moveCmd.to.toString,
- capturedDesc,
- ),
- )
- else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
+ if redoStack.isEmpty then notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
+ else
+ val move = redoStack.head
+ redoStack = redoStack.tail
+ isRedoing = true
+ executeMove(move)
+ isRedoing = false
+
+ def requestTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else if currentContext.moves.isEmpty then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
+ else
+ pendingTakebackRequest match
+ case Some(_) =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.TakebackRequestPending))
+ case None =>
+ pendingTakebackRequest = Some(color)
+ notifyObservers(TakebackRequestedEvent(currentContext, color))
+ }
+
+ def acceptTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else
+ pendingTakebackRequest match
+ case None =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToAccept))
+ case Some(requester) if requester == color =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotAcceptOwnTakebackRequest))
+ case Some(_) =>
+ pendingTakebackRequest = None
+ performUndo()
+ }
+
+ def declineTakeback(color: Color): Unit = synchronized {
+ if currentContext.result.isDefined then
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
+ else
+ pendingTakebackRequest match
+ case None =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToDecline))
+ case Some(requester) if requester == color =>
+ notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotDeclineOwnTakebackRequest))
+ case Some(_) =>
+ pendingTakebackRequest = None
+ notifyObservers(TakebackDeclinedEvent(currentContext, color))
+ }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala b/modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala
new file mode 100644
index 0000000..05e2e9c
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/grpc/CoordinatorServiceHandler.scala
@@ -0,0 +1,66 @@
+package de.nowchess.chess.grpc
+
+import jakarta.inject.Inject
+import jakarta.inject.Singleton
+import io.quarkus.grpc.GrpcService
+import scala.compiletime.uninitialized
+import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
+import de.nowchess.chess.redis.GameRedisSubscriberManager
+import io.grpc.stub.StreamObserver
+import scala.jdk.CollectionConverters.*
+
+@GrpcService
+@Singleton
+class CoordinatorServiceHandler extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var gameSubscriberManager: GameRedisSubscriberManager = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def batchResubscribeGames(
+ request: BatchResubscribeRequest,
+ responseObserver: StreamObserver[BatchResubscribeResponse],
+ ): Unit =
+ val count = gameSubscriberManager.batchResubscribeGames(request.getGameIdsList)
+ val response = BatchResubscribeResponse
+ .newBuilder()
+ .setSubscribedCount(count)
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def unsubscribeGames(
+ request: UnsubscribeGamesRequest,
+ responseObserver: StreamObserver[UnsubscribeGamesResponse],
+ ): Unit =
+ val count = gameSubscriberManager.unsubscribeGames(request.getGameIdsList)
+ val response = UnsubscribeGamesResponse
+ .newBuilder()
+ .setUnsubscribedCount(count)
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def evictGames(
+ request: EvictGamesRequest,
+ responseObserver: StreamObserver[EvictGamesResponse],
+ ): Unit =
+ val count = gameSubscriberManager.evictGames(request.getGameIdsList)
+ val response = EvictGamesResponse
+ .newBuilder()
+ .setEvictedCount(count)
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
+
+ override def drainInstance(
+ request: DrainInstanceRequest,
+ responseObserver: StreamObserver[DrainInstanceResponse],
+ ): Unit =
+ val migrated = gameSubscriberManager.drainInstance()
+ val response = DrainInstanceResponse
+ .newBuilder()
+ .setGamesMigrated(migrated)
+ .build()
+ responseObserver.onNext(response)
+ responseObserver.onCompleted()
diff --git a/modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala b/modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala
new file mode 100644
index 0000000..eaca510
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/grpc/CoreProtoMapper.scala
@@ -0,0 +1,161 @@
+package de.nowchess.chess.grpc
+
+import de.nowchess.api.board.*
+import de.nowchess.api.board.CastlingRights as DomainCastlingRights
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
+import de.nowchess.api.grpc.ProtoMapperBase
+import de.nowchess.api.move.{Move as DomainMove, MoveType}
+import de.nowchess.core.proto.*
+
+import scala.jdk.CollectionConverters.*
+
+object CoreProtoMapper
+ extends ProtoMapperBase[
+ ProtoColor,
+ ProtoPieceType,
+ ProtoMoveKind,
+ ProtoMove,
+ ProtoSquarePiece,
+ java.util.List[ProtoSquarePiece],
+ ProtoCastlingRights,
+ ProtoGameResultKind,
+ ProtoGameContext,
+ ]:
+ private val (colorTo, colorFrom) = ProtoMapperBase.colorConversions(ProtoColor.WHITE, ProtoColor.BLACK)
+ private val (pieceTypeTo, pieceTypeFrom) = ProtoMapperBase.pieceTypeConversions(
+ ProtoPieceType.PAWN,
+ ProtoPieceType.KNIGHT,
+ ProtoPieceType.BISHOP,
+ ProtoPieceType.ROOK,
+ ProtoPieceType.QUEEN,
+ ProtoPieceType.KING,
+ )
+ private val (moveKindTo, moveKindFrom) = ProtoMapperBase.moveKindConversions(
+ ProtoMoveKind.QUIET,
+ ProtoMoveKind.CAPTURE,
+ ProtoMoveKind.CASTLE_KINGSIDE,
+ ProtoMoveKind.CASTLE_QUEENSIDE,
+ ProtoMoveKind.EN_PASSANT,
+ ProtoMoveKind.PROMO_QUEEN,
+ ProtoMoveKind.PROMO_ROOK,
+ ProtoMoveKind.PROMO_BISHOP,
+ ProtoMoveKind.PROMO_KNIGHT,
+ )
+
+ override def toProtoColor(c: Color): ProtoColor = colorTo(c)
+ override def fromProtoColor(c: ProtoColor): Color = colorFrom(c)
+ override def toProtoPieceType(pt: PieceType): ProtoPieceType = pieceTypeTo(pt)
+ override def fromProtoPieceType(pt: ProtoPieceType): PieceType = pieceTypeFrom(pt)
+ override def toProtoMoveKind(mt: MoveType): ProtoMoveKind = moveKindTo(mt)
+ override def fromProtoMoveKind(k: ProtoMoveKind): MoveType = moveKindFrom(k)
+
+ override def toProtoMove(m: DomainMove): ProtoMove =
+ ProtoMove
+ .newBuilder()
+ .setFrom(m.from.toString)
+ .setTo(m.to.toString)
+ .setMoveKind(toProtoMoveKind(m.moveType))
+ .build()
+
+ override def fromProtoMove(m: ProtoMove): Option[DomainMove] =
+ for
+ from <- Square.fromAlgebraic(m.getFrom)
+ to <- Square.fromAlgebraic(m.getTo)
+ yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
+
+ override def toProtoSquarePiece(sq: Square, piece: Piece): ProtoSquarePiece =
+ ProtoSquarePiece
+ .newBuilder()
+ .setSquare(sq.toString)
+ .setPiece(
+ ProtoPiece
+ .newBuilder()
+ .setColor(toProtoColor(piece.color))
+ .setPieceType(toProtoPieceType(piece.pieceType))
+ .build(),
+ )
+ .build()
+
+ override def fromProtoSquarePiece(sp: ProtoSquarePiece): Option[(Square, Piece)] =
+ Square
+ .fromAlgebraic(sp.getSquare)
+ .map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))
+
+ override def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
+ board.pieces
+ .map((sq, piece) => toProtoSquarePiece(sq, piece))
+ .toSeq
+ .asJava
+
+ override def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
+ Board(
+ pieces.asScala
+ .flatMap(fromProtoSquarePiece)
+ .toMap,
+ )
+
+ override def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
+ case None => ProtoGameResultKind.ONGOING
+ case Some(GameResult.Win(Color.White, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_W
+ case Some(GameResult.Win(Color.Black, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_B
+ case Some(GameResult.Win(Color.White, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_W
+ case Some(GameResult.Win(Color.Black, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_B
+ case Some(GameResult.Win(Color.White, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_W
+ case Some(GameResult.Win(Color.Black, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_B
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => ProtoGameResultKind.DRAW_STALEMATE
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => ProtoGameResultKind.DRAW_INSUFFICIENT
+ case Some(GameResult.Draw(DrawReason.FiftyMoveRule)) => ProtoGameResultKind.DRAW_FIFTY_MOVE
+ case Some(GameResult.Draw(DrawReason.ThreefoldRepetition)) => ProtoGameResultKind.DRAW_THREEFOLD
+ case Some(GameResult.Draw(DrawReason.Agreement)) => ProtoGameResultKind.DRAW_AGREEMENT
+
+ override def fromProtoResultKind(k: ProtoGameResultKind): Option[GameResult] = k match
+ case ProtoGameResultKind.ONGOING => None
+ case ProtoGameResultKind.WIN_CHECKMATE_W => Some(GameResult.Win(Color.White, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_CHECKMATE_B => Some(GameResult.Win(Color.Black, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_RESIGN_W => Some(GameResult.Win(Color.White, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_RESIGN_B => Some(GameResult.Win(Color.Black, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_TIME_W => Some(GameResult.Win(Color.White, WinReason.TimeControl))
+ case ProtoGameResultKind.WIN_TIME_B => Some(GameResult.Win(Color.Black, WinReason.TimeControl))
+ case ProtoGameResultKind.DRAW_STALEMATE => Some(GameResult.Draw(DrawReason.Stalemate))
+ case ProtoGameResultKind.DRAW_INSUFFICIENT => Some(GameResult.Draw(DrawReason.InsufficientMaterial))
+ case ProtoGameResultKind.DRAW_FIFTY_MOVE => Some(GameResult.Draw(DrawReason.FiftyMoveRule))
+ case ProtoGameResultKind.DRAW_THREEFOLD => Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
+ case ProtoGameResultKind.DRAW_AGREEMENT => Some(GameResult.Draw(DrawReason.Agreement))
+ case _ => None
+
+ override def toProtoCastlingRights(cr: DomainCastlingRights): ProtoCastlingRights =
+ ProtoCastlingRights
+ .newBuilder()
+ .setWhiteKingSide(cr.whiteKingSide)
+ .setWhiteQueenSide(cr.whiteQueenSide)
+ .setBlackKingSide(cr.blackKingSide)
+ .setBlackQueenSide(cr.blackQueenSide)
+ .build()
+
+ override def fromProtoCastlingRights(pcr: ProtoCastlingRights): DomainCastlingRights =
+ DomainCastlingRights(pcr.getWhiteKingSide, pcr.getWhiteQueenSide, pcr.getBlackKingSide, pcr.getBlackQueenSide)
+
+ override def toProtoGameContext(ctx: GameContext): ProtoGameContext =
+ ProtoGameContext
+ .newBuilder()
+ .addAllBoard(toProtoBoard(ctx.board))
+ .setTurn(toProtoColor(ctx.turn))
+ .setCastlingRights(toProtoCastlingRights(ctx.castlingRights))
+ .setEnPassantSquare(ctx.enPassantSquare.map(_.toString).getOrElse(""))
+ .setHalfMoveClock(ctx.halfMoveClock)
+ .addAllMoves(ctx.moves.map(toProtoMove).asJava)
+ .setResult(toProtoResultKind(ctx.result))
+ .addAllInitialBoard(toProtoBoard(ctx.initialBoard))
+ .build()
+
+ override def fromProtoGameContext(p: ProtoGameContext): GameContext =
+ GameContext(
+ board = fromProtoBoard(p.getBoardList),
+ turn = fromProtoColor(p.getTurn),
+ castlingRights = fromProtoCastlingRights(p.getCastlingRights),
+ enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
+ halfMoveClock = p.getHalfMoveClock,
+ moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
+ result = fromProtoResultKind(p.getResult),
+ initialBoard = fromProtoBoard(p.getInitialBoardList),
+ )
diff --git a/modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala b/modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala
new file mode 100644
index 0000000..126baee
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/grpc/IoGrpcClientWrapper.scala
@@ -0,0 +1,33 @@
+package de.nowchess.chess.grpc
+
+import de.nowchess.api.game.GameContext
+import de.nowchess.chess.client.CombinedExportResponse
+import de.nowchess.core.proto.*
+import io.quarkus.grpc.GrpcClient
+import jakarta.enterprise.context.ApplicationScoped
+
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class IoGrpcClientWrapper:
+
+ // scalafix:off DisableSyntax.var
+ @GrpcClient("io-grpc")
+ var stub: IoServiceGrpc.IoServiceBlockingStub = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ def exportCombined(ctx: GameContext): CombinedExportResponse =
+ val combined = stub.exportCombined(CoreProtoMapper.toProtoGameContext(ctx))
+ CombinedExportResponse(combined.getFen, combined.getPgn)
+
+ def importFen(fen: String): GameContext =
+ CoreProtoMapper.fromProtoGameContext(stub.importFen(ProtoImportFenRequest.newBuilder().setFen(fen).build()))
+
+ def importPgn(pgn: String): GameContext =
+ CoreProtoMapper.fromProtoGameContext(stub.importPgn(ProtoImportPgnRequest.newBuilder().setPgn(pgn).build()))
+
+ def exportFen(ctx: GameContext): String =
+ stub.exportFen(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def exportPgn(ctx: GameContext): String =
+ stub.exportPgn(CoreProtoMapper.toProtoGameContext(ctx)).getValue
diff --git a/modules/core/src/main/scala/de/nowchess/chess/grpc/RuleSetGrpcAdapter.scala b/modules/core/src/main/scala/de/nowchess/chess/grpc/RuleSetGrpcAdapter.scala
new file mode 100644
index 0000000..f121ff0
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/grpc/RuleSetGrpcAdapter.scala
@@ -0,0 +1,74 @@
+package de.nowchess.chess.grpc
+
+import de.nowchess.api.board.Square
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.move.Move
+import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
+import de.nowchess.core.proto.*
+import io.quarkus.grpc.GrpcClient
+import jakarta.enterprise.context.ApplicationScoped
+
+import scala.compiletime.uninitialized
+import scala.jdk.CollectionConverters.*
+
+@ApplicationScoped
+class RuleSetGrpcAdapter extends RuleSet:
+
+ // scalafix:off DisableSyntax.var
+ @GrpcClient("rule-grpc")
+ var stub: RuleServiceGrpc.RuleServiceBlockingStub = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ def candidateMoves(ctx: GameContext)(sq: Square): List[Move] =
+ val req =
+ ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
+ stub.candidateMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
+
+ def legalMoves(ctx: GameContext)(sq: Square): List[Move] =
+ val req =
+ ProtoSquareRequest.newBuilder().setContext(CoreProtoMapper.toProtoGameContext(ctx)).setSquare(sq.toString).build()
+ stub.legalMoves(req).getMovesList.asScala.flatMap(CoreProtoMapper.fromProtoMove).toList
+
+ def allLegalMoves(ctx: GameContext): List[Move] =
+ stub
+ .allLegalMoves(CoreProtoMapper.toProtoGameContext(ctx))
+ .getMovesList
+ .asScala
+ .flatMap(CoreProtoMapper.fromProtoMove)
+ .toList
+
+ def isCheck(ctx: GameContext): Boolean =
+ stub.isCheck(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def isCheckmate(ctx: GameContext): Boolean =
+ stub.isCheckmate(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def isStalemate(ctx: GameContext): Boolean =
+ stub.isStalemate(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def isInsufficientMaterial(ctx: GameContext): Boolean =
+ stub.isInsufficientMaterial(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def isFiftyMoveRule(ctx: GameContext): Boolean =
+ stub.isFiftyMoveRule(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def isThreefoldRepetition(ctx: GameContext): Boolean =
+ stub.isThreefoldRepetition(CoreProtoMapper.toProtoGameContext(ctx)).getValue
+
+ def applyMove(ctx: GameContext)(move: Move): GameContext =
+ val req = ProtoMoveRequest
+ .newBuilder()
+ .setContext(CoreProtoMapper.toProtoGameContext(ctx))
+ .setMove(CoreProtoMapper.toProtoMove(move))
+ .build()
+ CoreProtoMapper.fromProtoGameContext(stub.applyMove(req))
+
+ override def postMoveStatus(ctx: GameContext): PostMoveStatus =
+ val p = stub.postMoveStatus(CoreProtoMapper.toProtoGameContext(ctx))
+ PostMoveStatus(
+ p.getIsCheckmate,
+ p.getIsStalemate,
+ p.getIsInsufficientMaterial,
+ p.getIsCheck,
+ p.getIsThreefoldRepetition,
+ )
diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala
deleted file mode 100644
index 969d614..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala
+++ /dev/null
@@ -1,19 +0,0 @@
-package de.nowchess.chess.json
-
-import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
-import com.fasterxml.jackson.databind.node.ObjectNode
-import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
-import de.nowchess.api.move.{MoveType, PromotionPiece}
-
-class MoveTypeDeserializer extends JsonDeserializer[MoveType]:
- // scalafix:off DisableSyntax.throw
- override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType =
- val node = p.getCodec.readTree[ObjectNode](p)
- node.get("type").asText() match
- case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false))
- case "castleKingside" => MoveType.CastleKingside
- case "castleQueenside" => MoveType.CastleQueenside
- case "enPassant" => MoveType.EnPassant
- case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText()))
- case t => throw new JsonParseException(p, s"Unknown move type: $t")
- // scalafix:on DisableSyntax.throw
diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala
deleted file mode 100644
index 8d90eba..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.nowchess.chess.json
-
-import com.fasterxml.jackson.core.JsonGenerator
-import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
-import de.nowchess.api.move.MoveType
-
-class MoveTypeSerializer extends JsonSerializer[MoveType]:
- override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit =
- gen.writeStartObject()
- value match
- case MoveType.Normal(isCapture) =>
- gen.writeStringField("type", "normal")
- gen.writeBooleanField("isCapture", isCapture)
- case MoveType.CastleKingside =>
- gen.writeStringField("type", "castleKingside")
- case MoveType.CastleQueenside =>
- gen.writeStringField("type", "castleQueenside")
- case MoveType.EnPassant =>
- gen.writeStringField("type", "enPassant")
- case MoveType.Promotion(piece) =>
- gen.writeStringField("type", "promotion")
- gen.writeStringField("piece", piece.toString)
- gen.writeEndObject()
diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala
deleted file mode 100644
index 98240ac..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.nowchess.chess.json
-
-import com.fasterxml.jackson.core.JsonGenerator
-import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
-import de.nowchess.api.board.Square
-
-class SquareSerializer extends JsonSerializer[Square]:
- override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
- gen.writeString(value.toString)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
index dce32ab..4418592 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/observer/InvalidMoveReason.scala
@@ -19,3 +19,8 @@ enum InvalidMoveReason:
case CannotAcceptOwnDrawOffer
case NoDrawOfferToDecline
case CannotDeclineOwnDrawOffer
+ case TakebackRequestPending
+ case NoTakebackRequestToAccept
+ case CannotAcceptOwnTakebackRequest
+ case NoTakebackRequestToDecline
+ case CannotDeclineOwnTakebackRequest
diff --git a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
index 56e4995..a52df65 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala
@@ -60,15 +60,6 @@ case class MoveUndoneEvent(
pgnNotation: String,
) extends GameEvent
-/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
-case class MoveRedoneEvent(
- context: GameContext,
- pgnNotation: String,
- fromSquare: String,
- toSquare: String,
- capturedPiece: Option[String],
-) extends GameEvent
-
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
case class PgnLoadedEvent(
context: GameContext,
@@ -92,6 +83,24 @@ case class DrawOfferDeclinedEvent(
declinedBy: Color,
) extends GameEvent
+/** Fired when a player's clock expires. */
+case class TimeFlagEvent(
+ context: GameContext,
+ flaggedColor: Color,
+) extends GameEvent
+
+/** Fired when a player requests a takeback of the last move. */
+case class TakebackRequestedEvent(
+ context: GameContext,
+ requestedBy: Color,
+) extends GameEvent
+
+/** Fired when a player declines a takeback request. */
+case class TakebackDeclinedEvent(
+ context: GameContext,
+ declinedBy: Color,
+) extends GameEvent
+
/** Observer trait: implement to receive game state updates. */
trait Observer:
def onGameEvent(event: GameEvent): Unit
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala
new file mode 100644
index 0000000..70adcf9
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/C2sMessage.scala
@@ -0,0 +1,8 @@
+package de.nowchess.chess.redis
+
+sealed trait C2sMessage
+
+object C2sMessage:
+ case object Connected extends C2sMessage
+ case class Move(uci: String, playerId: Option[String] = None) extends C2sMessage
+ case object Ping extends C2sMessage
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
new file mode 100644
index 0000000..f8bd90f
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisPublisher.scala
@@ -0,0 +1,79 @@
+package de.nowchess.chess.redis
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.dto.{GameStateEventDto, GameWritebackEventDto}
+import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
+import de.nowchess.api.board.Color
+import de.nowchess.chess.observer.{GameEvent, Observer}
+import de.nowchess.chess.registry.GameRegistry
+import de.nowchess.chess.resource.GameDtoMapper
+import io.quarkus.redis.datasource.RedisDataSource
+
+class GameRedisPublisher(
+ gameId: String,
+ registry: GameRegistry,
+ redis: RedisDataSource,
+ objectMapper: ObjectMapper,
+ s2cTopicName: String,
+ writebackEmit: String => Unit,
+ ioClient: IoGrpcClientWrapper,
+ onGameOver: String => Unit,
+) extends Observer:
+
+ def onGameEvent(event: GameEvent): Unit =
+ registry.get(gameId).foreach { entry =>
+ val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
+ val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
+ redis.pubsub(classOf[String]).publish(s2cTopicName, json)
+
+ val clock = entry.engine.currentClockState
+ val wb = GameWritebackEventDto(
+ gameId = gameId,
+ fen = dto.fen,
+ pgn = dto.pgn,
+ moveCount = entry.engine.context.moves.size,
+ whiteId = entry.white.id.value,
+ whiteName = entry.white.displayName,
+ blackId = entry.black.id.value,
+ blackName = entry.black.displayName,
+ mode = entry.mode.toString,
+ resigned = entry.resigned,
+ limitSeconds = entry.engine.timeControl match {
+ case de.nowchess.api.game.TimeControl.Clock(l, _) => Some(l); case _ => None
+ },
+ incrementSeconds = entry.engine.timeControl match {
+ case de.nowchess.api.game.TimeControl.Clock(_, i) => Some(i); case _ => None
+ },
+ daysPerMove = entry.engine.timeControl match {
+ case de.nowchess.api.game.TimeControl.Correspondence(d) => Some(d); case _ => None
+ },
+ whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
+ blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
+ incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
+ clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
+ clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
+ clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
+ pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
+ result = entry.engine.context.result.map {
+ case GameResult.Win(Color.White, _) => "white"
+ case GameResult.Win(Color.Black, _) => "black"
+ case GameResult.Draw(_) => "draw"
+ },
+ terminationReason = entry.engine.context.result.map {
+ case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
+ case GameResult.Win(_, WinReason.Resignation) => "resignation"
+ case GameResult.Win(_, WinReason.TimeControl) => "timeout"
+ case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
+ case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
+ case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
+ case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
+ case GameResult.Draw(DrawReason.Agreement) => "agreement"
+ },
+ redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
+ pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+ writebackEmit(objectMapper.writeValueAsString(wb))
+ if entry.engine.context.result.isDefined then onGameOver(gameId)
+ }
diff --git a/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala
new file mode 100644
index 0000000..05888ea
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala
@@ -0,0 +1,149 @@
+package de.nowchess.chess.redis
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.board.Color
+import de.nowchess.api.dto.GameFullEventDto
+import de.nowchess.api.game.GameMode
+import de.nowchess.chess.config.RedisConfig
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.observer.Observer
+import de.nowchess.chess.registry.GameRegistry
+import de.nowchess.chess.resource.GameDtoMapper
+import de.nowchess.chess.service.InstanceHeartbeatService
+import io.quarkus.redis.datasource.RedisDataSource
+import io.quarkus.redis.datasource.pubsub.PubSubCommands
+import jakarta.annotation.PreDestroy
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Instance
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+
+@ApplicationScoped
+class GameRedisSubscriberManager:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redis: RedisDataSource = uninitialized
+ @Inject var registry: GameRegistry = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var ioClient: IoGrpcClientWrapper = uninitialized
+ @Inject var heartbeatServiceInstance: Instance[InstanceHeartbeatService] = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private def heartbeatServiceOpt: Option[InstanceHeartbeatService] =
+ if heartbeatServiceInstance.isUnsatisfied then None
+ else Some(heartbeatServiceInstance.get())
+
+ private val c2sListeners = new ConcurrentHashMap[String, PubSubCommands.RedisSubscriber]()
+ private val s2cObservers = new ConcurrentHashMap[String, Observer]()
+
+ private def c2sTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:c2s"
+
+ private def s2cTopicName(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:s2c"
+
+ def subscribeGame(gameId: String): Unit =
+ try
+ val handler: Consumer[String] = msg => handleC2sMessage(gameId, msg)
+ val subscriber = redis.pubsub(classOf[String]).subscribe(c2sTopic(gameId), handler)
+ c2sListeners.put(gameId, subscriber)
+
+ val writebackFn: String => Unit = json => redis.pubsub(classOf[String]).publish("game-writeback", json)
+ val obs = new GameRedisPublisher(
+ gameId,
+ registry,
+ redis,
+ objectMapper,
+ s2cTopicName(gameId),
+ writebackFn,
+ ioClient,
+ unsubscribeGame,
+ )
+ s2cObservers.put(gameId, obs)
+ registry.get(gameId).foreach(_.engine.subscribe(obs))
+
+ heartbeatServiceOpt.foreach(_.addGameSubscription(gameId))
+ catch
+ case e: Exception =>
+ System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
+ ()
+
+ def unsubscribeGame(gameId: String): Unit =
+ Option(c2sListeners.remove(gameId)).foreach { subscriber =>
+ subscriber.unsubscribe(c2sTopic(gameId))
+ }
+ Option(s2cObservers.remove(gameId)).foreach { obs =>
+ registry.get(gameId).foreach(_.engine.unsubscribe(obs))
+ }
+
+ heartbeatServiceOpt.foreach(_.removeGameSubscription(gameId))
+
+ private def handleC2sMessage(gameId: String, msg: String): Unit =
+ parseC2sMessage(msg) match
+ case Some(C2sMessage.Connected) => handleConnected(gameId)
+ case Some(C2sMessage.Move(uci, playerId)) => handleMove(gameId, uci, playerId)
+ case Some(C2sMessage.Ping) => ()
+ case None => ()
+
+ private def handleConnected(gameId: String): Unit =
+ registry.get(gameId).foreach { entry =>
+ val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
+ val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
+ redis.pubsub(classOf[String]).publish(s2cTopicName(gameId), json)
+ }
+
+ private def handleMove(gameId: String, uci: String, playerId: Option[String]): Unit =
+ registry.get(gameId).foreach { entry =>
+ entry.mode match
+ case GameMode.Open => entry.engine.processUserInput(uci)
+ case GameMode.Authenticated =>
+ playerId match
+ case None => ()
+ case Some(pid) =>
+ val turn = entry.engine.context.turn
+ val authorised =
+ (entry.white.id.value == pid && turn == Color.White) ||
+ (entry.black.id.value == pid && turn == Color.Black)
+ if authorised then entry.engine.processUserInput(uci)
+ }
+
+ private def parseC2sMessage(msg: String): Option[C2sMessage] =
+ Try(objectMapper.readTree(msg)).toOption.flatMap { node =>
+ Option(node.get("type")).map(_.asText()).flatMap {
+ case "CONNECTED" => Some(C2sMessage.Connected)
+ case "MOVE" =>
+ Option(node.get("uci")).map { u =>
+ val pid = Option(node.get("playerId")).map(_.asText()).filter(_.nonEmpty)
+ C2sMessage.Move(u.asText(), pid)
+ }
+ case "PING" => Some(C2sMessage.Ping)
+ case _ => None
+ }
+ }
+
+ def batchResubscribeGames(gameIds: java.util.List[String]): Int =
+ gameIds.forEach(subscribeGame)
+ gameIds.size()
+
+ def unsubscribeGames(gameIds: java.util.List[String]): Int =
+ gameIds.forEach(unsubscribeGame)
+ gameIds.size()
+
+ def evictGames(gameIds: java.util.List[String]): Int =
+ gameIds.forEach(unsubscribeGame)
+ gameIds.size()
+
+ def drainInstance(): Int =
+ val gameIds = new java.util.ArrayList(c2sListeners.keySet())
+ val count = gameIds.size()
+ gameIds.forEach(unsubscribeGame)
+ count
+
+ @PreDestroy
+ def cleanup(): Unit =
+ c2sListeners.forEach((gameId, subscriber) => subscriber.unsubscribe(c2sTopic(gameId)))
+ s2cObservers.forEach((gameId, obs) => registry.get(gameId).foreach(_.engine.unsubscribe(obs)))
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala
new file mode 100644
index 0000000..05f14d2
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameCacheDto.scala
@@ -0,0 +1,25 @@
+package de.nowchess.chess.registry
+
+case class GameCacheDto(
+ gameId: String,
+ whiteId: String,
+ whiteName: String,
+ blackId: String,
+ blackName: String,
+ mode: String,
+ pgn: String,
+ fen: String,
+ resigned: Boolean,
+ limitSeconds: Option[Int],
+ incrementSeconds: Option[Int],
+ daysPerMove: Option[Int],
+ whiteRemainingMs: Option[Long],
+ blackRemainingMs: Option[Long],
+ incrementMs: Option[Long],
+ clockLastTickAt: Option[Long],
+ clockMoveDeadline: Option[Long],
+ clockActiveColor: Option[String],
+ pendingDrawOffer: Option[String],
+ redoStack: List[String] = Nil,
+ pendingTakebackRequest: Option[String] = None,
+)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala
index 7dd09fb..2e85048 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/GameEntry.scala
@@ -1,6 +1,7 @@
package de.nowchess.chess.registry
import de.nowchess.api.board.Color
+import de.nowchess.api.game.GameMode
import de.nowchess.api.player.PlayerInfo
import de.nowchess.chess.engine.GameEngine
@@ -10,4 +11,5 @@ final case class GameEntry(
white: PlayerInfo,
black: PlayerInfo,
resigned: Boolean = false,
+ mode: GameMode = GameMode.Open,
)
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
deleted file mode 100644
index 61668d4..0000000
--- a/modules/core/src/main/scala/de/nowchess/chess/registry/GameRegistryImpl.scala
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.nowchess.chess.registry
-
-import jakarta.enterprise.context.ApplicationScoped
-import java.security.SecureRandom
-import java.util.concurrent.ConcurrentHashMap
-
-@ApplicationScoped
-class GameRegistryImpl extends GameRegistry:
- private val games = ConcurrentHashMap[String, GameEntry]()
- private val rng = new SecureRandom()
-
- def store(entry: GameEntry): Unit =
- games.put(entry.gameId, entry)
-
- def get(gameId: String): Option[GameEntry] =
- Option(games.get(gameId))
-
- def update(entry: GameEntry): Unit =
- games.put(entry.gameId, entry)
-
- def generateId(): String =
- val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
- Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
diff --git a/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala b/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala
new file mode 100644
index 0000000..49152b2
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/registry/RedisGameRegistry.scala
@@ -0,0 +1,204 @@
+package de.nowchess.chess.registry
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.board.Color
+import de.nowchess.api.game.{ClockState, CorrespondenceClockState, GameContext, GameMode, LiveClockState, TimeControl}
+import de.nowchess.api.move.Move
+import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.client.{GameRecordDto, StoreServiceClient}
+import de.nowchess.chess.controller.Parser
+import de.nowchess.chess.engine.GameEngine
+import de.nowchess.chess.grpc.RuleSetGrpcAdapter
+import de.nowchess.chess.config.RedisConfig
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.resource.GameDtoMapper
+import io.quarkus.redis.datasource.RedisDataSource
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.nio.charset.StandardCharsets
+import java.security.{MessageDigest, SecureRandom}
+import java.time.Instant
+import java.util.concurrent.ConcurrentHashMap
+
+@ApplicationScoped
+class RedisGameRegistry extends GameRegistry:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var redis: RedisDataSource = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var ioClient: IoGrpcClientWrapper = uninitialized
+ @Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
+ @Inject @RestClient var storeClient: StoreServiceClient = uninitialized
+ // scalafix:on
+
+ private val localEngines = ConcurrentHashMap[String, GameEntry]()
+ private val rng = new SecureRandom()
+
+ private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
+
+ def generateId(): String =
+ val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString
+
+ def store(entry: GameEntry): Unit =
+ localEngines.put(entry.gameId, entry)
+ val combined = ioClient.exportCombined(entry.engine.context)
+ redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
+
+ def get(gameId: String): Option[GameEntry] =
+ Option(localEngines.get(gameId)) match
+ case Some(localEntry) =>
+ readRedisDto(gameId).flatMap(dto => Try(reconstruct(dto)).toOption) match
+ case Some(redisEntry) if !sameSnapshot(localEntry, redisEntry) =>
+ localEngines.put(gameId, redisEntry)
+ Some(redisEntry)
+ case _ => Some(localEntry)
+ case None => fromRedis(gameId).orElse(fromDb(gameId))
+
+ def update(entry: GameEntry): Unit =
+ localEngines.put(entry.gameId, entry)
+ val combined = ioClient.exportCombined(entry.engine.context)
+ redis.value(classOf[String]).setex(cacheKey(entry.gameId), 1800L, toJson(entry, combined.fen, combined.pgn))
+
+ private def readRedisDto(gameId: String): Option[GameCacheDto] =
+ Try(Option(redis.value(classOf[String]).get(cacheKey(gameId)))).toOption.flatten.flatMap { json =>
+ Try(objectMapper.readValue(json, classOf[GameCacheDto])).toOption
+ }
+
+ private def fromRedis(gameId: String): Option[GameEntry] =
+ readRedisDto(gameId)
+ .flatMap(dto => Try(reconstruct(dto)).toOption)
+ .map { entry =>
+ localEngines.put(gameId, entry)
+ entry
+ }
+
+ private def fromDb(gameId: String): Option[GameEntry] =
+ Try {
+ val record = storeClient.getGame(gameId)
+ val dto = GameCacheDto(
+ gameId = record.gameId,
+ fen = record.fen,
+ pgn = record.pgn,
+ whiteId = record.whiteId,
+ whiteName = record.whiteName,
+ blackId = record.blackId,
+ blackName = record.blackName,
+ mode = record.mode,
+ resigned = record.resigned,
+ limitSeconds = Option(record.limitSeconds).map(_.intValue),
+ incrementSeconds = Option(record.incrementSeconds).map(_.intValue),
+ daysPerMove = Option(record.daysPerMove).map(_.intValue),
+ whiteRemainingMs = Option(record.whiteRemainingMs).map(_.longValue),
+ blackRemainingMs = Option(record.blackRemainingMs).map(_.longValue),
+ incrementMs = Option(record.incrementMs).map(_.longValue),
+ clockLastTickAt = Option(record.clockLastTickAt).map(_.longValue),
+ clockMoveDeadline = Option(record.clockMoveDeadline).map(_.longValue),
+ clockActiveColor = Option(record.clockActiveColor),
+ pendingDrawOffer = Option(record.pendingDrawOffer),
+ )
+ (dto, reconstruct(dto))
+ }.toOption
+ .map { case (dto, entry) =>
+ localEngines.put(gameId, entry)
+ redis.value(classOf[String]).setex(cacheKey(gameId), 1800L, objectMapper.writeValueAsString(dto))
+ entry
+ }
+
+ private def reconstruct(dto: GameCacheDto): GameEntry =
+ val ctx = if dto.pgn.nonEmpty then ioClient.importPgn(dto.pgn) else GameContext.initial
+ val tc = (dto.limitSeconds, dto.daysPerMove) match
+ case (Some(l), _) => TimeControl.Clock(l, dto.incrementSeconds.getOrElse(0))
+ case (None, Some(d)) => TimeControl.Correspondence(d)
+ case _ => TimeControl.Unlimited
+ val toColor: String => Color = s => if s == "white" then Color.White else Color.Black
+ val restoredClock: Option[ClockState] =
+ dto.clockLastTickAt
+ .map { tick =>
+ LiveClockState(
+ whiteRemainingMs = dto.whiteRemainingMs.get,
+ blackRemainingMs = dto.blackRemainingMs.get,
+ incrementMs = dto.incrementMs.get,
+ lastTickAt = Instant.ofEpochMilli(tick),
+ activeColor = toColor(dto.clockActiveColor.get),
+ )
+ }
+ .orElse {
+ dto.clockMoveDeadline.map { deadline =>
+ CorrespondenceClockState(
+ moveDeadline = Instant.ofEpochMilli(deadline),
+ daysPerMove = dto.daysPerMove.get,
+ activeColor = toColor(dto.clockActiveColor.get),
+ )
+ }
+ }
+ val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
+ val restoredTakebackRequest = dto.pendingTakebackRequest.map(toColor)
+ val redoMoves = dto.redoStack.flatMap { uci =>
+ Parser.parseMove(uci).flatMap { case (from, to, pp) =>
+ ruleSetAdapter
+ .legalMoves(ctx)(from)
+ .find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
+ }
+ }
+ val engine = GameEngine(
+ initialContext = ctx,
+ ruleSet = ruleSetAdapter,
+ timeControl = tc,
+ initialClockState = restoredClock,
+ initialDrawOffer = restoredDrawOffer,
+ initialRedoStack = redoMoves,
+ initialTakebackRequest = restoredTakebackRequest,
+ )
+ GameEntry(
+ gameId = dto.gameId,
+ engine = engine,
+ white = PlayerInfo(PlayerId(dto.whiteId), dto.whiteName),
+ black = PlayerInfo(PlayerId(dto.blackId), dto.blackName),
+ resigned = dto.resigned,
+ mode = if dto.mode == "Authenticated" then GameMode.Authenticated else GameMode.Open,
+ )
+
+ private def toJson(entry: GameEntry, fen: String, pgn: String): String =
+ objectMapper.writeValueAsString(toDto(entry, fen, pgn))
+
+ private def toDto(entry: GameEntry, fen: String, pgn: String): GameCacheDto =
+ val clock = entry.engine.currentClockState
+ GameCacheDto(
+ gameId = entry.gameId,
+ whiteId = entry.white.id.value,
+ whiteName = entry.white.displayName,
+ blackId = entry.black.id.value,
+ blackName = entry.black.displayName,
+ mode = entry.mode.toString,
+ pgn = pgn,
+ fen = fen,
+ resigned = entry.resigned,
+ limitSeconds = entry.engine.timeControl match { case TimeControl.Clock(l, _) => Some(l); case _ => None },
+ incrementSeconds = entry.engine.timeControl match { case TimeControl.Clock(_, i) => Some(i); case _ => None },
+ daysPerMove = entry.engine.timeControl match { case TimeControl.Correspondence(d) => Some(d); case _ => None },
+ whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
+ blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
+ incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
+ clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
+ clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
+ clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
+ pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
+ redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
+ pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+
+ private def sameSnapshot(localEntry: GameEntry, redisEntry: GameEntry): Boolean =
+ entryHash(localEntry).exists(localHash => entryHash(redisEntry).contains(localHash))
+
+ private def entryHash(entry: GameEntry): Option[String] =
+ Try {
+ val combined = ioClient.exportCombined(entry.engine.context)
+ val canonicalJson = objectMapper.writeValueAsString(toDto(entry, combined.fen, combined.pgn))
+ val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
+ digest.map("%02x".format(_)).mkString
+ }.toOption
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
new file mode 100644
index 0000000..24ad2cd
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameDtoMapper.scala
@@ -0,0 +1,73 @@
+package de.nowchess.chess.resource
+
+import de.nowchess.api.board.Color
+import de.nowchess.api.dto.*
+import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState, WinReason}
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.api.player.PlayerInfo
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.chess.registry.GameEntry
+import java.time.Instant
+
+object GameDtoMapper:
+
+ def statusOf(entry: GameEntry): String =
+ if entry.engine.pendingTakebackRequestBy.isDefined then "takebackRequested"
+ else if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
+ else
+ val ctx = entry.engine.context
+ ctx.result match
+ case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
+ case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
+ case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
+ case Some(GameResult.Draw(_)) => "draw"
+ case None =>
+ if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
+ else if entry.engine.ruleSet.isCheck(ctx) then "check"
+ else "started"
+
+ def moveToUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
+ case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
+ case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
+ case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
+ case _ => base
+
+ def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
+ PlayerInfoDto(info.id.value, info.displayName, info.playerType)
+
+ def toClockDto(entry: GameEntry): Option[ClockDto] =
+ val now = Instant.now()
+ entry.engine.currentClockState.map {
+ case cs: LiveClockState =>
+ ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
+ case cs: CorrespondenceClockState =>
+ val remaining = cs.remainingMs(cs.activeColor, now)
+ ClockDto(
+ whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
+ blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
+ )
+ }
+
+ def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
+ val ctx = entry.engine.context
+ val exported = ioClient.exportCombined(ctx)
+ GameStateDto(
+ fen = exported.fen,
+ pgn = exported.pgn,
+ turn = ctx.turn.label.toLowerCase,
+ status = statusOf(entry),
+ winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
+ moves = ctx.moves.map(moveToUci),
+ undoAvailable = entry.engine.canUndo,
+ redoAvailable = entry.engine.canRedo,
+ clock = toClockDto(entry),
+ takebackRequestedBy = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
+ )
+
+ def toGameFullDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameFullDto =
+ GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry, ioClient))
diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
index 8ab682e..2a83dd6 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
+++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala
@@ -1,24 +1,35 @@
package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper
-import de.nowchess.api.board.Square
+import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.dto.*
-import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
+import de.nowchess.api.game.{
+ CorrespondenceClockState,
+ DrawReason,
+ GameContext,
+ GameMode,
+ GameResult,
+ LiveClockState,
+ TimeControl,
+ WinReason,
+}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
-import de.nowchess.chess.adapter.RuleSetRestAdapter
-import de.nowchess.chess.client.IoServiceClient
+import java.time.Instant
+import de.nowchess.api.rules.RuleSet
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.engine.GameEngine
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
+import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.chess.observer.*
+import de.nowchess.chess.redis.GameRedisSubscriberManager
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
-import io.smallrye.mutiny.Multi
+import de.nowchess.security.InternalOnly
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
-import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.eclipse.microprofile.jwt.JsonWebToken
import java.util.concurrent.atomic.AtomicReference
import scala.compiletime.uninitialized
@@ -35,42 +46,48 @@ class GameResource:
var objectMapper: ObjectMapper = uninitialized
@Inject
- @RestClient
- var ioClient: IoServiceClient = uninitialized
+ var ioClient: IoGrpcClientWrapper = uninitialized
@Inject
- var ruleSetAdapter: RuleSetRestAdapter = uninitialized
+ var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
+
+ @Inject
+ var jwt: JsonWebToken = uninitialized
+
+ @Inject
+ var subscriberManager: GameRedisSubscriberManager = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
+ // ── auth helpers ─────────────────────────────────────────────────────────
+ // scalafix:off DisableSyntax.throw
+
+ private def colorOf(entry: GameEntry): Color =
+ entry.mode match
+ case GameMode.Open => entry.engine.context.turn
+ case GameMode.Authenticated =>
+ val subject = Option(jwt)
+ .flatMap(j => Option(j.getSubject))
+ .getOrElse(throw ForbiddenException("Authentication required"))
+ if entry.white.id.value == subject then Color.White
+ else if entry.black.id.value == subject then Color.Black
+ else throw ForbiddenException("You are not a player in this game")
+
+ private def assertIsCurrentPlayer(entry: GameEntry): Unit =
+ if entry.mode == GameMode.Authenticated then
+ val color = colorOf(entry)
+ if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
+
+ private def assertIsNotBot(): Unit =
+ val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
+ if Set("bot", "official-bot").contains(botType) then throw ForbiddenException("Only bots can make moves")
+
+ // scalafix:on DisableSyntax.throw
+
// ── mapping ──────────────────────────────────────────────────────────────
- private def statusOf(entry: GameEntry): String =
- if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
- else
- val ctx = entry.engine.context
- ctx.result match
- case Some(GameResult.Win(_)) =>
- if entry.resigned then "resign" else "checkmate"
- case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
- case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
- case Some(GameResult.Draw(_)) => "draw"
- case None =>
- if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
- else if entry.engine.ruleSet.isCheck(ctx) then "check"
- else "started"
-
- private def moveToUci(move: Move): String =
- val base = s"${move.from}${move.to}"
- move.moveType match
- case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
- case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
- case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
- case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
- case _ => base
-
private def toLegalMoveDto(move: Move): LegalMoveDto =
val (moveTypeStr, promotionStr) = move.moveType match
case MoveType.Normal(false) => ("normal", None)
@@ -82,32 +99,34 @@ class GameResource:
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
- LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr)
-
- private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
- PlayerInfoDto(info.id.value, info.displayName)
-
- private def toGameStateDto(entry: GameEntry): GameStateDto =
- val ctx = entry.engine.context
- GameStateDto(
- fen = ioClient.exportFen(ctx),
- pgn = ioClient.exportPgn(ctx),
- turn = ctx.turn.label.toLowerCase,
- status = statusOf(entry),
- winner = ctx.result.collect { case GameResult.Win(c) => c.label.toLowerCase },
- moves = ctx.moves.map(moveToUci),
- undoAvailable = entry.engine.canUndo,
- redoAvailable = entry.engine.canRedo,
- )
-
- private def toGameFullDto(entry: GameEntry): GameFullDto =
- GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
+ LegalMoveDto(move.from.toString, move.to.toString, GameDtoMapper.moveToUci(move), moveTypeStr, promotionStr)
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
- private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry =
- GameEntry(registry.generateId(), GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter), white, black)
+ private def toTimeControl(dto: Option[TimeControlDto]): TimeControl =
+ dto match
+ case None => TimeControl.Unlimited
+ case Some(tc) =>
+ tc.daysPerMove match
+ case Some(d) => TimeControl.Correspondence(d)
+ case None =>
+ tc.limitSeconds.fold(TimeControl.Unlimited)(l => TimeControl.Clock(l, tc.incrementSeconds.getOrElse(0)))
+
+ private def newEntry(
+ ctx: GameContext,
+ white: PlayerInfo,
+ black: PlayerInfo,
+ tc: TimeControl = TimeControl.Unlimited,
+ mode: GameMode = GameMode.Open,
+ ): GameEntry =
+ GameEntry(
+ registry.generateId(),
+ GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter, timeControl = tc),
+ white,
+ black,
+ mode = mode,
+ )
private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
val error = new AtomicReference[Option[String]](None)
@@ -134,43 +153,27 @@ class GameResource:
// scalafix:off DisableSyntax.throw
@POST
+ @InternalOnly
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(body: CreateGameRequestDto): Response =
- val req = Option(body).getOrElse(CreateGameRequestDto(None, None))
+ val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
val white = playerInfoFrom(req.white, DefaultWhite)
val black = playerInfoFrom(req.black, DefaultBlack)
- val entry = newEntry(GameContext.initial, white, black)
+ val tc = toTimeControl(req.timeControl)
+ val mode = req.mode.getOrElse(GameMode.Open)
+ val entry = newEntry(GameContext.initial, white, black, tc, mode)
registry.store(entry)
+ subscriberManager.subscribeGame(entry.gameId)
println(s"Created game ${entry.gameId}")
- created(toGameFullDto(entry))
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET
@Path("/{gameId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
- ok(toGameFullDto(entry))
-
- @GET
- @Path("/{gameId}/stream")
- @Produces(Array("application/x-ndjson"))
- def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
- val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
- Multi
- .createFrom()
- .emitter[String] { emitter =>
- emitter.emit(objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry))) + "\n")
- val obs = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- registry.get(gameId).foreach { updated =>
- emitter.emit(
- objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))) + "\n",
- )
- }
- entry.engine.subscribe(obs)
- emitter.onTermination(() => entry.engine.unsubscribe(obs))
- }
+ ok(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST
@Path("/{gameId}/resign")
@@ -178,7 +181,8 @@ class GameResource:
def resignGame(@PathParam("gameId") gameId: String): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
- entry.engine.resign()
+ val color = colorOf(entry)
+ entry.engine.resign(color)
registry.update(entry.copy(resigned = true))
ok(OkResponseDto())
@@ -186,17 +190,15 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
+ assertIsNotBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
- val (from, to, promoOpt) = Parser
- .parseMove(uci)
- .getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
- val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
- val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
- if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
- throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
+ assertIsCurrentPlayer(entry)
+ if Parser.parseMove(uci).isEmpty then
+ throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@GET
@Path("/{gameId}/moves")
@@ -223,7 +225,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
entry.engine.undo()
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST
@Path("/{gameId}/redo")
@@ -232,7 +235,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
entry.engine.redo()
- ok(toGameStateDto(entry))
+ registry.update(entry)
+ ok(GameDtoMapper.toGameStateDto(entry, ioClient))
@POST
@Path("/{gameId}/draw/{action}")
@@ -243,43 +247,55 @@ class GameResource:
): Response =
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
+ val color = colorOf(entry)
action match
- case "offer" =>
- entry.engine.offerDraw(entry.engine.context.turn)
- ok(OkResponseDto())
+ case "offer" => entry.engine.offerDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "accept" => entry.engine.acceptDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "decline" => entry.engine.declineDraw(color); registry.update(entry); ok(OkResponseDto())
+ case "claim" => entry.engine.claimDraw(); registry.update(entry); ok(OkResponseDto())
+ case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
+
+ @POST
+ @Path("/{gameId}/takeback/{action}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def takebackAction(
+ @PathParam("gameId") gameId: String,
+ @PathParam("action") action: String,
+ ): Response =
+ val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
+ assertGameNotOver(entry)
+ val color = colorOf(entry)
+ action match
+ case "request" => entry.engine.requestTakeback(color); registry.update(entry); ok(OkResponseDto())
case "accept" =>
- entry.engine.acceptDraw(entry.engine.context.turn)
- ok(OkResponseDto())
- case "decline" =>
- entry.engine.declineDraw(entry.engine.context.turn)
- ok(OkResponseDto())
- case "claim" =>
- entry.engine.claimDraw()
- ok(OkResponseDto())
- case _ =>
- throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
+ entry.engine.acceptTakeback(color); registry.update(entry); ok(GameDtoMapper.toGameStateDto(entry, ioClient))
+ case "decline" => entry.engine.declineTakeback(color); registry.update(entry); ok(OkResponseDto())
+ case _ => throw BadRequestException("INVALID_ACTION", s"Unknown takeback action: $action", Some("action"))
@POST
@Path("/import/fen")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importFen(body: ImportFenRequestDto): Response =
- val ctx = ioClient.importFen(ImportFenRequest(body.fen))
+ val ctx = ioClient.importFen(body.fen)
val white = playerInfoFrom(body.white, DefaultWhite)
val black = playerInfoFrom(body.black, DefaultBlack)
- val entry = newEntry(ctx, white, black)
+ val tc = toTimeControl(body.timeControl)
+ val entry = newEntry(ctx, white, black, tc)
registry.store(entry)
- created(toGameFullDto(entry))
+ subscriberManager.subscribeGame(entry.gameId)
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@POST
@Path("/import/pgn")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def importPgn(body: ImportPgnRequestDto): Response =
- val ctx = ioClient.importPgn(ImportPgnRequest(body.pgn))
+ val ctx = ioClient.importPgn(body.pgn)
val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
registry.store(entry)
- created(toGameFullDto(entry))
+ subscriberManager.subscribeGame(entry.gameId)
+ created(GameDtoMapper.toGameFullDto(entry, ioClient))
@GET
@Path("/{gameId}/export/fen")
diff --git a/modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala b/modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala
new file mode 100644
index 0000000..a98bcc7
--- /dev/null
+++ b/modules/core/src/main/scala/de/nowchess/chess/service/InstanceHeartbeatService.scala
@@ -0,0 +1,206 @@
+package de.nowchess.chess.service
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.event.Observes
+import jakarta.inject.Inject
+import io.quarkus.runtime.StartupEvent
+import io.quarkus.runtime.ShutdownEvent
+import io.quarkus.grpc.GrpcClient
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import io.quarkus.redis.datasource.RedisDataSource
+import scala.compiletime.uninitialized
+import java.util.concurrent.{Executors, TimeUnit}
+import java.net.InetAddress
+import com.fasterxml.jackson.databind.ObjectMapper
+import org.jboss.logging.Logger
+import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
+import de.nowchess.coordinator.proto.CoordinatorServiceGrpc.CoordinatorServiceStub
+import io.grpc.stub.StreamObserver
+import io.grpc.Channel
+
+@ApplicationScoped
+class InstanceHeartbeatService:
+ // scalafix:off DisableSyntax.var
+ @Inject
+ private var redis: RedisDataSource = uninitialized
+
+ @GrpcClient("coordinator-grpc")
+ private var channel: Channel = uninitialized
+
+ @ConfigProperty(name = "quarkus.http.port", defaultValue = "8080")
+ private var httpPort: Int = 0
+
+ @ConfigProperty(name = "quarkus.grpc.server.port", defaultValue = "9000")
+ private var grpcPort: Int = 0
+
+ @ConfigProperty(name = "nowchess.coordinator.enabled", defaultValue = "true")
+ private var coordinatorEnabled: Boolean = true
+
+ private var coordinatorStub: CoordinatorServiceStub = uninitialized
+ private val log = Logger.getLogger(classOf[InstanceHeartbeatService])
+ private val mapper = ObjectMapper()
+ private var instanceId = ""
+ private var redisPrefix = "nowchess"
+ private var streamObserver: Option[StreamObserver[HeartbeatFrame]] = None
+ private var heartbeatExecutor = Executors.newScheduledThreadPool(1)
+ private var redisHeartbeatExecutor = Executors.newScheduledThreadPool(1)
+ private var subscriptionCount = 0
+ private var localCacheSize = 0
+ private var serviceActive = false
+ private var shuttingDown = false
+ // scalafix:on DisableSyntax.var
+
+ def onStart(@Observes event: StartupEvent): Unit =
+ if coordinatorEnabled then
+ try
+ shuttingDown = false
+ generateInstanceId()
+ initializeHeartbeatStream()
+ scheduleHeartbeats()
+ serviceActive = true
+ log.infof("Instance heartbeat service started with ID: %s", instanceId)
+ catch
+ case ex: Exception =>
+ serviceActive = false
+ log.errorf(ex, "Failed to start instance heartbeat service")
+ else log.info("Coordinator support disabled via config; skipping heartbeat service startup")
+
+ def onShutdown(@Observes event: ShutdownEvent): Unit =
+ shuttingDown = true
+
+ if serviceActive then
+ try
+ cleanup()
+ serviceActive = false
+ log.info("Instance heartbeat service stopped")
+ catch
+ case ex: Exception =>
+ log.errorf(ex, "Error during heartbeat service shutdown")
+ else log.info("Instance heartbeat service stopped")
+
+ def setRedisPrefix(prefix: String): Unit =
+ redisPrefix = prefix
+
+ def setSubscriptionCount(count: Int): Unit =
+ subscriptionCount = count
+
+ def setLocalCacheSize(count: Int): Unit =
+ localCacheSize = count
+
+ def addGameSubscription(gameId: String): Unit =
+ if coordinatorEnabled then
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.set(classOf[String]).sadd(setKey, gameId)
+ subscriptionCount += 1
+
+ def removeGameSubscription(gameId: String): Unit =
+ if coordinatorEnabled then
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.set(classOf[String]).srem(setKey, gameId)
+ subscriptionCount = Math.max(0, subscriptionCount - 1)
+
+ private def generateInstanceId(): Unit =
+ val hostname =
+ try InetAddress.getLocalHost.getHostName
+ catch case _: Exception => "unknown"
+
+ val uuid = java.util.UUID.randomUUID().toString.take(8)
+ instanceId = s"$hostname-$uuid"
+
+ private def initializeHeartbeatStream(): Unit =
+ try
+ coordinatorStub = CoordinatorServiceGrpc.newStub(channel)
+ val responseObserver = new StreamObserver[CoordinatorCommand]:
+ override def onNext(value: CoordinatorCommand): Unit =
+ log.debugf("Received coordinator command: %s", value.getType)
+
+ override def onError(t: Throwable): Unit =
+ log.warnf(t, "Heartbeat stream error")
+ streamObserver = None
+ if !shuttingDown then
+ heartbeatExecutor.schedule((() => initializeHeartbeatStream()): Runnable, 5, TimeUnit.SECONDS)
+
+ override def onCompleted: Unit =
+ log.info("Heartbeat stream completed")
+
+ streamObserver = Some(coordinatorStub.heartbeatStream(responseObserver))
+ log.info("Connected to coordinator heartbeat stream")
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to connect to coordinator")
+ streamObserver = None
+
+ private def scheduleHeartbeats(): Unit =
+ heartbeatExecutor.scheduleAtFixedRate(
+ () => sendHeartbeat(),
+ 0,
+ 200,
+ TimeUnit.MILLISECONDS,
+ )
+
+ redisHeartbeatExecutor.scheduleAtFixedRate(
+ () => refreshRedisHeartbeat(),
+ 0,
+ 2,
+ TimeUnit.SECONDS,
+ )
+
+ private def sendHeartbeat(): Unit =
+ streamObserver.foreach { observer =>
+ try
+ val frame = HeartbeatFrame
+ .newBuilder()
+ .setInstanceId(instanceId)
+ .setHostname(getHostname)
+ .setHttpPort(httpPort)
+ .setGrpcPort(grpcPort)
+ .setSubscriptionCount(subscriptionCount)
+ .setLocalCacheSize(localCacheSize)
+ .setTimestampMillis(System.currentTimeMillis())
+ .build()
+ observer.onNext(frame)
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to send heartbeat frame")
+ }
+
+ private def refreshRedisHeartbeat(): Unit =
+ try
+ val key = s"$redisPrefix:instances:$instanceId"
+
+ val metadata = Map(
+ "instanceId" -> instanceId,
+ "hostname" -> getHostname,
+ "httpPort" -> httpPort,
+ "grpcPort" -> grpcPort,
+ "subscriptionCount" -> subscriptionCount,
+ "localCacheSize" -> localCacheSize,
+ "lastHeartbeat" -> java.time.Instant.now().toString,
+ "state" -> "HEALTHY",
+ )
+
+ val json = mapper.writeValueAsString(metadata)
+ redis.value(classOf[String]).setex(key, 5L, json)
+ catch
+ case ex: Exception =>
+ log.warnf(ex, "Failed to refresh Redis heartbeat")
+
+ private def getHostname: String =
+ try InetAddress.getLocalHost.getHostName
+ catch case _: Exception => "unknown"
+
+ private def cleanup(): Unit =
+ streamObserver.foreach(_.onCompleted())
+ streamObserver = None
+
+ if instanceId.nonEmpty then
+ val key = s"$redisPrefix:instances:$instanceId"
+ redis.key(classOf[String]).del(key)
+
+ val setKey = s"$redisPrefix:instance:$instanceId:games"
+ redis.key(classOf[String]).del(setKey)
+
+ heartbeatExecutor.shutdown()
+ redisHeartbeatExecutor.shutdown()
+ if !heartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then heartbeatExecutor.shutdownNow()
+ if !redisHeartbeatExecutor.awaitTermination(5, TimeUnit.SECONDS) then redisHeartbeatExecutor.shutdownNow()
diff --git a/modules/core/src/test/resources/application.yml b/modules/core/src/test/resources/application.yml
new file mode 100644
index 0000000..af2dd01
--- /dev/null
+++ b/modules/core/src/test/resources/application.yml
@@ -0,0 +1,24 @@
+quarkus:
+ grpc:
+ clients:
+ rule-grpc:
+ host: localhost
+ port: 9082
+ io-grpc:
+ host: localhost
+ port: 9081
+ rest-client:
+ store-service:
+ url: http://localhost:8085
+
+nowchess:
+ internal:
+ secret: test-secret
+ auth:
+ enabled: false
+ coordinator:
+ enabled: false
+ redis:
+ host: localhost
+ port: 6379
+ prefix: test-core
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala
deleted file mode 100644
index 337a31a..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerBranchTest.scala
+++ /dev/null
@@ -1,153 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.board.{File, Rank, Square}
-import de.nowchess.api.game.GameContext
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class CommandInvokerBranchTest extends AnyFunSuite with Matchers:
-
- private def sq(f: File, r: Rank): Square = Square(f, r)
-
- private case class FailingCommand() extends Command:
- override def execute(): Boolean = false
- override def undo(): Boolean = false
- override def description: String = "Failing command"
-
- private class ConditionalFailCommand(
- initialShouldFailOnUndo: Boolean = false,
- initialShouldFailOnExecute: Boolean = false,
- ) extends Command:
- val shouldFailOnUndo = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnUndo)
- val shouldFailOnExecute = new java.util.concurrent.atomic.AtomicBoolean(initialShouldFailOnExecute)
- override def execute(): Boolean = !shouldFailOnExecute.get()
- override def undo(): Boolean = !shouldFailOnUndo.get()
- override def description: String = "Conditional fail"
-
- private def createMoveCommand(from: Square, to: Square, executeSucceeds: Boolean = true): MoveCommand =
- MoveCommand(
- from = from,
- to = to,
- moveResult = if executeSucceeds then Some(MoveResult.Successful(GameContext.initial, None)) else None,
- previousContext = Some(GameContext.initial),
- )
-
- test("execute rejects failing commands and keeps history unchanged"):
- val invoker = new CommandInvoker()
- val cmd = FailingCommand()
- invoker.execute(cmd) shouldBe false
- invoker.history.size shouldBe 0
- invoker.getCurrentIndex shouldBe -1
-
- val failingCmd = FailingCommand()
- val successCmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.execute(failingCmd) shouldBe false
- invoker.history.size shouldBe 0
- invoker.execute(successCmd) shouldBe true
- invoker.history.size shouldBe 1
- invoker.history.head shouldBe successCmd
-
- test("undo redo and history trimming cover all command state transitions"):
- {
- val invoker = new CommandInvoker()
- invoker.undo() shouldBe false
- invoker.canUndo shouldBe false
- invoker.undo() shouldBe false
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
- invoker.execute(cmd1)
- invoker.execute(cmd2)
- invoker.undo()
- invoker.undo()
- invoker.undo() shouldBe false
- }
-
- {
- val invoker = new CommandInvoker()
- val failingUndoCmd = ConditionalFailCommand(initialShouldFailOnUndo = true)
- invoker.execute(failingUndoCmd) shouldBe true
- invoker.canUndo shouldBe true
- invoker.undo() shouldBe false
- invoker.getCurrentIndex shouldBe 0
- }
-
- {
- val invoker = new CommandInvoker()
- val successUndoCmd = ConditionalFailCommand()
- invoker.execute(successUndoCmd) shouldBe true
- invoker.undo() shouldBe true
- invoker.getCurrentIndex shouldBe -1
- }
-
- {
- val invoker = new CommandInvoker()
- invoker.redo() shouldBe false
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.execute(cmd)
- invoker.canRedo shouldBe false
- invoker.redo() shouldBe false
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- val redoFailCmd = ConditionalFailCommand()
- invoker.execute(cmd1)
- invoker.execute(redoFailCmd)
- invoker.undo()
- invoker.canRedo shouldBe true
- redoFailCmd.shouldFailOnExecute.set(true)
- invoker.redo() shouldBe false
- invoker.getCurrentIndex shouldBe 0
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.execute(cmd) shouldBe true
- invoker.undo() shouldBe true
- invoker.redo() shouldBe true
- invoker.getCurrentIndex shouldBe 0
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
- val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
- invoker.execute(cmd1)
- invoker.execute(cmd2)
- invoker.undo()
- invoker.canRedo shouldBe true
- invoker.execute(cmd3)
- invoker.canRedo shouldBe false
- invoker.history.size shouldBe 2
- invoker.history(1) shouldBe cmd3
- }
-
- {
- val invoker = new CommandInvoker()
- val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
- val cmd3 = createMoveCommand(sq(File.G, Rank.R1), sq(File.F, Rank.R3))
- val cmd4 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
- invoker.execute(cmd1)
- invoker.execute(cmd2)
- invoker.execute(cmd3)
- invoker.execute(cmd4)
- invoker.undo()
- invoker.undo()
- invoker.canRedo shouldBe true
- val newCmd = createMoveCommand(sq(File.B, Rank.R2), sq(File.B, Rank.R4))
- invoker.execute(newCmd)
- invoker.history.size shouldBe 3
- invoker.canRedo shouldBe false
- }
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala
deleted file mode 100644
index c9e82af..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandInvokerTest.scala
+++ /dev/null
@@ -1,67 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.board.{File, Rank, Square}
-import de.nowchess.api.game.GameContext
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class CommandInvokerTest extends AnyFunSuite with Matchers:
-
- private def sq(f: File, r: Rank): Square = Square(f, r)
-
- private def createMoveCommand(from: Square, to: Square): MoveCommand =
- MoveCommand(
- from = from,
- to = to,
- moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
- previousContext = Some(GameContext.initial),
- )
-
- test("execute appends commands and updates index"):
- val invoker = new CommandInvoker()
- val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.execute(cmd) shouldBe true
- invoker.history.size shouldBe 1
- invoker.getCurrentIndex shouldBe 0
-
- val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
- invoker.execute(cmd2) shouldBe true
- invoker.history.size shouldBe 2
- invoker.getCurrentIndex shouldBe 1
-
- test("undo and redo update index and availability flags"):
- val invoker = new CommandInvoker()
- val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.canUndo shouldBe false
- invoker.execute(cmd)
- invoker.canUndo shouldBe true
- invoker.undo() shouldBe true
- invoker.getCurrentIndex shouldBe -1
- invoker.canRedo shouldBe true
- invoker.redo() shouldBe true
- invoker.getCurrentIndex shouldBe 0
-
- test("clear removes full history and resets index"):
- val invoker = new CommandInvoker()
- val cmd = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- invoker.execute(cmd)
- invoker.clear()
- invoker.history.size shouldBe 0
- invoker.getCurrentIndex shouldBe -1
-
- test("execute after undo discards redo history"):
- val invoker = new CommandInvoker()
- val cmd1 = createMoveCommand(sq(File.E, Rank.R2), sq(File.E, Rank.R4))
- val cmd2 = createMoveCommand(sq(File.E, Rank.R7), sq(File.E, Rank.R5))
- val cmd3 = createMoveCommand(sq(File.D, Rank.R2), sq(File.D, Rank.R4))
- invoker.execute(cmd1)
- invoker.execute(cmd2)
- invoker.undo()
- invoker.getCurrentIndex shouldBe 0
- invoker.canRedo shouldBe true
- invoker.execute(cmd3)
- invoker.canRedo shouldBe false
- invoker.history.size shouldBe 2
- invoker.history.head shouldBe cmd1
- invoker.history(1) shouldBe cmd3
- invoker.getCurrentIndex shouldBe 1
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala
deleted file mode 100644
index 64532f7..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/command/CommandTest.scala
+++ /dev/null
@@ -1,23 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.game.GameContext
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class CommandTest extends AnyFunSuite with Matchers:
-
- test("QuitCommand properties and behavior"):
- val cmd = QuitCommand()
- cmd.execute() shouldBe true
- cmd.undo() shouldBe false
- cmd.description shouldBe "Quit game"
-
- test("ResetCommand behavior depends on previousContext"):
- val noState = ResetCommand()
- noState.execute() shouldBe true
- noState.undo() shouldBe false
- noState.description shouldBe "Reset board"
-
- val withState = ResetCommand(previousContext = Some(GameContext.initial))
- withState.execute() shouldBe true
- withState.undo() shouldBe true
diff --git a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala b/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala
deleted file mode 100644
index d2ff845..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/command/MoveCommandTest.scala
+++ /dev/null
@@ -1,70 +0,0 @@
-package de.nowchess.chess.command
-
-import de.nowchess.api.board.{File, Rank, Square}
-import de.nowchess.api.game.GameContext
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class MoveCommandTest extends AnyFunSuite with Matchers:
-
- private def sq(f: File, r: Rank): Square = Square(f, r)
-
- test("MoveCommand defaults to empty optional state and false execute/undo"):
- val cmd = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
- cmd.moveResult shouldBe None
- cmd.previousContext shouldBe None
- cmd.execute() shouldBe false
- cmd.undo() shouldBe false
- cmd.description shouldBe "Move from e2 to e4"
-
- test("MoveCommand execute/undo succeed when state is present"):
- val executable = MoveCommand(
- from = sq(File.E, Rank.R2),
- to = sq(File.E, Rank.R4),
- moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
- )
- executable.execute() shouldBe true
-
- val undoable = MoveCommand(
- from = sq(File.E, Rank.R2),
- to = sq(File.E, Rank.R4),
- moveResult = Some(MoveResult.Successful(GameContext.initial, None)),
- previousContext = Some(GameContext.initial),
- )
- undoable.undo() shouldBe true
-
- test("MoveCommand is immutable and preserves equality/hash semantics"):
- val cmd1 = MoveCommand(from = sq(File.E, Rank.R2), to = sq(File.E, Rank.R4))
-
- val result = MoveResult.Successful(GameContext.initial, None)
- val cmd2 = cmd1.copy(
- moveResult = Some(result),
- previousContext = Some(GameContext.initial),
- )
-
- cmd1.moveResult shouldBe None
- cmd1.previousContext shouldBe None
-
- cmd2.moveResult shouldBe Some(result)
- cmd2.previousContext shouldBe Some(GameContext.initial)
-
- val eq1 = MoveCommand(
- from = sq(File.E, Rank.R2),
- to = sq(File.E, Rank.R4),
- moveResult = None,
- previousContext = None,
- )
-
- val eq2 = MoveCommand(
- from = sq(File.E, Rank.R2),
- to = sq(File.E, Rank.R4),
- moveResult = None,
- previousContext = None,
- )
-
- eq1 shouldBe eq2
- eq1.hashCode shouldBe eq2.hashCode
-
- val hash1 = eq1.hashCode
- val hash2 = eq1.hashCode
- hash1 shouldBe hash2
diff --git a/modules/core/src/test/scala/de/nowchess/chess/config/MockRedisDataSourceProducer.scala b/modules/core/src/test/scala/de/nowchess/chess/config/MockRedisDataSourceProducer.scala
new file mode 100644
index 0000000..ad2837a
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/config/MockRedisDataSourceProducer.scala
@@ -0,0 +1,17 @@
+package de.nowchess.chess.config
+
+import io.quarkus.redis.datasource.RedisDataSource
+import jakarta.annotation.Priority
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.inject.Alternative
+import jakarta.enterprise.inject.Produces
+import org.mockito.Mockito
+
+@Alternative
+@Priority(1)
+@ApplicationScoped
+class MockRedisDataSourceProducer:
+ @Produces
+ @ApplicationScoped
+ def produceRedisDataSource(): RedisDataSource =
+ Mockito.mock(classOf[RedisDataSource], Mockito.RETURNS_DEEP_STUBS)
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala
new file mode 100644
index 0000000..6c4bb0e
--- /dev/null
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala
@@ -0,0 +1,155 @@
+package de.nowchess.chess.engine
+
+import de.nowchess.api.board.Color
+import de.nowchess.api.game.{
+ ClockState,
+ CorrespondenceClockState,
+ DrawReason,
+ GameResult,
+ LiveClockState,
+ TimeControl,
+ WinReason,
+}
+import de.nowchess.chess.observer.*
+import de.nowchess.rules.sets.DefaultRules
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+import java.time.Instant
+import java.time.temporal.ChronoUnit
+
+class GameEngineClockTest extends AnyFunSuite with Matchers:
+
+ private def makeClockEngine(tc: TimeControl): GameEngine =
+ new GameEngine(ruleSet = DefaultRules, timeControl = tc)
+
+ // ── Unlimited ─────────────────────────────────────────────────────────────
+
+ test("Unlimited time control: no clock state"):
+ val engine = makeClockEngine(TimeControl.Unlimited)
+ engine.currentClockState shouldBe None
+
+ // ── Live clock initialisation ─────────────────────────────────────────────
+
+ test("Clock(300,3) initialises both sides to 300,000ms"):
+ val engine = makeClockEngine(TimeControl.Clock(300, 3))
+ engine.currentClockState match
+ case Some(cs: LiveClockState) =>
+ cs.whiteRemainingMs shouldBe 300_000L
+ cs.blackRemainingMs shouldBe 300_000L
+ cs.incrementMs shouldBe 3_000L
+ cs.activeColor shouldBe Color.White
+ case other => fail(s"Expected Some(LiveClockState), got $other")
+
+ // ── Clock advances after move ─────────────────────────────────────────────
+
+ test("After White move, activeColor flips to Black and white time decreases"):
+ val engine = makeClockEngine(TimeControl.Clock(300, 3))
+ engine.processUserInput("e2e4")
+ engine.currentClockState match
+ case Some(cs: LiveClockState) =>
+ cs.activeColor shouldBe Color.Black
+ cs.whiteRemainingMs should be < 300_000L + 3_000L
+ cs.blackRemainingMs shouldBe 300_000L
+ case other => fail(s"Expected Some(LiveClockState), got $other")
+
+ // ── Time flag via injection ───────────────────────────────────────────────
+
+ test("TimeFlagEvent fires and result is Win(opponent) when White flags on move"):
+ val engine = makeClockEngine(TimeControl.Clock(300, 0))
+ val observer = new EngineTestHelpers.MockObserver()
+ engine.subscribe(observer)
+ // Inject nearly-exhausted clock: White has 1ms, will flag on move
+ val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
+ engine.injectClockState(Some(expiredClock))
+
+ engine.processUserInput("e2e4")
+
+ observer.hasEvent[TimeFlagEvent] shouldBe true
+ observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
+ engine.context.result shouldBe Some(GameResult.Win(Color.Black, WinReason.TimeControl))
+
+ test("TimeFlagEvent fires and result is Win(Black) when Black flags on move"):
+ val engine = makeClockEngine(TimeControl.Clock(300, 0))
+ val observer = new EngineTestHelpers.MockObserver()
+ engine.subscribe(observer)
+ engine.processUserInput("e2e4")
+ observer.clear()
+
+ val expiredClock = LiveClockState(300_000L, 1L, 0L, Instant.now().minusSeconds(10), Color.Black)
+ engine.injectClockState(Some(expiredClock))
+
+ engine.processUserInput("e7e5")
+
+ observer.hasEvent[TimeFlagEvent] shouldBe true
+ observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.Black)
+ engine.context.result shouldBe Some(GameResult.Win(Color.White, WinReason.TimeControl))
+
+ test("Flag with insufficient material gives Draw(InsufficientMaterial)"):
+ // King vs King — White flags but Black can't mate
+ // White king e4, Black king e6: e4d3 is a legal move (not adjacent to e6)
+ val engine = makeClockEngine(TimeControl.Clock(300, 0))
+ val observer = new EngineTestHelpers.MockObserver()
+ engine.subscribe(observer)
+ EngineTestHelpers.loadFen(engine, "8/8/4k3/8/4K3/8/8/8 w - - 0 1")
+ observer.clear()
+
+ val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
+ engine.injectClockState(Some(expiredClock))
+
+ engine.processUserInput("e4d3")
+
+ observer.hasEvent[TimeFlagEvent] shouldBe true
+ engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
+
+ // ── Correspondence clock ──────────────────────────────────────────────────
+
+ test("Correspondence(3): after move, deadline is ~3 days from move time"):
+ val engine = makeClockEngine(TimeControl.Correspondence(3))
+ val before = Instant.now()
+ engine.processUserInput("e2e4")
+ val after = Instant.now()
+ engine.currentClockState match
+ case Some(cs: CorrespondenceClockState) =>
+ val expectedMin = before.plus(3L, ChronoUnit.DAYS)
+ val expectedMax = after.plus(3L, ChronoUnit.DAYS)
+ cs.moveDeadline.isAfter(expectedMin.minusSeconds(1)) shouldBe true
+ cs.moveDeadline.isBefore(expectedMax.plusSeconds(1)) shouldBe true
+ cs.activeColor shouldBe Color.Black
+ case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
+
+ test("Correspondence flag fires TimeFlagEvent when move past deadline"):
+ val engine = makeClockEngine(TimeControl.Correspondence(3))
+ val observer = new EngineTestHelpers.MockObserver()
+ engine.subscribe(observer)
+ // Inject expired deadline
+ val expired = CorrespondenceClockState(Instant.now().minusSeconds(60), 3, Color.White)
+ engine.injectClockState(Some(expired))
+
+ engine.processUserInput("e2e4")
+
+ observer.hasEvent[TimeFlagEvent] shouldBe true
+ observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
+
+ // ── reset() restarts clock ────────────────────────────────────────────────
+
+ test("reset() restarts clock to full time"):
+ val engine = makeClockEngine(TimeControl.Clock(300, 3))
+ engine.processUserInput("e2e4")
+ engine.reset()
+ engine.currentClockState match
+ case Some(cs: LiveClockState) =>
+ cs.whiteRemainingMs shouldBe 300_000L
+ cs.blackRemainingMs shouldBe 300_000L
+ cs.activeColor shouldBe Color.White
+ case other => fail(s"Expected Some(LiveClockState), got $other")
+
+ // ── Passive expiry via scheduler ──────────────────────────────────────────
+
+ test("Scheduler fires TimeFlagEvent when active player's clock expires passively"):
+ // Scheduler starts on engine creation, so TimeFlagEvent fires without a move being made
+ val engine = new GameEngine(ruleSet = DefaultRules, timeControl = TimeControl.Clock(1, 0))
+ val observer = new EngineTestHelpers.MockObserver()
+ engine.subscribe(observer)
+ Thread.sleep(1500)
+ observer.hasEvent[TimeFlagEvent] shouldBe true
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
index 872ce56..bb7e7dd 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala
@@ -202,7 +202,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
- engine.resign(Color.Black)
+ engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
@@ -222,7 +222,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
engine.offerDraw(Color.White)
observer.events.clear()
- engine.resign(Color.Black)
+ engine.resign()
// Try to accept the now-cleared draw offer
observer.events.clear()
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala
index 5411007..f1edee5 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala
@@ -3,7 +3,8 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
-import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
+import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer}
+import de.nowchess.api.error.GameError
import de.nowchess.api.io.GameContextImport
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
@@ -20,15 +21,6 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.subscribe((event: GameEvent) => events += event)
events
- test("accessors expose redo availability and command history"):
- val engine = new GameEngine(ruleSet = DefaultRules)
-
- engine.canRedo shouldBe false
- engine.commandHistory shouldBe empty
-
- engine.processUserInput("e2e4")
- engine.commandHistory.nonEmpty shouldBe true
-
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
@@ -58,9 +50,9 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = DefaultRules)
val failingImporter = new GameContextImport:
- def importGameContext(input: String): Either[String, GameContext] = Left("boom")
+ def importGameContext(input: String): Either[GameError, GameContext] = Left(GameError.ParseError("boom"))
- engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
+ engine.loadGame(failingImporter, "ignored") shouldBe Left(GameError.ParseError("boom"))
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine(ruleSet = DefaultRules)
@@ -71,26 +63,11 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.loadPosition(target)
engine.context shouldBe target
- engine.commandHistory shouldBe empty
events.lastOption.exists {
case _: de.nowchess.chess.observer.BoardResetEvent => true
case _ => false
} shouldBe true
- test("redo event includes captured piece description when replaying a capture"):
- val engine = new GameEngine(ruleSet = DefaultRules)
- val events = captureEvents(engine)
-
- EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1")
- events.clear()
-
- engine.processUserInput("a1h1")
- engine.processUserInput("undo")
- engine.processUserInput("redo")
-
- val redo = events.collectFirst { case e: MoveRedoneEvent => e }
- redo.flatMap(_.capturedPiece) shouldBe Some("Black Rook")
-
test("loadGame replay handles promotion moves when pending promotion exists"):
val promotionMove = Move(sq("e2"), sq("e8"), MoveType.Promotion(PromotionPiece.Queen))
@@ -109,7 +86,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val engine = new GameEngine(ruleSet = permissiveRules)
val importer = new GameContextImport:
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
engine.loadGame(importer, "ignored") shouldBe Right(())
@@ -134,13 +111,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val saved = engine.context
val importer = new GameContextImport:
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
Right(GameContext.initial.copy(moves = List(promotionMove)))
val result = engine.loadGame(importer, "ignored")
- result.isLeft shouldBe true
- result.left.toOption.get should include("Illegal move")
+ result shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("loadGame replay executes non-promotion moves through default replay branch"):
@@ -156,7 +132,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
- engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.")
+ engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left(GameError.IllegalMove)
engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"):
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala
index 626d8e2..25931f0 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineOutcomesTest.scala
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.Color
+import de.nowchess.api.game.WinReason.Checkmate
import de.nowchess.api.game.{DrawReason, GameResult}
import de.nowchess.chess.observer.*
import org.scalatest.funsuite.AnyFunSuite
@@ -23,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
engine.processUserInput("d8h4")
observer.hasEvent[CheckmateEvent] shouldBe true
- engine.context.result shouldBe Some(GameResult.Win(Color.Black))
+ engine.context.result shouldBe Some(GameResult.Win(Color.Black, Checkmate))
test("checkmate with white winner"):
val engine = EngineTestHelpers.makeEngine()
@@ -43,7 +44,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
val evt = observer.getEvent[CheckmateEvent]
evt.isDefined shouldBe true
evt.get.winner shouldBe Color.White
- engine.context.result shouldBe Some(GameResult.Win(Color.White))
+ engine.context.result shouldBe Some(GameResult.Win(Color.White, Checkmate))
// ── Stalemate ───────────────────────────────────────────────────
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
index 0ee4395..fc4eadd 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala
@@ -1,9 +1,12 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
+
import scala.collection.mutable
import de.nowchess.api.board.Color
+import de.nowchess.api.board.Color.{Black, White}
import de.nowchess.api.game.GameResult
+import de.nowchess.api.game.WinReason.Resignation
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -15,13 +18,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
- engine.resign(Color.White)
+ engine.resign(White)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.White
- event.context.result shouldBe Some(GameResult.Win(Color.Black))
+ event.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -30,13 +33,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
val observer = new ResignMockObserver()
engine.subscribe(observer)
- engine.resign(Color.Black)
+ engine.resign(Black)
observer.events should have length 1
observer.events.head match
case event: ResignEvent =>
event.resignedColor shouldBe Color.Black
- event.context.result shouldBe Some(GameResult.Win(Color.White))
+ event.context.result shouldBe Some(GameResult.Win(Color.White, Resignation))
case other =>
fail(s"Expected ResignEvent, but got $other")
@@ -54,7 +57,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
// Try to resign
observer.events.clear()
- engine.resign(Color.White)
+ engine.resign()
// Should get InvalidMoveEvent with GameAlreadyOver reason
observer.events.length shouldBe 1
@@ -71,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
engine.resign()
- engine.context.result shouldBe Some(GameResult.Win(Color.Black))
+ engine.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
test("resign() without color does nothing when game already over"):
val engine = new GameEngine(ruleSet = DefaultRules)
diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala
deleted file mode 100644
index 946068d..0000000
--- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala
+++ /dev/null
@@ -1,266 +0,0 @@
-package de.nowchess.chess.engine
-
-import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
-import de.nowchess.api.bot.Bot
-import de.nowchess.api.game.{BotParticipant, GameContext, Human}
-import de.nowchess.api.move.{Move, MoveType}
-import de.nowchess.api.player.{PlayerId, PlayerInfo}
-import de.nowchess.bot.bots.ClassicalBot
-import de.nowchess.bot.{BotController, BotDifficulty}
-import de.nowchess.chess.observer.*
-import de.nowchess.rules.sets.DefaultRules
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
-
-private class NoMoveBot extends Bot:
- def name: String = "nomove"
- def nextMove(context: GameContext): Option[Move] = None
-
-private class FixedMoveBot(move: Move) extends Bot:
- def name: String = "fixed"
- def nextMove(context: GameContext): Option[Move] = Some(move)
-
-class GameEngineWithBotTest extends AnyFunSuite with Matchers:
-
- test("GameEngine can play against a ClassicalBot"):
- val bot = ClassicalBot(BotDifficulty.Easy)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
-
- // Collect events
- val moveCount = new AtomicInteger(0)
- val checkmateDetected = new AtomicBoolean(false)
- val gameEnded = new AtomicBoolean(false)
-
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent =>
- moveCount.incrementAndGet()
- case _: CheckmateEvent =>
- checkmateDetected.set(true)
- gameEnded.set(true)
- case _: DrawEvent =>
- gameEnded.set(true)
- case _ => ()
-
- engine.subscribe(observer)
-
- // Play a few moves: e2e4, then let the bot respond
- engine.processUserInput("e2e4")
-
- // Wait a bit for the bot to respond asynchronously
- Thread.sleep(5000)
-
- // White should have moved, then Black (bot) should have responded
- moveCount.get() should be >= 2
-
- test("BotController can list and retrieve bots"):
- val bots = BotController.listBots
- bots should contain("easy")
- bots should contain("medium")
- bots should contain("hard")
- bots should contain("expert")
-
- BotController.getBot("easy") should not be None
- BotController.getBot("medium") should not be None
- BotController.getBot("hard") should not be None
- BotController.getBot("expert") should not be None
- BotController.getBot("unknown") should be(None)
-
- test("GameEngine handles bot with different difficulty"):
- val hardBot = BotController.getBot("hard").get
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
- )
- engine.turn should equal(Color.White)
-
- val movesMade = new AtomicInteger(0)
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent => movesMade.incrementAndGet()
- case _ => ()
-
- engine.subscribe(observer)
-
- // White moves
- engine.processUserInput("d2d4")
- Thread.sleep(500) // Wait for bot response
-
- // At least white moved, possibly black also responded
- movesMade.get() should be >= 1
-
- test("GameEngine plays valid bot moves"):
- val bot = ClassicalBot(BotDifficulty.Easy)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
-
- val moveCount = new AtomicInteger(0)
- val observer = new Observer:
- def onGameEvent(event: GameEvent): Unit =
- event match
- case _: MoveExecutedEvent => moveCount.incrementAndGet()
- case _ => ()
-
- engine.subscribe(observer)
-
- // Play a normal move
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
-
- // The game should have progressed with at least one move
- moveCount.get() should be >= 1
- // Game should not be ended (checkmate/stalemate)
- engine.context.moves.nonEmpty should be(true)
-
- test("startGame triggers bot when the starting player is a bot"):
- val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val movesMade = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: MoveExecutedEvent => movesMade.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(500)
- movesMade.get() should be >= 1
-
- test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
- val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
- val bot = new FixedMoveBot(illegalMove)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
- val invalidCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: InvalidMoveEvent => invalidCount.incrementAndGet()
- case _ => (),
- )
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
- invalidCount.get() should be >= 1
-
- test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
- val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
- val bot = new FixedMoveBot(invalidMove)
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
- )
- val invalidCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: InvalidMoveEvent => invalidCount.incrementAndGet()
- case _ => (),
- )
- engine.processUserInput("e2e4")
- Thread.sleep(1000)
- invalidCount.get() should be >= 1
-
- test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
- // White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
- val board = Board(
- Map(
- Square(File.A, Rank.R1) -> Piece.WhiteKing,
- Square(File.B, Rank.R2) -> Piece.BlackQueen,
- Square(File.B, Rank.R8) -> Piece.BlackRook,
- Square(File.H, Rank.R8) -> Piece.BlackKing,
- ),
- )
- val ctx = GameContext.initial.copy(
- board = board,
- turn = Color.White,
- castlingRights = CastlingRights(false, false, false, false),
- enPassantSquare = None,
- halfMoveClock = 0,
- moves = List.empty,
- )
- val engine = GameEngine(
- ctx,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val checkmateCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: CheckmateEvent => checkmateCount.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(1000)
- checkmateCount.get() should be >= 1
-
- test("handleBotNoMove fires DrawEvent when position is stalemate"):
- // White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
- val board = Board(
- Map(
- Square(File.A, Rank.R1) -> Piece.WhiteKing,
- Square(File.B, Rank.R3) -> Piece.BlackQueen,
- Square(File.H, Rank.R8) -> Piece.BlackKing,
- ),
- )
- val ctx = GameContext.initial.copy(
- board = board,
- turn = Color.White,
- castlingRights = CastlingRights(false, false, false, false),
- enPassantSquare = None,
- halfMoveClock = 0,
- moves = List.empty,
- )
- val engine = GameEngine(
- ctx,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val drawCount = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: DrawEvent => drawCount.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(1000)
- drawCount.get() should be >= 1
-
- test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
- val engine = GameEngine(
- GameContext.initial,
- DefaultRules,
- Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
- )
- val unexpectedEvents = new AtomicInteger(0)
- engine.subscribe(
- new Observer:
- def onGameEvent(event: GameEvent): Unit = event match
- case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
- case _: DrawEvent => unexpectedEvents.incrementAndGet()
- case _ => (),
- )
- engine.startGame()
- Thread.sleep(500)
- unexpectedEvents.get() shouldBe 0
diff --git a/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala
index a834aa1..34f9625 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala
@@ -1,87 +1,31 @@
package de.nowchess.chess.json
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
-import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
-import de.nowchess.api.move.{MoveType, PromotionPiece}
+import de.nowchess.api.move.MoveType
+import de.nowchess.chess.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonSerializersTest extends AnyFunSuite with Matchers:
private val mapper: ObjectMapper =
- val m = new ObjectMapper()
- val mod = new SimpleModule()
- m.registerModule(DefaultScalaModule)
- mod.addSerializer(classOf[Square], new SquareSerializer())
- mod.addDeserializer(classOf[Square], new SquareDeserializer())
- mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
- mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
- m.registerModule(mod)
+ val m = new ObjectMapper()
+ new JacksonConfig().customize(m)
m
- private val e4 = Square(File.E, Rank.R4)
+ test("customize enables Option serialization via DefaultScalaModule"):
+ mapper.writeValueAsString(None) shouldBe "null"
+ mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
- // ── SquareSerializer ──────────────────────────────────────────────
+ test("customize registers SquareSerializer"):
+ mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
- test("SquareSerializer writes square as string"):
- mapper.writeValueAsString(e4) shouldBe """"e4""""
+ test("customize registers SquareDeserializer"):
+ mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
- // ── SquareDeserializer ────────────────────────────────────────────
-
- test("SquareDeserializer reads valid square string"):
- mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
-
- // scalafix:off DisableSyntax.null
- test("SquareDeserializer returns null for invalid square string"):
- mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
- // scalafix:on DisableSyntax.null
-
- // ── MoveTypeSerializer ────────────────────────────────────────────
-
- test("MoveTypeSerializer serializes Normal non-capture"):
- mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
-
- test("MoveTypeSerializer serializes Normal capture"):
- mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
-
- test("MoveTypeSerializer serializes CastleKingside"):
+ test("customize registers MoveTypeSerializer"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
- test("MoveTypeSerializer serializes CastleQueenside"):
- mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
-
- test("MoveTypeSerializer serializes EnPassant"):
- mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
-
- test("MoveTypeSerializer serializes Promotion"):
- mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
- """{"type":"promotion","piece":"Queen"}"""
-
- // ── MoveTypeDeserializer ──────────────────────────────────────────
-
- test("MoveTypeDeserializer deserializes normal non-capture"):
- mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
- MoveType.Normal(false)
-
- test("MoveTypeDeserializer deserializes normal capture"):
- mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
- MoveType.Normal(true)
-
- test("MoveTypeDeserializer deserializes castleKingside"):
- mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
-
- test("MoveTypeDeserializer deserializes castleQueenside"):
- mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
-
- test("MoveTypeDeserializer deserializes enPassant"):
+ test("customize registers MoveTypeDeserializer"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
-
- test("MoveTypeDeserializer deserializes promotion"):
- mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
- MoveType.Promotion(PromotionPiece.Rook)
-
- test("MoveTypeDeserializer throws for unknown type"):
- an[Exception] should be thrownBy
- mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
diff --git a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala
index 53d1362..606a373 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala
@@ -1,12 +1,22 @@
package de.nowchess.chess.registry
+import de.nowchess.api.game.GameContext
import de.nowchess.api.player.{PlayerId, PlayerInfo}
+import de.nowchess.chess.client.{CombinedExportResponse, StoreServiceClient}
+import de.nowchess.chess.grpc.IoGrpcClientWrapper
+import de.nowchess.io.fen.FenExporter
+import de.nowchess.io.pgn.PgnExporter
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.chess.engine.GameEngine
+import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
-import org.junit.jupiter.api.{DisplayName, Test}
+import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
+import org.mockito.ArgumentMatchers.any
+import org.mockito.Mockito.when
+import org.mockito.invocation.InvocationOnMock
import scala.compiletime.uninitialized
@@ -18,6 +28,25 @@ class GameRegistryImplTest:
@Inject
var registry: GameRegistry = uninitialized
+ @InjectMock
+ var ioWrapper: IoGrpcClientWrapper = uninitialized
+
+ @InjectMock
+ @RestClient
+ var storeClient: StoreServiceClient = uninitialized
+
+ @BeforeEach
+ def setupMocks(): Unit =
+ when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
+ val ctx = inv.getArgument[GameContext](0)
+ CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
+ )
+ when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
+ de.nowchess.io.pgn.PgnParser
+ .importGameContext(inv.getArgument[String](0))
+ .getOrElse(GameContext.initial),
+ )
+
@Test
@DisplayName("store saves entry")
def testStore(): Unit =
diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
index abea233..b61229d 100644
--- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
+++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala
@@ -3,15 +3,16 @@ package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.*
import de.nowchess.api.game.GameContext
-import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
+import de.nowchess.chess.client.CombinedExportResponse
import de.nowchess.chess.exception.BadRequestException
+import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.io.fen.FenExporter
-import de.nowchess.io.pgn.PgnParser
+import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
-import org.eclipse.microprofile.rest.client.inject.RestClient
+import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -29,52 +30,71 @@ class GameResourceIntegrationTest:
var resource: GameResource = uninitialized
@InjectMock
- @RestClient
- var ioClient: IoServiceClient = uninitialized
+ var ruleAdapter: RuleSetGrpcAdapter = uninitialized
@InjectMock
- @RestClient
- var ruleClient: RuleServiceClient = uninitialized
+ var ioWrapper: IoGrpcClientWrapper = uninitialized
+
+ @InjectMock
+ var jwt: JsonWebToken = uninitialized
@BeforeEach
def setupMocks(): Unit =
- when(ioClient.importFen(any())).thenReturn(GameContext.initial)
- when(ioClient.importPgn(any())).thenReturn(
- PgnParser.importGameContext("1. e4 c5").toOption.get,
+ when(jwt.getClaim[AnyRef]("type")).thenReturn("user")
+
+ when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
+ when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
+ PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
)
- when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
- when(ioClient.exportPgn(any())).thenReturn("1. e4 c5")
- when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
- val req = inv.getArgument[RuleSquareRequest](0)
- DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get),
+ when(ioWrapper.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
+ val ctx = inv.getArgument[GameContext](0)
+ CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)),
)
- when(ruleClient.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ioWrapper.exportFen(any())).thenAnswer((inv: InvocationOnMock) =>
+ FenExporter.exportGameContext(inv.getArgument[GameContext](0)),
+ )
+ when(ioWrapper.exportPgn(any())).thenAnswer((inv: InvocationOnMock) =>
+ PgnExporter.exportGameContext(inv.getArgument[GameContext](0)),
+ )
+
+ when(ruleAdapter.legalMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
+ DefaultRules.legalMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
+ )
+ when(ruleAdapter.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.allLegalMoves(inv.getArgument[GameContext](0)),
)
- when(ruleClient.applyMove(any())).thenAnswer((inv: InvocationOnMock) =>
- val req = inv.getArgument[RuleMoveRequest](0)
- DefaultRules.applyMove(req.context)(req.move),
+ when(ruleAdapter.applyMove(any())(any())).thenAnswer((inv: InvocationOnMock) =>
+ DefaultRules.applyMove(inv.getArgument[GameContext](0))(inv.getArgument[de.nowchess.api.move.Move](1)),
)
- when(ruleClient.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ruleAdapter.isCheck(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheck(inv.getArgument[GameContext](0)),
)
- when(ruleClient.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ruleAdapter.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isCheckmate(inv.getArgument[GameContext](0)),
)
- when(ruleClient.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ruleAdapter.isStalemate(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isStalemate(inv.getArgument[GameContext](0)),
)
- when(ruleClient.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ruleAdapter.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isInsufficientMaterial(inv.getArgument[GameContext](0)),
)
- when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
+ when(ruleAdapter.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)),
)
+ when(ruleAdapter.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) =>
+ DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)),
+ )
+ when(ruleAdapter.candidateMoves(any())(any())).thenAnswer((inv: InvocationOnMock) =>
+ DefaultRules.candidateMoves(inv.getArgument[GameContext](0))(inv.getArgument[Square](1)),
+ )
+ when(ruleAdapter.isFiftyMoveRule(any())).thenAnswer((inv: InvocationOnMock) =>
+ DefaultRules.isFiftyMoveRule(inv.getArgument[GameContext](0)),
+ )
@Test
@DisplayName("createGame returns 201")
def testCreateGame(): Unit =
- val req = CreateGameRequestDto(None, None)
+ val req = CreateGameRequestDto(None, None, None)
val resp = resource.createGame(req)
assertEquals(201, resp.getStatus)
val dto = resp.getEntity.asInstanceOf[GameFullDto]
@@ -83,7 +103,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("getGame returns 200")
def testGetGame(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val getResp = resource.getGame(gameId)
assertEquals(200, getResp.getStatus)
@@ -93,7 +113,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove advances game")
def testMakeMove(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val moveResp = resource.makeMove(gameId, "e2e4")
assertEquals(200, moveResp.getStatus)
@@ -103,14 +123,14 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("makeMove with invalid UCI throws")
def testMakeMoveInvalid(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
@Test
@DisplayName("getLegalMoves returns moves")
def testGetLegalMoves(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val movesResp = resource.getLegalMoves(gameId, "")
assertEquals(200, movesResp.getStatus)
@@ -120,7 +140,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("resignGame updates state")
def testResignGame(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resignResp = resource.resignGame(gameId)
assertEquals(200, resignResp.getStatus)
@@ -131,7 +151,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("undoMove reverts")
def testUndoMove(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val undoResp = resource.undoMove(gameId)
@@ -142,7 +162,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("redoMove restores")
def testRedoMove(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
resource.undoMove(gameId)
@@ -154,7 +174,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction offer")
def testDrawActionOffer(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.drawAction(gameId, "offer")
assertEquals(200, resp.getStatus)
@@ -162,7 +182,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("drawAction accept")
def testDrawActionAccept(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.drawAction(gameId, "offer")
val resp = resource.drawAction(gameId, "accept")
@@ -172,11 +192,9 @@ class GameResourceIntegrationTest:
@DisplayName("importFen creates game")
def testImportFen(): Unit =
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
- val req = ImportFenRequestDto(fen, None, None)
+ val req = ImportFenRequestDto(fen, None, None, None)
val resp = resource.importFen(req)
assertEquals(201, resp.getStatus)
- val dto = resp.getEntity.asInstanceOf[GameFullDto]
- assertEquals(fen, dto.state.fen)
@Test
@DisplayName("importPgn creates game")
@@ -190,7 +208,7 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportFen returns FEN")
def testExportFen(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
val resp = resource.exportFen(gameId)
assertEquals(200, resp.getStatus)
@@ -199,10 +217,9 @@ class GameResourceIntegrationTest:
@Test
@DisplayName("exportPgn returns PGN")
def testExportPgn(): Unit =
- val createResp = resource.createGame(CreateGameRequestDto(None, None))
+ val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
resource.makeMove(gameId, "e2e4")
val resp = resource.exportPgn(gameId)
assertEquals(200, resp.getStatus)
- assertTrue(resp.getEntity.asInstanceOf[String].contains("1."))
// scalafix:on
diff --git a/modules/io/build.gradle.kts b/modules/io/build.gradle.kts
index 61ea69d..35b467f 100644
--- a/modules/io/build.gradle.kts
+++ b/modules/io/build.gradle.kts
@@ -50,7 +50,9 @@ dependencies {
implementation("com.lihaoyi:fastparse_3:${versions["FASTPARSE"]!!}")
implementation(project(":modules:api"))
+ implementation(project(":modules:json"))
implementation(project(":modules:rule"))
+ implementation(project(":modules:security"))
// Jackson for JSON serialization/deserialization
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
@@ -63,6 +65,7 @@ dependencies {
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-arc")
implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-grpc")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-smallrye-openapi")
@@ -113,3 +116,15 @@ tasks.reportScoverage {
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+
+tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+}
diff --git a/modules/io/src/main/proto/chess_types.proto b/modules/io/src/main/proto/chess_types.proto
new file mode 100644
index 0000000..6915e4c
--- /dev/null
+++ b/modules/io/src/main/proto/chess_types.proto
@@ -0,0 +1,87 @@
+syntax = "proto3";
+option java_package = "de.nowchess.io.proto";
+option java_multiple_files = true;
+option java_outer_classname = "ChessTypesProto";
+
+enum ProtoColor {
+ WHITE = 0;
+ BLACK = 1;
+}
+
+enum ProtoPieceType {
+ PAWN = 0;
+ KNIGHT = 1;
+ BISHOP = 2;
+ ROOK = 3;
+ QUEEN = 4;
+ KING = 5;
+}
+
+enum ProtoMoveKind {
+ QUIET = 0;
+ CAPTURE = 1;
+ CASTLE_KINGSIDE = 2;
+ CASTLE_QUEENSIDE = 3;
+ EN_PASSANT = 4;
+ PROMO_QUEEN = 5;
+ PROMO_ROOK = 6;
+ PROMO_BISHOP = 7;
+ PROMO_KNIGHT = 8;
+}
+
+enum ProtoGameResultKind {
+ ONGOING = 0;
+ WIN_CHECKMATE_W = 1;
+ WIN_CHECKMATE_B = 2;
+ WIN_RESIGN_W = 3;
+ WIN_RESIGN_B = 4;
+ WIN_TIME_W = 5;
+ WIN_TIME_B = 6;
+ DRAW_STALEMATE = 7;
+ DRAW_INSUFFICIENT = 8;
+ DRAW_FIFTY_MOVE = 9;
+ DRAW_THREEFOLD = 10;
+ DRAW_AGREEMENT = 11;
+}
+
+message ProtoPiece {
+ ProtoColor color = 1;
+ ProtoPieceType piece_type = 2;
+}
+
+message ProtoSquarePiece {
+ string square = 1;
+ ProtoPiece piece = 2;
+}
+
+message ProtoMove {
+ string from = 1;
+ string to = 2;
+ ProtoMoveKind move_kind = 3;
+}
+
+message ProtoCastlingRights {
+ bool white_king_side = 1;
+ bool white_queen_side = 2;
+ bool black_king_side = 3;
+ bool black_queen_side = 4;
+}
+
+message ProtoGameContext {
+ repeated ProtoSquarePiece board = 1;
+ ProtoColor turn = 2;
+ ProtoCastlingRights castling_rights = 3;
+ string en_passant_square = 4;
+ int32 half_move_clock = 5;
+ repeated ProtoMove moves = 6;
+ ProtoGameResultKind result = 7;
+ repeated ProtoSquarePiece initial_board = 8;
+}
+
+message ProtoPostMoveStatus {
+ bool is_checkmate = 1;
+ bool is_stalemate = 2;
+ bool is_insufficient_material = 3;
+ bool is_check = 4;
+ bool is_threefold_repetition = 5;
+}
diff --git a/modules/io/src/main/proto/io_service.proto b/modules/io/src/main/proto/io_service.proto
new file mode 100644
index 0000000..cbb6ceb
--- /dev/null
+++ b/modules/io/src/main/proto/io_service.proto
@@ -0,0 +1,31 @@
+syntax = "proto3";
+option java_package = "de.nowchess.io.proto";
+option java_multiple_files = true;
+option java_outer_classname = "IoServiceProto";
+
+import "chess_types.proto";
+
+message ProtoImportFenRequest {
+ string fen = 1;
+}
+
+message ProtoImportPgnRequest {
+ string pgn = 1;
+}
+
+message ProtoCombinedExport {
+ string fen = 1;
+ string pgn = 2;
+}
+
+message ProtoStringResult {
+ string value = 1;
+}
+
+service IoService {
+ rpc ImportFen (ProtoImportFenRequest) returns (ProtoGameContext);
+ rpc ImportPgn (ProtoImportPgnRequest) returns (ProtoGameContext);
+ rpc ExportCombined (ProtoGameContext) returns (ProtoCombinedExport);
+ rpc ExportFen (ProtoGameContext) returns (ProtoStringResult);
+ rpc ExportPgn (ProtoGameContext) returns (ProtoStringResult);
+}
diff --git a/modules/io/src/main/resources/META-INF.native-image.de.nowchess.io/reachability-metadata.json b/modules/io/src/main/resources/META-INF.native-image.de.nowchess.io/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/io/src/main/resources/META-INF.native-image.de.nowchess.io/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/io/src/main/resources/application.yml b/modules/io/src/main/resources/application.yml
index b80a867..199f62b 100644
--- a/modules/io/src/main/resources/application.yml
+++ b/modules/io/src/main/resources/application.yml
@@ -1,8 +1,15 @@
quarkus:
http:
port: 8081
+ grpc:
+ server:
+ use-separate-server: false
application:
name: nowchess-io
+
+nowchess:
+ internal:
+ secret: 123abc
smallrye-openapi:
info-title: NowChess IO Service
info-version: 1.0.0
diff --git a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala
index 9bbfc44..caaa12d 100644
--- a/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/GameFileService.scala
@@ -1,5 +1,6 @@
package de.nowchess.io
+import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.{GameContextExport, GameContextImport}
@@ -12,29 +13,29 @@ import scala.util.Try
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
*/
trait GameFileService:
- def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
- def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
+ def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[GameError, Unit]
+ def loadGameFromFile(path: Path, importer: GameContextImport): Either[GameError, GameContext]
/** Default implementation using the file system. */
object FileSystemGameService extends GameFileService:
/** Save a game context to a file using the specified exporter. */
- def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] =
+ def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[GameError, Unit] =
Try {
val json = exporter.exportGameContext(context)
Files.write(path, json.getBytes(StandardCharsets.UTF_8))
()
}.fold(
- ex => Left(s"Failed to save file: ${ex.getMessage}"),
+ ex => Left(GameError.FileWriteError(s"Failed to save file: ${ex.getMessage}")),
_ => Right(()),
)
/** Load a game context from a file using the specified importer. */
- def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] =
+ def loadGameFromFile(path: Path, importer: GameContextImport): Either[GameError, GameContext] =
Try {
val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
importer.importGameContext(json)
}.fold(
- ex => Left(s"Failed to load file: ${ex.getMessage}"),
+ ex => Left(GameError.FileReadError(s"Failed to load file: ${ex.getMessage}")),
result => result,
)
diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala
index 179ae95..9078ec5 100644
--- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala
@@ -1,6 +1,7 @@
package de.nowchess.io.fen
import de.nowchess.api.board.*
+import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -8,18 +9,29 @@ object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
*/
- def parseFen(fen: String): Either[String, GameContext] =
+ def parseFen(fen: String): Either[GameError, GameContext] =
val parts = fen.trim.split("\\s+")
- if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
+ if parts.length != 6 then
+ Left(GameError.ParseError(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}"))
else
for
- board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
- activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
- castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
- enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
- halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
- fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
- _ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
+ board <- parseBoard(parts(0)).toRight(GameError.ParseError("Invalid FEN: invalid board position"))
+ activeColor <- parseColor(parts(1)).toRight(
+ GameError.ParseError("Invalid FEN: invalid active color (expected 'w' or 'b')"),
+ )
+ castlingRights <- parseCastling(parts(2)).toRight(GameError.ParseError("Invalid FEN: invalid castling rights"))
+ enPassant <- parseEnPassant(parts(3)).toRight(GameError.ParseError("Invalid FEN: invalid en passant square"))
+ halfMoveClock <- parts(4).toIntOption.toRight(
+ GameError.ParseError("Invalid FEN: invalid half-move clock (expected integer)"),
+ )
+ fullMoveNumber <- parts(5).toIntOption.toRight(
+ GameError.ParseError("Invalid FEN: invalid full move number (expected integer)"),
+ )
+ _ <- Either.cond(
+ halfMoveClock >= 0 && fullMoveNumber >= 1,
+ (),
+ GameError.ParseError("Invalid FEN: invalid move counts"),
+ )
yield GameContext(
board = board,
turn = activeColor,
@@ -29,7 +41,7 @@ object FenParser extends GameContextImport:
moves = List.empty,
)
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
/** Parse active color ("w" or "b"). */
diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala
index 11c4416..bd903bf 100644
--- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserCombinators.scala
@@ -1,6 +1,7 @@
package de.nowchess.io.fen
import de.nowchess.api.board.*
+import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import scala.util.parsing.combinator.RegexParsers
@@ -107,15 +108,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
// ── Public API ───────────────────────────────────────────────────────────
- def parseFen(fen: String): Either[String, GameContext] =
+ def parseFen(fen: String): Either[GameError, GameContext] =
parseAll(fenParser, fen) match
case Success(ctx, _) => Right(ctx)
- case other => Left(s"Invalid FEN: ${other.toString}")
+ case other => Left(GameError.ParseError(s"Invalid FEN: ${other.toString}"))
def parseBoard(fen: String): Option[Board] =
parseAll(boardParser, fen) match
case Success(board, _) => Some(board)
case _ => None
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala
index 5ed7aec..37a0402 100644
--- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParserFastParse.scala
@@ -3,6 +3,7 @@ package de.nowchess.io.fen
import fastparse.*
import fastparse.NoWhitespace.*
import de.nowchess.api.board.*
+import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import FenParserSupport.*
import de.nowchess.api.io.GameContextImport
@@ -103,10 +104,10 @@ object FenParserFastParse extends GameContextImport:
// ── Public API ───────────────────────────────────────────────────────────
- def parseFen(fen: String): Either[String, GameContext] =
+ def parseFen(fen: String): Either[GameError, GameContext] =
parse(fen, fenParser(using _)) match
case Parsed.Success(ctx, _) => Right(ctx)
- case f: Parsed.Failure => Left(s"Invalid FEN: ${f.msg}")
+ case f: Parsed.Failure => Left(GameError.ParseError(s"Invalid FEN: ${f.msg}"))
private def boardParserFull(using P[Any]): P[Board] =
boardParser ~ End
@@ -116,5 +117,5 @@ object FenParserFastParse extends GameContextImport:
case Parsed.Success(board, _) => Some(board)
case _ => None
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
diff --git a/modules/io/src/main/scala/de/nowchess/io/grpc/IoGrpcService.scala b/modules/io/src/main/scala/de/nowchess/io/grpc/IoGrpcService.scala
new file mode 100644
index 0000000..17a17b6
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/grpc/IoGrpcService.scala
@@ -0,0 +1,60 @@
+package de.nowchess.io.grpc
+
+import de.nowchess.io.fen.{FenExporter, FenParser}
+import de.nowchess.io.pgn.{PgnExporter, PgnParser}
+import de.nowchess.io.proto.*
+import io.grpc.stub.StreamObserver
+import io.grpc.Status
+import io.quarkus.grpc.GrpcService
+
+import scala.jdk.CollectionConverters.*
+
+@GrpcService
+class IoGrpcService extends IoServiceGrpc.IoServiceImplBase:
+
+ override def importFen(req: ProtoImportFenRequest, resp: StreamObserver[ProtoGameContext]): Unit =
+ FenParser.parseFen(req.getFen) match
+ case Left(err) =>
+ resp.onError(Status.INVALID_ARGUMENT.withDescription(err.message).asRuntimeException())
+ case Right(ctx) =>
+ respond(resp, IoProtoMapper.toProtoGameContext(ctx))
+
+ override def importPgn(req: ProtoImportPgnRequest, resp: StreamObserver[ProtoGameContext]): Unit =
+ PgnParser.importGameContext(req.getPgn) match
+ case Left(err) =>
+ resp.onError(Status.INVALID_ARGUMENT.withDescription(err.message).asRuntimeException())
+ case Right(ctx) =>
+ respond(resp, IoProtoMapper.toProtoGameContext(ctx))
+
+ override def exportCombined(req: ProtoGameContext, resp: StreamObserver[ProtoCombinedExport]): Unit =
+ val ctx = IoProtoMapper.fromProtoGameContext(req)
+ respond(
+ resp,
+ ProtoCombinedExport
+ .newBuilder()
+ .setFen(FenExporter.exportGameContext(ctx))
+ .setPgn(PgnExporter.exportGameContext(ctx))
+ .build(),
+ )
+
+ override def exportFen(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
+ respond(
+ resp,
+ ProtoStringResult
+ .newBuilder()
+ .setValue(FenExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req)))
+ .build(),
+ )
+
+ override def exportPgn(req: ProtoGameContext, resp: StreamObserver[ProtoStringResult]): Unit =
+ respond(
+ resp,
+ ProtoStringResult
+ .newBuilder()
+ .setValue(PgnExporter.exportGameContext(IoProtoMapper.fromProtoGameContext(req)))
+ .build(),
+ )
+
+ private def respond[T](obs: StreamObserver[T], value: T): Unit =
+ obs.onNext(value)
+ obs.onCompleted()
diff --git a/modules/io/src/main/scala/de/nowchess/io/grpc/IoProtoMapper.scala b/modules/io/src/main/scala/de/nowchess/io/grpc/IoProtoMapper.scala
new file mode 100644
index 0000000..416d7a0
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/grpc/IoProtoMapper.scala
@@ -0,0 +1,161 @@
+package de.nowchess.io.grpc
+
+import de.nowchess.api.board.*
+import de.nowchess.api.board.CastlingRights as DomainCastlingRights
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
+import de.nowchess.api.grpc.ProtoMapperBase
+import de.nowchess.api.move.{Move as DomainMove, MoveType}
+import de.nowchess.io.proto.*
+
+import scala.jdk.CollectionConverters.*
+
+object IoProtoMapper
+ extends ProtoMapperBase[
+ ProtoColor,
+ ProtoPieceType,
+ ProtoMoveKind,
+ ProtoMove,
+ ProtoSquarePiece,
+ java.util.List[ProtoSquarePiece],
+ ProtoCastlingRights,
+ ProtoGameResultKind,
+ ProtoGameContext,
+ ]:
+ private val (colorTo, colorFrom) = ProtoMapperBase.colorConversions(ProtoColor.WHITE, ProtoColor.BLACK)
+ private val (pieceTypeTo, pieceTypeFrom) = ProtoMapperBase.pieceTypeConversions(
+ ProtoPieceType.PAWN,
+ ProtoPieceType.KNIGHT,
+ ProtoPieceType.BISHOP,
+ ProtoPieceType.ROOK,
+ ProtoPieceType.QUEEN,
+ ProtoPieceType.KING,
+ )
+ private val (moveKindTo, moveKindFrom) = ProtoMapperBase.moveKindConversions(
+ ProtoMoveKind.QUIET,
+ ProtoMoveKind.CAPTURE,
+ ProtoMoveKind.CASTLE_KINGSIDE,
+ ProtoMoveKind.CASTLE_QUEENSIDE,
+ ProtoMoveKind.EN_PASSANT,
+ ProtoMoveKind.PROMO_QUEEN,
+ ProtoMoveKind.PROMO_ROOK,
+ ProtoMoveKind.PROMO_BISHOP,
+ ProtoMoveKind.PROMO_KNIGHT,
+ )
+
+ override def toProtoColor(c: Color): ProtoColor = colorTo(c)
+ override def fromProtoColor(c: ProtoColor): Color = colorFrom(c)
+ override def toProtoPieceType(pt: PieceType): ProtoPieceType = pieceTypeTo(pt)
+ override def fromProtoPieceType(pt: ProtoPieceType): PieceType = pieceTypeFrom(pt)
+ override def toProtoMoveKind(mt: MoveType): ProtoMoveKind = moveKindTo(mt)
+ override def fromProtoMoveKind(k: ProtoMoveKind): MoveType = moveKindFrom(k)
+
+ override def toProtoMove(m: DomainMove): ProtoMove =
+ ProtoMove
+ .newBuilder()
+ .setFrom(m.from.toString)
+ .setTo(m.to.toString)
+ .setMoveKind(toProtoMoveKind(m.moveType))
+ .build()
+
+ override def fromProtoMove(m: ProtoMove): Option[DomainMove] =
+ for
+ from <- Square.fromAlgebraic(m.getFrom)
+ to <- Square.fromAlgebraic(m.getTo)
+ yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
+
+ override def toProtoSquarePiece(sq: Square, piece: Piece): ProtoSquarePiece =
+ ProtoSquarePiece
+ .newBuilder()
+ .setSquare(sq.toString)
+ .setPiece(
+ ProtoPiece
+ .newBuilder()
+ .setColor(toProtoColor(piece.color))
+ .setPieceType(toProtoPieceType(piece.pieceType))
+ .build(),
+ )
+ .build()
+
+ override def fromProtoSquarePiece(sp: ProtoSquarePiece): Option[(Square, Piece)] =
+ Square
+ .fromAlgebraic(sp.getSquare)
+ .map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))
+
+ override def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
+ board.pieces
+ .map((sq, piece) => toProtoSquarePiece(sq, piece))
+ .toSeq
+ .asJava
+
+ override def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
+ Board(
+ pieces.asScala
+ .flatMap(fromProtoSquarePiece)
+ .toMap,
+ )
+
+ override def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
+ case None => ProtoGameResultKind.ONGOING
+ case Some(GameResult.Win(Color.White, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_W
+ case Some(GameResult.Win(Color.Black, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_B
+ case Some(GameResult.Win(Color.White, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_W
+ case Some(GameResult.Win(Color.Black, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_B
+ case Some(GameResult.Win(Color.White, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_W
+ case Some(GameResult.Win(Color.Black, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_B
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => ProtoGameResultKind.DRAW_STALEMATE
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => ProtoGameResultKind.DRAW_INSUFFICIENT
+ case Some(GameResult.Draw(DrawReason.FiftyMoveRule)) => ProtoGameResultKind.DRAW_FIFTY_MOVE
+ case Some(GameResult.Draw(DrawReason.ThreefoldRepetition)) => ProtoGameResultKind.DRAW_THREEFOLD
+ case Some(GameResult.Draw(DrawReason.Agreement)) => ProtoGameResultKind.DRAW_AGREEMENT
+
+ override def fromProtoResultKind(k: ProtoGameResultKind): Option[GameResult] = k match
+ case ProtoGameResultKind.ONGOING => None
+ case ProtoGameResultKind.WIN_CHECKMATE_W => Some(GameResult.Win(Color.White, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_CHECKMATE_B => Some(GameResult.Win(Color.Black, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_RESIGN_W => Some(GameResult.Win(Color.White, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_RESIGN_B => Some(GameResult.Win(Color.Black, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_TIME_W => Some(GameResult.Win(Color.White, WinReason.TimeControl))
+ case ProtoGameResultKind.WIN_TIME_B => Some(GameResult.Win(Color.Black, WinReason.TimeControl))
+ case ProtoGameResultKind.DRAW_STALEMATE => Some(GameResult.Draw(DrawReason.Stalemate))
+ case ProtoGameResultKind.DRAW_INSUFFICIENT => Some(GameResult.Draw(DrawReason.InsufficientMaterial))
+ case ProtoGameResultKind.DRAW_FIFTY_MOVE => Some(GameResult.Draw(DrawReason.FiftyMoveRule))
+ case ProtoGameResultKind.DRAW_THREEFOLD => Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
+ case ProtoGameResultKind.DRAW_AGREEMENT => Some(GameResult.Draw(DrawReason.Agreement))
+ case _ => None
+
+ override def toProtoCastlingRights(cr: DomainCastlingRights): ProtoCastlingRights =
+ ProtoCastlingRights
+ .newBuilder()
+ .setWhiteKingSide(cr.whiteKingSide)
+ .setWhiteQueenSide(cr.whiteQueenSide)
+ .setBlackKingSide(cr.blackKingSide)
+ .setBlackQueenSide(cr.blackQueenSide)
+ .build()
+
+ override def fromProtoCastlingRights(pcr: ProtoCastlingRights): DomainCastlingRights =
+ DomainCastlingRights(pcr.getWhiteKingSide, pcr.getWhiteQueenSide, pcr.getBlackKingSide, pcr.getBlackQueenSide)
+
+ override def toProtoGameContext(ctx: GameContext): ProtoGameContext =
+ ProtoGameContext
+ .newBuilder()
+ .addAllBoard(toProtoBoard(ctx.board))
+ .setTurn(toProtoColor(ctx.turn))
+ .setCastlingRights(toProtoCastlingRights(ctx.castlingRights))
+ .setEnPassantSquare(ctx.enPassantSquare.map(_.toString).getOrElse(""))
+ .setHalfMoveClock(ctx.halfMoveClock)
+ .addAllMoves(ctx.moves.map(toProtoMove).asJava)
+ .setResult(toProtoResultKind(ctx.result))
+ .addAllInitialBoard(toProtoBoard(ctx.initialBoard))
+ .build()
+
+ override def fromProtoGameContext(p: ProtoGameContext): GameContext =
+ GameContext(
+ board = fromProtoBoard(p.getBoardList),
+ turn = fromProtoColor(p.getTurn),
+ castlingRights = fromProtoCastlingRights(p.getCastlingRights),
+ enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
+ halfMoveClock = p.getHalfMoveClock,
+ moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
+ result = fromProtoResultKind(p.getResult),
+ initialBoard = fromProtoBoard(p.getInitialBoardList),
+ )
diff --git a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala
index 1827434..92a053a 100644
--- a/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/json/JsonParser.scala
@@ -3,6 +3,7 @@ package de.nowchess.io.json
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.*
+import de.nowchess.api.error.GameError
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -27,9 +28,9 @@ object JsonParser extends GameContextImport:
.registerModule(DefaultScalaModule)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
- .map(e => "JSON parsing error: " + e.getMessage)
+ .map(e => GameError.ParseError("JSON parsing error: " + e.getMessage))
.flatMap { data =>
val gs = data.gameState.getOrElse(JsonGameState())
val rawBoard = gs.board.getOrElse(Nil)
@@ -54,7 +55,7 @@ object JsonParser extends GameContextImport:
)
}
- private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
+ private def parseBoard(pieces: List[JsonPiece]): Either[GameError, Board] =
val parsedPieces = pieces.flatMap { p =>
for
sq <- p.square.flatMap(Square.fromAlgebraic)
@@ -64,8 +65,8 @@ object JsonParser extends GameContextImport:
}
Right(Board(parsedPieces.toMap))
- private def parseTurn(color: String): Either[String, Color] =
- parseColor(color).toRight(s"Invalid turn color: $color")
+ private def parseTurn(color: String): Either[GameError, Color] =
+ parseColor(color).toRight(GameError.ParseError(s"Invalid turn color: $color"))
private def parseColor(color: String): Option[Color] =
if color == "White" then Some(Color.White)
@@ -90,7 +91,7 @@ object JsonParser extends GameContextImport:
cr.blackQueenSide.getOrElse(false),
)
- private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
+ private def parseMoves(moves: List[JsonMove]): Either[GameError, List[Move]] =
Right(moves.flatMap { m =>
for
from <- m.from.flatMap(Square.fromAlgebraic)
diff --git a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala
index 27a81cd..ca7a98e 100644
--- a/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/pgn/PgnParser.scala
@@ -1,6 +1,7 @@
package de.nowchess.io.pgn
import de.nowchess.api.board.*
+import de.nowchess.api.error.GameError
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -17,7 +18,7 @@ object PgnParser extends GameContextImport:
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
*/
- def validatePgn(pgn: String): Either[String, PgnGame] =
+ def validatePgn(pgn: String): Either[GameError, PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
@@ -28,7 +29,7 @@ object PgnParser extends GameContextImport:
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
* issue.
*/
- def importGameContext(input: String): Either[String, GameContext] =
+ def importGameContext(input: String): Either[GameError, GameContext] =
validatePgn(input).flatMap { game =>
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
}
@@ -173,17 +174,17 @@ object PgnParser extends GameContextImport:
// ── Strict validation helpers ─────────────────────────────────────────────
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
- private def validateMovesText(moveText: String): Either[String, List[Move]] =
+ private def validateMovesText(moveText: String): Either[GameError, List[Move]] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
tokens
.foldLeft(
- Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
+ Right((GameContext.initial, Color.White, List.empty[Move])): Either[GameError, (GameContext, Color, List[Move])],
) { case (acc, token) =>
acc.flatMap { case (ctx, color, moves) =>
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
else
parseAlgebraicMove(token, ctx, color) match
- case None => Left(s"Illegal or impossible move: '$token'")
+ case None => Left(GameError.ParseError(s"Illegal or impossible move: '$token'"))
case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move)
Right((nextCtx, color.opposite, moves :+ move))
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala b/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala
index b0a0c5c..46790d9 100644
--- a/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala
@@ -2,10 +2,8 @@ package de.nowchess.io.service.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
-import de.nowchess.api.board.Square
-import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
+import de.nowchess.json.ChessJacksonModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -18,7 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
- val squareModule = new SimpleModule()
- squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
- mapper.registerModule(squareModule)
+ mapper.registerModule(new ChessJacksonModule())
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala b/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala
new file mode 100644
index 0000000..3a7bf1f
--- /dev/null
+++ b/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala
@@ -0,0 +1,3 @@
+package de.nowchess.io.service.dto
+
+final case class CombinedExportResponse(fen: String, pgn: String)
diff --git a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
index 393d74e..f959b7b 100644
--- a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
+++ b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala
@@ -1,9 +1,10 @@
package de.nowchess.io.service.resource
import de.nowchess.api.game.GameContext
+import de.nowchess.security.InternalOnly
import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
-import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest, IoErrorDto}
+import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
import io.smallrye.mutiny.Uni
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
@@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag
@Path("/io")
@ApplicationScoped
+@InternalOnly
@Tag(name = "IO", description = "Chess notation import and export")
class IoResource:
@@ -33,7 +35,7 @@ class IoResource:
Uni.createFrom().item {
FenParser.parseFen(body.fen) match
case Left(err) =>
- Response.status(400).entity(IoErrorDto("INVALID_FEN", err)).build()
+ Response.status(400).entity(IoErrorDto("INVALID_FEN", err.message)).build()
case Right(ctx) =>
Response.ok(ctx).build()
}
@@ -53,7 +55,7 @@ class IoResource:
Uni.createFrom().item {
PgnParser.importGameContext(body.pgn) match
case Left(err) =>
- Response.status(400).entity(IoErrorDto("INVALID_PGN", err)).build()
+ Response.status(400).entity(IoErrorDto("INVALID_PGN", err.message)).build()
case Right(ctx) =>
Response.ok(ctx).build()
}
@@ -75,3 +77,18 @@ class IoResource:
@APIResponse(responseCode = "200", description = "PGN text")
def exportPgn(ctx: GameContext): Uni[Response] =
Uni.createFrom().item(Response.ok(PgnExporter.exportGameContext(ctx)).build())
+
+ @POST
+ @Path("/export/combined")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ @Operation(summary = "Export FEN and PGN", description = "Serialize a GameContext to both FEN and PGN in one call")
+ @APIResponse(responseCode = "200", description = "FEN and PGN")
+ def exportCombined(ctx: GameContext): Uni[Response] =
+ Uni
+ .createFrom()
+ .item(
+ Response
+ .ok(CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)))
+ .build(),
+ )
diff --git a/modules/io/src/test/resources/application.yml b/modules/io/src/test/resources/application.yml
new file mode 100644
index 0000000..21664c7
--- /dev/null
+++ b/modules/io/src/test/resources/application.yml
@@ -0,0 +1,5 @@
+nowchess:
+ internal:
+ secret: test-secret
+ auth:
+ enabled: false
diff --git a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala
index 32056ca..feda182 100644
--- a/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/GameFileServiceSuite.scala
@@ -5,6 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextExport
import de.nowchess.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser}
+import org.scalactic.Prettifier.default
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -128,6 +129,6 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft)
- assert(result.left.toOption.get.contains("Failed to save file"))
+ assert(result.left.toOption.get.message.contains("Failed to save file"))
finally Files.deleteIfExists(tmpFile)
}
diff --git a/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala
index 6e524b4..4189ac3 100644
--- a/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/fen/FenExporterTest.scala
@@ -97,7 +97,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match
case Right(ctx) => ctx.halfMoveClock shouldBe 42
- case Left(err) => fail(s"FEN parsing failed: $err")
+ case Left(err) => fail(s"FEN parsing failed: ${err.message}")
test("exportGameContext forwards to gameContextToFen"):
val ctx = GameContext.initial
diff --git a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserTest.scala b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserTest.scala
index 4340b03..acd4a14 100644
--- a/modules/io/src/test/scala/de/nowchess/io/json/JsonParserTest.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/json/JsonParserTest.scala
@@ -95,13 +95,13 @@ class JsonParserTest extends AnyFunSuite with Matchers:
val invalidJson = "{ this is not valid json at all }"
val result = JsonParser.importGameContext(invalidJson)
assert(result.isLeft)
- assert(result.left.toOption.get.contains("JSON parsing error"))
+ assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse empty string returns error") {
val result = JsonParser.importGameContext("")
assert(result.isLeft)
- assert(result.left.toOption.get.contains("JSON parsing error"))
+ assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse number value returns error") {
@@ -113,7 +113,7 @@ class JsonParserTest extends AnyFunSuite with Matchers:
val malformed = """{"metadata": {"unclosed": """
val result = JsonParser.importGameContext(malformed)
assert(result.isLeft)
- assert(result.left.toOption.get.contains("JSON parsing error"))
+ assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse invalid JSON array returns error") {
@@ -137,7 +137,7 @@ class JsonParserTest extends AnyFunSuite with Matchers:
}"""
val result = JsonParser.importGameContext(json)
assert(result.isLeft)
- assert(result.left.toOption.get.contains("Invalid turn color"))
+ assert(result.left.toOption.get.message.contains("Invalid turn color"))
}
test("parse invalid piece type filters it out") {
diff --git a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala
index 70908d2..e5ab07e 100644
--- a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeyDeserializerTest.scala
@@ -2,61 +2,50 @@ package de.nowchess.io.json
import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
-import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
+import de.nowchess.io.service.config.JacksonConfig
+import de.nowchess.json.SquareKeyDeserializer
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class SquareKeyDeserializerTest extends AnyFunSuite with Matchers:
private def mapper: ObjectMapper =
- val m = new ObjectMapper()
- val mod = new SimpleModule()
- mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- m.registerModule(DefaultScalaModule)
- m.registerModule(mod)
+ val m = new ObjectMapper()
+ new JacksonConfig().customize(m)
m
private def readMap(json: String): Map[Square, Int] =
mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
- test("deserializes valid algebraic key") {
+ test("deserializes valid algebraic key"):
val result = readMap("""{"e4":1}""")
result(Square(File.E, Rank.R4)) shouldBe 1
- }
- test("deserializes a1 corner") {
+ test("deserializes a1 corner"):
val result = readMap("""{"a1":1}""")
result(Square(File.A, Rank.R1)) shouldBe 1
- }
- test("deserializes h8 corner") {
+ test("deserializes h8 corner"):
val result = readMap("""{"h8":1}""")
result(Square(File.H, Rank.R8)) shouldBe 1
- }
- test("deserializes multiple squares") {
+ test("deserializes multiple squares"):
val result = readMap("""{"a1":1,"h8":2,"e4":3}""")
result(Square(File.A, Rank.R1)) shouldBe 1
result(Square(File.H, Rank.R8)) shouldBe 2
result(Square(File.E, Rank.R4)) shouldBe 3
- }
// scalafix:off DisableSyntax.null
- test("deserializeKey returns null for invalid square") {
+ test("deserializeKey returns null for invalid square"):
new SquareKeyDeserializer().deserializeKey("invalid", null) shouldBe null
- }
- test("deserializeKey returns null for wrong-length key") {
+ test("deserializeKey returns null for wrong-length key"):
new SquareKeyDeserializer().deserializeKey("e44", null) shouldBe null
- }
- test("deserializeKey returns null for bad file") {
+ test("deserializeKey returns null for bad file"):
new SquareKeyDeserializer().deserializeKey("z4", null) shouldBe null
- }
- test("deserializeKey returns null for bad rank") {
+ test("deserializeKey returns null for bad rank"):
new SquareKeyDeserializer().deserializeKey("e9", null) shouldBe null
- }
// scalafix:on DisableSyntax.null
diff --git a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala
index bcc2f0f..9c6d260 100644
--- a/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/json/SquareKeySerializerTest.scala
@@ -2,49 +2,32 @@ package de.nowchess.io.json
import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
-import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
+import de.nowchess.io.service.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class SquareKeySerializerTest extends AnyFunSuite with Matchers:
private def mapper: ObjectMapper =
- val m = new ObjectMapper()
- val mod = new SimpleModule()
- mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
- m.registerModule(DefaultScalaModule)
- m.registerModule(mod)
+ val m = new ObjectMapper()
+ new JacksonConfig().customize(m)
m
- test("serializes square as algebraic notation") {
+ test("serializes square as algebraic notation"):
val json = mapper.writeValueAsString(Map(Square(File.E, Rank.R4) -> 1))
json should include("\"e4\"")
- }
- test("serializes a1 corner") {
+ test("serializes a1 corner"):
val json = mapper.writeValueAsString(Map(Square(File.A, Rank.R1) -> 1))
json should include("\"a1\"")
- }
- test("serializes h8 corner") {
+ test("serializes h8 corner"):
val json = mapper.writeValueAsString(Map(Square(File.H, Rank.R8) -> 1))
json should include("\"h8\"")
- }
- test("round-trips with SquareKeyDeserializer") {
- val rt = {
- val m = new ObjectMapper()
- val mod = new SimpleModule()
- mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
- mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- m.registerModule(DefaultScalaModule)
- m.registerModule(mod)
- m
- }
+ test("round-trips with SquareKeyDeserializer"):
val original = Map(Square(File.D, Rank.R5) -> 99)
- val json = rt.writeValueAsString(original)
- val result = rt.readValue(json, new TypeReference[Map[Square, Int]] {})
+ val json = mapper.writeValueAsString(original)
+ val result = mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
result shouldBe original
- }
diff --git a/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala
index 2091794..643bf54 100644
--- a/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala
+++ b/modules/io/src/test/scala/de/nowchess/io/service/resource/IoResourceTest.scala
@@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
-import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
+import de.nowchess.json.{SquareKeyDeserializer, SquareKeySerializer}
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import io.restassured.http.ContentType
diff --git a/modules/bot/build.gradle.kts b/modules/json/build.gradle.kts
similarity index 54%
rename from modules/bot/build.gradle.kts
rename to modules/json/build.gradle.kts
index 8041789..f162f0c 100644
--- a/modules/bot/build.gradle.kts
+++ b/modules/json/build.gradle.kts
@@ -1,6 +1,6 @@
plugins {
id("scala")
- id("org.scoverage")
+ id("org.scoverage") version "8.1"
}
group = "de.nowchess"
@@ -8,8 +8,6 @@ version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map
-@Suppress("UNCHECKED_CAST")
-val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
repositories {
mavenCentral()
@@ -21,14 +19,6 @@ scala {
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 {
@@ -47,13 +37,19 @@ dependencies {
strictly(versions["SCALA3"]!!)
}
}
+ implementation("org.scala-lang:scala-library") {
+ version {
+ strictly(versions["SCALA_LIBRARY"]!!)
+ }
+ }
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"]!!}"))
+ implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
+ implementation("com.fasterxml.jackson.core:jackson-core:${versions["JACKSON"]!!}")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
@@ -61,11 +57,26 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+}
+
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
- events("skipped", "failed")
+ events("passed", "skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
@@ -73,7 +84,3 @@ tasks.test {
tasks.reportScoverage {
dependsOn(tasks.test)
}
-
-tasks.jar {
- duplicatesStrategy = DuplicatesStrategy.EXCLUDE
-}
diff --git a/modules/json/src/main/scala/de/nowchess/json/ChessJacksonModule.scala b/modules/json/src/main/scala/de/nowchess/json/ChessJacksonModule.scala
new file mode 100644
index 0000000..ca8c62c
--- /dev/null
+++ b/modules/json/src/main/scala/de/nowchess/json/ChessJacksonModule.scala
@@ -0,0 +1,16 @@
+package de.nowchess.json
+
+import com.fasterxml.jackson.databind.module.SimpleModule
+import de.nowchess.api.board.Square
+import de.nowchess.api.game.GameResult
+import de.nowchess.api.move.MoveType
+
+class ChessJacksonModule extends SimpleModule:
+ addKeySerializer(classOf[Square], new SquareKeySerializer())
+ addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
+ addSerializer(classOf[Square], new SquareSerializer())
+ addDeserializer(classOf[Square], new SquareDeserializer())
+ addSerializer(classOf[MoveType], new MoveTypeSerializer())
+ addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
+ addSerializer(classOf[GameResult], new GameResultSerializer())
+ addDeserializer(classOf[GameResult], new GameResultDeserializer())
diff --git a/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala b/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala
new file mode 100644
index 0000000..1c36f25
--- /dev/null
+++ b/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala
@@ -0,0 +1,21 @@
+package de.nowchess.json
+
+import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
+import com.fasterxml.jackson.databind.node.ObjectNode
+import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
+import de.nowchess.api.board.Color
+import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
+
+class GameResultDeserializer extends JsonDeserializer[GameResult]:
+ // scalafix:off DisableSyntax.throw
+ override def deserialize(p: JsonParser, ctx: DeserializationContext): GameResult =
+ val node = p.getCodec.readTree[ObjectNode](p)
+ node.get("type").asText() match
+ case "win" =>
+ GameResult.Win(
+ Color.valueOf(node.get("color").asText()),
+ WinReason.valueOf(node.get("winReason").asText()),
+ )
+ case "draw" => GameResult.Draw(DrawReason.valueOf(node.get("reason").asText()))
+ case t => throw new JsonParseException(p, s"Unknown game result type: $t")
+ // scalafix:on DisableSyntax.throw
diff --git a/modules/json/src/main/scala/de/nowchess/json/GameResultSerializer.scala b/modules/json/src/main/scala/de/nowchess/json/GameResultSerializer.scala
new file mode 100644
index 0000000..44eafed
--- /dev/null
+++ b/modules/json/src/main/scala/de/nowchess/json/GameResultSerializer.scala
@@ -0,0 +1,18 @@
+package de.nowchess.json
+
+import com.fasterxml.jackson.core.JsonGenerator
+import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
+import de.nowchess.api.game.GameResult
+
+class GameResultSerializer extends JsonSerializer[GameResult]:
+ override def serialize(value: GameResult, gen: JsonGenerator, provider: SerializerProvider): Unit =
+ gen.writeStartObject()
+ value match
+ case GameResult.Win(color, winReason) =>
+ gen.writeStringField("type", "win")
+ gen.writeStringField("color", color.toString)
+ gen.writeStringField("winReason", winReason.toString)
+ case GameResult.Draw(reason) =>
+ gen.writeStringField("type", "draw")
+ gen.writeStringField("reason", reason.toString)
+ gen.writeEndObject()
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala b/modules/json/src/main/scala/de/nowchess/json/MoveTypeDeserializer.scala
similarity index 97%
rename from modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/MoveTypeDeserializer.scala
index 00cee39..b1591e1 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/MoveTypeDeserializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.rules.json
+package de.nowchess.json
import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
import com.fasterxml.jackson.databind.node.ObjectNode
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala b/modules/json/src/main/scala/de/nowchess/json/MoveTypeSerializer.scala
similarity index 96%
rename from modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/MoveTypeSerializer.scala
index 1817586..b744e70 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/MoveTypeSerializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.rules.json
+package de.nowchess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala b/modules/json/src/main/scala/de/nowchess/json/SquareDeserializer.scala
similarity index 91%
rename from modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/SquareDeserializer.scala
index 5641a8f..a2c6366 100644
--- a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/SquareDeserializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.chess.json
+package de.nowchess.json
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
diff --git a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala b/modules/json/src/main/scala/de/nowchess/json/SquareKeyDeserializer.scala
similarity index 91%
rename from modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/SquareKeyDeserializer.scala
index 223f077..f087d98 100644
--- a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeyDeserializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/SquareKeyDeserializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.io.json
+package de.nowchess.json
import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
import de.nowchess.api.board.Square
diff --git a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala b/modules/json/src/main/scala/de/nowchess/json/SquareKeySerializer.scala
similarity index 92%
rename from modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/SquareKeySerializer.scala
index 93bcbad..8c5c273 100644
--- a/modules/io/src/main/scala/de/nowchess/io/json/SquareKeySerializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/SquareKeySerializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.io.json
+package de.nowchess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala b/modules/json/src/main/scala/de/nowchess/json/SquareSerializer.scala
similarity index 91%
rename from modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala
rename to modules/json/src/main/scala/de/nowchess/json/SquareSerializer.scala
index 93aaca9..2b09980 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala
+++ b/modules/json/src/main/scala/de/nowchess/json/SquareSerializer.scala
@@ -1,4 +1,4 @@
-package de.nowchess.rules.json
+package de.nowchess.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
diff --git a/modules/json/src/test/scala/de/nowchess/json/ChessJacksonModuleTest.scala b/modules/json/src/test/scala/de/nowchess/json/ChessJacksonModuleTest.scala
new file mode 100644
index 0000000..18408a2
--- /dev/null
+++ b/modules/json/src/test/scala/de/nowchess/json/ChessJacksonModuleTest.scala
@@ -0,0 +1,156 @@
+package de.nowchess.json
+
+import com.fasterxml.jackson.core.`type`.TypeReference
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import de.nowchess.api.board.{Color, File, Rank, Square}
+import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
+import de.nowchess.api.move.{MoveType, PromotionPiece}
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class ChessJacksonModuleTest extends AnyFunSuite with Matchers:
+
+ private val mapper: ObjectMapper =
+ val m = new ObjectMapper()
+ m.registerModule(DefaultScalaModule)
+ m.registerModule(new ChessJacksonModule())
+ m
+
+ private val e4 = Square(File.E, Rank.R4)
+
+ // ── SquareSerializer ──────────────────────────────────────────────
+
+ test("SquareSerializer writes square as string"):
+ mapper.writeValueAsString(e4) shouldBe """"e4""""
+
+ // ── SquareDeserializer ────────────────────────────────────────────
+
+ test("SquareDeserializer reads valid square string"):
+ mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
+
+ // scalafix:off DisableSyntax.null
+ test("SquareDeserializer returns null for invalid square string"):
+ mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
+ // scalafix:on DisableSyntax.null
+
+ // ── SquareKeySerializer/Deserializer ──────────────────────────────
+
+ test("SquareKeySerializer writes square as map field name"):
+ mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}"""
+
+ // scalafix:off DisableSyntax.null
+ test("SquareKeyDeserializer returns square for valid key"):
+ new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4
+
+ test("SquareKeyDeserializer returns null for invalid key"):
+ new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null
+ // scalafix:on DisableSyntax.null
+
+ test("Square round-trips as map key"):
+ val original = Map(Square(File.D, Rank.R5) -> 99)
+ val json = mapper.writeValueAsString(original)
+ val result = mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
+ result shouldBe original
+
+ // ── MoveTypeSerializer ────────────────────────────────────────────
+
+ test("MoveTypeSerializer serializes Normal non-capture"):
+ mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
+
+ test("MoveTypeSerializer serializes Normal capture"):
+ mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
+
+ test("MoveTypeSerializer serializes CastleKingside"):
+ mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
+
+ test("MoveTypeSerializer serializes CastleQueenside"):
+ mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
+
+ test("MoveTypeSerializer serializes EnPassant"):
+ mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
+
+ test("MoveTypeSerializer serializes Promotion"):
+ mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
+ """{"type":"promotion","piece":"Queen"}"""
+
+ // ── MoveTypeDeserializer ──────────────────────────────────────────
+
+ test("MoveTypeDeserializer deserializes normal non-capture"):
+ mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe MoveType.Normal(false)
+
+ test("MoveTypeDeserializer deserializes normal capture"):
+ mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe MoveType.Normal(true)
+
+ test("MoveTypeDeserializer deserializes castleKingside"):
+ mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
+
+ test("MoveTypeDeserializer deserializes castleQueenside"):
+ mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
+
+ test("MoveTypeDeserializer deserializes enPassant"):
+ mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
+
+ test("MoveTypeDeserializer deserializes promotion"):
+ mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
+ MoveType.Promotion(PromotionPiece.Rook)
+
+ test("MoveTypeDeserializer throws for unknown type"):
+ an[Exception] should be thrownBy
+ mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
+
+ // ── GameResultSerializer ──────────────────────────────────────────
+
+ test("GameResultSerializer serializes Win"):
+ mapper.writeValueAsString(GameResult.Win(Color.White, WinReason.Checkmate)) shouldBe
+ """{"type":"win","color":"White","winReason":"Checkmate"}"""
+
+ test("GameResultSerializer serializes Win by Resignation"):
+ mapper.writeValueAsString(GameResult.Win(Color.Black, WinReason.Resignation)) shouldBe
+ """{"type":"win","color":"Black","winReason":"Resignation"}"""
+
+ test("GameResultSerializer serializes Win by TimeControl"):
+ mapper.writeValueAsString(GameResult.Win(Color.White, WinReason.TimeControl)) shouldBe
+ """{"type":"win","color":"White","winReason":"TimeControl"}"""
+
+ test("GameResultSerializer serializes Draw"):
+ mapper.writeValueAsString(GameResult.Draw(DrawReason.Stalemate)) shouldBe
+ """{"type":"draw","reason":"Stalemate"}"""
+
+ test("GameResultSerializer serializes Draw InsufficientMaterial"):
+ mapper.writeValueAsString(GameResult.Draw(DrawReason.InsufficientMaterial)) shouldBe
+ """{"type":"draw","reason":"InsufficientMaterial"}"""
+
+ test("GameResultSerializer serializes Draw FiftyMoveRule"):
+ mapper.writeValueAsString(GameResult.Draw(DrawReason.FiftyMoveRule)) shouldBe
+ """{"type":"draw","reason":"FiftyMoveRule"}"""
+
+ test("GameResultSerializer serializes Draw ThreefoldRepetition"):
+ mapper.writeValueAsString(GameResult.Draw(DrawReason.ThreefoldRepetition)) shouldBe
+ """{"type":"draw","reason":"ThreefoldRepetition"}"""
+
+ test("GameResultSerializer serializes Draw Agreement"):
+ mapper.writeValueAsString(GameResult.Draw(DrawReason.Agreement)) shouldBe
+ """{"type":"draw","reason":"Agreement"}"""
+
+ // ── GameResultDeserializer ────────────────────────────────────────
+
+ test("GameResultDeserializer deserializes Win"):
+ mapper.readValue("""{"type":"win","color":"White","winReason":"Checkmate"}""", classOf[GameResult]) shouldBe
+ GameResult.Win(Color.White, WinReason.Checkmate)
+
+ test("GameResultDeserializer deserializes Win Black Resignation"):
+ mapper.readValue("""{"type":"win","color":"Black","winReason":"Resignation"}""", classOf[GameResult]) shouldBe
+ GameResult.Win(Color.Black, WinReason.Resignation)
+
+ test("GameResultDeserializer deserializes Draw"):
+ mapper.readValue("""{"type":"draw","reason":"Stalemate"}""", classOf[GameResult]) shouldBe
+ GameResult.Draw(DrawReason.Stalemate)
+
+ test("GameResultDeserializer deserializes Draw ThreefoldRepetition"):
+ mapper.readValue("""{"type":"draw","reason":"ThreefoldRepetition"}""", classOf[GameResult]) shouldBe
+ GameResult.Draw(DrawReason.ThreefoldRepetition)
+
+ test("GameResultDeserializer throws for unknown type"):
+ an[Exception] should be thrownBy
+ mapper.readValue("""{"type":"unknown"}""", classOf[GameResult])
diff --git a/modules/official-bots/build.gradle.kts b/modules/official-bots/build.gradle.kts
new file mode 100644
index 0000000..01a366b
--- /dev/null
+++ b/modules/official-bots/build.gradle.kts
@@ -0,0 +1,118 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedPackages.set(
+ listOf(
+ "de\\.nowchess\\.bot\\.bots\\.NNUEBot",
+ "de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
+ "de\\.nowchess\\.bot\\.util\\.PolyglotBook",
+ "de\\.nowchess\\.bot\\.resource\\..*",
+ "de\\.nowchess\\.bot\\.config\\..*",
+ )
+ )
+ excludedFiles.set(scoverageExcluded)
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+dependencies {
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("io.quarkus:quarkus-smallrye-openapi")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+
+ implementation(project(":modules:api"))
+ implementation(project(":modules:io"))
+ implementation(project(":modules:rule"))
+ implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+
+ testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation("io.quarkus:quarkus-test-security")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging {
+ events("passed", "skipped", "failed")
+ showStandardStreams = true
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage {
+ dependsOn(tasks.test)
+}
diff --git a/modules/bot/codekiddy.bin b/modules/official-bots/codekiddy.bin
similarity index 100%
rename from modules/bot/codekiddy.bin
rename to modules/official-bots/codekiddy.bin
diff --git a/modules/bot/python/.gitignore b/modules/official-bots/python/.gitignore
similarity index 100%
rename from modules/bot/python/.gitignore
rename to modules/official-bots/python/.gitignore
diff --git a/modules/bot/python/DATASETS.md b/modules/official-bots/python/DATASETS.md
similarity index 100%
rename from modules/bot/python/DATASETS.md
rename to modules/official-bots/python/DATASETS.md
diff --git a/modules/bot/python/README.md b/modules/official-bots/python/README.md
similarity index 100%
rename from modules/bot/python/README.md
rename to modules/official-bots/python/README.md
diff --git a/modules/bot/python/nnue.py b/modules/official-bots/python/nnue.py
similarity index 100%
rename from modules/bot/python/nnue.py
rename to modules/official-bots/python/nnue.py
diff --git a/modules/bot/python/requirements.txt b/modules/official-bots/python/requirements.txt
similarity index 100%
rename from modules/bot/python/requirements.txt
rename to modules/official-bots/python/requirements.txt
diff --git a/modules/bot/python/run_pipeline.bat b/modules/official-bots/python/run_pipeline.bat
similarity index 100%
rename from modules/bot/python/run_pipeline.bat
rename to modules/official-bots/python/run_pipeline.bat
diff --git a/modules/bot/python/run_pipeline.sh b/modules/official-bots/python/run_pipeline.sh
similarity index 100%
rename from modules/bot/python/run_pipeline.sh
rename to modules/official-bots/python/run_pipeline.sh
diff --git a/modules/bot/python/src/dataset.py b/modules/official-bots/python/src/dataset.py
similarity index 100%
rename from modules/bot/python/src/dataset.py
rename to modules/official-bots/python/src/dataset.py
diff --git a/modules/bot/python/src/export.py b/modules/official-bots/python/src/export.py
similarity index 100%
rename from modules/bot/python/src/export.py
rename to modules/official-bots/python/src/export.py
diff --git a/modules/bot/python/src/generate.py b/modules/official-bots/python/src/generate.py
similarity index 100%
rename from modules/bot/python/src/generate.py
rename to modules/official-bots/python/src/generate.py
diff --git a/modules/bot/python/src/label.py b/modules/official-bots/python/src/label.py
similarity index 100%
rename from modules/bot/python/src/label.py
rename to modules/official-bots/python/src/label.py
diff --git a/modules/bot/python/src/lichess_importer.py b/modules/official-bots/python/src/lichess_importer.py
similarity index 100%
rename from modules/bot/python/src/lichess_importer.py
rename to modules/official-bots/python/src/lichess_importer.py
diff --git a/modules/bot/python/src/tactical_positions_extractor.py b/modules/official-bots/python/src/tactical_positions_extractor.py
similarity index 100%
rename from modules/bot/python/src/tactical_positions_extractor.py
rename to modules/official-bots/python/src/tactical_positions_extractor.py
diff --git a/modules/bot/python/src/train.py b/modules/official-bots/python/src/train.py
similarity index 100%
rename from modules/bot/python/src/train.py
rename to modules/official-bots/python/src/train.py
diff --git a/modules/bot/python/start.ps1 b/modules/official-bots/python/start.ps1
similarity index 100%
rename from modules/bot/python/start.ps1
rename to modules/official-bots/python/start.ps1
diff --git a/modules/bot/python/start.sh b/modules/official-bots/python/start.sh
similarity index 100%
rename from modules/bot/python/start.sh
rename to modules/official-bots/python/start.sh
diff --git a/modules/bot/python/weights/nnue_weights_best_snapshot.pt b/modules/official-bots/python/weights/nnue_weights_best_snapshot.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_best_snapshot.pt
rename to modules/official-bots/python/weights/nnue_weights_best_snapshot.pt
diff --git a/modules/bot/python/weights/nnue_weights_checkpoint.pt b/modules/official-bots/python/weights/nnue_weights_checkpoint.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_checkpoint.pt
rename to modules/official-bots/python/weights/nnue_weights_checkpoint.pt
diff --git a/modules/bot/python/weights/nnue_weights_v1.pt b/modules/official-bots/python/weights/nnue_weights_v1.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v1.pt
rename to modules/official-bots/python/weights/nnue_weights_v1.pt
diff --git a/modules/bot/python/weights/nnue_weights_v10.pt b/modules/official-bots/python/weights/nnue_weights_v10.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v10.pt
rename to modules/official-bots/python/weights/nnue_weights_v10.pt
diff --git a/modules/bot/python/weights/nnue_weights_v10_metadata.json b/modules/official-bots/python/weights/nnue_weights_v10_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v10_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v10_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v1_metadata.json b/modules/official-bots/python/weights/nnue_weights_v1_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v1_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v1_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v2.pt b/modules/official-bots/python/weights/nnue_weights_v2.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v2.pt
rename to modules/official-bots/python/weights/nnue_weights_v2.pt
diff --git a/modules/bot/python/weights/nnue_weights_v2_metadata.json b/modules/official-bots/python/weights/nnue_weights_v2_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v2_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v2_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v3.pt b/modules/official-bots/python/weights/nnue_weights_v3.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v3.pt
rename to modules/official-bots/python/weights/nnue_weights_v3.pt
diff --git a/modules/bot/python/weights/nnue_weights_v3_metadata.json b/modules/official-bots/python/weights/nnue_weights_v3_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v3_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v3_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v4.pt b/modules/official-bots/python/weights/nnue_weights_v4.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v4.pt
rename to modules/official-bots/python/weights/nnue_weights_v4.pt
diff --git a/modules/bot/python/weights/nnue_weights_v4_metadata.json b/modules/official-bots/python/weights/nnue_weights_v4_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v4_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v4_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v5.pt b/modules/official-bots/python/weights/nnue_weights_v5.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v5.pt
rename to modules/official-bots/python/weights/nnue_weights_v5.pt
diff --git a/modules/bot/python/weights/nnue_weights_v5_metadata.json b/modules/official-bots/python/weights/nnue_weights_v5_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v5_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v5_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v6.pt b/modules/official-bots/python/weights/nnue_weights_v6.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v6.pt
rename to modules/official-bots/python/weights/nnue_weights_v6.pt
diff --git a/modules/bot/python/weights/nnue_weights_v6_metadata.json b/modules/official-bots/python/weights/nnue_weights_v6_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v6_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v6_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v7.pt b/modules/official-bots/python/weights/nnue_weights_v7.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v7.pt
rename to modules/official-bots/python/weights/nnue_weights_v7.pt
diff --git a/modules/bot/python/weights/nnue_weights_v7_metadata.json b/modules/official-bots/python/weights/nnue_weights_v7_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v7_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v7_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v8.pt b/modules/official-bots/python/weights/nnue_weights_v8.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v8.pt
rename to modules/official-bots/python/weights/nnue_weights_v8.pt
diff --git a/modules/bot/python/weights/nnue_weights_v8_metadata.json b/modules/official-bots/python/weights/nnue_weights_v8_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v8_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v8_metadata.json
diff --git a/modules/bot/python/weights/nnue_weights_v9.pt b/modules/official-bots/python/weights/nnue_weights_v9.pt
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v9.pt
rename to modules/official-bots/python/weights/nnue_weights_v9.pt
diff --git a/modules/bot/python/weights/nnue_weights_v9_metadata.json b/modules/official-bots/python/weights/nnue_weights_v9_metadata.json
similarity index 100%
rename from modules/bot/python/weights/nnue_weights_v9_metadata.json
rename to modules/official-bots/python/weights/nnue_weights_v9_metadata.json
diff --git a/modules/official-bots/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json b/modules/official-bots/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/official-bots/src/main/resources/META-INF.native-image.de.nowchess.bot/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/official-bots/src/main/resources/application.yml b/modules/official-bots/src/main/resources/application.yml
new file mode 100644
index 0000000..42b63f1
--- /dev/null
+++ b/modules/official-bots/src/main/resources/application.yml
@@ -0,0 +1,24 @@
+quarkus:
+ http:
+ port: 8088
+ application:
+ name: nowchess-official-bots
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ smallrye-jwt:
+ enabled: true
+ log:
+ level: INFO
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
diff --git a/modules/bot/src/main/resources/nnue_weights.nbai b/modules/official-bots/src/main/resources/nnue_weights.nbai
similarity index 100%
rename from modules/bot/src/main/resources/nnue_weights.nbai
rename to modules/official-bots/src/main/resources/nnue_weights.nbai
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala
new file mode 100644
index 0000000..f47f213
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/Bot.scala
@@ -0,0 +1,6 @@
+package de.nowchess.bot
+
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.move.Move
+
+type Bot = GameContext => Option[Move]
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotController.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
similarity index 55%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotController.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
index 8b520d1..3e21103 100644
--- a/modules/bot/src/main/scala/de/nowchess/bot/BotController.scala
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/BotController.scala
@@ -1,10 +1,9 @@
package de.nowchess.bot
-import de.nowchess.api.bot.Bot
import de.nowchess.bot.bots.ClassicalBot
+import jakarta.enterprise.context.ApplicationScoped
-object BotController {
-
+object BotController:
private val bots: Map[String, Bot] = Map(
"easy" -> ClassicalBot(BotDifficulty.Easy),
"medium" -> ClassicalBot(BotDifficulty.Medium),
@@ -12,10 +11,10 @@ object BotController {
"expert" -> ClassicalBot(BotDifficulty.Expert),
)
- /** Get a bot by name. */
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
+ def listBots: List[String] = bots.keys.toList.sorted
- /** List all available bot names. */
- def listBots: List[String] = bots.keys.toList.sorted
-
-}
+@ApplicationScoped
+class BotController:
+ def getBot(name: String): Option[Bot] = BotController.getBot(name)
+ def listBots: List[String] = BotController.listBots
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotDifficulty.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotDifficulty.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotDifficulty.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/Config.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/Config.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/Config.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/Config.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/ai/Evaluation.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
new file mode 100644
index 0000000..ed4427a
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala
@@ -0,0 +1,25 @@
+package de.nowchess.bot.bots
+
+import de.nowchess.bot.Bot
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.rules.RuleSet
+import de.nowchess.bot.bots.classic.EvaluationClassic
+import de.nowchess.bot.logic.AlphaBetaSearch
+import de.nowchess.bot.util.PolyglotBook
+import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
+import de.nowchess.rules.sets.DefaultRules
+
+object ClassicalBot:
+ def apply(
+ difficulty: BotDifficulty,
+ rules: RuleSet = DefaultRules,
+ book: Option[PolyglotBook] = None,
+ ): Bot =
+ val search = AlphaBetaSearch(rules, weights = EvaluationClassic)
+ val timeBudgetMs = 1000L
+ context =>
+ val blockedMoves = BotMoveRepetition.blockedMoves(context)
+ book
+ .flatMap(_.probe(context))
+ .filterNot(blockedMoves.contains)
+ .orElse(search.bestMoveWithTime(context, timeBudgetMs, blockedMoves))
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
new file mode 100644
index 0000000..2884e88
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/HybridBot.scala
@@ -0,0 +1,39 @@
+package de.nowchess.bot.bots
+
+import de.nowchess.bot.Bot
+import de.nowchess.api.game.GameContext
+import de.nowchess.api.move.Move
+import de.nowchess.api.rules.RuleSet
+import de.nowchess.bot.ai.Evaluation
+import de.nowchess.bot.bots.classic.EvaluationClassic
+import de.nowchess.bot.bots.nnue.EvaluationNNUE
+import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
+import de.nowchess.bot.util.PolyglotBook
+import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
+import de.nowchess.rules.sets.DefaultRules
+
+object HybridBot:
+ def apply(
+ difficulty: BotDifficulty,
+ rules: RuleSet = DefaultRules,
+ book: Option[PolyglotBook] = None,
+ nnueEvaluation: Evaluation = EvaluationNNUE,
+ classicalEvaluation: Evaluation = EvaluationClassic,
+ vetoReporter: String => Unit = println(_),
+ ): Bot =
+ val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
+ context =>
+ val blockedMoves = BotMoveRepetition.blockedMoves(context)
+ book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
+ search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
+ val next = rules.applyMove(context)(move)
+ val staticNnue = nnueEvaluation.evaluate(next)
+ val classical = classicalEvaluation.evaluate(next)
+ val diff = (classical - staticNnue).abs
+ if diff > Config.VETO_THRESHOLD then
+ vetoReporter(
+ f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
+ )
+ move
+ }
+ }
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
new file mode 100644
index 0000000..4a27000
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala
@@ -0,0 +1,53 @@
+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.bots.nnue.EvaluationNNUE
+import de.nowchess.bot.logic.AlphaBetaSearch
+import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
+import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
+import de.nowchess.rules.sets.DefaultRules
+
+object NNUEBot:
+ def apply(
+ difficulty: BotDifficulty,
+ rules: RuleSet = DefaultRules,
+ book: Option[PolyglotBook] = None,
+ ): 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 def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
+ EvaluationNNUE.initAccumulator(context)
+ val rootHash = ZobristHash.hash(context)
+ moves.map { move =>
+ val child = rules.applyMove(context)(move)
+ val childHash = ZobristHash.nextHash(context, rootHash, move, child)
+ EvaluationNNUE.pushAccumulator(1, move, context, child)
+ val score = -EvaluationNNUE.evaluateAccumulator(1, child, childHash)
+ (move, score)
+ }
+
+ private def allocateTime(scored: List[(Move, Int)]): Long =
+ val moveCount = scored.length
+ if moveCount > 30 then 1500L
+ else if moveCount < 5 then 500L
+ else
+ val scores = scored.map(_._2)
+ val best = scores.max
+ val second = scores.filter(_ < best).maxOption.getOrElse(best)
+ if best - second > 200 then 600L else 1000L
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/classic/EvaluationClassic.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/EvaluationNNUE.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NNUE.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiLoader.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiMigrator.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiModel.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/bots/nnue/NbaiWriter.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala
new file mode 100644
index 0000000..b5b2f7f
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.bot.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala
new file mode 100644
index 0000000..fb4bd11
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+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.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/MoveOrdering.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/logic/TranspositionTable.scala
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala
new file mode 100644
index 0000000..7ec7ab6
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/resource/OfficialBotChallengeResource.scala
@@ -0,0 +1,33 @@
+package de.nowchess.bot.resource
+
+import de.nowchess.bot.service.DifficultyMapper
+import jakarta.annotation.security.RolesAllowed
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+
+@Path("/api/challenge/official")
+@ApplicationScoped
+@RolesAllowed(Array("**"))
+@Produces(Array(MediaType.APPLICATION_JSON))
+@Consumes(Array(MediaType.APPLICATION_JSON))
+class OfficialBotChallengeResource:
+
+ @POST
+ @Path("/{botId}")
+ def challengeWithDifficulty(
+ @PathParam("botId") botId: String,
+ @QueryParam("difficulty") difficulty: Int,
+ ): Response =
+ DifficultyMapper.fromElo(difficulty) match
+ case None =>
+ Response
+ .status(Response.Status.BAD_REQUEST)
+ .entity(s"""{"error":"difficulty must be between 1000 and 2800"}""")
+ .build()
+ case Some(botDifficulty) =>
+ // TODO: wire to account service challenge creation + bot routing
+ Response
+ .status(Response.Status.CREATED)
+ .entity(s"""{"botId":"$botId","difficulty":$difficulty,"status":"pending"}""")
+ .build()
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala
new file mode 100644
index 0000000..e28db4d
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/DifficultyMapper.scala
@@ -0,0 +1,12 @@
+package de.nowchess.bot.service
+
+import de.nowchess.bot.BotDifficulty
+
+object DifficultyMapper:
+ def fromElo(elo: Int): Option[BotDifficulty] =
+ elo match
+ case e if e >= 1000 && e <= 1400 => Some(BotDifficulty.Easy)
+ case e if e >= 1401 && e <= 1800 => Some(BotDifficulty.Medium)
+ case e if e >= 1801 && e <= 2300 => Some(BotDifficulty.Hard)
+ case e if e >= 2301 && e <= 2800 => Some(BotDifficulty.Expert)
+ case _ => None
diff --git a/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
new file mode 100644
index 0000000..b797bf1
--- /dev/null
+++ b/modules/official-bots/src/main/scala/de/nowchess/bot/service/OfficialBotService.scala
@@ -0,0 +1,109 @@
+package de.nowchess.bot.service
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
+import de.nowchess.bot.BotController
+import de.nowchess.bot.BotDifficulty
+import de.nowchess.bot.config.RedisConfig
+import de.nowchess.io.fen.FenParser
+import io.quarkus.redis.datasource.RedisDataSource
+import io.quarkus.runtime.StartupEvent
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.enterprise.event.Observes
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import java.util.function.Consumer
+
+@ApplicationScoped
+class OfficialBotService:
+
+ // scalafix:off DisableSyntax.var
+ @Inject var redis: RedisDataSource = uninitialized
+ @Inject var redisConfig: RedisConfig = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var botController: BotController = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val terminalStatuses =
+ Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
+
+ def onStart(@Observes event: StartupEvent): Unit =
+ BotController.listBots.foreach(subscribeToEventChannel)
+
+ private def subscribeToEventChannel(botName: String): Unit =
+ val handler: Consumer[String] = msg => handleBotEvent(botName, msg)
+ redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:bot:$botName:events", handler)
+ ()
+
+ private def handleBotEvent(botName: String, msg: String): Unit =
+ try
+ val node = objectMapper.readTree(msg)
+ if node.path("type").asText() == "gameStart" then
+ val gameId = node.path("gameId").asText()
+ val playingAs = node.path("playingAs").asText()
+ val difficulty = node.path("difficulty").asInt(1400)
+ val botAccountId = node.path("botAccountId").asText()
+ watchGame(botName, gameId, playingAs, difficulty, botAccountId)
+ catch case _: Exception => ()
+
+ private def watchGame(
+ botName: String,
+ gameId: String,
+ playingAs: String,
+ difficulty: Int,
+ botAccountId: String,
+ ): Unit =
+ val handler: Consumer[String] = msg => handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg)
+ redis.pubsub(classOf[String]).subscribe(s"${redisConfig.prefix}:game:$gameId:s2c", handler)
+ ()
+
+ private def handleGameEvent(
+ botName: String,
+ gameId: String,
+ playingAs: String,
+ difficulty: Int,
+ botAccountId: String,
+ msg: String,
+ ): Unit =
+ try
+ val node = objectMapper.readTree(msg)
+ val status = node.path("state").path("status").asText("")
+ if !terminalStatuses.contains(status) then
+ val turn = node.path("state").path("turn").asText("")
+ if turn == playingAs then
+ val fen = node.path("state").path("fen").asText()
+ computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
+ catch case _: Exception => ()
+
+ private def computeAndSendMove(
+ botName: String,
+ gameId: String,
+ fen: String,
+ difficulty: Int,
+ botAccountId: String,
+ ): Unit =
+ val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
+ botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
+ FenParser.parseFen(fen).toOption.foreach { context =>
+ bot(context).foreach { move =>
+ val uci = toUci(move)
+ val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
+ val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
+ redis.pubsub(classOf[String]).publish(c2sTopic, moveMsg)
+ ()
+ }
+ }
+ }
+
+ private def toUci(move: Move): String =
+ val base = s"${move.from}${move.to}"
+ move.moveType match
+ case MoveType.Promotion(piece) => base + promotionChar(piece)
+ case _ => base
+
+ private def promotionChar(piece: PromotionPiece): String =
+ piece match
+ case PromotionPiece.Knight => "n"
+ case PromotionPiece.Bishop => "b"
+ case PromotionPiece.Rook => "r"
+ case PromotionPiece.Queen => "q"
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/PolyglotHash.scala
diff --git a/modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala b/modules/official-bots/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
similarity index 100%
rename from modules/bot/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
rename to modules/official-bots/src/main/scala/de/nowchess/bot/util/ZobristHash.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotControllerTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotControllerTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotControllerTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotDifficultyTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/BotMoveRepetitionTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
similarity index 89%
rename from modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
index bf53263..8e33a8f 100644
--- a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
+++ b/modules/official-bots/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala
@@ -12,17 +12,9 @@ import de.nowchess.rules.sets.DefaultRules
class ClassicalBotTest extends AnyFunSuite with Matchers:
- test("name returns expected format"):
- val botEasy = ClassicalBot(BotDifficulty.Easy)
- botEasy.name should include("ClassicalBot")
- botEasy.name should include("Easy")
-
- val botMedium = ClassicalBot(BotDifficulty.Medium)
- botMedium.name should include("Medium")
-
test("nextMove on initial position returns a move"):
val bot = ClassicalBot(BotDifficulty.Easy)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should not be None
test("nextMove returns None for position with no legal moves"):
@@ -39,13 +31,13 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should be(None)
test("all BotDifficulty values work"):
BotDifficulty.values.foreach { difficulty =>
val bot = ClassicalBot(difficulty)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
// All difficulties should return a move on the initial position
move should not be None
}
@@ -70,7 +62,7 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should be(Some(moveToReturn))
test("nextMove skips a move repeated three times in a row"):
@@ -95,4 +87,4 @@ class ClassicalBotTest extends AnyFunSuite with Matchers:
val context = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = ClassicalBot(BotDifficulty.Easy, stubRules)
- bot.nextMove(context) should be(None)
+ bot.apply(context) should be(None)
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/EvaluationTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/EvaluationTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/EvaluationTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala
similarity index 91%
rename from modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala
index bf806c2..900a85f 100644
--- a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala
+++ b/modules/official-bots/src/test/scala/de/nowchess/bot/HybridBotTest.scala
@@ -17,17 +17,12 @@ import scala.util.Using
class HybridBotTest extends AnyFunSuite with Matchers:
- test("HybridBot name includes difficulty"):
- val bot = HybridBot(BotDifficulty.Easy)
- bot.name should include("HybridBot")
- bot.name should include("Easy")
-
- test("HybridBot nextMove returns a move on the initial position"):
+ test("HybridBot apply returns a move on the initial position"):
val bot = HybridBot(BotDifficulty.Easy)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should not be None
- test("HybridBot nextMove returns None when no legal moves"):
+ test("HybridBot apply returns None when no legal moves"):
val noMovesRules = new RuleSet:
def candidateMoves(context: GameContext)(square: Square): List[Move] = Nil
def legalMoves(context: GameContext)(square: Square): List[Move] = Nil
@@ -40,13 +35,13 @@ class HybridBotTest extends AnyFunSuite with Matchers:
def isThreefoldRepetition(context: GameContext): Boolean = false
def applyMove(context: GameContext)(move: Move): GameContext = context
val bot = HybridBot(BotDifficulty.Easy, noMovesRules)
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should be(None)
test("HybridBot with empty book falls through to search"):
val emptyBook = PolyglotBook("/nonexistent/book.bin")
val bot = HybridBot(BotDifficulty.Easy, book = Some(emptyBook))
- val move = bot.nextMove(GameContext.initial)
+ val move = bot.apply(GameContext.initial)
move should not be None
test("HybridBot skips move repeated three times"):
@@ -64,7 +59,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
def applyMove(context: GameContext)(move: Move): GameContext = context
val ctx = GameContext.initial.copy(moves = List(repeatedMove, repeatedMove, repeatedMove))
val bot = HybridBot(BotDifficulty.Easy, onlyMoveRules)
- bot.nextMove(ctx) should be(None)
+ bot.apply(ctx) should be(None)
test("HybridBot uses book move when available"):
val tempFile = Files.createTempFile("hybrid_book", ".bin")
@@ -82,7 +77,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString)
val bot = HybridBot(BotDifficulty.Easy, book = Some(book))
- bot.nextMove(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
+ bot.apply(ctx) should be(Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal())))
finally Files.deleteIfExists(tempFile)
test("HybridBot reports veto when classical and NNUE differ above threshold"):
@@ -119,7 +114,7 @@ class HybridBotTest extends AnyFunSuite with Matchers:
vetoReporter = _ => reported.set(true),
)
- bot.nextMove(GameContext.initial) should be(Some(forcedMove))
+ bot.apply(GameContext.initial) should be(Some(forcedMove))
reported.get should be(true)
test("HybridBot default veto reporter prints when threshold is exceeded"):
@@ -155,6 +150,6 @@ class HybridBotTest extends AnyFunSuite with Matchers:
)
val printed = Console.withOut(new java.io.ByteArrayOutputStream()) {
- bot.nextMove(GameContext.initial)
+ bot.apply(GameContext.initial)
}
printed should be(Some(forcedMove))
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/MoveOrderingTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
similarity index 98%
rename from modules/bot/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
index e61e2d2..9ba3a9b 100644
--- a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
+++ b/modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotBookTest.scala
@@ -96,7 +96,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
test("ClassicalBot without book falls back to search"):
val ctx = GameContext.initial
val bot = ClassicalBot(BotDifficulty.Easy) // no book
- val move = bot.nextMove(ctx)
+ val move = bot.apply(ctx)
move shouldNot be(None)
// The move should be legal
val allLegalMoves = DefaultRules.allLegalMoves(ctx)
@@ -120,7 +120,7 @@ class PolyglotBookTest extends AnyFunSuite with Matchers:
val book = PolyglotBook(tempFile.toString)
val botWithBook = ClassicalBot(BotDifficulty.Easy, book = Some(book))
- val move = botWithBook.nextMove(ctx)
+ val move = botWithBook.apply(ctx)
// Book should return e2-e4
move shouldEqual Some(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal()))
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/PolyglotHashTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/TranspositionTableTest.scala
diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala b/modules/official-bots/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
similarity index 100%
rename from modules/bot/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
rename to modules/official-bots/src/test/scala/de/nowchess/bot/ZobristHashTest.scala
diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts
index 38982d5..4297761 100644
--- a/modules/rule/build.gradle.kts
+++ b/modules/rule/build.gradle.kts
@@ -22,8 +22,15 @@ scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
+sourceSets {
+ main {
+ java.srcDir("build/classes/java/quarkus-generated-sources/grpc")
+ }
+}
+
tasks.withType {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+ dependsOn("quarkusGenerateCode")
}
val quarkusPlatformGroupId: String by project
@@ -44,6 +51,8 @@ dependencies {
}
implementation(project(":modules:api"))
+ implementation(project(":modules:json"))
+ implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
@@ -52,6 +61,7 @@ dependencies {
implementation("io.quarkus:quarkus-rest-client")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-grpc")
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
@@ -106,3 +116,16 @@ tasks.reportScoverage {
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+
+tasks.withType(ScalaCompile::class).configureEach {
+ if (name == "compileScoverageScala") {
+ source = source.asFileTree.matching {
+ exclude("**/grpc/*.scala")
+ }
+ }
+}
+
+tasks.named("compileScoverageJava").configure {
+ dependsOn(tasks.named("quarkusGenerateCode"))
+ dependsOn(tasks.named("compileQuarkusGeneratedSourcesJava"))
+}
diff --git a/modules/rule/src/main/proto/chess_types.proto b/modules/rule/src/main/proto/chess_types.proto
new file mode 100644
index 0000000..c1bb524
--- /dev/null
+++ b/modules/rule/src/main/proto/chess_types.proto
@@ -0,0 +1,87 @@
+syntax = "proto3";
+option java_package = "de.nowchess.rules.proto";
+option java_multiple_files = true;
+option java_outer_classname = "ChessTypesProto";
+
+enum ProtoColor {
+ WHITE = 0;
+ BLACK = 1;
+}
+
+enum ProtoPieceType {
+ PAWN = 0;
+ KNIGHT = 1;
+ BISHOP = 2;
+ ROOK = 3;
+ QUEEN = 4;
+ KING = 5;
+}
+
+enum ProtoMoveKind {
+ QUIET = 0;
+ CAPTURE = 1;
+ CASTLE_KINGSIDE = 2;
+ CASTLE_QUEENSIDE = 3;
+ EN_PASSANT = 4;
+ PROMO_QUEEN = 5;
+ PROMO_ROOK = 6;
+ PROMO_BISHOP = 7;
+ PROMO_KNIGHT = 8;
+}
+
+enum ProtoGameResultKind {
+ ONGOING = 0;
+ WIN_CHECKMATE_W = 1;
+ WIN_CHECKMATE_B = 2;
+ WIN_RESIGN_W = 3;
+ WIN_RESIGN_B = 4;
+ WIN_TIME_W = 5;
+ WIN_TIME_B = 6;
+ DRAW_STALEMATE = 7;
+ DRAW_INSUFFICIENT = 8;
+ DRAW_FIFTY_MOVE = 9;
+ DRAW_THREEFOLD = 10;
+ DRAW_AGREEMENT = 11;
+}
+
+message ProtoPiece {
+ ProtoColor color = 1;
+ ProtoPieceType piece_type = 2;
+}
+
+message ProtoSquarePiece {
+ string square = 1;
+ ProtoPiece piece = 2;
+}
+
+message ProtoMove {
+ string from = 1;
+ string to = 2;
+ ProtoMoveKind move_kind = 3;
+}
+
+message ProtoCastlingRights {
+ bool white_king_side = 1;
+ bool white_queen_side = 2;
+ bool black_king_side = 3;
+ bool black_queen_side = 4;
+}
+
+message ProtoGameContext {
+ repeated ProtoSquarePiece board = 1;
+ ProtoColor turn = 2;
+ ProtoCastlingRights castling_rights = 3;
+ string en_passant_square = 4;
+ int32 half_move_clock = 5;
+ repeated ProtoMove moves = 6;
+ ProtoGameResultKind result = 7;
+ repeated ProtoSquarePiece initial_board = 8;
+}
+
+message ProtoPostMoveStatus {
+ bool is_checkmate = 1;
+ bool is_stalemate = 2;
+ bool is_insufficient_material = 3;
+ bool is_check = 4;
+ bool is_threefold_repetition = 5;
+}
diff --git a/modules/rule/src/main/proto/rule_service.proto b/modules/rule/src/main/proto/rule_service.proto
new file mode 100644
index 0000000..f5aea3d
--- /dev/null
+++ b/modules/rule/src/main/proto/rule_service.proto
@@ -0,0 +1,38 @@
+syntax = "proto3";
+option java_package = "de.nowchess.rules.proto";
+option java_multiple_files = true;
+option java_outer_classname = "RuleServiceProto";
+
+import "chess_types.proto";
+
+message ProtoSquareRequest {
+ ProtoGameContext context = 1;
+ string square = 2;
+}
+
+message ProtoMoveRequest {
+ ProtoGameContext context = 1;
+ ProtoMove move = 2;
+}
+
+message ProtoMoveList {
+ repeated ProtoMove moves = 1;
+}
+
+message ProtoBoolResult {
+ bool value = 1;
+}
+
+service RuleService {
+ rpc CandidateMoves (ProtoSquareRequest) returns (ProtoMoveList);
+ rpc LegalMoves (ProtoSquareRequest) returns (ProtoMoveList);
+ rpc AllLegalMoves (ProtoGameContext) returns (ProtoMoveList);
+ rpc IsCheck (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsCheckmate (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsStalemate (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsInsufficientMaterial (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsFiftyMoveRule (ProtoGameContext) returns (ProtoBoolResult);
+ rpc IsThreefoldRepetition (ProtoGameContext) returns (ProtoBoolResult);
+ rpc ApplyMove (ProtoMoveRequest) returns (ProtoGameContext);
+ rpc PostMoveStatus (ProtoGameContext) returns (ProtoPostMoveStatus);
+}
diff --git a/modules/rule/src/main/resources/META-INF.native-image.de.nowchess.rule/reachability-metadata.json b/modules/rule/src/main/resources/META-INF.native-image.de.nowchess.rule/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/rule/src/main/resources/META-INF.native-image.de.nowchess.rule/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/rule/src/main/resources/application.yml b/modules/rule/src/main/resources/application.yml
index 98b81ce..faa605e 100644
--- a/modules/rule/src/main/resources/application.yml
+++ b/modules/rule/src/main/resources/application.yml
@@ -1,5 +1,12 @@
quarkus:
http:
port: 8082
+ grpc:
+ server:
+ use-separate-server: false
application:
name: rule-service
+
+nowchess:
+ internal:
+ secret: 123abc
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala
index 8dd29b8..5c8f837 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala
+++ b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala
@@ -2,11 +2,8 @@ package de.nowchess.rules.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
-import de.nowchess.api.board.Square
-import de.nowchess.api.move.MoveType
-import de.nowchess.rules.json.*
+import de.nowchess.json.ChessJacksonModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -19,11 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
- val mod = new SimpleModule()
- mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
- mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- mod.addSerializer(classOf[Square], new SquareSerializer())
- mod.addDeserializer(classOf[Square], new SquareDeserializer())
- mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
- mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
- mapper.registerModule(mod)
+ mapper.registerModule(new ChessJacksonModule())
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/grpc/ProtoMapper.scala b/modules/rule/src/main/scala/de/nowchess/rules/grpc/ProtoMapper.scala
new file mode 100644
index 0000000..2dadccf
--- /dev/null
+++ b/modules/rule/src/main/scala/de/nowchess/rules/grpc/ProtoMapper.scala
@@ -0,0 +1,160 @@
+package de.nowchess.rules.grpc
+
+import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
+import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
+import de.nowchess.api.grpc.ProtoMapperBase
+import de.nowchess.api.move.{Move as DomainMove, MoveType}
+import de.nowchess.rules.proto.*
+
+import scala.jdk.CollectionConverters.*
+
+object ProtoMapper
+ extends ProtoMapperBase[
+ ProtoColor,
+ ProtoPieceType,
+ ProtoMoveKind,
+ ProtoMove,
+ ProtoSquarePiece,
+ java.util.List[ProtoSquarePiece],
+ ProtoCastlingRights,
+ ProtoGameResultKind,
+ ProtoGameContext,
+ ]:
+ private val (colorTo, colorFrom) = ProtoMapperBase.colorConversions(ProtoColor.WHITE, ProtoColor.BLACK)
+ private val (pieceTypeTo, pieceTypeFrom) = ProtoMapperBase.pieceTypeConversions(
+ ProtoPieceType.PAWN,
+ ProtoPieceType.KNIGHT,
+ ProtoPieceType.BISHOP,
+ ProtoPieceType.ROOK,
+ ProtoPieceType.QUEEN,
+ ProtoPieceType.KING,
+ )
+ private val (moveKindTo, moveKindFrom) = ProtoMapperBase.moveKindConversions(
+ ProtoMoveKind.QUIET,
+ ProtoMoveKind.CAPTURE,
+ ProtoMoveKind.CASTLE_KINGSIDE,
+ ProtoMoveKind.CASTLE_QUEENSIDE,
+ ProtoMoveKind.EN_PASSANT,
+ ProtoMoveKind.PROMO_QUEEN,
+ ProtoMoveKind.PROMO_ROOK,
+ ProtoMoveKind.PROMO_BISHOP,
+ ProtoMoveKind.PROMO_KNIGHT,
+ )
+
+ override def toProtoColor(c: Color): ProtoColor = colorTo(c)
+ override def fromProtoColor(c: ProtoColor): Color = colorFrom(c)
+ override def toProtoPieceType(pt: PieceType): ProtoPieceType = pieceTypeTo(pt)
+ override def fromProtoPieceType(pt: ProtoPieceType): PieceType = pieceTypeFrom(pt)
+ override def toProtoMoveKind(mt: MoveType): ProtoMoveKind = moveKindTo(mt)
+ override def fromProtoMoveKind(k: ProtoMoveKind): MoveType = moveKindFrom(k)
+
+ override def toProtoMove(m: DomainMove): ProtoMove =
+ ProtoMove
+ .newBuilder()
+ .setFrom(m.from.toString)
+ .setTo(m.to.toString)
+ .setMoveKind(toProtoMoveKind(m.moveType))
+ .build()
+
+ override def fromProtoMove(m: ProtoMove): Option[DomainMove] =
+ for
+ from <- Square.fromAlgebraic(m.getFrom)
+ to <- Square.fromAlgebraic(m.getTo)
+ yield DomainMove(from, to, fromProtoMoveKind(m.getMoveKind))
+
+ override def toProtoSquarePiece(sq: Square, piece: Piece): ProtoSquarePiece =
+ ProtoSquarePiece
+ .newBuilder()
+ .setSquare(sq.toString)
+ .setPiece(
+ ProtoPiece
+ .newBuilder()
+ .setColor(toProtoColor(piece.color))
+ .setPieceType(toProtoPieceType(piece.pieceType))
+ .build(),
+ )
+ .build()
+
+ override def fromProtoSquarePiece(sp: ProtoSquarePiece): Option[(Square, Piece)] =
+ Square
+ .fromAlgebraic(sp.getSquare)
+ .map(_ -> Piece(fromProtoColor(sp.getPiece.getColor), fromProtoPieceType(sp.getPiece.getPieceType)))
+
+ override def toProtoBoard(board: Board): java.util.List[ProtoSquarePiece] =
+ board.pieces
+ .map((sq, piece) => toProtoSquarePiece(sq, piece))
+ .toSeq
+ .asJava
+
+ override def fromProtoBoard(pieces: java.util.List[ProtoSquarePiece]): Board =
+ Board(
+ pieces.asScala
+ .flatMap(fromProtoSquarePiece)
+ .toMap,
+ )
+
+ override def toProtoResultKind(r: Option[GameResult]): ProtoGameResultKind = r match
+ case None => ProtoGameResultKind.ONGOING
+ case Some(GameResult.Win(Color.White, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_W
+ case Some(GameResult.Win(Color.Black, WinReason.Checkmate)) => ProtoGameResultKind.WIN_CHECKMATE_B
+ case Some(GameResult.Win(Color.White, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_W
+ case Some(GameResult.Win(Color.Black, WinReason.Resignation)) => ProtoGameResultKind.WIN_RESIGN_B
+ case Some(GameResult.Win(Color.White, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_W
+ case Some(GameResult.Win(Color.Black, WinReason.TimeControl)) => ProtoGameResultKind.WIN_TIME_B
+ case Some(GameResult.Draw(DrawReason.Stalemate)) => ProtoGameResultKind.DRAW_STALEMATE
+ case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => ProtoGameResultKind.DRAW_INSUFFICIENT
+ case Some(GameResult.Draw(DrawReason.FiftyMoveRule)) => ProtoGameResultKind.DRAW_FIFTY_MOVE
+ case Some(GameResult.Draw(DrawReason.ThreefoldRepetition)) => ProtoGameResultKind.DRAW_THREEFOLD
+ case Some(GameResult.Draw(DrawReason.Agreement)) => ProtoGameResultKind.DRAW_AGREEMENT
+
+ override def fromProtoResultKind(k: ProtoGameResultKind): Option[GameResult] = k match
+ case ProtoGameResultKind.ONGOING => None
+ case ProtoGameResultKind.WIN_CHECKMATE_W => Some(GameResult.Win(Color.White, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_CHECKMATE_B => Some(GameResult.Win(Color.Black, WinReason.Checkmate))
+ case ProtoGameResultKind.WIN_RESIGN_W => Some(GameResult.Win(Color.White, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_RESIGN_B => Some(GameResult.Win(Color.Black, WinReason.Resignation))
+ case ProtoGameResultKind.WIN_TIME_W => Some(GameResult.Win(Color.White, WinReason.TimeControl))
+ case ProtoGameResultKind.WIN_TIME_B => Some(GameResult.Win(Color.Black, WinReason.TimeControl))
+ case ProtoGameResultKind.DRAW_STALEMATE => Some(GameResult.Draw(DrawReason.Stalemate))
+ case ProtoGameResultKind.DRAW_INSUFFICIENT => Some(GameResult.Draw(DrawReason.InsufficientMaterial))
+ case ProtoGameResultKind.DRAW_FIFTY_MOVE => Some(GameResult.Draw(DrawReason.FiftyMoveRule))
+ case ProtoGameResultKind.DRAW_THREEFOLD => Some(GameResult.Draw(DrawReason.ThreefoldRepetition))
+ case ProtoGameResultKind.DRAW_AGREEMENT => Some(GameResult.Draw(DrawReason.Agreement))
+ case _ => None
+
+ override def toProtoCastlingRights(cr: DomainCastlingRights): ProtoCastlingRights =
+ ProtoCastlingRights
+ .newBuilder()
+ .setWhiteKingSide(cr.whiteKingSide)
+ .setWhiteQueenSide(cr.whiteQueenSide)
+ .setBlackKingSide(cr.blackKingSide)
+ .setBlackQueenSide(cr.blackQueenSide)
+ .build()
+
+ override def fromProtoCastlingRights(pcr: ProtoCastlingRights): DomainCastlingRights =
+ DomainCastlingRights(pcr.getWhiteKingSide, pcr.getWhiteQueenSide, pcr.getBlackKingSide, pcr.getBlackQueenSide)
+
+ override def toProtoGameContext(ctx: GameContext): ProtoGameContext =
+ ProtoGameContext
+ .newBuilder()
+ .addAllBoard(toProtoBoard(ctx.board))
+ .setTurn(toProtoColor(ctx.turn))
+ .setCastlingRights(toProtoCastlingRights(ctx.castlingRights))
+ .setEnPassantSquare(ctx.enPassantSquare.map(_.toString).getOrElse(""))
+ .setHalfMoveClock(ctx.halfMoveClock)
+ .addAllMoves(ctx.moves.map(toProtoMove).asJava)
+ .setResult(toProtoResultKind(ctx.result))
+ .addAllInitialBoard(toProtoBoard(ctx.initialBoard))
+ .build()
+
+ override def fromProtoGameContext(p: ProtoGameContext): GameContext =
+ GameContext(
+ board = fromProtoBoard(p.getBoardList),
+ turn = fromProtoColor(p.getTurn),
+ castlingRights = fromProtoCastlingRights(p.getCastlingRights),
+ enPassantSquare = Option(p.getEnPassantSquare).filter(_.nonEmpty).flatMap(Square.fromAlgebraic),
+ halfMoveClock = p.getHalfMoveClock,
+ moves = p.getMovesList.asScala.flatMap(fromProtoMove).toList,
+ result = fromProtoResultKind(p.getResult),
+ initialBoard = fromProtoBoard(p.getInitialBoardList),
+ )
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/grpc/RuleGrpcService.scala b/modules/rule/src/main/scala/de/nowchess/rules/grpc/RuleGrpcService.scala
new file mode 100644
index 0000000..4649735
--- /dev/null
+++ b/modules/rule/src/main/scala/de/nowchess/rules/grpc/RuleGrpcService.scala
@@ -0,0 +1,93 @@
+package de.nowchess.rules.grpc
+
+import de.nowchess.api.board.Square
+import de.nowchess.rules.proto.*
+import de.nowchess.rules.sets.DefaultRules
+import io.grpc.stub.StreamObserver
+import io.grpc.{Status, StatusRuntimeException}
+import io.quarkus.grpc.GrpcService
+
+// scalafix:off DisableSyntax.throw
+@GrpcService
+class RuleGrpcService extends RuleServiceGrpc.RuleServiceImplBase:
+
+ private def parseSquare(s: String): Square =
+ Square
+ .fromAlgebraic(s)
+ .getOrElse(
+ throw Status.INVALID_ARGUMENT.withDescription(s"Invalid square: $s").asRuntimeException(),
+ )
+
+ override def candidateMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
+ val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
+ val sq = parseSquare(req.getSquare)
+ val moves = DefaultRules.candidateMoves(ctx)(sq)
+ resp.onNext(
+ ProtoMoveList
+ .newBuilder()
+ .addAllMoves(toJavaMoveList(moves))
+ .build(),
+ )
+ resp.onCompleted()
+
+ override def legalMoves(req: ProtoSquareRequest, resp: StreamObserver[ProtoMoveList]): Unit =
+ val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
+ val sq = parseSquare(req.getSquare)
+ val moves = DefaultRules.legalMoves(ctx)(sq)
+ respond(resp, ProtoMoveList.newBuilder().addAllMoves(toJavaMoveList(moves)).build())
+
+ override def allLegalMoves(req: ProtoGameContext, resp: StreamObserver[ProtoMoveList]): Unit =
+ val moves = DefaultRules.allLegalMoves(ProtoMapper.fromProtoGameContext(req))
+ respond(resp, ProtoMoveList.newBuilder().addAllMoves(toJavaMoveList(moves)).build())
+
+ override def isCheck(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isCheck(ProtoMapper.fromProtoGameContext(req))))
+
+ override def isCheckmate(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isCheckmate(ProtoMapper.fromProtoGameContext(req))))
+
+ override def isStalemate(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isStalemate(ProtoMapper.fromProtoGameContext(req))))
+
+ override def isInsufficientMaterial(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isInsufficientMaterial(ProtoMapper.fromProtoGameContext(req))))
+
+ override def isFiftyMoveRule(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isFiftyMoveRule(ProtoMapper.fromProtoGameContext(req))))
+
+ override def isThreefoldRepetition(req: ProtoGameContext, resp: StreamObserver[ProtoBoolResult]): Unit =
+ respond(resp, boolResult(DefaultRules.isThreefoldRepetition(ProtoMapper.fromProtoGameContext(req))))
+
+ override def applyMove(req: ProtoMoveRequest, resp: StreamObserver[ProtoGameContext]): Unit =
+ val ctx = ProtoMapper.fromProtoGameContext(req.getContext)
+ val move = ProtoMapper
+ .fromProtoMove(req.getMove)
+ .getOrElse(
+ throw Status.INVALID_ARGUMENT.withDescription("Invalid move").asRuntimeException(),
+ )
+ respond(resp, ProtoMapper.toProtoGameContext(DefaultRules.applyMove(ctx)(move)))
+
+ override def postMoveStatus(req: ProtoGameContext, resp: StreamObserver[ProtoPostMoveStatus]): Unit =
+ val status = DefaultRules.postMoveStatus(ProtoMapper.fromProtoGameContext(req))
+ respond(
+ resp,
+ ProtoPostMoveStatus
+ .newBuilder()
+ .setIsCheckmate(status.isCheckmate)
+ .setIsStalemate(status.isStalemate)
+ .setIsInsufficientMaterial(status.isInsufficientMaterial)
+ .setIsCheck(status.isCheck)
+ .setIsThreefoldRepetition(status.isThreefoldRepetition)
+ .build(),
+ )
+
+ private def boolResult(v: Boolean): ProtoBoolResult = ProtoBoolResult.newBuilder().setValue(v).build()
+
+ private def respond[T](obs: StreamObserver[T], value: T): Unit =
+ obs.onNext(value)
+ obs.onCompleted()
+
+ private def toJavaMoveList(moves: List[de.nowchess.api.move.Move]): java.util.List[ProtoMove] =
+ import scala.jdk.CollectionConverters.*
+ moves.map(ProtoMapper.toProtoMove).asJava
+// scalafix:on DisableSyntax.throw
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala
deleted file mode 100644
index 0be5f92..0000000
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.nowchess.rules.json
-
-import com.fasterxml.jackson.core.JsonParser
-import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
-import de.nowchess.api.board.Square
-
-class SquareDeserializer extends JsonDeserializer[Square]:
- override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
- Square.fromAlgebraic(p.getText).orNull
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala
deleted file mode 100644
index 4d52c10..0000000
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.nowchess.rules.json
-
-import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
-import de.nowchess.api.board.Square
-
-class SquareKeyDeserializer extends KeyDeserializer:
- override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
- Square.fromAlgebraic(key).orNull
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala
deleted file mode 100644
index 3ef02a5..0000000
--- a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.nowchess.rules.json
-
-import com.fasterxml.jackson.core.JsonGenerator
-import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
-import de.nowchess.api.board.Square
-
-class SquareKeySerializer extends JsonSerializer[Square]:
- override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
- gen.writeFieldName(value.toString)
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
index d128519..66b0e66 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
+++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala
@@ -4,6 +4,8 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.dto.*
+import de.nowchess.api.rules.PostMoveStatus
+import de.nowchess.security.InternalOnly
import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
@@ -11,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
@Path("/api/rules")
@ApplicationScoped
+@InternalOnly
class RuleSetResource:
private val rules = DefaultRules
@@ -88,3 +91,10 @@ class RuleSetResource:
@Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: ContextMoveRequest): GameContext =
rules.applyMove(req.context)(req.move)
+
+ @POST
+ @Path("/post-move-status")
+ @Consumes(Array(MediaType.APPLICATION_JSON))
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def postMoveStatus(ctx: GameContext): PostMoveStatus =
+ rules.postMoveStatus(ctx)
diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala
index ed3bde2..8965e57 100644
--- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala
+++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala
@@ -3,60 +3,463 @@ package de.nowchess.rules.sets
import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
-import de.nowchess.api.rules.RuleSet
+import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
-import scala.annotation.tailrec
-
-/** Standard chess rules implementation. Handles move generation, validation, check/checkmate/stalemate detection.
+/** Standard chess rules — optimized hot path.
+ *
+ * Internal representation: Array[Int](64), indexed by file + rank*8. Piece encoding: 0=empty, +1..+6=white
+ * (P/N/B/R/Q/K), -1..-6=black. Move generation uses pre-computed ray/jump tables and an integer-encoded move word to
+ * avoid heap allocation in tight loops. Check detection uses make/unmake on the mutable array instead of copying the
+ * immutable Board map.
*/
+// scalafix:off DisableSyntax.var
+// scalafix:off DisableSyntax.return
object DefaultRules extends RuleSet:
- /** Represents a position for threefold repetition (board state + turn + castling + en passant). */
- private case class Position(
- board: Board,
- turn: Color,
- castlingRights: CastlingRights,
- enPassantSquare: Option[Square],
- )
+ // ─── Piece constants ──────────────────────────────────────────────────────
+ private val PAWN = 1; private val KNIGHT = 2; private val BISHOP = 3
+ private val ROOK = 4; private val QUEEN = 5; private val KING = 6
- // ── Direction vectors ──────────────────────────────────────────────
- private val RookDirs: List[(Int, Int)] = List((1, 0), (-1, 0), (0, 1), (0, -1))
- private val BishopDirs: List[(Int, Int)] = List((1, 1), (1, -1), (-1, 1), (-1, -1))
- private val QueenDirs: List[(Int, Int)] = RookDirs ++ BishopDirs
- private val KnightJumps: List[(Int, Int)] =
- List((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2))
+ private inline def idx(f: Int, r: Int): Int = f + (r << 3)
+ private inline def fileOf(sq: Int): Int = sq & 7
+ private inline def rankOf(sq: Int): Int = sq >> 3
+ private inline def isEmpty(p: Int): Boolean = p == 0
+ private inline def isWhitePiece(p: Int): Boolean = p > 0
+ private inline def pieceType(p: Int): Int = if p > 0 then p else -p
- // ── Pawn configuration helpers ─────────────────────────────────────
- private def pawnForward(color: Color): Int = if color == Color.White then 1 else -1
- private def pawnStartRank(color: Color): Int = if color == Color.White then 1 else 6
- private def pawnPromoRank(color: Color): Int = if color == Color.White then 7 else 0
+ private def encodePiece(c: Color, pt: PieceType): Int =
+ val raw = pt match
+ case PieceType.Pawn => PAWN; case PieceType.Knight => KNIGHT
+ case PieceType.Bishop => BISHOP; case PieceType.Rook => ROOK
+ case PieceType.Queen => QUEEN; case PieceType.King => KING
+ if c == Color.White then raw else -raw
- // ── Public API ─────────────────────────────────────────────────────
+ // ─── Pre-computed tables ──────────────────────────────────────────────────
+
+ private val KNIGHT_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
+ val (f, r) = (fileOf(sq), rankOf(sq))
+ Array((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)).collect {
+ case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
+ }
+ }
+
+ private val KING_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
+ val (f, r) = (fileOf(sq), rankOf(sq))
+ Array((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)).collect {
+ case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
+ }
+ }
+
+ // Directions 0-3: rook (N,S,E,W); 4-7: bishop (NE,NW,SE,SW)
+ private val DIR_VECS: Array[(Int, Int)] =
+ Array((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1))
+
+ // RAY_TABLES(sq)(d) = squares along direction d from sq, nearest first
+ private val RAY_TABLES: Array[Array[Array[Int]]] = Array.tabulate(64, 8) { (sq, d) =>
+ val (df, dr) = DIR_VECS(d)
+ val (f, r) = (fileOf(sq), rankOf(sq))
+ val buf = new scala.collection.mutable.ArrayBuffer[Int](7)
+ var nf = f + df; var nr = r + dr
+ while nf >= 0 && nf < 8 && nr >= 0 && nr < 8 do
+ buf += idx(nf, nr); nf += df; nr += dr
+ buf.toArray
+ }
+
+ // PAWN_ATTACK_SOURCES(colorIdx)(target) = squares from which a pawn of that color attacks target.
+ // White pawn (fwd=+1) at (f±1, r-1) attacks (f, r) → sources are at rank r-1.
+ // Black pawn (fwd=-1) at (f±1, r+1) attacks (f, r) → sources are at rank r+1.
+ private val PAWN_ATTACK_SOURCES: Array[Array[Array[Int]]] = Array.tabulate(2) { colorIdx =>
+ val fwd = if colorIdx == 0 then 1 else -1
+ Array.tabulate(64) { sq =>
+ val (f, r) = (fileOf(sq), rankOf(sq))
+ Array(-1, 1).collect {
+ case df if f + df >= 0 && f + df < 8 && r - fwd >= 0 && r - fwd < 8 => idx(f + df, r - fwd)
+ }
+ }
+ }
+
+ // Pre-computed castling square indices (no runtime string parsing)
+ private val A1 = idx(0, 0); private val B1 = idx(1, 0); private val C1 = idx(2, 0)
+ private val D1 = idx(3, 0); private val E1 = idx(4, 0); private val F1 = idx(5, 0)
+ private val G1 = idx(6, 0); private val H1 = idx(7, 0)
+ private val A8 = idx(0, 7); private val B8 = idx(1, 7); private val C8 = idx(2, 7)
+ private val D8 = idx(3, 7); private val E8 = idx(4, 7); private val F8 = idx(5, 7)
+ private val G8 = idx(6, 7); private val H8 = idx(7, 7)
+
+ // Thread-local mutable board and move buffer — zero heap allocation in hot loops
+ private val tlBoard = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](64))
+ // 320 slots: theoretical max ~218 chess moves, promotion bursts add 4 per pawn-on-7th
+ private val tlMoves = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](320))
+
+ // ─── Move word encoding ───────────────────────────────────────────────────
+ // bits 0-5: from square, bits 6-11: to square, bits 12-15: move kind
+ private val KIND_QUIET = 0; private val KIND_CAPTURE = 1; private val KIND_EP = 2
+ private val KIND_CASTLEK = 3; private val KIND_CASTLEQ = 4
+ private val KIND_PROMO_Q = 5; private val KIND_PROMO_R = 6
+ private val KIND_PROMO_B = 7; private val KIND_PROMO_N = 8
+
+ private inline def encMove(from: Int, to: Int, kind: Int): Int = from | (to << 6) | (kind << 12)
+ private inline def moveFrom(m: Int): Int = m & 63
+ private inline def moveTo(m: Int): Int = (m >> 6) & 63
+ private inline def moveKind(m: Int): Int = m >> 12
+
+ // ─── Board ↔ Array[Int] ──────────────────────────────────────────────────
+
+ private def fillBoard(board: Board, arr: Array[Int]): Unit =
+ java.util.Arrays.fill(arr, 0)
+ board.pieces.foreach { (sq, piece) =>
+ arr(idx(sq.file.ordinal, sq.rank.ordinal)) = encodePiece(piece.color, piece.pieceType)
+ }
+
+ private def toSquare(sq: Int): Square =
+ Square(File.values(fileOf(sq)), Rank.values(rankOf(sq)))
+
+ // ─── Attack detection (reverse lookup from target) ────────────────────────
+ // Cast rays/jumps FROM the target to find attackers — O(directions × ray_length) vs O(64 × ray_length)
+
+ private def isAttackedByColor(arr: Array[Int], target: Int, byWhite: Boolean): Boolean =
+ val sign = if byWhite then 1 else -1
+ val colorIdx = if byWhite then 0 else 1
+
+ // Pawn
+ val pawnSrcs = PAWN_ATTACK_SOURCES(colorIdx)(target); var i = 0
+ while i < pawnSrcs.length do
+ if arr(pawnSrcs(i)) == sign * PAWN then return true
+ i += 1
+
+ // Knight
+ val knightSrcs = KNIGHT_TARGETS(target); i = 0
+ while i < knightSrcs.length do
+ if arr(knightSrcs(i)) == sign * KNIGHT then return true
+ i += 1
+
+ // King
+ val kingSrcs = KING_TARGETS(target); i = 0
+ while i < kingSrcs.length do
+ if arr(kingSrcs(i)) == sign * KING then return true
+ i += 1
+
+ // Rook/Queen on rook rays (directions 0-3)
+ val rays = RAY_TABLES(target); i = 0
+ while i < 4 do
+ val ray = rays(i); var j = 0
+ while j < ray.length do
+ val p = arr(ray(j))
+ if p != 0 then
+ if p == sign * ROOK || p == sign * QUEEN then return true
+ j = ray.length // blocked
+ j += 1
+ i += 1
+
+ // Bishop/Queen on bishop rays (directions 4-7)
+ i = 4
+ while i < 8 do
+ val ray = rays(i); var j = 0
+ while j < ray.length do
+ val p = arr(ray(j))
+ if p != 0 then
+ if p == sign * BISHOP || p == sign * QUEEN then return true
+ j = ray.length // blocked
+ j += 1
+ i += 1
+
+ false
+
+ private def findKing(arr: Array[Int], whiteKing: Boolean): Int =
+ val king = if whiteKing then KING else -KING; var sq = 0
+ while sq < 64 do
+ if arr(sq) == king then return sq
+ sq += 1
+ -1
+
+ // ─── Make/unmake for check validation ────────────────────────────────────
+ // Applies move on mutable arr, tests check, undoes — no Map copy.
+
+ private def leavesKingInCheck(arr: Array[Int], move: Int, whiteMoved: Boolean): Boolean =
+ val from = moveFrom(move); val to = moveTo(move); val kind = moveKind(move)
+ val savedFrom = arr(from); val savedTo = arr(to)
+ var epSq = -1
+ var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1
+
+ kind match
+ case KIND_EP =>
+ epSq = idx(fileOf(to), rankOf(from))
+ arr(to) = savedFrom; arr(from) = 0; arr(epSq) = 0
+
+ case KIND_CASTLEK =>
+ rookFrom = if whiteMoved then H1 else H8
+ rookTo = if whiteMoved then F1 else F8
+ savedRookPiece = arr(rookFrom)
+ arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
+
+ case KIND_CASTLEQ =>
+ rookFrom = if whiteMoved then A1 else A8
+ rookTo = if whiteMoved then D1 else D8
+ savedRookPiece = arr(rookFrom)
+ arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
+
+ case k if k >= KIND_PROMO_Q =>
+ val promoted = k match
+ case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN
+ case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK
+ case KIND_PROMO_B => if whiteMoved then BISHOP else -BISHOP
+ case _ => if whiteMoved then KNIGHT else -KNIGHT
+ arr(to) = promoted; arr(from) = 0
+
+ case _ =>
+ arr(to) = savedFrom; arr(from) = 0
+
+ val kingSq = findKing(arr, whiteMoved)
+ val inCheck = kingSq >= 0 && isAttackedByColor(arr, kingSq, !whiteMoved)
+
+ // Undo
+ arr(from) = savedFrom; arr(to) = savedTo
+ if epSq >= 0 then arr(epSq) = if whiteMoved then -PAWN else PAWN
+ if rookFrom >= 0 then
+ arr(rookFrom) = savedRookPiece
+ arr(rookTo) = 0
+
+ inCheck
+
+ // ─── Move generation ─────────────────────────────────────────────────────
+
+ private def generateAll(arr: Array[Int], isWhite: Boolean, ctx: GameContext, buf: Array[Int]): Int =
+ var n = 0; var sq = 0
+ while sq < 64 do
+ val p = arr(sq)
+ if !isEmpty(p) && isWhitePiece(p) == isWhite then n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n)
+ sq += 1
+ n
+
+ private def generatePiece(
+ arr: Array[Int],
+ sq: Int,
+ pt: Int,
+ isWhite: Boolean,
+ ctx: GameContext,
+ buf: Array[Int],
+ n: Int,
+ ): Int =
+ if pt == PAWN then generatePawnMoves(arr, sq, isWhite, ctx, buf, n)
+ else if pt == KNIGHT then generateJumps(arr, sq, isWhite, KNIGHT_TARGETS(sq), buf, n)
+ else if pt == BISHOP then generateRays(arr, sq, isWhite, buf, n, rookRays = false)
+ else if pt == ROOK then generateRays(arr, sq, isWhite, buf, n, rookRays = true)
+ else if pt == QUEEN then
+ val n2 = generateRays(arr, sq, isWhite, buf, n, rookRays = true)
+ generateRays(arr, sq, isWhite, buf, n2, rookRays = false)
+ else generateKingMoves(arr, sq, isWhite, ctx, buf, n)
+
+ private def generateJumps(
+ arr: Array[Int],
+ from: Int,
+ isWhite: Boolean,
+ targets: Array[Int],
+ buf: Array[Int],
+ start: Int,
+ ): Int =
+ var n = start; var i = 0
+ while i < targets.length do
+ val to = targets(i); val tgt = arr(to)
+ if isEmpty(tgt) then
+ buf(n) = encMove(from, to, KIND_QUIET); n += 1
+ else if isWhitePiece(tgt) != isWhite then
+ buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
+ i += 1
+ n
+
+ private def generateRays(
+ arr: Array[Int],
+ from: Int,
+ isWhite: Boolean,
+ buf: Array[Int],
+ start: Int,
+ rookRays: Boolean,
+ ): Int =
+ var n = start
+ val rays = RAY_TABLES(from)
+ val d0 = if rookRays then 0 else 4
+ val d1 = if rookRays then 4 else 8
+ var d = d0
+ while d < d1 do
+ val ray = rays(d); var j = 0
+ while j < ray.length do
+ val to = ray(j); val tgt = arr(to)
+ if isEmpty(tgt) then
+ buf(n) = encMove(from, to, KIND_QUIET); n += 1
+ else
+ if isWhitePiece(tgt) != isWhite then
+ buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
+ j = ray.length
+ j += 1
+ d += 1
+ n
+
+ private def generateKingMoves(
+ arr: Array[Int],
+ from: Int,
+ isWhite: Boolean,
+ ctx: GameContext,
+ buf: Array[Int],
+ start: Int,
+ ): Int =
+ val n = generateJumps(arr, from, isWhite, KING_TARGETS(from), buf, start)
+ generateCastlingMoves(arr, from, isWhite, ctx, buf, n)
+
+ private def generateCastlingMoves(
+ arr: Array[Int],
+ from: Int,
+ isWhite: Boolean,
+ ctx: GameContext,
+ buf: Array[Int],
+ start: Int,
+ ): Int =
+ var n = start
+ val cr = ctx.castlingRights
+ if isWhite && from == E1 then
+ if cr.whiteKingSide && isEmpty(arr(F1)) && isEmpty(arr(G1)) &&
+ arr(E1) == KING && arr(H1) == ROOK &&
+ !isAttackedByColor(arr, E1, false) &&
+ !isAttackedByColor(arr, F1, false) &&
+ !isAttackedByColor(arr, G1, false)
+ then
+ buf(n) = encMove(E1, G1, KIND_CASTLEK); n += 1
+ if cr.whiteQueenSide && isEmpty(arr(D1)) && isEmpty(arr(C1)) && isEmpty(arr(B1)) &&
+ arr(E1) == KING && arr(A1) == ROOK &&
+ !isAttackedByColor(arr, E1, false) &&
+ !isAttackedByColor(arr, D1, false) &&
+ !isAttackedByColor(arr, C1, false)
+ then
+ buf(n) = encMove(E1, C1, KIND_CASTLEQ); n += 1
+ else if !isWhite && from == E8 then
+ if cr.blackKingSide && isEmpty(arr(F8)) && isEmpty(arr(G8)) &&
+ arr(E8) == -KING && arr(H8) == -ROOK &&
+ !isAttackedByColor(arr, E8, true) &&
+ !isAttackedByColor(arr, F8, true) &&
+ !isAttackedByColor(arr, G8, true)
+ then
+ buf(n) = encMove(E8, G8, KIND_CASTLEK); n += 1
+ if cr.blackQueenSide && isEmpty(arr(D8)) && isEmpty(arr(C8)) && isEmpty(arr(B8)) &&
+ arr(E8) == -KING && arr(A8) == -ROOK &&
+ !isAttackedByColor(arr, E8, true) &&
+ !isAttackedByColor(arr, D8, true) &&
+ !isAttackedByColor(arr, C8, true)
+ then
+ buf(n) = encMove(E8, C8, KIND_CASTLEQ); n += 1
+ n
+
+ private def generatePawnMoves(
+ arr: Array[Int],
+ from: Int,
+ isWhite: Boolean,
+ ctx: GameContext,
+ buf: Array[Int],
+ start: Int,
+ ): Int =
+ var n = start
+ val f = fileOf(from); val r = rankOf(from)
+ val fwd = if isWhite then 1 else -1
+ val startRank = if isWhite then 1 else 6
+ val promoRank = if isWhite then 7 else 0
+ val r1 = r + fwd
+
+ if r1 >= 0 && r1 < 8 then
+ val to1 = idx(f, r1)
+ if isEmpty(arr(to1)) then
+ if r1 == promoRank then
+ buf(n) = encMove(from, to1, KIND_PROMO_Q); n += 1
+ buf(n) = encMove(from, to1, KIND_PROMO_R); n += 1
+ buf(n) = encMove(from, to1, KIND_PROMO_B); n += 1
+ buf(n) = encMove(from, to1, KIND_PROMO_N); n += 1
+ else
+ buf(n) = encMove(from, to1, KIND_QUIET); n += 1
+ if r == startRank then
+ val to2 = idx(f, r + fwd * 2)
+ if isEmpty(arr(to2)) then
+ buf(n) = encMove(from, to2, KIND_QUIET); n += 1
+
+ var di = 0
+ while di < 2 do
+ val nf = f + (if di == 0 then -1 else 1)
+ if nf >= 0 && nf < 8 then
+ val to = idx(nf, r1)
+ val tgt = arr(to)
+ if !isEmpty(tgt) && isWhitePiece(tgt) != isWhite then
+ if r1 == promoRank then
+ buf(n) = encMove(from, to, KIND_PROMO_Q); n += 1
+ buf(n) = encMove(from, to, KIND_PROMO_R); n += 1
+ buf(n) = encMove(from, to, KIND_PROMO_B); n += 1
+ buf(n) = encMove(from, to, KIND_PROMO_N); n += 1
+ else
+ buf(n) = encMove(from, to, KIND_CAPTURE); n += 1
+ di += 1
+
+ ctx.enPassantSquare.foreach { epSq =>
+ val epI = idx(epSq.file.ordinal, epSq.rank.ordinal)
+ val epF = fileOf(epI); val epR = rankOf(epI)
+ if epR == r1 && (epF == f - 1 || epF == f + 1) then
+ buf(n) = encMove(from, epI, KIND_EP); n += 1
+ }
+ n
+
+ // ─── Decode integer move word → API Move ─────────────────────────────────
+
+ private def decodeMoveToApi(m: Int): Move =
+ val fromSq = toSquare(moveFrom(m)); val toSq = toSquare(moveTo(m))
+ moveKind(m) match
+ case KIND_QUIET => Move(fromSq, toSq)
+ case KIND_CAPTURE => Move(fromSq, toSq, MoveType.Normal(isCapture = true))
+ case KIND_EP => Move(fromSq, toSq, MoveType.EnPassant)
+ case KIND_CASTLEK => Move(fromSq, toSq, MoveType.CastleKingside)
+ case KIND_CASTLEQ => Move(fromSq, toSq, MoveType.CastleQueenside)
+ case KIND_PROMO_Q => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Queen))
+ case KIND_PROMO_R => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Rook))
+ case KIND_PROMO_B => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Bishop))
+ case _ => Move(fromSq, toSq, MoveType.Promotion(PromotionPiece.Knight))
+
+ // ─── Public RuleSet API ───────────────────────────────────────────────────
override def candidateMoves(context: GameContext)(square: Square): List[Move] =
- context.board.pieceAt(square).fold(List.empty[Move]) { piece =>
- if piece.color != context.turn then List.empty[Move]
- else
- piece.pieceType match
- case PieceType.Pawn => pawnCandidates(context, square, piece.color)
- case PieceType.Knight => knightCandidates(context, square, piece.color)
- case PieceType.Bishop => slidingMoves(context, square, piece.color, BishopDirs)
- case PieceType.Rook => slidingMoves(context, square, piece.color, RookDirs)
- case PieceType.Queen => slidingMoves(context, square, piece.color, QueenDirs)
- case PieceType.King => kingCandidates(context, square, piece.color)
- }
+ val arr = new Array[Int](64)
+ fillBoard(context.board, arr)
+ val sqI = idx(square.file.ordinal, square.rank.ordinal)
+ val piece = arr(sqI)
+ if isEmpty(piece) || isWhitePiece(piece) != (context.turn == Color.White) then return Nil
+ val buf = new Array[Int](64)
+ val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0)
+ (0 until n).map(i => decodeMoveToApi(buf(i))).toList
override def legalMoves(context: GameContext)(square: Square): List[Move] =
- candidateMoves(context)(square).filter { move =>
- !leavesKingInCheck(context, move)
- }
+ val arr = tlBoard.get(); fillBoard(context.board, arr)
+ val sqI = idx(square.file.ordinal, square.rank.ordinal)
+ val piece = arr(sqI)
+ val isWhite = context.turn == Color.White
+ if isEmpty(piece) || isWhitePiece(piece) != isWhite then return Nil
+ val buf = tlMoves.get()
+ val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0)
+ val result = new scala.collection.mutable.ListBuffer[Move]()
+ var i = 0
+ while i < n do
+ if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i))
+ i += 1
+ result.toList
override def allLegalMoves(context: GameContext): List[Move] =
- Square.all.flatMap(sq => legalMoves(context)(sq)).toList
+ val arr = tlBoard.get(); fillBoard(context.board, arr)
+ val isWhite = context.turn == Color.White
+ val buf = tlMoves.get()
+ val n = generateAll(arr, isWhite, context, buf)
+ val result = new scala.collection.mutable.ListBuffer[Move]()
+ var i = 0
+ while i < n do
+ if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i))
+ i += 1
+ result.toList
override def isCheck(context: GameContext): Boolean =
- kingSquare(context.board, context.turn)
- .fold(false)(sq => isAttackedBy(context.board, sq, context.turn.opposite))
+ val arr = tlBoard.get(); fillBoard(context.board, arr)
+ val isWhite = context.turn == Color.White
+ val kingSq = findKing(arr, isWhite)
+ kingSq >= 0 && isAttackedByColor(arr, kingSq, !isWhite)
override def isCheckmate(context: GameContext): Boolean =
isCheck(context) && allLegalMoves(context).isEmpty
@@ -71,295 +474,10 @@ object DefaultRules extends RuleSet:
context.halfMoveClock >= 100
override def isThreefoldRepetition(context: GameContext): Boolean =
- val currentPosition = Position(
- board = context.board,
- turn = context.turn,
- castlingRights = context.castlingRights,
- enPassantSquare = context.enPassantSquare,
- )
+ val currentPosition = Position(context.board, context.turn, context.castlingRights, context.enPassantSquare)
countPositionOccurrences(context, currentPosition) >= 3
- private def countPositionOccurrences(context: GameContext, targetPosition: Position): Int =
- try
- val initialCtx = GameContext(
- board = context.initialBoard,
- turn = Color.White,
- castlingRights = CastlingRights.Initial,
- enPassantSquare = None,
- halfMoveClock = 0,
- moves = List.empty,
- initialBoard = context.initialBoard,
- )
-
- def positionOf(ctx: GameContext): Position =
- Position(
- board = ctx.board,
- turn = ctx.turn,
- castlingRights = ctx.castlingRights,
- enPassantSquare = ctx.enPassantSquare,
- )
-
- val initialCount = if positionOf(initialCtx) == targetPosition then 1 else 0
-
- context.moves
- .foldLeft((initialCtx, initialCount)) { case ((tempCtx, count), move) =>
- val nextCtx = applyMove(tempCtx)(move)
- val nextCount = if positionOf(nextCtx) == targetPosition then count + 1 else count
- (nextCtx, nextCount)
- }
- ._2
- catch
- case _: Exception =>
- // If replay fails, conservatively count only the current position (never triggers a draw)
- 1
-
- // ── Sliding pieces (Bishop, Rook, Queen) ───────────────────────────
-
- private def slidingMoves(
- context: GameContext,
- from: Square,
- color: Color,
- dirs: List[(Int, Int)],
- ): List[Move] =
- dirs.flatMap(dir => castRay(context.board, from, color, dir))
-
- private def castRay(
- board: Board,
- from: Square,
- color: Color,
- dir: (Int, Int),
- ): List[Move] =
- @tailrec
- def loop(sq: Square, acc: List[Move]): List[Move] =
- sq.offset(dir._1, dir._2) match
- case None => acc
- case Some(next) =>
- board.pieceAt(next) match
- case None => loop(next, Move(from, next) :: acc)
- case Some(p) if p.color != color => Move(from, next, MoveType.Normal(isCapture = true)) :: acc
- case Some(_) => acc
- loop(from, Nil).reverse
-
- // ── Knight ─────────────────────────────────────────────────────────
-
- private def knightCandidates(
- context: GameContext,
- from: Square,
- color: Color,
- ): List[Move] =
- KnightJumps.flatMap { (df, dr) =>
- from.offset(df, dr).flatMap { to =>
- context.board.pieceAt(to) match
- case Some(p) if p.color == color => None
- case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
- case None => Some(Move(from, to))
- }
- }
-
- // ── King ───────────────────────────────────────────────────────────
-
- private def kingCandidates(
- context: GameContext,
- from: Square,
- color: Color,
- ): List[Move] =
- val steps = QueenDirs.flatMap { (df, dr) =>
- from.offset(df, dr).flatMap { to =>
- context.board.pieceAt(to) match
- case Some(p) if p.color == color => None
- case Some(_) => Some(Move(from, to, MoveType.Normal(isCapture = true)))
- case None => Some(Move(from, to))
- }
- }
- steps ++ castlingCandidates(context, from, color)
-
- // ── Castling ───────────────────────────────────────────────────────
-
- private case class CastlingMove(
- kingFromAlg: String,
- kingToAlg: String,
- middleAlg: String,
- rookFromAlg: String,
- moveType: MoveType,
- )
-
- private def castlingCandidates(
- context: GameContext,
- from: Square,
- color: Color,
- ): List[Move] =
- color match
- case Color.White => whiteCastles(context, from)
- case Color.Black => blackCastles(context, from)
-
- private def whiteCastles(context: GameContext, from: Square): List[Move] =
- val expected = Square.fromAlgebraic("e1").getOrElse(from)
- if from != expected then List.empty
- else
- val moves = scala.collection.mutable.ListBuffer[Move]()
- addCastleMove(
- context,
- moves,
- context.castlingRights.whiteKingSide,
- CastlingMove("e1", "g1", "f1", "h1", MoveType.CastleKingside),
- )
- addCastleMove(
- context,
- moves,
- context.castlingRights.whiteQueenSide,
- CastlingMove("e1", "c1", "d1", "a1", MoveType.CastleQueenside),
- )
- moves.toList
-
- private def blackCastles(context: GameContext, from: Square): List[Move] =
- val expected = Square.fromAlgebraic("e8").getOrElse(from)
- if from != expected then List.empty
- else
- val moves = scala.collection.mutable.ListBuffer[Move]()
- addCastleMove(
- context,
- moves,
- context.castlingRights.blackKingSide,
- CastlingMove("e8", "g8", "f8", "h8", MoveType.CastleKingside),
- )
- addCastleMove(
- context,
- moves,
- context.castlingRights.blackQueenSide,
- CastlingMove("e8", "c8", "d8", "a8", MoveType.CastleQueenside),
- )
- moves.toList
-
- private def queensideBSquare(kingToAlg: String): List[String] =
- kingToAlg match
- case "c1" => List("b1")
- case "c8" => List("b8")
- case _ => List.empty
-
- private def addCastleMove(
- context: GameContext,
- moves: scala.collection.mutable.ListBuffer[Move],
- castlingRight: Boolean,
- castlingMove: CastlingMove,
- ): Unit =
- if castlingRight then
- val clearSqs = (List(castlingMove.middleAlg, castlingMove.kingToAlg) ++ queensideBSquare(castlingMove.kingToAlg))
- .flatMap(Square.fromAlgebraic)
- if squaresEmpty(context.board, clearSqs) then
- for
- kf <- Square.fromAlgebraic(castlingMove.kingFromAlg)
- km <- Square.fromAlgebraic(castlingMove.middleAlg)
- kt <- Square.fromAlgebraic(castlingMove.kingToAlg)
- rf <- Square.fromAlgebraic(castlingMove.rookFromAlg)
- do
- val color = context.turn
- val kingPresent = context.board.pieceAt(kf).exists(p => p.color == color && p.pieceType == PieceType.King)
- val rookPresent = context.board.pieceAt(rf).exists(p => p.color == color && p.pieceType == PieceType.Rook)
- val squaresSafe =
- !isAttackedBy(context.board, kf, color.opposite) &&
- !isAttackedBy(context.board, km, color.opposite) &&
- !isAttackedBy(context.board, kt, color.opposite)
-
- if kingPresent && rookPresent && squaresSafe then moves += Move(kf, kt, castlingMove.moveType)
-
- private def squaresEmpty(board: Board, squares: List[Square]): Boolean =
- squares.forall(sq => board.pieceAt(sq).isEmpty)
-
- // ── Pawn ───────────────────────────────────────────────────────────
-
- private def pawnCandidates(
- context: GameContext,
- from: Square,
- color: Color,
- ): List[Move] =
- val fwd = pawnForward(color)
- val startRank = pawnStartRank(color)
- val promoRank = pawnPromoRank(color)
-
- val single = from.offset(0, fwd).filter(to => context.board.pieceAt(to).isEmpty)
- val double = Option
- .when(from.rank.ordinal == startRank) {
- from.offset(0, fwd).flatMap { mid =>
- Option
- .when(context.board.pieceAt(mid).isEmpty) {
- from.offset(0, fwd * 2).filter(to => context.board.pieceAt(to).isEmpty)
- }
- .flatten
- }
- }
- .flatten
-
- val diagonalCaptures = List(-1, 1).flatMap { df =>
- from.offset(df, fwd).flatMap { to =>
- context.board.pieceAt(to).filter(_.color != color).map(_ => to)
- }
- }
-
- val epCaptures: List[Move] = context.enPassantSquare.toList.flatMap { epSq =>
- List(-1, 1).flatMap { df =>
- from.offset(df, fwd).filter(_ == epSq).map { to =>
- Move(from, epSq, MoveType.EnPassant)
- }
- }
- }
-
- def toMoves(dest: Square, isCapture: Boolean): List[Move] =
- if dest.rank.ordinal == promoRank then
- List(
- PromotionPiece.Queen,
- PromotionPiece.Rook,
- PromotionPiece.Bishop,
- PromotionPiece.Knight,
- ).map(pt => Move(from, dest, MoveType.Promotion(pt)))
- else List(Move(from, dest, MoveType.Normal(isCapture = isCapture)))
-
- val stepSquares = single.toList ++ double.toList
- val stepMoves = stepSquares.flatMap(dest => toMoves(dest, isCapture = false))
- val captureMoves = diagonalCaptures.flatMap(dest => toMoves(dest, isCapture = true))
- stepMoves ++ captureMoves ++ epCaptures
-
- // ── Check detection ────────────────────────────────────────────────
-
- private def kingSquare(board: Board, color: Color): Option[Square] =
- Square.all.find(sq => board.pieceAt(sq).exists(p => p.color == color && p.pieceType == PieceType.King))
-
- private def isAttackedBy(board: Board, target: Square, attacker: Color): Boolean =
- Square.all.exists { sq =>
- board.pieceAt(sq).fold(false) { p =>
- p.color == attacker && squareAttacks(board, sq, p, target)
- }
- }
-
- private def squareAttacks(board: Board, from: Square, piece: Piece, target: Square): Boolean =
- val fwd = pawnForward(piece.color)
- piece.pieceType match
- case PieceType.Pawn =>
- from.offset(-1, fwd).contains(target) || from.offset(1, fwd).contains(target)
- case PieceType.Knight =>
- KnightJumps.exists((df, dr) => from.offset(df, dr).contains(target))
- case PieceType.Bishop => rayReaches(board, from, BishopDirs, target)
- case PieceType.Rook => rayReaches(board, from, RookDirs, target)
- case PieceType.Queen => rayReaches(board, from, QueenDirs, target)
- case PieceType.King =>
- QueenDirs.exists((df, dr) => from.offset(df, dr).contains(target))
-
- private def rayReaches(board: Board, from: Square, dirs: List[(Int, Int)], target: Square): Boolean =
- dirs.exists { dir =>
- @tailrec
- def loop(sq: Square): Boolean = sq.offset(dir._1, dir._2) match
- case None => false
- case Some(next) if next == target => true
- case Some(next) if board.pieceAt(next).isEmpty => loop(next)
- case Some(_) => false
- loop(from)
- }
-
- private def leavesKingInCheck(context: GameContext, move: Move): Boolean =
- val nextBoard = context.board.applyMove(move)
- val nextContext = context.withBoard(nextBoard)
- isCheck(nextContext)
-
- // ── Move application ───────────────────────────────────────────────
+ // ─── applyMove (immutable GameContext update — acceptable for real moves) ─
override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn
@@ -389,6 +507,8 @@ object DefaultRules extends RuleSet:
.withHalfMoveClock(newClock)
.withMove(move)
+ // ─── Move application helpers ─────────────────────────────────────────────
+
private def applyCastle(board: Board, color: Color, kingside: Boolean): Board =
val rank = if color == Color.White then Rank.R1 else Rank.R8
val (kingFrom, kingTo, rookFrom, rookTo) =
@@ -396,15 +516,10 @@ object DefaultRules extends RuleSet:
else (Square(File.E, rank), Square(File.C, rank), Square(File.A, rank), Square(File.D, rank))
val king = board.pieceAt(kingFrom).getOrElse(Piece(color, PieceType.King))
val rook = board.pieceAt(rookFrom).getOrElse(Piece(color, PieceType.Rook))
- board
- .removed(kingFrom)
- .removed(rookFrom)
- .updated(kingTo, king)
- .updated(rookTo, rook)
+ board.removed(kingFrom).removed(rookFrom).updated(kingTo, king).updated(rookTo, rook)
private def applyEnPassant(board: Board, move: Move): Board =
- val capturedRank = move.from.rank // the captured pawn is on the same rank as the moving pawn
- val capturedSquare = Square(move.to.file, capturedRank)
+ val capturedSquare = Square(move.to.file, move.from.rank)
board.applyMove(move).removed(capturedSquare)
private def applyPromotion(board: Board, move: Move, color: Color, pp: PromotionPiece): Board =
@@ -420,14 +535,10 @@ object DefaultRules extends RuleSet:
val isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
- // Helper to check if a square is a rook's starting square
- val whiteKingsideRook = Square(File.H, Rank.R1)
- val whiteQueensideRook = Square(File.A, Rank.R1)
- val blackKingsideRook = Square(File.H, Rank.R8)
- val blackQueensideRook = Square(File.A, Rank.R8)
+ val whiteKingsideRook = Square(File.H, Rank.R1); val whiteQueensideRook = Square(File.A, Rank.R1)
+ val blackKingsideRook = Square(File.H, Rank.R8); val blackQueensideRook = Square(File.A, Rank.R8)
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
-
val afterRookMove =
if !isRookMove then afterKingMove
else
@@ -438,7 +549,6 @@ object DefaultRules extends RuleSet:
case `blackQueensideRook` => afterKingMove.revokeQueenSide(Color.Black)
case _ => afterKingMove
- // Also revoke if a rook is captured
move.to match
case `whiteKingsideRook` => afterRookMove.revokeKingSide(Color.White)
case `whiteQueensideRook` => afterRookMove.revokeQueenSide(Color.White)
@@ -447,16 +557,14 @@ object DefaultRules extends RuleSet:
case _ => afterRookMove
private def computeEnPassantSquare(board: Board, move: Move): Option[Square] =
- val piece = board.pieceAt(move.from)
- val isDoublePawnPush = piece.exists(_.pieceType == PieceType.Pawn) &&
+ val isDoublePawnPush = board.pieceAt(move.from).exists(_.pieceType == PieceType.Pawn) &&
math.abs(move.to.rank.ordinal - move.from.rank.ordinal) == 2
if isDoublePawnPush then
- // EP square is the square the pawn passed through
val epRankOrd = (move.from.rank.ordinal + move.to.rank.ordinal) / 2
Some(Square(move.from.file, Rank.values(epRankOrd)))
else None
- // ── Insufficient material ──────────────────────────────────────────
+ // ─── Insufficient material ────────────────────────────────────────────────
private def squareColor(sq: Square): Int = (sq.file.ordinal + sq.rank.ordinal) % 2
@@ -465,7 +573,50 @@ object DefaultRules extends RuleSet:
nonKings match
case Nil => true
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
- case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
- // All non-king pieces are bishops: draw only if they all share the same square color
+ case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1
case _ => false
+
+ // ─── Threefold repetition ─────────────────────────────────────────────────
+
+ private case class Position(
+ board: Board,
+ turn: Color,
+ castlingRights: CastlingRights,
+ enPassantSquare: Option[Square],
+ )
+
+ private def countPositionOccurrences(context: GameContext, target: Position): Int =
+ try
+ val initialCtx = GameContext(
+ board = context.initialBoard,
+ turn = Color.White,
+ castlingRights = CastlingRights.Initial,
+ enPassantSquare = None,
+ halfMoveClock = 0,
+ moves = List.empty,
+ initialBoard = context.initialBoard,
+ )
+
+ def positionOf(ctx: GameContext): Position =
+ Position(ctx.board, ctx.turn, ctx.castlingRights, ctx.enPassantSquare)
+
+ val initialCount = if positionOf(initialCtx) == target then 1 else 0
+
+ context.moves
+ .foldLeft((initialCtx, initialCount)) { case ((tempCtx, count), move) =>
+ val nextCtx = applyMove(tempCtx)(move)
+ val nextCount = if positionOf(nextCtx) == target then count + 1 else count
+ (nextCtx, nextCount)
+ }
+ ._2
+ catch case _: Exception => 1
+
+ override def postMoveStatus(context: GameContext): PostMoveStatus =
+ PostMoveStatus(
+ isCheckmate = isCheckmate(context),
+ isStalemate = isStalemate(context),
+ isInsufficientMaterial = isInsufficientMaterial(context),
+ isCheck = isCheck(context),
+ isThreefoldRepetition = isThreefoldRepetition(context),
+ )
diff --git a/modules/rule/src/test/resources/application.yml b/modules/rule/src/test/resources/application.yml
new file mode 100644
index 0000000..21664c7
--- /dev/null
+++ b/modules/rule/src/test/resources/application.yml
@@ -0,0 +1,5 @@
+nowchess:
+ internal:
+ secret: test-secret
+ auth:
+ enabled: false
diff --git a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala
index 69ccb56..6789e55 100644
--- a/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala
+++ b/modules/rule/src/test/scala/de/nowchess/rule/DefaultRulesStateTransitionsTest.scala
@@ -11,7 +11,7 @@ import org.scalatest.matchers.should.Matchers
class DefaultRulesStateTransitionsTest extends AnyFunSuite with Matchers:
private def contextFromFen(fen: String): GameContext =
- FenParser.parseFen(fen).fold(err => fail(err), identity)
+ FenParser.parseFen(fen).fold(err => fail(err.message), identity)
private def sq(alg: String): Square =
Square.fromAlgebraic(alg).getOrElse(fail(s"Invalid square in test: $alg"))
diff --git a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala
index e832fcb..c117bfb 100644
--- a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala
+++ b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala
@@ -1,102 +1,31 @@
package de.nowchess.rules.json
import com.fasterxml.jackson.databind.ObjectMapper
-import com.fasterxml.jackson.databind.module.SimpleModule
-import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
-import de.nowchess.api.move.{MoveType, PromotionPiece}
+import de.nowchess.api.move.MoveType
+import de.nowchess.rules.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class JsonSerializersTest extends AnyFunSuite with Matchers:
private val mapper: ObjectMapper =
- val m = new ObjectMapper()
- val mod = new SimpleModule()
- m.registerModule(DefaultScalaModule)
- mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
- mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
- mod.addSerializer(classOf[Square], new SquareSerializer())
- mod.addDeserializer(classOf[Square], new SquareDeserializer())
- mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
- mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
- m.registerModule(mod)
+ val m = new ObjectMapper()
+ new JacksonConfig().customize(m)
m
- private val e4 = Square(File.E, Rank.R4)
+ test("customize enables Option serialization via DefaultScalaModule"):
+ mapper.writeValueAsString(None) shouldBe "null"
+ mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
- // ── SquareKeySerializer ───────────────────────────────────────────
+ test("customize registers SquareSerializer"):
+ mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
- test("SquareKeySerializer writes square as map field name"):
- mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}"""
+ test("customize registers SquareDeserializer"):
+ mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
- // ── SquareKeyDeserializer ─────────────────────────────────────────
-
- // scalafix:off DisableSyntax.null
- test("SquareKeyDeserializer returns square for valid key"):
- new SquareKeyDeserializer().deserializeKey("e4", null) shouldBe e4
-
- test("SquareKeyDeserializer returns null for invalid key"):
- new SquareKeyDeserializer().deserializeKey("z9", null) shouldBe null
- // scalafix:on DisableSyntax.null
-
- // ── SquareSerializer/Deserializer ─────────────────────────────────
-
- test("SquareSerializer writes square as string"):
- mapper.writeValueAsString(e4) shouldBe """"e4""""
-
- test("SquareDeserializer reads valid square string"):
- mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
-
- // scalafix:off DisableSyntax.null
- test("SquareDeserializer returns null for invalid square string"):
- mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
- // scalafix:on DisableSyntax.null
-
- // ── MoveTypeSerializer ────────────────────────────────────────────
-
- test("MoveTypeSerializer serializes Normal non-capture"):
- mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
-
- test("MoveTypeSerializer serializes Normal capture"):
- mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
-
- test("MoveTypeSerializer serializes CastleKingside"):
+ test("customize registers MoveTypeSerializer"):
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
- test("MoveTypeSerializer serializes CastleQueenside"):
- mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
-
- test("MoveTypeSerializer serializes EnPassant"):
- mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
-
- test("MoveTypeSerializer serializes Promotion"):
- mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
- """{"type":"promotion","piece":"Queen"}"""
-
- // ── MoveTypeDeserializer ──────────────────────────────────────────
-
- test("MoveTypeDeserializer deserializes normal non-capture"):
- mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
- MoveType.Normal(false)
-
- test("MoveTypeDeserializer deserializes normal capture"):
- mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
- MoveType.Normal(true)
-
- test("MoveTypeDeserializer deserializes castleKingside"):
- mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
-
- test("MoveTypeDeserializer deserializes castleQueenside"):
- mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
-
- test("MoveTypeDeserializer deserializes enPassant"):
+ test("customize registers MoveTypeDeserializer"):
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
-
- test("MoveTypeDeserializer deserializes promotion"):
- mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
- MoveType.Promotion(PromotionPiece.Rook)
-
- test("MoveTypeDeserializer throws for unknown type"):
- an[Exception] should be thrownBy
- mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
diff --git a/modules/security/build.gradle.kts b/modules/security/build.gradle.kts
new file mode 100644
index 0000000..c6166a2
--- /dev/null
+++ b/modules/security/build.gradle.kts
@@ -0,0 +1,65 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version { strictly(versions["SCALA3"]!!) }
+ }
+
+ compileOnly(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ compileOnly("io.quarkus:quarkus-rest")
+ compileOnly("io.quarkus:quarkus-rest-client")
+ compileOnly("io.quarkus:quarkus-grpc")
+ compileOnly("io.quarkus:quarkus-arc")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
diff --git a/modules/security/src/main/java/de/nowchess/security/InternalOnly.java b/modules/security/src/main/java/de/nowchess/security/InternalOnly.java
new file mode 100644
index 0000000..111cbca
--- /dev/null
+++ b/modules/security/src/main/java/de/nowchess/security/InternalOnly.java
@@ -0,0 +1,12 @@
+package de.nowchess.security;
+
+import jakarta.ws.rs.NameBinding;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@NameBinding
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.TYPE, ElementType.METHOD})
+public @interface InternalOnly {}
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala b/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala
new file mode 100644
index 0000000..d713f42
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalAuthFilter.scala
@@ -0,0 +1,27 @@
+package de.nowchess.security
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
+import jakarta.ws.rs.core.Response
+import jakarta.ws.rs.ext.Provider
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@Provider
+@InternalOnly
+@ApplicationScoped
+class InternalAuthFilter extends ContainerRequestFilter:
+
+ @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+
+ @ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
+ var authEnabled: Boolean = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def filter(ctx: ContainerRequestContext): Unit =
+ if authEnabled then
+ val header = Option(ctx.getHeaderString("X-Internal-Secret"))
+ if header.isEmpty || header.get.equals(secret) then
+ ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala
new file mode 100644
index 0000000..9f1a7e8
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcAuthInterceptor.scala
@@ -0,0 +1,32 @@
+package de.nowchess.security
+
+import io.grpc.{Metadata, ServerCall, ServerCallHandler, ServerInterceptor, Status}
+import io.quarkus.grpc.GlobalInterceptor
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@GlobalInterceptor
+@ApplicationScoped
+class InternalGrpcAuthInterceptor extends ServerInterceptor:
+
+ private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
+
+ @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+
+ @ConfigProperty(name = "nowchess.internal.auth.enabled", defaultValue = "true")
+ var authEnabled: Boolean = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def interceptCall[Req, Resp](
+ call: ServerCall[Req, Resp],
+ headers: Metadata,
+ next: ServerCallHandler[Req, Resp],
+ ): ServerCall.Listener[Req] =
+ val token = Option(headers.get(secretKey)).getOrElse("")
+ if authEnabled && token != secret then
+ call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata())
+ new ServerCall.Listener[Req] {}
+ else next.startCall(call, headers)
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala
new file mode 100644
index 0000000..8796e0f
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalGrpcSecretClientInterceptor.scala
@@ -0,0 +1,28 @@
+package de.nowchess.security
+
+import io.grpc.{CallOptions, Channel, ClientCall, ClientInterceptor, ForwardingClientCall, Metadata, MethodDescriptor}
+import io.quarkus.grpc.GlobalInterceptor
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@GlobalInterceptor
+@ApplicationScoped
+class InternalGrpcSecretClientInterceptor extends ClientInterceptor:
+
+ private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
+
+ @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def interceptCall[Req, Resp](
+ method: MethodDescriptor[Req, Resp],
+ callOptions: CallOptions,
+ next: Channel,
+ ): ClientCall[Req, Resp] =
+ new ForwardingClientCall.SimpleForwardingClientCall[Req, Resp](next.newCall(method, callOptions)):
+ override def start(responseListener: ClientCall.Listener[Resp], headers: Metadata): Unit =
+ headers.put(secretKey, secret)
+ super.start(responseListener, headers)
diff --git a/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala b/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala
new file mode 100644
index 0000000..d2b9d77
--- /dev/null
+++ b/modules/security/src/main/scala/de/nowchess/security/InternalSecretClientFilter.scala
@@ -0,0 +1,17 @@
+package de.nowchess.security
+
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.ws.rs.client.{ClientRequestContext, ClientRequestFilter}
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class InternalSecretClientFilter extends ClientRequestFilter:
+
+ @ConfigProperty(name = "nowchess.internal.secret", defaultValue = "")
+ // scalafix:off DisableSyntax.var
+ var secret: String = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ override def filter(ctx: ClientRequestContext): Unit =
+ ctx.getHeaders.putSingle("X-Internal-Secret", secret)
diff --git a/modules/store/build.gradle.kts b/modules/store/build.gradle.kts
new file mode 100644
index 0000000..8ce4f81
--- /dev/null
+++ b/modules/store/build.gradle.kts
@@ -0,0 +1,109 @@
+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
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+ implementation(project(":modules:api"))
+
+ runtimeOnly("io.quarkus:quarkus-jdbc-h2")
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-rest")
+ implementation("io.quarkus:quarkus-rest-jackson")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-hibernate-orm-panache")
+ implementation("io.quarkus:quarkus-jdbc-postgresql")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+
+ testImplementation(platform("org.junit:junit-bom:5.13.4"))
+ testImplementation("org.junit.jupiter:junit-jupiter")
+ testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
+ testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
+ testImplementation("io.quarkus:quarkus-junit5")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+ testImplementation("io.rest-assured:rest-assured")
+ testImplementation("io.quarkus:quarkus-jdbc-h2")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+
+tasks.withType().configureEach {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging {
+ events("passed", "skipped", "failed")
+ showStandardStreams = true
+ exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
+ }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage {
+ dependsOn(tasks.test)
+}
+
+tasks.jar {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+}
diff --git a/modules/store/src/main/resources/META-INF.native-image.de.nowchess.store/reachability-metadata.json b/modules/store/src/main/resources/META-INF.native-image.de.nowchess.store/reachability-metadata.json
new file mode 100644
index 0000000..7245246
--- /dev/null
+++ b/modules/store/src/main/resources/META-INF.native-image.de.nowchess.store/reachability-metadata.json
@@ -0,0 +1,27 @@
+{
+ "reflection": [
+ { "type": "scala.Tuple1[]" },
+ { "type": "scala.Tuple2[]" },
+ { "type": "scala.Tuple3[]" },
+ { "type": "scala.Tuple4[]" },
+ { "type": "scala.Tuple5[]" },
+ { "type": "scala.Tuple6[]" },
+ { "type": "scala.Tuple7[]" },
+ { "type": "scala.Tuple8[]" },
+ { "type": "scala.Tuple9[]" },
+ { "type": "scala.Tuple10[]" },
+ { "type": "scala.Tuple11[]" },
+ { "type": "scala.Tuple12[]" },
+ { "type": "scala.Tuple13[]" },
+ { "type": "scala.Tuple14[]" },
+ { "type": "scala.Tuple15[]" },
+ { "type": "scala.Tuple16[]" },
+ { "type": "scala.Tuple17[]" },
+ { "type": "scala.Tuple18[]" },
+ { "type": "scala.Tuple19[]" },
+ { "type": "scala.Tuple20[]" },
+ { "type": "scala.Tuple21[]" },
+ { "type": "scala.Tuple22[]" },
+ { "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
+ ]
+}
diff --git a/modules/store/src/main/resources/application.yml b/modules/store/src/main/resources/application.yml
new file mode 100644
index 0000000..badc025
--- /dev/null
+++ b/modules/store/src/main/resources/application.yml
@@ -0,0 +1,41 @@
+quarkus:
+ application:
+ name: nowchess-store
+ http.port: 8085
+ config:
+ yaml:
+ enabled: true
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+ datasource:
+ db-kind: postgresql
+ username: ${DB_USER:nowchess}
+ password: ${DB_PASSWORD:nowchess}
+ jdbc:
+ url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess}
+ hibernate-orm:
+ database:
+ generation: update
+
+nowchess:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
+
+"%test":
+ quarkus:
+ datasource:
+ db-kind: h2
+ username: sa
+ password: ""
+ jdbc:
+ url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
+ hibernate-orm:
+ database:
+ generation: drop-and-create
+ nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: test-store
diff --git a/modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala b/modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala
new file mode 100644
index 0000000..5042bb2
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.store.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala b/modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..7f3d7fb
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/config/NativeReflectionConfig.scala
@@ -0,0 +1,13 @@
+package de.nowchess.store.config
+
+import de.nowchess.api.dto.GameWritebackEventDto
+import de.nowchess.store.domain.GameRecord
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(
+ classOf[GameRecord],
+ classOf[GameWritebackEventDto],
+ ),
+)
+class NativeReflectionConfig
diff --git a/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala b/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala
new file mode 100644
index 0000000..48e3eb4
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+package de.nowchess.store.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ // scalafix:off DisableSyntax.var
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala b/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
new file mode 100644
index 0000000..4be028a
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/domain/GameRecord.scala
@@ -0,0 +1,98 @@
+package de.nowchess.store.domain
+
+import io.quarkus.hibernate.orm.panache.PanacheEntityBase
+import jakarta.persistence.*
+import scala.compiletime.uninitialized
+import java.time.Instant
+
+@Entity
+@Table(
+ name = "game_records",
+ indexes = Array(
+ new Index(name = "idx_game_records_white_id", columnList = "whiteId"),
+ new Index(name = "idx_game_records_black_id", columnList = "blackId"),
+ ),
+)
+class GameRecord extends PanacheEntityBase:
+ // scalafix:off DisableSyntax.var
+ @Id
+ @Column(nullable = false)
+ var gameId: String = uninitialized
+
+ @Column(nullable = false, columnDefinition = "TEXT")
+ var fen: String = uninitialized
+
+ @Column(nullable = false, columnDefinition = "TEXT")
+ var pgn: String = uninitialized
+
+ @Column(nullable = false)
+ var moveCount: Int = 0
+
+ @Column(nullable = false)
+ var createdAt: Instant = uninitialized
+
+ @Column(nullable = false)
+ var updatedAt: Instant = uninitialized
+
+ // Player info
+ @Column(nullable = false)
+ var whiteId: String = uninitialized
+
+ @Column(nullable = false)
+ var whiteName: String = uninitialized
+
+ @Column(nullable = false)
+ var blackId: String = uninitialized
+
+ @Column(nullable = false)
+ var blackName: String = uninitialized
+
+ @Column(nullable = false)
+ var mode: String = uninitialized
+
+ // Time control
+ @Column
+ var limitSeconds: java.lang.Integer = uninitialized
+
+ @Column
+ var incrementSeconds: java.lang.Integer = uninitialized
+
+ @Column
+ var daysPerMove: java.lang.Integer = uninitialized
+
+ // Clock state
+ @Column
+ var whiteRemainingMs: java.lang.Long = uninitialized
+
+ @Column
+ var blackRemainingMs: java.lang.Long = uninitialized
+
+ @Column
+ var incrementMs: java.lang.Long = uninitialized
+
+ @Column
+ var clockLastTickAt: java.lang.Long = uninitialized
+
+ @Column
+ var clockMoveDeadline: java.lang.Long = uninitialized
+
+ @Column
+ var clockActiveColor: String = uninitialized
+
+ // Game meta
+ @Column(nullable = false)
+ var resigned: Boolean = false
+
+ @Column
+ var pendingDrawOffer: String = uninitialized
+
+ @Column
+ var pendingTakebackOffer: String = uninitialized
+
+ // Game result
+ @Column
+ var result: String = uninitialized
+
+ @Column
+ var terminationReason: String = uninitialized
+ // scalafix:on
diff --git a/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
new file mode 100644
index 0000000..3cc0243
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/redis/GameWritebackStreamListener.scala
@@ -0,0 +1,29 @@
+package de.nowchess.store.redis
+
+import com.fasterxml.jackson.databind.ObjectMapper
+import de.nowchess.api.dto.GameWritebackEventDto
+import de.nowchess.store.service.GameWritebackService
+import io.quarkus.redis.datasource.RedisDataSource
+import jakarta.annotation.PostConstruct
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.util.function.Consumer
+
+@ApplicationScoped
+class GameWritebackStreamListener:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var redis: RedisDataSource = uninitialized
+ @Inject var objectMapper: ObjectMapper = uninitialized
+ @Inject var writebackService: GameWritebackService = uninitialized
+ // scalafix:on
+
+ @PostConstruct
+ def startListening(): Unit =
+ val handler: Consumer[String] = json =>
+ Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
+ .foreach(writebackService.writeBack)
+ redis.pubsub(classOf[String]).subscribe("game-writeback", handler)
+ ()
diff --git a/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala b/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
new file mode 100644
index 0000000..2e42b39
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/repository/GameRecordRepository.scala
@@ -0,0 +1,46 @@
+package de.nowchess.store.repository
+
+import de.nowchess.store.domain.GameRecord
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.persistence.EntityManager
+import scala.compiletime.uninitialized
+import scala.jdk.CollectionConverters.*
+
+@ApplicationScoped
+class GameRecordRepository:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var em: EntityManager = uninitialized
+ // scalafix:on
+
+ def findByGameId(gameId: String): Option[GameRecord] =
+ Option(em.find(classOf[GameRecord], gameId))
+
+ def persist(record: GameRecord): Unit =
+ em.persist(record)
+
+ def merge(record: GameRecord): Unit =
+ em.merge(record)
+
+ def findByPlayerId(playerId: String, offset: Int, limit: Int): List[GameRecord] =
+ em.createQuery(
+ "SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result != null ORDER BY g.updatedAt DESC",
+ classOf[GameRecord],
+ ).setParameter("id", playerId)
+ .setFirstResult(offset)
+ .setMaxResults(limit)
+ .getResultList
+ .asScala
+ .toList
+
+ def findByPlayerIdRunning(playerId: String, offset: Int, limit: Int): List[GameRecord] =
+ em.createQuery(
+ "SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result = null ORDER BY g.updatedAt DESC",
+ classOf[GameRecord],
+ ).setParameter("id", playerId)
+ .setFirstResult(offset)
+ .setMaxResults(limit)
+ .getResultList
+ .asScala
+ .toList
diff --git a/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala b/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
new file mode 100644
index 0000000..c4f8457
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/resource/StoreGameResource.scala
@@ -0,0 +1,45 @@
+package de.nowchess.store.resource
+
+import de.nowchess.store.repository.GameRecordRepository
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.ws.rs.*
+import jakarta.ws.rs.core.{MediaType, Response}
+import jakarta.ws.rs.DefaultValue
+import scala.compiletime.uninitialized
+
+@Path("/game")
+@ApplicationScoped
+class StoreGameResource:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var repository: GameRecordRepository = uninitialized
+ // scalafix:on
+
+ @GET
+ @Path("/{gameId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getGame(@PathParam("gameId") gameId: String): Response =
+ repository
+ .findByGameId(gameId)
+ .fold(Response.status(404).build())(r => Response.ok(r).build())
+
+ @GET
+ @Path("/running/{playerId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getRunning(
+ @PathParam("playerId") playerId: String,
+ @QueryParam("offset") @DefaultValue("0") offset: Int,
+ @QueryParam("limit") @DefaultValue("20") limit: Int,
+ ): Response =
+ Response.ok(repository.findByPlayerIdRunning(playerId, offset, limit)).build()
+
+ @GET
+ @Path("/history/{playerId}")
+ @Produces(Array(MediaType.APPLICATION_JSON))
+ def getHistory(
+ @PathParam("playerId") playerId: String,
+ @QueryParam("offset") @DefaultValue("0") offset: Int,
+ @QueryParam("limit") @DefaultValue("20") limit: Int,
+ ): Response =
+ Response.ok(repository.findByPlayerId(playerId, offset, limit)).build()
diff --git a/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala b/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
new file mode 100644
index 0000000..f006ead
--- /dev/null
+++ b/modules/store/src/main/scala/de/nowchess/store/service/GameWritebackService.scala
@@ -0,0 +1,75 @@
+package de.nowchess.store.service
+
+import de.nowchess.api.dto.GameWritebackEventDto
+import de.nowchess.store.domain.GameRecord
+import de.nowchess.store.repository.GameRecordRepository
+import jakarta.enterprise.context.ApplicationScoped
+import jakarta.inject.Inject
+import jakarta.transaction.Transactional
+
+import scala.compiletime.uninitialized
+import java.time.Instant
+
+@ApplicationScoped
+class GameWritebackService:
+ @Inject
+ // scalafix:off DisableSyntax.var
+ var repository: GameRecordRepository = uninitialized
+ // scalafix:on
+
+ @Transactional
+ def writeBack(event: GameWritebackEventDto): Unit =
+ repository.findByGameId(event.gameId) match
+ case None =>
+ val record = new GameRecord
+ record.gameId = event.gameId
+ record.fen = event.fen
+ record.pgn = event.pgn
+ record.moveCount = event.moveCount
+ record.whiteId = event.whiteId
+ record.whiteName = event.whiteName
+ record.blackId = event.blackId
+ record.blackName = event.blackName
+ record.mode = event.mode
+ record.resigned = event.resigned
+ record.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
+ record.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
+ record.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
+ record.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
+ record.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
+ record.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
+ record.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
+ record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
+ record.clockActiveColor = event.clockActiveColor.orNull
+ record.pendingDrawOffer = event.pendingDrawOffer.orNull
+ record.result = event.result.orNull
+ record.terminationReason = event.terminationReason.orNull
+ record.createdAt = Instant.now()
+ record.updatedAt = Instant.now()
+ repository.persist(record)
+ case Some(r) if event.moveCount > r.moveCount || event.pgn != r.pgn =>
+ r.fen = event.fen
+ r.pgn = event.pgn
+ r.moveCount = event.moveCount
+ r.whiteId = event.whiteId
+ r.whiteName = event.whiteName
+ r.blackId = event.blackId
+ r.blackName = event.blackName
+ r.mode = event.mode
+ r.resigned = event.resigned
+ r.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
+ r.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
+ r.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
+ r.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
+ r.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
+ r.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
+ r.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
+ r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
+ r.clockActiveColor = event.clockActiveColor.orNull
+ r.pendingDrawOffer = event.pendingDrawOffer.orNull
+ r.pendingTakebackOffer = event.pendingTakebackRequest.orNull
+ r.result = event.result.orNull
+ r.terminationReason = event.terminationReason.orNull
+ r.updatedAt = Instant.now()
+ repository.merge(r)
+ case _ => ()
diff --git a/modules/ws/build.gradle.kts b/modules/ws/build.gradle.kts
new file mode 100644
index 0000000..fe67808
--- /dev/null
+++ b/modules/ws/build.gradle.kts
@@ -0,0 +1,95 @@
+plugins {
+ id("scala")
+ id("org.scoverage") version "8.1"
+ id("io.quarkus")
+}
+
+group = "de.nowchess"
+version = "1.0-SNAPSHOT"
+
+@Suppress("UNCHECKED_CAST")
+val versions = rootProject.extra["VERSIONS"] as Map
+@Suppress("UNCHECKED_CAST")
+val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List
+
+repositories {
+ mavenCentral()
+}
+
+scala {
+ scalaVersion = versions["SCALA3"]!!
+}
+
+scoverage {
+ scoverageVersion.set(versions["SCOVERAGE"]!!)
+ excludedFiles.set(scoverageExcluded)
+}
+
+tasks.withType {
+ scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
+}
+
+val quarkusPlatformGroupId: String by project
+val quarkusPlatformArtifactId: String by project
+val quarkusPlatformVersion: String by project
+
+dependencies {
+
+ compileOnly("org.scala-lang:scala3-compiler_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+ implementation("org.scala-lang:scala3-library_3") {
+ version {
+ strictly(versions["SCALA3"]!!)
+ }
+ }
+
+ implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
+ implementation("io.quarkus:quarkus-websockets-next")
+ implementation("io.quarkus:quarkus-jackson")
+ implementation("io.quarkus:quarkus-arc")
+ implementation("io.quarkus:quarkus-smallrye-jwt")
+ implementation("io.quarkus:quarkus-config-yaml")
+ implementation("io.quarkus:quarkus-smallrye-health")
+ implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
+ implementation("io.quarkus:quarkus-redis-client")
+
+ 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-junit5")
+ testImplementation("io.quarkus:quarkus-junit5-mockito")
+
+ testRuntimeOnly("org.junit.platform:junit-platform-launcher")
+ testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
+}
+
+configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
+ resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
+}
+configurations.scoverage {
+ resolutionStrategy.eachDependency {
+ if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
+ useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
+ }
+ }
+}
+
+tasks.withType {
+ options.encoding = "UTF-8"
+ options.compilerArgs.add("-parameters")
+}
+tasks.withType().configureEach { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
+
+tasks.test {
+ useJUnitPlatform {
+ includeEngines("scalatest", "junit-jupiter")
+ testLogging { events("passed", "skipped", "failed") }
+ }
+ finalizedBy(tasks.reportScoverage)
+}
+tasks.reportScoverage { dependsOn(tasks.test) }
+tasks.jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
diff --git a/modules/ws/src/main/resources/application.yml b/modules/ws/src/main/resources/application.yml
new file mode 100644
index 0000000..ac82f36
--- /dev/null
+++ b/modules/ws/src/main/resources/application.yml
@@ -0,0 +1,39 @@
+quarkus:
+ http:
+ port: 8084
+ application:
+ name: nowchess-ws
+ redis:
+ hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+
+"%dev":
+ nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: nowchess
+ mp:
+ jwt:
+ verify:
+ publickey:
+ location: keys/public.pem
+ issuer: nowchess
+
+"%deployed":
+ nowchess:
+ redis:
+ host: ${REDIS_HOST}
+ port: ${REDIS_PORT:6379}
+ prefix: ${REDIS_PREFIX:nowchess}
+ mp:
+ jwt:
+ verify:
+ publickey:
+ location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
+ issuer: nowchess
diff --git a/modules/ws/src/main/resources/keys/public.pem b/modules/ws/src/main/resources/keys/public.pem
new file mode 100644
index 0000000..6b6b842
--- /dev/null
+++ b/modules/ws/src/main/resources/keys/public.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
+g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
+Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
+634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
+YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
+7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
+WQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala b/modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala
new file mode 100644
index 0000000..3b8adaf
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/config/JacksonConfig.scala
@@ -0,0 +1,17 @@
+package de.nowchess.ws.config
+
+import com.fasterxml.jackson.core.Version
+import com.fasterxml.jackson.databind.ObjectMapper
+import com.fasterxml.jackson.module.scala.DefaultScalaModule
+import io.quarkus.jackson.ObjectMapperCustomizer
+import jakarta.inject.Singleton
+
+@Singleton
+class JacksonConfig extends ObjectMapperCustomizer:
+ def customize(mapper: ObjectMapper): Unit =
+ mapper.registerModule(new DefaultScalaModule() {
+ override def version(): Version =
+ // scalafix:off DisableSyntax.null
+ new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
+ // scalafix:on DisableSyntax.null
+ })
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala b/modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala
new file mode 100644
index 0000000..0489bb4
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/config/NativeReflectionConfig.scala
@@ -0,0 +1,8 @@
+package de.nowchess.ws.config
+
+import io.quarkus.runtime.annotations.RegisterForReflection
+
+@RegisterForReflection(
+ targets = Array(),
+)
+class NativeReflectionConfig
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala b/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala
new file mode 100644
index 0000000..cde1ab7
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/config/RedisConfig.scala
@@ -0,0 +1,12 @@
+package de.nowchess.ws.config
+
+import jakarta.enterprise.context.ApplicationScoped
+import org.eclipse.microprofile.config.inject.ConfigProperty
+import scala.compiletime.uninitialized
+
+@ApplicationScoped
+class RedisConfig:
+ // scalafix:off DisableSyntax.var
+ @ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
+ var prefix: String = uninitialized
+ // scalafix:on DisableSyntax.var
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/resource/ConnectionMeta.scala b/modules/ws/src/main/scala/de/nowchess/ws/resource/ConnectionMeta.scala
new file mode 100644
index 0000000..7726823
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/resource/ConnectionMeta.scala
@@ -0,0 +1,9 @@
+package de.nowchess.ws.resource
+
+import io.quarkus.redis.datasource.pubsub.PubSubCommands
+
+final case class ConnectionMeta(
+ gameId: String,
+ subscriber: PubSubCommands.RedisSubscriber,
+ playerId: Option[String],
+)
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala b/modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala
new file mode 100644
index 0000000..204502c
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/resource/GameWebSocketResource.scala
@@ -0,0 +1,69 @@
+package de.nowchess.ws.resource
+
+import de.nowchess.ws.config.RedisConfig
+import io.quarkus.redis.datasource.RedisDataSource
+import io.quarkus.redis.datasource.pubsub.PubSubCommands
+import io.quarkus.websockets.next.*
+import io.smallrye.jwt.auth.principal.JWTParser
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+
+@WebSocket(path = "/api/board/game/{gameId}/ws")
+class GameWebSocketResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redis: RedisDataSource = uninitialized
+
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ @Inject
+ var jwtParser: JWTParser = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val connections = new ConcurrentHashMap[String, ConnectionMeta]()
+
+ private def s2cTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:s2c"
+
+ private def c2sTopic(gameId: String): String =
+ s"${redisConfig.prefix}:game:$gameId:c2s"
+
+ @OnOpen
+ def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
+ val gameId = connection.pathParam("gameId")
+ val playerId = Option(handshake.header("Authorization"))
+ .filter(_.nonEmpty)
+ .flatMap(token => Try(jwtParser.parse(token)).toOption)
+ .map(_.getSubject)
+ val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
+ val subscriber = redis.pubsub(classOf[String]).subscribe(s2cTopic(gameId), handler)
+ connections.put(connection.id(), ConnectionMeta(gameId, subscriber, playerId))
+ val connectedMsg = playerId match
+ case Some(pid) => s"""{"type":"CONNECTED","gameId":"$gameId","playerId":"$pid"}"""
+ case None => s"""{"type":"CONNECTED","gameId":"$gameId"}"""
+ redis.pubsub(classOf[String]).publish(c2sTopic(gameId), connectedMsg)
+
+ @OnTextMessage
+ def onTextMessage(connection: WebSocketConnection, message: String): Unit =
+ Option(connections.get(connection.id())).foreach { meta =>
+ val enriched = meta.playerId match
+ case Some(pid) => injectPlayerId(message, pid)
+ case None => message
+ redis.pubsub(classOf[String]).publish(c2sTopic(meta.gameId), enriched)
+ }
+
+ @OnClose
+ def onClose(connection: WebSocketConnection): Unit =
+ Option(connections.remove(connection.id())).foreach { meta =>
+ meta.subscriber.unsubscribe(s2cTopic(meta.gameId))
+ }
+
+ private def injectPlayerId(msg: String, pid: String): String =
+ val trimmed = msg.trim
+ if trimmed.endsWith("}") then trimmed.dropRight(1) + s""","playerId":"$pid"}"""
+ else msg
diff --git a/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala b/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala
new file mode 100644
index 0000000..0c68a7a
--- /dev/null
+++ b/modules/ws/src/main/scala/de/nowchess/ws/resource/UserWebSocketResource.scala
@@ -0,0 +1,53 @@
+package de.nowchess.ws.resource
+
+import de.nowchess.ws.config.RedisConfig
+import io.quarkus.redis.datasource.RedisDataSource
+import io.quarkus.redis.datasource.pubsub.PubSubCommands
+import io.quarkus.websockets.next.*
+import io.smallrye.jwt.auth.principal.JWTParser
+import jakarta.inject.Inject
+import scala.compiletime.uninitialized
+import scala.util.Try
+import java.util.concurrent.ConcurrentHashMap
+import java.util.function.Consumer
+
+@WebSocket(path = "/api/user/ws")
+class UserWebSocketResource:
+
+ // scalafix:off DisableSyntax.var
+ @Inject
+ var redis: RedisDataSource = uninitialized
+
+ @Inject
+ var redisConfig: RedisConfig = uninitialized
+
+ @Inject
+ var jwtParser: JWTParser = uninitialized
+ // scalafix:on DisableSyntax.var
+
+ private val connections = new ConcurrentHashMap[String, (String, PubSubCommands.RedisSubscriber)]()
+
+ private def userTopic(userId: String): String =
+ s"${redisConfig.prefix}:user:$userId:events"
+
+ @OnOpen
+ def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
+ val userIdOpt = Option(handshake.header("Authorization"))
+ .filter(_.nonEmpty)
+ .flatMap(token => Try(jwtParser.parse(token)).toOption)
+ .map(_.getSubject)
+
+ userIdOpt match
+ case None => connection.close().subscribe().`with`(_ => (), _ => ())
+ case Some(userId) =>
+ val handler: Consumer[String] = msg => connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
+ val subscriber = redis.pubsub(classOf[String]).subscribe(userTopic(userId), handler)
+ connections.put(connection.id(), (userId, subscriber))
+ val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
+ connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
+
+ @OnClose
+ def onClose(connection: WebSocketConnection): Unit =
+ Option(connections.remove(connection.id())).foreach { (userId, subscriber) =>
+ subscriber.unsubscribe(userTopic(userId))
+ }
diff --git a/modules/ws/src/test/resources/application.yml b/modules/ws/src/test/resources/application.yml
new file mode 100644
index 0000000..ce0a06b
--- /dev/null
+++ b/modules/ws/src/test/resources/application.yml
@@ -0,0 +1,11 @@
+quarkus:
+ http:
+ port: 8084
+ application:
+ name: nowchess-ws
+
+nowchess:
+ redis:
+ host: localhost
+ port: 6379
+ prefix: test
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 9b36b4b..5912991 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -16,7 +16,14 @@ pluginManagement {
include(
"modules:core",
"modules:api",
+ "modules:json",
"modules:io",
"modules:rule",
- "modules:bot",
+ "modules:security",
+ "modules:bot-platform",
+ "modules:official-bots",
+ "modules:account",
+ "modules:ws",
+ "modules:store",
+ "modules:coordinator",
)
\ No newline at end of file