feat(game): introduce game modes and time control features
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
+78
-13
@@ -2,8 +2,8 @@
|
||||
|
||||
> **Stack:** raw-http | none | unknown | scala
|
||||
|
||||
> 0 routes | 0 models | 0 components | 89 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.**
|
||||
> 0 routes | 0 models | 0 components | 107 lib files | 1 env vars | 1 middleware
|
||||
> **Token savings:** this file is ~7,200 tokens. Without it, AI exploration would cost ~37,300 tokens. **Saves ~30,100 tokens per conversation.**
|
||||
|
||||
---
|
||||
|
||||
@@ -25,6 +25,62 @@
|
||||
- function main: () -> None
|
||||
- class TestCase
|
||||
- _...2 more_
|
||||
- `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/Account.scala` — class Account
|
||||
- `modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala`
|
||||
- class Challenge
|
||||
- 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/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/repository/AccountRepository.scala`
|
||||
- class AccountRepository
|
||||
- function findByUsername
|
||||
- function findById
|
||||
- function persist
|
||||
- function findByEmail
|
||||
- function findAll
|
||||
- `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
|
||||
- `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
|
||||
- `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 +110,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
|
||||
@@ -228,8 +293,8 @@
|
||||
- function turn
|
||||
- function context
|
||||
- function pendingDrawOfferBy
|
||||
- function canUndo
|
||||
- _...17 more_
|
||||
- function currentClockState
|
||||
- _...18 more_
|
||||
- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala`
|
||||
- class ApiException
|
||||
- class GameNotFoundException
|
||||
@@ -364,36 +429,36 @@
|
||||
- `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 **38** files
|
||||
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **26** 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
|
||||
- `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/CastlingRights.scala` — imported by **13** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **12** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala` — imported by **12** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **11** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **10** files
|
||||
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **9** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/error/GameError.scala` — imported by **9** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — imported by **8** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **7** files
|
||||
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **7** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.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/io/GameContextExport.scala` — imported by **6** files
|
||||
- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — imported by **5** files
|
||||
|
||||
## Import Map (who imports what)
|
||||
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +69 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` +61 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` +47 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` +33 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` +21 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` +37 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` +22 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` +16 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/CastlingRights.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/test/scala/de/nowchess/bot/ZobristHashTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala` +8 more
|
||||
- `modules/api/src/main/scala/de/nowchess/api/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` +7 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/observer/Observer.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala` +7 more
|
||||
|
||||
---
|
||||
|
||||
|
||||
+9
-9
@@ -5,33 +5,33 @@
|
||||
- `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 **38** files
|
||||
- `modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala` — imported by **26** 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
|
||||
- `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/CastlingRights.scala` — imported by **13** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **12** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala` — imported by **12** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/DrawReason.scala` — imported by **11** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **10** files
|
||||
- `modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala` — imported by **10** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameResult.scala` — imported by **9** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/error/GameError.scala` — imported by **9** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala` — imported by **8** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.scala` — imported by **7** files
|
||||
- `modules/core/src/main/scala/de/nowchess/chess/observer/Observer.scala` — imported by **7** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala` — imported by **6** files
|
||||
- `modules/api/src/main/scala/de/nowchess/api/player/PlayerInfo.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/io/GameContextExport.scala` — imported by **6** files
|
||||
- `modules/bot/src/main/scala/de/nowchess/bot/util/PolyglotBook.scala` — imported by **5** files
|
||||
|
||||
## Import Map (who imports what)
|
||||
|
||||
- `modules/api/src/main/scala/de/nowchess/api/game/GameContext.scala` ← `modules/api/src/main/scala/de/nowchess/api/bot/Bot.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextExport.scala`, `modules/api/src/main/scala/de/nowchess/api/io/GameContextImport.scala`, `modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala`, `modules/bot/src/main/scala/de/nowchess/bot/BotMoveRepetition.scala` +69 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` +61 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` +47 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` +33 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` +21 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` +37 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` +22 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` +16 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/CastlingRights.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/test/scala/de/nowchess/bot/ZobristHashTest.scala`, `modules/core/src/main/scala/de/nowchess/chess/config/NativeReflectionConfig.scala`, `modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineWithBotTest.scala` +8 more
|
||||
- `modules/api/src/main/scala/de/nowchess/api/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` +7 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/observer/Observer.scala`, `modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala` +7 more
|
||||
|
||||
+67
-2
@@ -16,6 +16,62 @@
|
||||
- function main: () -> None
|
||||
- class TestCase
|
||||
- _...2 more_
|
||||
- `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/Account.scala` — class Account
|
||||
- `modules/account/src/main/scala/de/nowchess/account/domain/Challenge.scala`
|
||||
- class Challenge
|
||||
- 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/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/repository/AccountRepository.scala`
|
||||
- class AccountRepository
|
||||
- function findByUsername
|
||||
- function findById
|
||||
- function persist
|
||||
- function findByEmail
|
||||
- function findAll
|
||||
- `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
|
||||
- `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
|
||||
- `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 +101,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
|
||||
@@ -219,8 +284,8 @@
|
||||
- function turn
|
||||
- function context
|
||||
- function pendingDrawOfferBy
|
||||
- function canUndo
|
||||
- _...17 more_
|
||||
- function currentClockState
|
||||
- _...18 more_
|
||||
- `modules/core/src/main/scala/de/nowchess/chess/exception/ApiException.scala`
|
||||
- class ApiException
|
||||
- class GameNotFoundException
|
||||
|
||||
@@ -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)_
|
||||
_Last compiled: 2026-04-23 · 2 articles · [codesight](https://github.com/Houseofmvps/codesight)_
|
||||
@@ -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
|
||||
|
||||
@@ -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_
|
||||
_Back to [index.md](./index.md) · Generated 2026-04-23_
|
||||
Generated
+1
@@ -15,6 +15,7 @@
|
||||
<option value="$PROJECT_DIR$/modules/bot" />
|
||||
<option value="$PROJECT_DIR$/modules/core" />
|
||||
<option value="$PROJECT_DIR$/modules/io" />
|
||||
<option value="$PROJECT_DIR$/modules/json" />
|
||||
<option value="$PROJECT_DIR$/modules/rule" />
|
||||
</set>
|
||||
</option>
|
||||
|
||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
</profile>
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
||||
<option name="deprecationWarnings" value="true" />
|
||||
<option name="uncheckedWarnings" value="true" />
|
||||
<parameters>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: Yg200tOF
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
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: message 1
|
||||
content: '''
|
||||
{}
|
||||
'''
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
gameId: tjOgyEcS
|
||||
gameId: uWm99efJ
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,5 @@ post {
|
||||
}
|
||||
|
||||
vars:pre-request {
|
||||
gameId: tjOgyEcS
|
||||
gameId: Yg200tOF
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
vars {
|
||||
baseUrl: http://localhost:8080
|
||||
wsBaseUrl: ws://localhost:8080
|
||||
ioBaseUrl: http://localhost:8081
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -32,6 +32,8 @@ val quarkusPlatformVersion: String by project
|
||||
|
||||
dependencies {
|
||||
|
||||
runtimeOnly("io.quarkus:quarkus-jdbc-h2")
|
||||
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
@@ -46,6 +48,7 @@ dependencies {
|
||||
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")
|
||||
@@ -66,6 +69,7 @@ dependencies {
|
||||
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")
|
||||
}
|
||||
|
||||
+27
@@ -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[]" }
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,9 @@ quarkus:
|
||||
port: 8083
|
||||
application:
|
||||
name: nowchess-account
|
||||
rest-client:
|
||||
core-service:
|
||||
url: http://localhost:8080
|
||||
smallrye-openapi:
|
||||
info-title: NowChess Account Service
|
||||
path: /openapi
|
||||
@@ -10,17 +13,29 @@ quarkus:
|
||||
always-include: true
|
||||
path: /swagger-ui
|
||||
datasource:
|
||||
postgres:
|
||||
db-kind: postgresql
|
||||
username: ${DB_USER:nowchess}
|
||||
password: ${DB_PASSWORD:nowchess}
|
||||
db-kind: h2
|
||||
username: sa
|
||||
password: ""
|
||||
jdbc:
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess_account}
|
||||
url: jdbc:h2:mem:nowchess;DB_CLOSE_DELAY=-1
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
|
||||
"%live":
|
||||
quarkus:
|
||||
rest-client:
|
||||
core-service:
|
||||
url: ${CORE_SERVICE_URL}
|
||||
datasource:
|
||||
db-kind: postgresql
|
||||
username: ${DB_USER}
|
||||
password: ${DB_PASSWORD}
|
||||
jdbc:
|
||||
url: ${DB_URL}
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: update
|
||||
|
||||
"%prod":
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
@@ -32,10 +47,3 @@ quarkus:
|
||||
sign:
|
||||
key:
|
||||
location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
|
||||
quarkus:
|
||||
datasource:
|
||||
postgres:
|
||||
active: true
|
||||
hibernate-orm:
|
||||
postgres:
|
||||
active: true
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.nowchess.account.client
|
||||
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
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")
|
||||
trait CoreGameClient:
|
||||
|
||||
@POST
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(req: CoreCreateGameRequest): CoreGameResponse
|
||||
+14
-2
@@ -1,12 +1,19 @@
|
||||
package de.nowchess.account.config
|
||||
|
||||
import de.nowchess.account.domain.{ChallengeColor, ChallengeStatus, DeclineReason}
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
|
||||
import de.nowchess.account.domain.{Account, Challenge, ChallengeColor, ChallengeStatus, DeclineReason, TimeControl}
|
||||
import de.nowchess.account.dto.*
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[RegisterRequest],
|
||||
classOf[Account],
|
||||
classOf[Challenge],
|
||||
classOf[ChallengeColor],
|
||||
classOf[ChallengeStatus],
|
||||
classOf[DeclineReason],
|
||||
classOf[TimeControl],
|
||||
|
||||
classOf[LoginRequest],
|
||||
classOf[TokenResponse],
|
||||
classOf[PlayerInfo],
|
||||
@@ -20,6 +27,11 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[ChallengeStatus],
|
||||
classOf[ChallengeColor],
|
||||
classOf[DeclineReason],
|
||||
|
||||
classOf[CorePlayerInfo],
|
||||
classOf[CoreTimeControl],
|
||||
classOf[CoreCreateGameRequest],
|
||||
classOf[CoreGameResponse],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
|
||||
@@ -45,6 +45,10 @@ class Challenge extends PanacheEntityBase:
|
||||
|
||||
var expiresAt: Instant = uninitialized
|
||||
|
||||
@Column(nullable = true)
|
||||
var gameId: String = uninitialized
|
||||
|
||||
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())
|
||||
|
||||
@@ -23,6 +23,7 @@ case class ChallengeDto(
|
||||
timeControl: TimeControlDto,
|
||||
status: String,
|
||||
declineReason: Option[String],
|
||||
gameId: Option[String],
|
||||
createdAt: String,
|
||||
expiresAt: String,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.nowchess.account.error
|
||||
|
||||
enum AccountError:
|
||||
case UsernameTaken(username: String)
|
||||
case EmailAlreadyRegistered(email: String)
|
||||
case InvalidCredentials
|
||||
|
||||
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"
|
||||
@@ -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"
|
||||
@@ -2,6 +2,7 @@ package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.domain.Account
|
||||
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
|
||||
@@ -31,7 +32,7 @@ class AccountResource:
|
||||
case Right(account) =>
|
||||
Response.ok(toPublicDto(account)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error)).build()
|
||||
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@POST
|
||||
@Path("/login")
|
||||
@@ -40,7 +41,7 @@ class AccountResource:
|
||||
case Right(token) =>
|
||||
Response.ok(TokenResponse(token)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error)).build()
|
||||
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
|
||||
+13
-10
@@ -1,6 +1,7 @@
|
||||
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
|
||||
@@ -33,10 +34,11 @@ class ChallengeResource:
|
||||
case Right(challenge) =>
|
||||
Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
|
||||
case Left(error) =>
|
||||
val status = if error.contains("not found") then Response.Status.NOT_FOUND
|
||||
else if error.contains("yourself") then Response.Status.BAD_REQUEST
|
||||
else Response.Status.CONFLICT
|
||||
Response.status(status).entity(ErrorDto(error)).build()
|
||||
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 =
|
||||
@@ -67,9 +69,10 @@ class ChallengeResource:
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
private def errorResponse(error: String): Response =
|
||||
val status =
|
||||
if error.contains("not found") then Response.Status.NOT_FOUND
|
||||
else if error.contains("authorized") then Response.Status.FORBIDDEN
|
||||
else Response.Status.BAD_REQUEST
|
||||
Response.status(status).entity(ErrorDto(error)).build()
|
||||
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()
|
||||
|
||||
@@ -2,6 +2,7 @@ package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.domain.Account
|
||||
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
|
||||
import de.nowchess.account.error.AccountError
|
||||
import de.nowchess.account.repository.AccountRepository
|
||||
import io.quarkus.elytron.security.common.BcryptUtil
|
||||
import io.smallrye.jwt.build.Jwt
|
||||
@@ -20,11 +21,11 @@ class AccountService:
|
||||
var accountRepository: AccountRepository = uninitialized
|
||||
|
||||
@Transactional
|
||||
def register(req: RegisterRequest): Either[String, Account] =
|
||||
def register(req: RegisterRequest): Either[AccountError, Account] =
|
||||
if accountRepository.findByUsername(req.username).isDefined then
|
||||
Left(s"Username '${req.username}' is already taken")
|
||||
Left(AccountError.UsernameTaken(req.username))
|
||||
else if accountRepository.findByEmail(req.email).isDefined then
|
||||
Left(s"Email '${req.email}' is already registered")
|
||||
Left(AccountError.EmailAlreadyRegistered(req.email))
|
||||
else
|
||||
val account = new Account()
|
||||
account.username = req.username
|
||||
@@ -34,7 +35,7 @@ class AccountService:
|
||||
accountRepository.persist(account)
|
||||
Right(account)
|
||||
|
||||
def login(req: LoginRequest): Either[String, String] =
|
||||
def login(req: LoginRequest): Either[AccountError, String] =
|
||||
accountRepository
|
||||
.findByUsername(req.username)
|
||||
.filter(a => BcryptUtil.matches(req.password, a.passwordHash))
|
||||
@@ -45,7 +46,7 @@ class AccountService:
|
||||
.claim("username", account.username)
|
||||
.sign()
|
||||
}
|
||||
.toRight("Invalid credentials")
|
||||
.toRight(AccountError.InvalidCredentials)
|
||||
|
||||
def findByUsername(username: String): Option[Account] =
|
||||
accountRepository.findByUsername(username)
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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.{AccountRepository, ChallengeRepository}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.transaction.Transactional
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
import java.time.Instant
|
||||
@@ -21,16 +24,20 @@ class ChallengeService:
|
||||
@Inject
|
||||
var challengeRepository: ChallengeRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
|
||||
@Transactional
|
||||
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[String, Challenge] =
|
||||
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
destUser <- accountRepository.findByUsername(destUsername).toRight(s"User '$destUsername' not found")
|
||||
challenger <- accountRepository.findById(challengerId).toRight("Challenger not found")
|
||||
_ <- Either.cond(challenger.id != destUser.id, (), "Cannot challenge yourself")
|
||||
destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
|
||||
challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
|
||||
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
|
||||
_ <- Either.cond(
|
||||
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
|
||||
(),
|
||||
"Active challenge to this user already exists",
|
||||
ChallengeError.DuplicateChallenge,
|
||||
)
|
||||
color <- parseColor(req.color)
|
||||
yield
|
||||
@@ -50,22 +57,24 @@ class ChallengeService:
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def accept(challengeId: UUID, userId: UUID): Either[String, Challenge] =
|
||||
def accept(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to accept this challenge")
|
||||
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)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[String, Challenge] =
|
||||
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to decline this challenge")
|
||||
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
|
||||
@@ -74,11 +83,11 @@ class ChallengeService:
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def cancel(challengeId: UUID, userId: UUID): Either[String, Challenge] =
|
||||
def cancel(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
|
||||
_ <- Either.cond(challenge.challenger.id == userId, (), "Not authorized to cancel this challenge")
|
||||
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)
|
||||
@@ -89,20 +98,45 @@ class ChallengeService:
|
||||
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
|
||||
ChallengeListDto(in = incoming, out = outgoing)
|
||||
|
||||
private def parseColor(raw: String): Either[String, ChallengeColor] =
|
||||
// scalafix:off DisableSyntax.null
|
||||
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)
|
||||
// scalafix:on DisableSyntax.null
|
||||
|
||||
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 scala.util.Random.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(s"Unknown color: $raw")
|
||||
case _ => Left(ChallengeError.InvalidColor(raw))
|
||||
|
||||
private def parseDeclineReason(raw: Option[String]): Either[String, Option[DeclineReason]] =
|
||||
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(s"Unknown decline reason: $r")
|
||||
case None => Left(ChallengeError.InvalidDeclineReason(r))
|
||||
|
||||
def toDto(c: Challenge): ChallengeDto =
|
||||
ChallengeDto(
|
||||
@@ -114,6 +148,7 @@ class ChallengeService:
|
||||
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,
|
||||
)
|
||||
|
||||
+14
-1
@@ -1,14 +1,26 @@
|
||||
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.Test
|
||||
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||
import org.mockito.{ArgumentMatchers, Mockito}
|
||||
|
||||
@QuarkusTest
|
||||
class ChallengeResourceTest:
|
||||
|
||||
@InjectMock
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||
|
||||
@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 = "") =
|
||||
@@ -92,6 +104,7 @@ class ChallengeResourceTest:
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("status", is("accepted"))
|
||||
.body("gameId", is("test-game-id"))
|
||||
|
||||
@Test
|
||||
def declineChallengeReturns200(): Unit =
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
@@ -9,4 +9,5 @@ final case class GameStateDto(
|
||||
moves: List[String],
|
||||
undoAvailable: Boolean,
|
||||
redoAvailable: Boolean,
|
||||
clock: Option[ClockDto],
|
||||
)
|
||||
|
||||
@@ -4,4 +4,5 @@ final case class ImportFenRequestDto(
|
||||
fen: String,
|
||||
white: Option[PlayerInfoDto],
|
||||
black: Option[PlayerInfoDto],
|
||||
timeControl: Option[TimeControlDto],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
final case class TimeControlDto(
|
||||
limitSeconds: Option[Int],
|
||||
incrementSeconds: Option[Int],
|
||||
daysPerMove: Option[Int],
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
enum GameMode:
|
||||
case Open, Authenticated
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
enum TimeControl:
|
||||
case Clock(limitSeconds: Int, incrementSeconds: Int)
|
||||
case Correspondence(daysPerMove: Int)
|
||||
case Unlimited
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
@@ -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"):
|
||||
|
||||
@@ -48,6 +48,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:bot"))
|
||||
|
||||
|
||||
@@ -63,6 +64,7 @@ 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"]!!}")
|
||||
|
||||
|
||||
@@ -3,8 +3,43 @@ quarkus:
|
||||
port: 8080
|
||||
application:
|
||||
name: nowchess-core
|
||||
|
||||
"%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
|
||||
rest-client:
|
||||
io-service:
|
||||
url: http://localhost:8081
|
||||
rule-service:
|
||||
url: http://localhost:8082
|
||||
|
||||
"%prod":
|
||||
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
|
||||
rest-client:
|
||||
io-service:
|
||||
url: ${IO_SERVICE_URL}
|
||||
rule-service:
|
||||
url: ${RULE_SERVICE_URL}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -2,14 +2,18 @@ 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.game.{BotParticipant, ClockState, CorrespondenceClockState, DrawReason, GameContext, GameResult, Human, LiveClockState, Participant, TimeControl, WinReason}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import de.nowchess.chess.controller.Parser
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.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 java.time.Instant
|
||||
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
@@ -22,6 +26,7 @@ class GameEngine(
|
||||
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
|
||||
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
|
||||
),
|
||||
val timeControl: TimeControl = TimeControl.Unlimited,
|
||||
) extends Observable:
|
||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||
private val contextWithInitialBoard =
|
||||
@@ -32,8 +37,18 @@ class GameEngine(
|
||||
private var currentContext: GameContext = contextWithInitialBoard
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingDrawOffer: Option[Color] = None
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var clockState: Option[ClockState] =
|
||||
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()
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
|
||||
clockState.foreach(scheduleExpiryCheck)
|
||||
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
// Synchronized accessors for current state
|
||||
@@ -41,6 +56,7 @@ class GameEngine(
|
||||
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)
|
||||
@@ -130,10 +146,11 @@ class GameEngine(
|
||||
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
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(ResignEvent(currentContext, color))
|
||||
notifyObservers(ResignEvent(currentContext, color.opposite))
|
||||
}
|
||||
|
||||
/** Offer a draw. */
|
||||
@@ -162,6 +179,7 @@ class GameEngine(
|
||||
case Some(_) =>
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
|
||||
}
|
||||
@@ -187,10 +205,12 @@ class GameEngine(
|
||||
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||
else if currentContext.halfMoveClock >= 100 then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||
else if ruleSet.isThreefoldRepetition(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
||||
@@ -199,17 +219,19 @@ 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
|
||||
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()
|
||||
@@ -219,20 +241,20 @@ class GameEngine(
|
||||
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,6 +269,8 @@ class GameEngine(
|
||||
else newContext
|
||||
currentContext = contextWithInitialBoard
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
@@ -255,22 +279,17 @@ class GameEngine(
|
||||
def reset(): Unit = synchronized {
|
||||
currentContext = GameContext.initial
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||
invoker.clear()
|
||||
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)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, reason))
|
||||
}
|
||||
@@ -278,6 +297,57 @@ class GameEngine(
|
||||
/** 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
|
||||
invoker.clear()
|
||||
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 =
|
||||
@@ -295,6 +365,8 @@ class GameEngine(
|
||||
invoker.execute(cmd)
|
||||
currentContext = nextContext
|
||||
|
||||
advanceClock(contextBefore.turn)
|
||||
|
||||
notifyObservers(
|
||||
MoveExecutedEvent(
|
||||
currentContext,
|
||||
@@ -304,17 +376,21 @@ class GameEngine(
|
||||
),
|
||||
)
|
||||
|
||||
if currentContext.result.isEmpty then
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
|
||||
cancelScheduled()
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
invoker.clear()
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||
cancelScheduled()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||
invoker.clear()
|
||||
else if ruleSet.isInsufficientMaterial(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
|
||||
cancelScheduled()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
|
||||
invoker.clear()
|
||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -92,6 +92,12 @@ case class DrawOfferDeclinedEvent(
|
||||
declinedBy: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player's clock expires. */
|
||||
case class TimeFlagEvent(
|
||||
context: GameContext,
|
||||
flaggedColor: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import java.time.Instant
|
||||
import de.nowchess.chess.adapter.RuleSetRestAdapter
|
||||
import de.nowchess.chess.client.IoServiceClient
|
||||
import de.nowchess.chess.controller.Parser
|
||||
@@ -13,11 +14,11 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import io.smallrye.mutiny.Multi
|
||||
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 java.util.concurrent.atomic.AtomicReference
|
||||
@@ -40,11 +41,35 @@ class GameResource:
|
||||
|
||||
@Inject
|
||||
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = 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")
|
||||
|
||||
// scalafix:on DisableSyntax.throw
|
||||
|
||||
// ── mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
private def statusOf(entry: GameEntry): String =
|
||||
@@ -52,8 +77,10 @@ class GameResource:
|
||||
else
|
||||
val ctx = entry.engine.context
|
||||
ctx.result match
|
||||
case Some(GameResult.Win(_)) =>
|
||||
if entry.resigned then "resign" else "checkmate"
|
||||
case Some(GameResult.Win(_, _)) =>
|
||||
if entry.resigned then "resign"
|
||||
else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
|
||||
else "timeout"
|
||||
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
||||
case Some(GameResult.Draw(_)) => "draw"
|
||||
@@ -87,6 +114,19 @@ class GameResource:
|
||||
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||
PlayerInfoDto(info.id.value, info.displayName)
|
||||
|
||||
private 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,
|
||||
)
|
||||
}
|
||||
|
||||
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
||||
val ctx = entry.engine.context
|
||||
GameStateDto(
|
||||
@@ -94,10 +134,11 @@ class GameResource:
|
||||
pgn = ioClient.exportPgn(ctx),
|
||||
turn = ctx.turn.label.toLowerCase,
|
||||
status = statusOf(entry),
|
||||
winner = ctx.result.collect { case GameResult.Win(c) => c.label.toLowerCase },
|
||||
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),
|
||||
)
|
||||
|
||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
||||
@@ -106,8 +147,29 @@ class GameResource:
|
||||
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)
|
||||
@@ -137,10 +199,12 @@ class GameResource:
|
||||
@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)
|
||||
println(s"Created game ${entry.gameId}")
|
||||
created(toGameFullDto(entry))
|
||||
@@ -152,33 +216,14 @@ class GameResource:
|
||||
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))
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{gameId}/resign")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
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())
|
||||
|
||||
@@ -188,6 +233,7 @@ class GameResource:
|
||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
assertGameNotOver(entry)
|
||||
assertIsCurrentPlayer(entry)
|
||||
val (from, to, promoOpt) = Parser
|
||||
.parseMove(uci)
|
||||
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
|
||||
@@ -243,21 +289,13 @@ 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 "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"))
|
||||
case "offer" => entry.engine.offerDraw(color); ok(OkResponseDto())
|
||||
case "accept" => entry.engine.acceptDraw(color); ok(OkResponseDto())
|
||||
case "decline" => entry.engine.declineDraw(color); ok(OkResponseDto())
|
||||
case "claim" => entry.engine.claimDraw(); ok(OkResponseDto())
|
||||
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
|
||||
|
||||
@POST
|
||||
@Path("/import/fen")
|
||||
@@ -267,7 +305,8 @@ class GameResource:
|
||||
val ctx = ioClient.importFen(ImportFenRequest(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))
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.nowchess.chess.resource
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.player.PlayerInfo
|
||||
import de.nowchess.chess.client.IoServiceClient
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import io.quarkus.websockets.next.*
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@WebSocket(path = "/api/board/game/{gameId}/ws")
|
||||
class GameWebSocketResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var registry: GameRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
var objectMapper: ObjectMapper = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var ioClient: IoServiceClient = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val connectionObservers = new ConcurrentHashMap[String, (String, Observer)]()
|
||||
|
||||
@OnOpen
|
||||
def onOpen(connection: WebSocketConnection): Unit =
|
||||
val gameId = connection.pathParam("gameId")
|
||||
registry.get(gameId) match
|
||||
case None =>
|
||||
val err = ErrorEventDto(ApiErrorDto("GAME_NOT_FOUND", s"Game $gameId not found", None))
|
||||
connection
|
||||
.sendText(objectMapper.writeValueAsString(err))
|
||||
.flatMap(_ => connection.close())
|
||||
.subscribe()
|
||||
.`with`(_ => (), _ => ())
|
||||
case Some(entry) =>
|
||||
val initial = objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry)))
|
||||
val obs = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
registry.get(gameId).foreach { updated =>
|
||||
connection
|
||||
.sendText(objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))))
|
||||
.subscribe()
|
||||
.`with`(_ => (), _ => ())
|
||||
}
|
||||
connection
|
||||
.sendText(initial)
|
||||
.subscribe()
|
||||
.`with`(
|
||||
_ => {
|
||||
connectionObservers.put(connection.id(), (gameId, obs))
|
||||
entry.engine.subscribe(obs)
|
||||
},
|
||||
_ => (),
|
||||
)
|
||||
|
||||
@OnClose
|
||||
def onClose(connection: WebSocketConnection): Unit =
|
||||
Option(connectionObservers.remove(connection.id())).foreach { case (gameId, obs) =>
|
||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||
}
|
||||
|
||||
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 if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
|
||||
else "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"
|
||||
|
||||
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 toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||
PlayerInfoDto(info.id.value, info.displayName)
|
||||
|
||||
private 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,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
clock = toClockDto(entry),
|
||||
)
|
||||
|
||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
||||
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
@@ -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()
|
||||
|
||||
+7
-7
@@ -4,6 +4,7 @@ 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.api.error.GameError
|
||||
import de.nowchess.api.io.GameContextImport
|
||||
import de.nowchess.api.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
@@ -58,9 +59,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)
|
||||
@@ -109,7 +110,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 +135,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 +156,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"):
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -12,76 +11,21 @@ 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)
|
||||
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])
|
||||
|
||||
+13
-13
@@ -74,7 +74,7 @@ class GameResourceIntegrationTest:
|
||||
@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 +83,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 +93,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 +103,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 +120,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 +131,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 +142,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 +154,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 +162,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,7 +172,7 @@ 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]
|
||||
@@ -190,7 +190,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,7 +199,7 @@ 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)
|
||||
|
||||
@@ -50,6 +50,7 @@ dependencies {
|
||||
implementation("com.lihaoyi:fastparse_3:${versions["FASTPARSE"]!!}")
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:rule"))
|
||||
|
||||
// Jackson for JSON serialization/deserialization
|
||||
|
||||
+27
@@ -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[]" }
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,18 @@ 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 +30,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"). */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -33,7 +33,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 +53,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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -2,9 +2,9 @@ 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
|
||||
|
||||
@@ -12,51 +12,40 @@ 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)
|
||||
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
|
||||
|
||||
@@ -2,9 +2,8 @@ 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
|
||||
|
||||
@@ -12,39 +11,23 @@ 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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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<String, String>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
|
||||
compileOnly("org.scala-lang:scala3-compiler_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala3-library_3") {
|
||||
version {
|
||||
strictly(versions["SCALA3"]!!)
|
||||
}
|
||||
}
|
||||
implementation("org.scala-lang:scala-library") {
|
||||
version {
|
||||
strictly(versions["SCALA_LIBRARY"]!!)
|
||||
}
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
|
||||
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"]!!}")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
|
||||
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
|
||||
}
|
||||
configurations.scoverage {
|
||||
resolutionStrategy.eachDependency {
|
||||
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
|
||||
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile> {
|
||||
options.encoding = "UTF-8"
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform {
|
||||
includeEngines("scalatest")
|
||||
testLogging {
|
||||
events("passed", "skipped", "failed")
|
||||
}
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage {
|
||||
dependsOn(tasks.test)
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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()
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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}
|
||||
+1
-1
@@ -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}
|
||||
+1
-1
@@ -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
|
||||
+1
-1
@@ -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}
|
||||
+1
-1
@@ -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}
|
||||
@@ -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])
|
||||
@@ -44,6 +44,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
|
||||
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
|
||||
+27
@@ -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[]" }
|
||||
]
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -12,91 +11,21 @@ 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)
|
||||
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])
|
||||
|
||||
@@ -16,6 +16,7 @@ pluginManagement {
|
||||
include(
|
||||
"modules:core",
|
||||
"modules:api",
|
||||
"modules:json",
|
||||
"modules:io",
|
||||
"modules:rule",
|
||||
"modules:bot",
|
||||
|
||||
Reference in New Issue
Block a user