diff --git a/build.gradle.kts b/build.gradle.kts index 39ce424..5127534 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -41,6 +41,8 @@ val coverageExclusions = listOf( "**/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala", // JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration "**/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala", + //RuleSetRestAdapter - Quarkus integration of rule into core, only testable with Quarkus tests + "**/core/src/main/de/nowchess/chess/adapter/RuleSetRestAdapter.scala" ) // Converts a Sonar-style glob to a scoverage regex (matched against full source path). diff --git a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala similarity index 98% rename from modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala rename to modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala index f2622bd..535b655 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/RuleSet.scala +++ b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala @@ -1,7 +1,7 @@ -package de.nowchess.rules +package de.nowchess.api.rules -import de.nowchess.api.game.GameContext import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move /** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala index 0573110..a52e5b9 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/ClassicalBot.scala @@ -7,7 +7,7 @@ import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class ClassicalBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala index 3a9147a..fd95d0d 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/HybridBot.scala @@ -9,7 +9,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class HybridBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala index 094989d..bfa2c6f 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/bots/NNUEBot.scala @@ -7,7 +7,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.util.{PolyglotBook, ZobristHash} import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules final class NNUEBot( diff --git a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala index 8a58b21..cf41d70 100644 --- a/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala +++ b/modules/bot/src/main/scala/de/nowchess/bot/logic/AlphaBetaSearch.scala @@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.util.ZobristHash -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import java.util.concurrent.atomic.{AtomicInteger, AtomicLong} diff --git a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala index 609f977..da826bd 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/AlphaBetaSearchTest.scala @@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.logic.AlphaBetaSearch -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import de.nowchess.rules.sets.DefaultRules diff --git a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala index 30b0214..bf53263 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/ClassicalBotTest.scala @@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.api.move.MoveType import de.nowchess.bot.bots.ClassicalBot -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers import de.nowchess.rules.sets.DefaultRules diff --git a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala index b1e7e81..bf806c2 100644 --- a/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala +++ b/modules/bot/src/test/scala/de/nowchess/bot/HybridBotTest.scala @@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType} import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.bots.HybridBot import de.nowchess.bot.util.{PolyglotBook, PolyglotHash} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers diff --git a/modules/core/build.gradle.kts b/modules/core/build.gradle.kts index d057a24..8596d8e 100644 --- a/modules/core/build.gradle.kts +++ b/modules/core/build.gradle.kts @@ -48,7 +48,6 @@ dependencies { } implementation(project(":modules:api")) - implementation(project(":modules:rule")) implementation(project(":modules:bot")) @@ -69,6 +68,7 @@ dependencies { testImplementation(project(":modules:io")) + testImplementation(project(":modules:rule")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") diff --git a/modules/core/src/main/resources/application.yml b/modules/core/src/main/resources/application.yml index 1663bc0..bbc7bff 100644 --- a/modules/core/src/main/resources/application.yml +++ b/modules/core/src/main/resources/application.yml @@ -6,3 +6,5 @@ quarkus: rest-client: io-service: url: http://localhost:8081 + rule-service: + url: http://localhost:8082 diff --git a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala new file mode 100644 index 0000000..a281095 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala @@ -0,0 +1,51 @@ +package de.nowchess.chess.adapter + +import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest} +import de.nowchess.api.rules.RuleSet +import jakarta.enterprise.context.ApplicationScoped +import jakarta.inject.Inject +import org.eclipse.microprofile.rest.client.inject.RestClient + +import scala.compiletime.uninitialized + +@ApplicationScoped +class RuleSetRestAdapter extends RuleSet: + + // scalafix:off DisableSyntax.var + @Inject + @RestClient + var client: RuleServiceClient = uninitialized + // scalafix:on DisableSyntax.var + + def candidateMoves(ctx: GameContext)(sq: Square): List[Move] = + client.candidateMoves(RuleSquareRequest(ctx, sq.toString)) + + def legalMoves(ctx: GameContext)(sq: Square): List[Move] = + client.legalMoves(RuleSquareRequest(ctx, sq.toString)) + + def allLegalMoves(ctx: GameContext): List[Move] = + client.allLegalMoves(ctx) + + def isCheck(ctx: GameContext): Boolean = + client.isCheck(ctx) + + def isCheckmate(ctx: GameContext): Boolean = + client.isCheckmate(ctx) + + def isStalemate(ctx: GameContext): Boolean = + client.isStalemate(ctx) + + def isInsufficientMaterial(ctx: GameContext): Boolean = + client.isInsufficientMaterial(ctx) + + def isFiftyMoveRule(ctx: GameContext): Boolean = + client.isFiftyMoveRule(ctx) + + def isThreefoldRepetition(ctx: GameContext): Boolean = + client.isThreefoldRepetition(ctx) + + def applyMove(ctx: GameContext)(move: Move): GameContext = + client.applyMove(RuleMoveRequest(ctx, move)) diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala new file mode 100644 index 0000000..b213ef4 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala @@ -0,0 +1,74 @@ +package de.nowchess.chess.client + +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import jakarta.ws.rs.* +import jakarta.ws.rs.core.MediaType +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient + +case class RuleSquareRequest(context: GameContext, square: String) +case class RuleMoveRequest(context: GameContext, move: Move) + +@Path("/api/rules") +@RegisterRestClient(configKey = "rule-service") +trait RuleServiceClient: + + @POST + @Path("/candidate-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def candidateMoves(req: RuleSquareRequest): List[Move] + + @POST + @Path("/legal-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def legalMoves(req: RuleSquareRequest): List[Move] + + @POST + @Path("/all-legal-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def allLegalMoves(ctx: GameContext): List[Move] + + @POST + @Path("/is-check") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isCheck(ctx: GameContext): Boolean + + @POST + @Path("/is-checkmate") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isCheckmate(ctx: GameContext): Boolean + + @POST + @Path("/is-stalemate") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isStalemate(ctx: GameContext): Boolean + + @POST + @Path("/is-insufficient-material") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isInsufficientMaterial(ctx: GameContext): Boolean + + @POST + @Path("/is-fifty-move-rule") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isFiftyMoveRule(ctx: GameContext): Boolean + + @POST + @Path("/is-threefold-repetition") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isThreefoldRepetition(ctx: GameContext): Boolean + + @POST + @Path("/apply-move") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def applyMove(req: RuleMoveRequest): GameContext diff --git a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala index 1393880..87b8ba7 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/config/JacksonConfig.scala @@ -5,6 +5,8 @@ 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 io.quarkus.jackson.ObjectMapperCustomizer import jakarta.inject.Singleton @@ -17,7 +19,11 @@ 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) + 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) diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 0759048..21903ca 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -8,8 +8,7 @@ import de.nowchess.chess.controller.Parser import de.nowchess.chess.observer.* import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.api.io.{GameContextExport, GameContextImport} -import de.nowchess.rules.RuleSet -import de.nowchess.rules.sets.DefaultRules +import de.nowchess.api.rules.RuleSet import scala.concurrent.{ExecutionContext, Future} @@ -18,7 +17,7 @@ import scala.concurrent.{ExecutionContext, Future} */ class GameEngine( val initialContext: GameContext = GameContext.initial, - val ruleSet: RuleSet = DefaultRules, + val ruleSet: RuleSet, val participants: Map[Color, Participant] = Map( Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")), diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala new file mode 100644 index 0000000..969d614 --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeDeserializer.scala @@ -0,0 +1,19 @@ +package de.nowchess.chess.json + +import com.fasterxml.jackson.core.{JsonParseException, JsonParser} +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer} +import de.nowchess.api.move.{MoveType, PromotionPiece} + +class MoveTypeDeserializer extends JsonDeserializer[MoveType]: + // scalafix:off DisableSyntax.throw + override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType = + val node = p.getCodec.readTree[ObjectNode](p) + node.get("type").asText() match + case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false)) + case "castleKingside" => MoveType.CastleKingside + case "castleQueenside" => MoveType.CastleQueenside + case "enPassant" => MoveType.EnPassant + case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText())) + case t => throw new JsonParseException(p, s"Unknown move type: $t") + // scalafix:on DisableSyntax.throw diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala new file mode 100644 index 0000000..8d90eba --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/MoveTypeSerializer.scala @@ -0,0 +1,23 @@ +package de.nowchess.chess.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.move.MoveType + +class MoveTypeSerializer extends JsonSerializer[MoveType]: + override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeStartObject() + value match + case MoveType.Normal(isCapture) => + gen.writeStringField("type", "normal") + gen.writeBooleanField("isCapture", isCapture) + case MoveType.CastleKingside => + gen.writeStringField("type", "castleKingside") + case MoveType.CastleQueenside => + gen.writeStringField("type", "castleQueenside") + case MoveType.EnPassant => + gen.writeStringField("type", "enPassant") + case MoveType.Promotion(piece) => + gen.writeStringField("type", "promotion") + gen.writeStringField("piece", piece.toString) + gen.writeEndObject() diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala new file mode 100644 index 0000000..5641a8f --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/SquareDeserializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.chess.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer} +import de.nowchess.api.board.Square + +class SquareDeserializer extends JsonDeserializer[Square]: + override def deserialize(p: JsonParser, ctx: DeserializationContext): Square = + Square.fromAlgebraic(p.getText).orNull diff --git a/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala b/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala new file mode 100644 index 0000000..98240ac --- /dev/null +++ b/modules/core/src/main/scala/de/nowchess/chess/json/SquareSerializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.chess.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.board.Square + +class SquareSerializer extends JsonSerializer[Square]: + override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeString(value.toString) diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index e5dbf94..8ab682e 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -6,6 +6,7 @@ import de.nowchess.api.dto.* import de.nowchess.api.game.{DrawReason, GameContext, GameResult} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.chess.adapter.RuleSetRestAdapter import de.nowchess.chess.client.IoServiceClient import de.nowchess.chess.controller.Parser import de.nowchess.chess.engine.GameEngine @@ -36,6 +37,9 @@ class GameResource: @Inject @RestClient var ioClient: IoServiceClient = uninitialized + + @Inject + var ruleSetAdapter: RuleSetRestAdapter = uninitialized // scalafix:on DisableSyntax.var private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1") @@ -103,7 +107,7 @@ class GameResource: 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), white, black) + GameEntry(registry.generateId(), GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter), white, black) private def applyMoveInput(engine: GameEngine, uci: String): Option[String] = val error = new AtomicReference[Option[String]](None) @@ -138,6 +142,7 @@ class GameResource: val black = playerInfoFrom(req.black, DefaultBlack) val entry = newEntry(GameContext.initial, white, black) registry.store(entry) + println(s"Created game ${entry.gameId}") created(toGameFullDto(entry)) @GET diff --git a/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala b/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala new file mode 100644 index 0000000..27147e2 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/adapter/RuleSetRestAdapterTest.scala @@ -0,0 +1,109 @@ +package de.nowchess.chess.adapter + +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType} +import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest} +import io.quarkus.test.InjectMock +import io.quarkus.test.junit.QuarkusTest +import jakarta.inject.Inject +import org.eclipse.microprofile.rest.client.inject.RestClient +import org.junit.jupiter.api.{BeforeEach, DisplayName, Test} +import org.junit.jupiter.api.Assertions.* +import org.mockito.Mockito.{verify, when} + +import scala.compiletime.uninitialized + +// scalafix:off +@QuarkusTest +@DisplayName("RuleSetRestAdapter") +class RuleSetRestAdapterTest: + + @Inject + var adapter: RuleSetRestAdapter = uninitialized + + @InjectMock + @RestClient + var client: RuleServiceClient = uninitialized + + private val ctx = GameContext.initial + private val sq = Square(File.E, Rank.R2) + private val move = Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4), MoveType.Normal(false)) + + @BeforeEach + def setup(): Unit = + when(client.candidateMoves(RuleSquareRequest(ctx, sq.toString))).thenReturn(List(move)) + when(client.legalMoves(RuleSquareRequest(ctx, sq.toString))).thenReturn(List(move)) + when(client.allLegalMoves(ctx)).thenReturn(List(move)) + when(client.isCheck(ctx)).thenReturn(false) + when(client.isCheckmate(ctx)).thenReturn(false) + when(client.isStalemate(ctx)).thenReturn(false) + when(client.isInsufficientMaterial(ctx)).thenReturn(false) + when(client.isFiftyMoveRule(ctx)).thenReturn(false) + when(client.isThreefoldRepetition(ctx)).thenReturn(false) + when(client.applyMove(RuleMoveRequest(ctx, move))).thenReturn(ctx) + + @Test + @DisplayName("candidateMoves delegates to client") + def testCandidateMoves(): Unit = + val result = adapter.candidateMoves(ctx)(sq) + assertEquals(List(move), result) + verify(client).candidateMoves(RuleSquareRequest(ctx, sq.toString)) + + @Test + @DisplayName("legalMoves delegates to client") + def testLegalMoves(): Unit = + val result = adapter.legalMoves(ctx)(sq) + assertEquals(List(move), result) + verify(client).legalMoves(RuleSquareRequest(ctx, sq.toString)) + + @Test + @DisplayName("allLegalMoves delegates to client") + def testAllLegalMoves(): Unit = + val result = adapter.allLegalMoves(ctx) + assertEquals(List(move), result) + verify(client).allLegalMoves(ctx) + + @Test + @DisplayName("isCheck delegates to client") + def testIsCheck(): Unit = + assertFalse(adapter.isCheck(ctx)) + verify(client).isCheck(ctx) + + @Test + @DisplayName("isCheckmate delegates to client") + def testIsCheckmate(): Unit = + assertFalse(adapter.isCheckmate(ctx)) + verify(client).isCheckmate(ctx) + + @Test + @DisplayName("isStalemate delegates to client") + def testIsStalemate(): Unit = + assertFalse(adapter.isStalemate(ctx)) + verify(client).isStalemate(ctx) + + @Test + @DisplayName("isInsufficientMaterial delegates to client") + def testIsInsufficientMaterial(): Unit = + assertFalse(adapter.isInsufficientMaterial(ctx)) + verify(client).isInsufficientMaterial(ctx) + + @Test + @DisplayName("isFiftyMoveRule delegates to client") + def testIsFiftyMoveRule(): Unit = + assertFalse(adapter.isFiftyMoveRule(ctx)) + verify(client).isFiftyMoveRule(ctx) + + @Test + @DisplayName("isThreefoldRepetition delegates to client") + def testIsThreefoldRepetition(): Unit = + assertFalse(adapter.isThreefoldRepetition(ctx)) + verify(client).isThreefoldRepetition(ctx) + + @Test + @DisplayName("applyMove delegates to client") + def testApplyMove(): Unit = + val result = adapter.applyMove(ctx)(move) + assertEquals(ctx, result) + verify(client).applyMove(RuleMoveRequest(ctx, move)) +// scalafix:on diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala index 3a32668..7c49e73 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/EngineTestHelpers.scala @@ -13,7 +13,7 @@ object EngineTestHelpers: new GameEngine(ruleSet = DefaultRules) def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine = - GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) + GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn), ruleSet = DefaultRules) def loadFen(engine: GameEngine, fen: String): Unit = engine.loadGame(FenParser, fen) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala index 03b39a6..872ce56 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineDrawOfferTest.scala @@ -1,5 +1,6 @@ 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.game.{DrawReason, GameResult} @@ -18,7 +19,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: test("White offers draw"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -32,7 +33,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferEvent, but got $other") test("Black accepts White's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -49,7 +50,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("Black declines White's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -65,7 +66,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferDeclinedEvent, but got $other") test("Black offers draw"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -79,7 +80,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawOfferEvent, but got $other") test("White accepts Black's draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -96,7 +97,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("Cannot accept draw when no offer pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -110,7 +111,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot decline draw when no offer pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -124,7 +125,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot offer draw when game is already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -147,7 +148,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot accept your own draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -163,7 +164,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot decline your own draw offer"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -179,7 +180,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Cannot make second draw offer when one is already pending"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -195,7 +196,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Draw offer is cleared when game ends by resignation (accept)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -215,7 +216,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("Draw offer is cleared when game ends by resignation (decline)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) @@ -235,22 +236,22 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("pendingDrawOfferBy returns None initially"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.pendingDrawOfferBy shouldBe None test("pendingDrawOfferBy returns White after White offers"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.offerDraw(Color.White) engine.pendingDrawOfferBy shouldBe Some(Color.White) test("pendingDrawOfferBy returns None after draw is accepted"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.offerDraw(Color.White) engine.acceptDraw(Color.Black) engine.pendingDrawOfferBy shouldBe None test("applyDraw sets draw result when game not over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) engine.applyDraw(DrawReason.Agreement) @@ -263,7 +264,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: fail(s"Expected DrawEvent, but got $other") test("applyDraw does nothing when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // End the game with checkmate @@ -276,7 +277,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: observer.events should have length 0 test("claimDraw with fifty-move rule when at half-move 100"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // Play moves to reach fifty-move rule claim @@ -288,7 +289,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: // This is hard to do naturally; skip for now if not critical test("claimDraw when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new DrawOfferMockObserver() engine.subscribe(observer) // End the game with checkmate diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala index 9e491e8..4bb9207 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineGameEndingTest.scala @@ -1,5 +1,6 @@ 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.game.DrawReason @@ -11,7 +12,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineGameEndingTest extends AnyFunSuite with Matchers: test("GameEngine handles Checkmate (Fool's Mate)"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) @@ -31,7 +32,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: fail(s"Expected CheckmateEvent, but got $other") test("GameEngine handles check detection"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) @@ -53,7 +54,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers: // Wait, let's just use Sam Loyd's 10-move stalemate: // 1. e3 a5 2. Qh5 Ra6 3. Qxa5 h5 4. h4 Rah6 5. Qxc7 f6 6. Qxd7+ Kf7 7. Qxb7 Qd3 8. Qxb8 Qh7 9. Qxc8 Kg6 10. Qe6 test("GameEngine handles Stalemate via 10-move known sequence"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new EndingMockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala index e07336b..5411007 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineIntegrationTest.scala @@ -5,7 +5,7 @@ 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.io.GameContextImport -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -21,7 +21,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: events test("accessors expose redo availability and command history"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.canRedo shouldBe false engine.commandHistory shouldBe empty @@ -30,7 +30,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.commandHistory.nonEmpty shouldBe true test("processUserInput handles undo redo empty and malformed commands"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("") @@ -44,7 +44,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: } should be >= 3 test("processUserInput emits Illegal move for syntactically valid but illegal target"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("e2e5") @@ -56,14 +56,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: test("loadGame returns Left when importer fails"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val failingImporter = new GameContextImport: def importGameContext(input: String): Either[String, GameContext] = Left("boom") engine.loadGame(failingImporter, "ignored") shouldBe Left("boom") test("loadPosition replaces context clears history and notifies reset"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) engine.processUserInput("e2e4") @@ -78,7 +78,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: } shouldBe true test("redo event includes captured piece description when replaying a capture"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val events = captureEvents(engine) EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1") @@ -145,13 +145,13 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: test("loadGame replay executes non-promotion moves through default replay branch"): val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal()) - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.replayMoves(List(normalMove), engine.context) shouldBe Right(()) engine.context.moves.lastOption shouldBe Some(normalMove) test("replayMoves skips later moves after the first move triggers an error"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val saved = engine.context val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) val trailingMove = Move(sq("e2"), sq("e4")) @@ -160,19 +160,19 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers: engine.context shouldBe saved test("normalMoveNotation handles missing source piece"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false) result shouldBe "e4" test("pieceNotation default branch returns empty string"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val result = engine.pieceNotation(PieceType.Pawn) result shouldBe "" test("observerCount reflects subscribe and unsubscribe operations"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new Observer: def onGameEvent(event: GameEvent): Unit = () diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala index 91ac2b1..42c45df 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineLoadGameTest.scala @@ -1,5 +1,11 @@ package de.nowchess.chess.engine +import de.nowchess.rules.sets.DefaultRules +import scala.collection.mutable +import de.nowchess.api.board.{Board, Color} +import de.nowchess.api.game.GameContext +import de.nowchess.chess.observer.{GameEvent, Observer, PgnLoadedEvent} +import de.nowchess.io.pgn.PgnParser import de.nowchess.chess.observer.{GameEvent, Observer} import de.nowchess.io.fen.FenParser import de.nowchess.io.pgn.{PgnExporter, PgnParser} @@ -11,7 +17,7 @@ import scala.collection.mutable class GameEngineLoadGameTest extends AnyFunSuite with Matchers: test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val pgn = "[Event \"Test\"]\n\n1. e4 e5\n" val result = engine.loadGame(PgnParser, pgn) result shouldBe Right(()) @@ -19,7 +25,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: engine.canUndo shouldBe true test("loadGame with FenParser: loads position without replaying moves"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1" val result = engine.loadGame(FenParser, fen) result shouldBe Right(()) @@ -27,7 +33,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers: engine.canUndo shouldBe false test("exportGame with PgnExporter: exports current game as PGN"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) engine.processUserInput("e2e4") engine.processUserInput("e7e5") val pgn = engine.exportGame(PgnExporter) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala index e1c1877..6482b35 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineNotationTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, File, Rank, Square} +import de.nowchess.rules.sets.DefaultRules import de.nowchess.api.game.GameContext import de.nowchess.io.fen.FenParser import de.nowchess.chess.observer.* @@ -37,7 +38,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(castlingRights) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // White castles queenside: e1c1 @@ -65,7 +66,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withEnPassantSquare(epSquare) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // White pawn on e5 captures en passant to d6 @@ -96,7 +97,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) engine.processUserInput("e7e8b") @@ -117,7 +118,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers: .withTurn(Color.White) .withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) - val engine = new GameEngine(ctx) + val engine = new GameEngine(ctx, DefaultRules) val events = captureEvents(engine) // King moves e1 -> f1 diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala index 4c86f17..edc74d7 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEnginePromotionTest.scala @@ -14,7 +14,7 @@ import de.nowchess.chess.observer.{ MoveExecutedEvent, Observer, } -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import de.nowchess.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite import org.scalatest.matchers.should.Matchers @@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers: events private def engineWith(board: Board, turn: Color = Color.White): GameEngine = - new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn)) + new GameEngine(initialContext = GameContext.initial.withBoard(board).withTurn(turn), ruleSet = DefaultRules) test("processUserInput without promotion suffix fires InvalidMoveEvent when pawn reaches back rank") { val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala index cf96cdf..0ee4395 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineResignTest.scala @@ -1,5 +1,6 @@ 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.game.GameResult @@ -10,7 +11,7 @@ import org.scalatest.matchers.should.Matchers class GameEngineResignTest extends AnyFunSuite with Matchers: test("White resigns"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -25,7 +26,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected ResignEvent, but got $other") test("Black resigns"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -40,7 +41,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected ResignEvent, but got $other") test("Cannot resign when game is already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -64,7 +65,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: fail(s"Expected InvalidMoveEvent, but got $other") test("resign() without color resigns side to move"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) @@ -73,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers: engine.context.result shouldBe Some(GameResult.Win(Color.Black)) test("resign() without color does nothing when game already over"): - val engine = new GameEngine() + val engine = new GameEngine(ruleSet = DefaultRules) val observer = new ResignMockObserver() engine.subscribe(observer) diff --git a/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala new file mode 100644 index 0000000..a834aa1 --- /dev/null +++ b/modules/core/src/test/scala/de/nowchess/chess/json/JsonSerializersTest.scala @@ -0,0 +1,87 @@ +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 org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonSerializersTest extends AnyFunSuite with Matchers: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + val mod = new SimpleModule() + m.registerModule(DefaultScalaModule) + mod.addSerializer(classOf[Square], new SquareSerializer()) + mod.addDeserializer(classOf[Square], new SquareDeserializer()) + mod.addSerializer(classOf[MoveType], new MoveTypeSerializer()) + mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer()) + m.registerModule(mod) + 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 + + // ── 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]) diff --git a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala index 6bea0c0..53d1362 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/registry/GameRegistryImplTest.scala @@ -1,6 +1,7 @@ package de.nowchess.chess.registry import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.rules.sets.DefaultRules import de.nowchess.chess.engine.GameEngine import io.quarkus.test.junit.QuarkusTest import jakarta.inject.Inject @@ -20,14 +21,24 @@ class GameRegistryImplTest: @Test @DisplayName("store saves entry") def testStore(): Unit = - val entry = GameEntry("g1", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g1", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) assertTrue(registry.get("g1").isDefined) @Test @DisplayName("get returns stored entry") def testGet(): Unit = - val entry = GameEntry("g2", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g2", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) val retrieved = registry.get("g2") assertTrue(retrieved.isDefined) @@ -41,7 +52,12 @@ class GameRegistryImplTest: @Test @DisplayName("update modifies existing entry") def testUpdate(): Unit = - val entry = GameEntry("g3", GameEngine(), PlayerInfo(PlayerId("p1"), "P1"), PlayerInfo(PlayerId("p2"), "P2")) + val entry = GameEntry( + "g3", + GameEngine(ruleSet = DefaultRules), + PlayerInfo(PlayerId("p1"), "P1"), + PlayerInfo(PlayerId("p2"), "P2"), + ) registry.store(entry) val updated = entry.copy(resigned = true) registry.update(updated) diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala index 1ee458e..abea233 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala @@ -1,11 +1,13 @@ package de.nowchess.chess.resource +import de.nowchess.api.board.Square import de.nowchess.api.dto.* import de.nowchess.api.game.GameContext -import de.nowchess.chess.client.IoServiceClient +import de.nowchess.chess.client.{IoServiceClient, RuleMoveRequest, RuleServiceClient, RuleSquareRequest} import de.nowchess.chess.exception.BadRequestException import de.nowchess.io.fen.FenExporter import de.nowchess.io.pgn.PgnParser +import de.nowchess.rules.sets.DefaultRules import io.quarkus.test.InjectMock import io.quarkus.test.junit.QuarkusTest import jakarta.inject.Inject @@ -14,6 +16,7 @@ import org.junit.jupiter.api.{BeforeEach, DisplayName, Test} import org.junit.jupiter.api.Assertions.* import org.mockito.ArgumentMatchers.any import org.mockito.Mockito.when +import org.mockito.invocation.InvocationOnMock import scala.compiletime.uninitialized @@ -29,6 +32,10 @@ class GameResourceIntegrationTest: @RestClient var ioClient: IoServiceClient = uninitialized + @InjectMock + @RestClient + var ruleClient: RuleServiceClient = uninitialized + @BeforeEach def setupMocks(): Unit = when(ioClient.importFen(any())).thenReturn(GameContext.initial) @@ -37,6 +44,32 @@ class GameResourceIntegrationTest: ) when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial)) when(ioClient.exportPgn(any())).thenReturn("1. e4 c5") + when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) => + val req = inv.getArgument[RuleSquareRequest](0) + DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get), + ) + when(ruleClient.allLegalMoves(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.allLegalMoves(inv.getArgument[GameContext](0)), + ) + when(ruleClient.applyMove(any())).thenAnswer((inv: InvocationOnMock) => + val req = inv.getArgument[RuleMoveRequest](0) + DefaultRules.applyMove(req.context)(req.move), + ) + when(ruleClient.isCheck(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isCheck(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isCheckmate(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isCheckmate(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isStalemate(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isStalemate(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isInsufficientMaterial(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isInsufficientMaterial(inv.getArgument[GameContext](0)), + ) + when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)), + ) @Test @DisplayName("createGame returns 201") diff --git a/modules/rule/build.gradle.kts b/modules/rule/build.gradle.kts index 093fe12..38982d5 100644 --- a/modules/rule/build.gradle.kts +++ b/modules/rule/build.gradle.kts @@ -1,6 +1,7 @@ plugins { id("scala") id("org.scoverage") version "8.1" + id("io.quarkus") } group = "de.nowchess" @@ -25,6 +26,10 @@ tasks.withType { scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") } +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + dependencies { compileOnly("org.scala-lang:scala3-compiler_3") { @@ -40,20 +45,56 @@ dependencies { implementation(project(":modules:api")) + implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-rest") + implementation("io.quarkus:quarkus-hibernate-orm") + implementation("io.quarkus:quarkus-rest-client-jackson") + implementation("io.quarkus:quarkus-rest-client") + implementation("io.quarkus:quarkus-rest-jackson") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-smallrye-fault-tolerance") + implementation("io.quarkus:quarkus-smallrye-jwt") + implementation("io.quarkus:quarkus-smallrye-health") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-arc") + implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") + testImplementation(project(":modules:io")) testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") + testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.rest-assured:rest-assured") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +configurations.matching { !it.name.startsWith("scoverage") }.configureEach { + resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}") +} +configurations.scoverage { + resolutionStrategy.eachDependency { + if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) { + useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0") + } + } +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.withType().configureEach { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} + tasks.test { useJUnitPlatform { - includeEngines("scalatest") + includeEngines("scalatest", "junit-jupiter") testLogging { - events("skipped", "failed") + events("passed", "skipped", "failed") } } finalizedBy(tasks.reportScoverage) @@ -61,3 +102,7 @@ tasks.test { tasks.reportScoverage { dependsOn(tasks.test) } + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE +} diff --git a/modules/rule/src/main/docker/Dockerfile.jvm b/modules/rule/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..c3c09fc --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.jvm @@ -0,0 +1,100 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/backcore-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/backcore-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override +# the default JVM options, use `JAVA_OPTS_APPEND` to append options +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +# You can find more information about the UBI base runtime images and their configuration here: +# https://rh-openjdk.github.io/redhat-openjdk-containers/ +### +FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24 + +ENV LANGUAGE='en_US:en' + + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] + diff --git a/modules/rule/src/main/docker/Dockerfile.legacy-jar b/modules/rule/src/main/docker/Dockerfile.legacy-jar new file mode 100644 index 0000000..8c89666 --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.legacy-jar @@ -0,0 +1,96 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.package.jar.type=legacy-jar +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.legacy-jar -t quarkus/backcore-legacy-jar . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/backcore-legacy-jar +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override +# the default JVM options, use `JAVA_OPTS_APPEND` to append options +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +# You can find more information about the UBI base runtime images and their configuration here: +# https://rh-openjdk.github.io/redhat-openjdk-containers/ +### +FROM registry.access.redhat.com/ubi9/openjdk-21-runtime:1.24 + +ENV LANGUAGE='en_US:en' + + +COPY build/lib/* /deployments/lib/ +COPY build/*-runner.jar /deployments/quarkus-run.jar + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] diff --git a/modules/rule/src/main/docker/Dockerfile.native b/modules/rule/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..57defbf --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.native @@ -0,0 +1,29 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.native.enabled=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/backcore . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/backcore +# +# The ` registry.access.redhat.com/ubi9/ubi-minimal:9.7` base image is based on UBI 9. +# To use UBI 8, switch to `quay.io/ubi8/ubi-minimal:8.10`. +### +FROM registry.access.redhat.com/ubi9/ubi-minimal:9.7 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root --chmod=0755 build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/modules/rule/src/main/docker/Dockerfile.native-micro b/modules/rule/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..9408243 --- /dev/null +++ b/modules/rule/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,32 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.native.enabled=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/backcore . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/backcore +# +# The `quay.io/quarkus/ubi9-quarkus-micro-image:2.0` base image is based on UBI 9. +# To use UBI 8, switch to `quay.io/quarkus/quarkus-micro-image:2.0`. +### +FROM quay.io/quarkus/ubi9-quarkus-micro-image:2.0 +WORKDIR /work/ +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work +COPY --chown=1001:root --chmod=0755 build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/modules/rule/src/main/resources/application.yml b/modules/rule/src/main/resources/application.yml new file mode 100644 index 0000000..98b81ce --- /dev/null +++ b/modules/rule/src/main/resources/application.yml @@ -0,0 +1,5 @@ +quarkus: + http: + port: 8082 + application: + name: rule-service diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala new file mode 100644 index 0000000..8dd29b8 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/JacksonConfig.scala @@ -0,0 +1,29 @@ +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 io.quarkus.jackson.ObjectMapperCustomizer +import jakarta.inject.Singleton + +@Singleton +class JacksonConfig extends ObjectMapperCustomizer: + def customize(mapper: ObjectMapper): Unit = + mapper.registerModule(new DefaultScalaModule() { + override def version(): Version = + // scalafix:off DisableSyntax.null + new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala") + // scalafix:on DisableSyntax.null + }) + 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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala b/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala new file mode 100644 index 0000000..2d6430d --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/config/NativeReflectionConfig.scala @@ -0,0 +1,28 @@ +package de.nowchess.rules.config + +import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.{DrawReason, GameContext, GameResult} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} +import io.quarkus.runtime.annotations.RegisterForReflection + +@RegisterForReflection( + targets = Array( + classOf[ContextSquareRequest], + classOf[ContextMoveRequest], + classOf[GameContext], + classOf[GameResult], + classOf[DrawReason], + classOf[Color], + classOf[Piece], + classOf[PieceType], + classOf[CastlingRights], + classOf[Square], + classOf[File], + classOf[Rank], + classOf[Move], + classOf[MoveType], + classOf[PromotionPiece], + ), +) +class NativeReflectionConfig diff --git a/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala new file mode 100644 index 0000000..4edf90f --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/dto/Dtos.scala @@ -0,0 +1,8 @@ +package de.nowchess.rules.dto + +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move + +case class ContextSquareRequest(context: GameContext, square: String) + +case class ContextMoveRequest(context: GameContext, move: Move) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala new file mode 100644 index 0000000..00cee39 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeDeserializer.scala @@ -0,0 +1,19 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.core.{JsonParseException, JsonParser} +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer} +import de.nowchess.api.move.{MoveType, PromotionPiece} + +class MoveTypeDeserializer extends JsonDeserializer[MoveType]: + // scalafix:off DisableSyntax.throw + override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType = + val node = p.getCodec.readTree[ObjectNode](p) + node.get("type").asText() match + case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false)) + case "castleKingside" => MoveType.CastleKingside + case "castleQueenside" => MoveType.CastleQueenside + case "enPassant" => MoveType.EnPassant + case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText())) + case t => throw new JsonParseException(p, s"Unknown move type: $t") + // scalafix:on DisableSyntax.throw diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala new file mode 100644 index 0000000..1817586 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/MoveTypeSerializer.scala @@ -0,0 +1,23 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.move.MoveType + +class MoveTypeSerializer extends JsonSerializer[MoveType]: + override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeStartObject() + value match + case MoveType.Normal(isCapture) => + gen.writeStringField("type", "normal") + gen.writeBooleanField("isCapture", isCapture) + case MoveType.CastleKingside => + gen.writeStringField("type", "castleKingside") + case MoveType.CastleQueenside => + gen.writeStringField("type", "castleQueenside") + case MoveType.EnPassant => + gen.writeStringField("type", "enPassant") + case MoveType.Promotion(piece) => + gen.writeStringField("type", "promotion") + gen.writeStringField("piece", piece.toString) + gen.writeEndObject() diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala new file mode 100644 index 0000000..0be5f92 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareDeserializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.core.JsonParser +import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer} +import de.nowchess.api.board.Square + +class SquareDeserializer extends JsonDeserializer[Square]: + override def deserialize(p: JsonParser, ctx: DeserializationContext): Square = + Square.fromAlgebraic(p.getText).orNull diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala new file mode 100644 index 0000000..4d52c10 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeyDeserializer.scala @@ -0,0 +1,8 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer} +import de.nowchess.api.board.Square + +class SquareKeyDeserializer extends KeyDeserializer: + override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef = + Square.fromAlgebraic(key).orNull diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala new file mode 100644 index 0000000..3ef02a5 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareKeySerializer.scala @@ -0,0 +1,9 @@ +package de.nowchess.rules.json + +import com.fasterxml.jackson.core.JsonGenerator +import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider} +import de.nowchess.api.board.Square + +class SquareKeySerializer extends JsonSerializer[Square]: + override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeFieldName(value.toString) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala new file mode 100644 index 0000000..93aaca9 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/json/SquareSerializer.scala @@ -0,0 +1,9 @@ +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 SquareSerializer extends JsonSerializer[Square]: + override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit = + gen.writeString(value.toString) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala new file mode 100644 index 0000000..d128519 --- /dev/null +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -0,0 +1,90 @@ +package de.nowchess.rules.resource + +import de.nowchess.api.board.Square +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.rules.dto.* +import de.nowchess.rules.sets.DefaultRules +import jakarta.enterprise.context.ApplicationScoped +import jakarta.ws.rs.* +import jakarta.ws.rs.core.MediaType + +@Path("/api/rules") +@ApplicationScoped +class RuleSetResource: + private val rules = DefaultRules + + // scalafix:off DisableSyntax.throw + private def parseSquare(s: String): Square = + Square.fromAlgebraic(s).getOrElse(throw new BadRequestException(s"Invalid square: $s")) + // scalafix:on DisableSyntax.throw + + @POST + @Path("/candidate-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def candidateMoves(req: ContextSquareRequest): List[Move] = + rules.candidateMoves(req.context)(parseSquare(req.square)) + + @POST + @Path("/legal-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def legalMoves(req: ContextSquareRequest): List[Move] = + rules.legalMoves(req.context)(parseSquare(req.square)) + + @POST + @Path("/all-legal-moves") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def allLegalMoves(ctx: GameContext): List[Move] = + rules.allLegalMoves(ctx) + + @POST + @Path("/is-check") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isCheck(ctx: GameContext): Boolean = + rules.isCheck(ctx) + + @POST + @Path("/is-checkmate") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isCheckmate(ctx: GameContext): Boolean = + rules.isCheckmate(ctx) + + @POST + @Path("/is-stalemate") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isStalemate(ctx: GameContext): Boolean = + rules.isStalemate(ctx) + + @POST + @Path("/is-insufficient-material") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isInsufficientMaterial(ctx: GameContext): Boolean = + rules.isInsufficientMaterial(ctx) + + @POST + @Path("/is-fifty-move-rule") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isFiftyMoveRule(ctx: GameContext): Boolean = + rules.isFiftyMoveRule(ctx) + + @POST + @Path("/is-threefold-repetition") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def isThreefoldRepetition(ctx: GameContext): Boolean = + rules.isThreefoldRepetition(ctx) + + @POST + @Path("/apply-move") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def applyMove(req: ContextMoveRequest): GameContext = + rules.applyMove(req.context)(req.move) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index 036f00d..ed3bde2 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -3,7 +3,7 @@ package de.nowchess.rules.sets import de.nowchess.api.board.* import de.nowchess.api.game.GameContext import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.rules.RuleSet +import de.nowchess.api.rules.RuleSet import scala.annotation.tailrec diff --git a/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala new file mode 100644 index 0000000..27c738d --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/config/JacksonConfigTest.scala @@ -0,0 +1,30 @@ +package de.nowchess.rules.config + +import com.fasterxml.jackson.databind.ObjectMapper +import de.nowchess.api.board.{File, Rank, Square} +import de.nowchess.api.move.MoveType +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JacksonConfigTest extends AnyFunSuite with Matchers: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + new JacksonConfig().customize(m) + m + + test("customize enables Option serialization via DefaultScalaModule"): + mapper.writeValueAsString(None) shouldBe "null" + mapper.writeValueAsString(Some("hello")) shouldBe """"hello"""" + + test("customize registers SquareSerializer"): + mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4"""" + + test("customize registers MoveTypeSerializer"): + mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}""" + + test("customize registers SquareDeserializer"): + mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4) + + test("customize registers MoveTypeDeserializer"): + mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant diff --git a/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala new file mode 100644 index 0000000..e832fcb --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/json/JsonSerializersTest.scala @@ -0,0 +1,102 @@ +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 org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class JsonSerializersTest extends AnyFunSuite with Matchers: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + val mod = new SimpleModule() + m.registerModule(DefaultScalaModule) + mod.addKeySerializer(classOf[Square], new SquareKeySerializer()) + mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer()) + mod.addSerializer(classOf[Square], new SquareSerializer()) + mod.addDeserializer(classOf[Square], new SquareDeserializer()) + mod.addSerializer(classOf[MoveType], new MoveTypeSerializer()) + mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer()) + m.registerModule(mod) + m + + private val e4 = Square(File.E, Rank.R4) + + // ── SquareKeySerializer ─────────────────────────────────────────── + + test("SquareKeySerializer writes square as map field name"): + mapper.writeValueAsString(Map(e4 -> "piece")) shouldBe """{"e4":"piece"}""" + + // ── 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"): + 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]) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala new file mode 100644 index 0000000..1e2f9a6 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceTest.scala @@ -0,0 +1,301 @@ +package de.nowchess.rules.resource + +import com.fasterxml.jackson.databind.ObjectMapper +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.rules.config.JacksonConfig +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} +import de.nowchess.rules.sets.DefaultRules +import io.quarkus.test.junit.QuarkusTest +import io.restassured.RestAssured +import io.restassured.http.ContentType +import org.hamcrest.Matchers.* +import org.junit.jupiter.api.Test + +@QuarkusTest +class RuleSetResourceTest: + + private val mapper: ObjectMapper = + val m = new ObjectMapper() + new JacksonConfig().customize(m) + m + + private val rules = DefaultRules + + private def request() = RestAssured.`given`() + + private def toJson(value: AnyRef): String = mapper.writeValueAsString(value) + + private def contextSquareBody(ctx: GameContext, square: String): String = + toJson(ContextSquareRequest(ctx, square)) + + private def contextMoveBody(ctx: GameContext, move: Move): String = + toJson(ContextMoveRequest(ctx, move)) + + // ── all-legal-moves ─────────────────────────────────────────────── + + @Test + def allLegalMoves_initialPositionHas20Moves(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/all-legal-moves") + .`then`() + .statusCode(200) + .body("size()", is(20)) + + // ── legal-moves ─────────────────────────────────────────────────── + + @Test + def legalMoves_e2PawnHas2Moves(): Unit = + request() + .contentType(ContentType.JSON) + .body(contextSquareBody(GameContext.initial, "e2")) + .when() + .post("/api/rules/legal-moves") + .`then`() + .statusCode(200) + .body("size()", is(2)) + + // ── candidate-moves ─────────────────────────────────────────────── + + @Test + def candidateMoves_e2PawnHas2Candidates(): Unit = + request() + .contentType(ContentType.JSON) + .body(contextSquareBody(GameContext.initial, "e2")) + .when() + .post("/api/rules/candidate-moves") + .`then`() + .statusCode(200) + .body("size()", is(2)) + + // ── is-check ────────────────────────────────────────────────────── + + @Test + def isCheck_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-check") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isCheck_trueWhenKingAttacked(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(buildCheckContext())) + .when() + .post("/api/rules/is-check") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── is-checkmate ────────────────────────────────────────────────── + + @Test + def isCheckmate_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-checkmate") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isCheckmate_trueForFoolsMate(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(buildFoolsMate())) + .when() + .post("/api/rules/is-checkmate") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── is-stalemate ────────────────────────────────────────────────── + + @Test + def isStalemate_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-stalemate") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isStalemate_trueForStalematePosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(buildStalemateContext())) + .when() + .post("/api/rules/is-stalemate") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── is-insufficient-material ────────────────────────────────────── + + @Test + def isInsufficientMaterial_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-insufficient-material") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isInsufficientMaterial_trueForKingsOnly(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(buildKingsOnlyContext())) + .when() + .post("/api/rules/is-insufficient-material") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── is-fifty-move-rule ──────────────────────────────────────────── + + @Test + def isFiftyMoveRule_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-fifty-move-rule") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isFiftyMoveRule_trueWhenClockAt100(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial.copy(halfMoveClock = 100))) + .when() + .post("/api/rules/is-fifty-move-rule") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── is-threefold-repetition ─────────────────────────────────────── + + @Test + def isThreefoldRepetition_falseForInitialPosition(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(GameContext.initial)) + .when() + .post("/api/rules/is-threefold-repetition") + .`then`() + .statusCode(200) + .body(is("false")) + + @Test + def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(buildThreefoldContext())) + .when() + .post("/api/rules/is-threefold-repetition") + .`then`() + .statusCode(200) + .body(is("true")) + + // ── apply-move ──────────────────────────────────────────────────── + + @Test + def applyMove_updatesContext(): Unit = + val move = rules + .legalMoves(GameContext.initial)(Square(File.E, Rank.R2)) + .find(_.to == Square(File.E, Rank.R4)) + .get + request() + .contentType(ContentType.JSON) + .body(contextMoveBody(GameContext.initial, move)) + .when() + .post("/api/rules/apply-move") + .`then`() + .statusCode(200) + .body("turn", is("Black")) + + // ── error handling ──────────────────────────────────────────────── + + @Test + def invalidSquare_returns400(): Unit = + request() + .contentType(ContentType.JSON) + .body(toJson(ContextSquareRequest(GameContext.initial, "z9"))) + .when() + .post("/api/rules/legal-moves") + .`then`() + .statusCode(400) + + // ── position builders ───────────────────────────────────────────── + + private def buildCheckContext(): GameContext = + val board = Board( + Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.E, Rank.R3) -> Piece(Color.Black, PieceType.Rook), + ), + ) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def buildFoolsMate(): GameContext = + val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4")) + moves.foldLeft(GameContext.initial) { (ctx, fromTo) => + val from = Square.fromAlgebraic(fromTo._1).get + val to = Square.fromAlgebraic(fromTo._2).get + rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx)) + } + + private def buildStalemateContext(): GameContext = + val board = Board( + Map( + Square(File.H, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.F, Rank.R7) -> Piece(Color.White, PieceType.Queen), + Square(File.G, Rank.R6) -> Piece(Color.White, PieceType.King), + ), + ) + GameContext(board, Color.Black, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def buildKingsOnlyContext(): GameContext = + val board = Board( + Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + ), + ) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def buildThreefoldContext(): GameContext = + val g1 = Square(File.G, Rank.R1) + val f3 = Square(File.F, Rank.R3) + val g8 = Square(File.G, Rank.R8) + val f6 = Square(File.F, Rank.R6) + def mv(ctx: GameContext, from: Square, to: Square): GameContext = + rules.legalMoves(ctx)(from).find(_.to == to).fold(ctx)(rules.applyMove(ctx)) + val ctx1 = mv(GameContext.initial, g1, f3) + val ctx2 = mv(ctx1, g8, f6) + val ctx3 = mv(ctx2, f3, g1) + val ctx4 = mv(ctx3, f6, g8) + val ctx5 = mv(ctx4, g1, f3) + val ctx6 = mv(ctx5, g8, f6) + val ctx7 = mv(ctx6, f3, g1) + mv(ctx7, f6, g8) diff --git a/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala new file mode 100644 index 0000000..4159df5 --- /dev/null +++ b/modules/rule/src/test/scala/de/nowchess/rules/resource/RuleSetResourceUnitTest.scala @@ -0,0 +1,153 @@ +package de.nowchess.rules.resource + +import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceType, Rank, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.Move +import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest} +import de.nowchess.rules.sets.DefaultRules +import jakarta.ws.rs.BadRequestException +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class RuleSetResourceUnitTest extends AnyFunSuite with Matchers: + + private val resource = new RuleSetResource() + private val rules = DefaultRules + + private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(g, sq) + private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(g, m) + + // ── position builders ───────────────────────────────────────────── + + private def checkContext(): GameContext = + val board = Board( + Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.E, Rank.R3) -> Piece(Color.Black, PieceType.Rook), + ), + ) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def foolsMate(): GameContext = + val moves = List(("f2", "f3"), ("e7", "e5"), ("g2", "g4"), ("d8", "h4")) + moves.foldLeft(GameContext.initial) { (c, ft) => + val from = Square.fromAlgebraic(ft._1).get + val to = Square.fromAlgebraic(ft._2).get + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + } + + private def stalemateContext(): GameContext = + val board = Board( + Map( + Square(File.H, Rank.R8) -> Piece(Color.Black, PieceType.King), + Square(File.F, Rank.R7) -> Piece(Color.White, PieceType.Queen), + Square(File.G, Rank.R6) -> Piece(Color.White, PieceType.King), + ), + ) + GameContext(board, Color.Black, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def kingsOnlyContext(): GameContext = + val board = Board( + Map( + Square(File.E, Rank.R1) -> Piece(Color.White, PieceType.King), + Square(File.E, Rank.R8) -> Piece(Color.Black, PieceType.King), + ), + ) + GameContext(board, Color.White, CastlingRights.None, None, 0, List.empty, initialBoard = board) + + private def threefoldContext(): GameContext = + val g1 = Square(File.G, Rank.R1) + val f3 = Square(File.F, Rank.R3) + val g8 = Square(File.G, Rank.R8) + val f6 = Square(File.F, Rank.R6) + def mv(c: GameContext, from: Square, to: Square): GameContext = + rules.legalMoves(c)(from).find(_.to == to).fold(c)(rules.applyMove(c)) + val c1 = mv(GameContext.initial, g1, f3) + val c2 = mv(c1, g8, f6) + val c3 = mv(c2, f3, g1) + val c4 = mv(c3, f6, g8) + val c5 = mv(c4, g1, f3) + val c6 = mv(c5, g8, f6) + val c7 = mv(c6, f3, g1) + mv(c7, f6, g8) + + // ── allLegalMoves ───────────────────────────────────────────────── + + test("allLegalMoves returns 20 moves for initial position"): + resource.allLegalMoves(GameContext.initial) should have size 20 + + // ── legalMoves ──────────────────────────────────────────────────── + + test("legalMoves returns 2 moves for e2 pawn"): + resource.legalMoves(ctxSq(GameContext.initial, "e2")) should have size 2 + + test("legalMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.legalMoves(ctxSq(GameContext.initial, "z9")) + + // ── candidateMoves ──────────────────────────────────────────────── + + test("candidateMoves returns moves for e2 pawn"): + resource.candidateMoves(ctxSq(GameContext.initial, "e2")) should not be empty + + test("candidateMoves throws BadRequestException for invalid square"): + an[BadRequestException] should be thrownBy + resource.candidateMoves(ctxSq(GameContext.initial, "z9")) + + // ── isCheck ─────────────────────────────────────────────────────── + + test("isCheck returns false for initial position"): + resource.isCheck(GameContext.initial) shouldBe false + + test("isCheck returns true when king is attacked"): + resource.isCheck(checkContext()) shouldBe true + + // ── isCheckmate ─────────────────────────────────────────────────── + + test("isCheckmate returns false for initial position"): + resource.isCheckmate(GameContext.initial) shouldBe false + + test("isCheckmate returns true for Fool's mate"): + resource.isCheckmate(foolsMate()) shouldBe true + + // ── isStalemate ─────────────────────────────────────────────────── + + test("isStalemate returns false for initial position"): + resource.isStalemate(GameContext.initial) shouldBe false + + test("isStalemate returns true for stalemate position"): + resource.isStalemate(stalemateContext()) shouldBe true + + // ── isInsufficientMaterial ──────────────────────────────────────── + + test("isInsufficientMaterial returns false for initial position"): + resource.isInsufficientMaterial(GameContext.initial) shouldBe false + + test("isInsufficientMaterial returns true for kings only"): + resource.isInsufficientMaterial(kingsOnlyContext()) shouldBe true + + // ── isFiftyMoveRule ─────────────────────────────────────────────── + + test("isFiftyMoveRule returns false for initial position"): + resource.isFiftyMoveRule(GameContext.initial) shouldBe false + + test("isFiftyMoveRule returns true when halfMoveClock is 100"): + resource.isFiftyMoveRule(GameContext.initial.copy(halfMoveClock = 100)) shouldBe true + + // ── isThreefoldRepetition ───────────────────────────────────────── + + test("isThreefoldRepetition returns false for initial position"): + resource.isThreefoldRepetition(GameContext.initial) shouldBe false + + test("isThreefoldRepetition returns true after repeated moves"): + resource.isThreefoldRepetition(threefoldContext()) shouldBe true + + // ── applyMove ───────────────────────────────────────────────────── + + test("applyMove returns updated context with switched turn"): + val move = rules + .legalMoves(GameContext.initial)(Square(File.E, Rank.R2)) + .find(_.to == Square(File.E, Rank.R4)) + .get + resource.applyMove(ctxMv(GameContext.initial, move)).turn shouldBe Color.Black