diff --git a/.idea/scala_compiler.xml b/.idea/scala_compiler.xml
index 5c60cfe..f2eb6d2 100644
--- a/.idea/scala_compiler.xml
+++ b/.idea/scala_compiler.xml
@@ -5,7 +5,7 @@
-
+
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