feat(rule): Rules as a microservice (#39)
Build & Test (NowChessSystems) TeamCity build finished

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: TeamCity <teamcity@service.local>
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-04-22 10:09:35 +02:00
parent ffeb3ce338
commit 093134d36c
54 changed files with 1655 additions and 76 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
</profile> </profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test"> <profile name="Gradle 2" modules="NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
<parameters> <parameters>
+2
View File
@@ -41,6 +41,8 @@ val coverageExclusions = listOf(
"**/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala", "**/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala",
// JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration // JacksonConfig — Quarkus lifecycle hook, no testable logic beyond ObjectMapper registration
"**/io/src/main/scala/de/nowchess/io/service/config/JacksonConfig.scala", "**/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). // Converts a Sonar-style glob to a scoverage regex (matched against full source path).
@@ -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.board.Square
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
/** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a /** Extension point for chess rule variants (standard, Chess960, etc.). All rule queries are stateless: given a
@@ -7,7 +7,7 @@ import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
final class ClassicalBot( final class ClassicalBot(
@@ -9,7 +9,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable} import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config} import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
final class HybridBot( final class HybridBot(
@@ -7,7 +7,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash} import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition} import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
final class NNUEBot( final class NNUEBot(
@@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType} import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.util.ZobristHash import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import java.util.concurrent.atomic.{AtomicInteger, AtomicLong} import java.util.concurrent.atomic.{AtomicInteger, AtomicLong}
@@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch 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.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.api.move.MoveType import de.nowchess.api.move.MoveType
import de.nowchess.bot.bots.ClassicalBot 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.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
@@ -6,7 +6,7 @@ import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.HybridBot import de.nowchess.bot.bots.HybridBot
import de.nowchess.bot.util.{PolyglotBook, PolyglotHash} 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.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
+1 -1
View File
@@ -48,7 +48,6 @@ dependencies {
} }
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:rule"))
implementation(project(":modules:bot")) implementation(project(":modules:bot"))
@@ -69,6 +68,7 @@ dependencies {
testImplementation(project(":modules:io")) testImplementation(project(":modules:io"))
testImplementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -6,3 +6,5 @@ quarkus:
rest-client: rest-client:
io-service: io-service:
url: http://localhost:8081 url: http://localhost:8081
rule-service:
url: http://localhost:8082
@@ -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))
@@ -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
@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square 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 io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton 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") new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null // scalafix:on DisableSyntax.null
}) })
val squareModule = new SimpleModule() val mod = new SimpleModule()
squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer()) mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer()) mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
mapper.registerModule(squareModule) 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)
@@ -8,8 +8,7 @@ import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.api.io.{GameContextExport, GameContextImport} import de.nowchess.api.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import scala.concurrent.{ExecutionContext, Future} import scala.concurrent.{ExecutionContext, Future}
@@ -18,7 +17,7 @@ import scala.concurrent.{ExecutionContext, Future}
*/ */
class GameEngine( class GameEngine(
val initialContext: GameContext = GameContext.initial, val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules, val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map( val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
@@ -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
@@ -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()
@@ -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
@@ -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)
@@ -6,6 +6,7 @@ import de.nowchess.api.dto.*
import de.nowchess.api.game.{DrawReason, GameContext, GameResult} import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.adapter.RuleSetRestAdapter
import de.nowchess.chess.client.IoServiceClient import de.nowchess.chess.client.IoServiceClient
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
@@ -36,6 +37,9 @@ class GameResource:
@Inject @Inject
@RestClient @RestClient
var ioClient: IoServiceClient = uninitialized var ioClient: IoServiceClient = uninitialized
@Inject
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1") private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -103,7 +107,7 @@ class GameResource:
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName)) dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry = 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] = private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
val error = new AtomicReference[Option[String]](None) val error = new AtomicReference[Option[String]](None)
@@ -138,6 +142,7 @@ class GameResource:
val black = playerInfoFrom(req.black, DefaultBlack) val black = playerInfoFrom(req.black, DefaultBlack)
val entry = newEntry(GameContext.initial, white, black) val entry = newEntry(GameContext.initial, white, black)
registry.store(entry) registry.store(entry)
println(s"Created game ${entry.gameId}")
created(toGameFullDto(entry)) created(toGameFullDto(entry))
@GET @GET
@@ -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
@@ -13,7 +13,7 @@ object EngineTestHelpers:
new GameEngine(ruleSet = DefaultRules) new GameEngine(ruleSet = DefaultRules)
def makeEngineWithBoard(board: Board, turn: Color = Color.White): GameEngine = 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 = def loadFen(engine: GameEngine, fen: String): Unit =
engine.loadGame(FenParser, fen) engine.loadGame(FenParser, fen)
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.{DrawReason, GameResult} import de.nowchess.api.game.{DrawReason, GameResult}
@@ -18,7 +19,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineDrawOfferTest extends AnyFunSuite with Matchers: class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
test("White offers draw"): test("White offers draw"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -32,7 +33,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferEvent, but got $other") fail(s"Expected DrawOfferEvent, but got $other")
test("Black accepts White's draw offer"): test("Black accepts White's draw offer"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -49,7 +50,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other") fail(s"Expected DrawEvent, but got $other")
test("Black declines White's draw offer"): test("Black declines White's draw offer"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -65,7 +66,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferDeclinedEvent, but got $other") fail(s"Expected DrawOfferDeclinedEvent, but got $other")
test("Black offers draw"): test("Black offers draw"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -79,7 +80,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferEvent, but got $other") fail(s"Expected DrawOfferEvent, but got $other")
test("White accepts Black's draw offer"): test("White accepts Black's draw offer"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -96,7 +97,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other") fail(s"Expected DrawEvent, but got $other")
test("Cannot accept draw when no offer pending"): test("Cannot accept draw when no offer pending"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -110,7 +111,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline draw when no offer pending"): test("Cannot decline draw when no offer pending"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -124,7 +125,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot offer draw when game is already over"): test("Cannot offer draw when game is already over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -147,7 +148,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot accept your own draw offer"): test("Cannot accept your own draw offer"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -163,7 +164,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline your own draw offer"): test("Cannot decline your own draw offer"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -179,7 +180,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot make second draw offer when one is already pending"): 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() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -195,7 +196,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Draw offer is cleared when game ends by resignation (accept)"): 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() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -215,7 +216,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("Draw offer is cleared when game ends by resignation (decline)"): 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() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -235,22 +236,22 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("pendingDrawOfferBy returns None initially"): test("pendingDrawOfferBy returns None initially"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
engine.pendingDrawOfferBy shouldBe None engine.pendingDrawOfferBy shouldBe None
test("pendingDrawOfferBy returns White after White offers"): test("pendingDrawOfferBy returns White after White offers"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
engine.offerDraw(Color.White) engine.offerDraw(Color.White)
engine.pendingDrawOfferBy shouldBe Some(Color.White) engine.pendingDrawOfferBy shouldBe Some(Color.White)
test("pendingDrawOfferBy returns None after draw is accepted"): test("pendingDrawOfferBy returns None after draw is accepted"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
engine.offerDraw(Color.White) engine.offerDraw(Color.White)
engine.acceptDraw(Color.Black) engine.acceptDraw(Color.Black)
engine.pendingDrawOfferBy shouldBe None engine.pendingDrawOfferBy shouldBe None
test("applyDraw sets draw result when game not over"): test("applyDraw sets draw result when game not over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
engine.applyDraw(DrawReason.Agreement) engine.applyDraw(DrawReason.Agreement)
@@ -263,7 +264,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other") fail(s"Expected DrawEvent, but got $other")
test("applyDraw does nothing when game already over"): test("applyDraw does nothing when game already over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// End the game with checkmate // End the game with checkmate
@@ -276,7 +277,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
observer.events should have length 0 observer.events should have length 0
test("claimDraw with fifty-move rule when at half-move 100"): 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() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// Play moves to reach fifty-move rule claim // 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 // This is hard to do naturally; skip for now if not critical
test("claimDraw when game already over"): test("claimDraw when game already over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver() val observer = new DrawOfferMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
// End the game with checkmate // End the game with checkmate
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.DrawReason import de.nowchess.api.game.DrawReason
@@ -11,7 +12,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineGameEndingTest extends AnyFunSuite with Matchers: class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"): test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -31,7 +32,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
fail(s"Expected CheckmateEvent, but got $other") fail(s"Expected CheckmateEvent, but got $other")
test("GameEngine handles check detection"): test("GameEngine handles check detection"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -53,7 +54,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
// Wait, let's just use Sam Loyd's 10-move stalemate: // 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 // 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"): test("GameEngine handles Stalemate via 10-move known sequence"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver() val observer = new EndingMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer} import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.api.io.GameContextImport import de.nowchess.api.io.GameContextImport
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -21,7 +21,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
events events
test("accessors expose redo availability and command history"): test("accessors expose redo availability and command history"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
engine.canRedo shouldBe false engine.canRedo shouldBe false
engine.commandHistory shouldBe empty engine.commandHistory shouldBe empty
@@ -30,7 +30,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.commandHistory.nonEmpty shouldBe true engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"): test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("") engine.processUserInput("")
@@ -44,7 +44,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
} should be >= 3 } should be >= 3
test("processUserInput emits Illegal move for syntactically valid but illegal target"): 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) val events = captureEvents(engine)
engine.processUserInput("e2e5") engine.processUserInput("e2e5")
@@ -56,14 +56,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
test("loadGame returns Left when importer fails"): test("loadGame returns Left when importer fails"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val failingImporter = new GameContextImport: val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom") def importGameContext(input: String): Either[String, GameContext] = Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom") engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
test("loadPosition replaces context clears history and notifies reset"): test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine) val events = captureEvents(engine)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
@@ -78,7 +78,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
} shouldBe true } shouldBe true
test("redo event includes captured piece description when replaying a capture"): 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) val events = captureEvents(engine)
EngineTestHelpers.loadFen(engine, "4k3/8/8/8/8/8/4K3/R6r w - - 0 1") 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"): test("loadGame replay executes non-promotion moves through default replay branch"):
val normalMove = Move(sq("e2"), sq("e4"), MoveType.Normal()) 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.replayMoves(List(normalMove), engine.context) shouldBe Right(())
engine.context.moves.lastOption shouldBe Some(normalMove) engine.context.moves.lastOption shouldBe Some(normalMove)
test("replayMoves skips later moves after the first move triggers an error"): 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 saved = engine.context
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen)) val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4")) val trailingMove = Move(sq("e2"), sq("e4"))
@@ -160,19 +160,19 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.context shouldBe saved engine.context shouldBe saved
test("normalMoveNotation handles missing source piece"): 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) val result = engine.normalMoveNotation(Move(sq("e3"), sq("e4")), Board.initial, isCapture = false)
result shouldBe "e4" result shouldBe "e4"
test("pieceNotation default branch returns empty string"): test("pieceNotation default branch returns empty string"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val result = engine.pieceNotation(PieceType.Pawn) val result = engine.pieceNotation(PieceType.Pawn)
result shouldBe "" result shouldBe ""
test("observerCount reflects subscribe and unsubscribe operations"): test("observerCount reflects subscribe and unsubscribe operations"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new Observer: val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = () def onGameEvent(event: GameEvent): Unit = ()
@@ -1,5 +1,11 @@
package de.nowchess.chess.engine 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.chess.observer.{GameEvent, Observer}
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser}
@@ -11,7 +17,7 @@ import scala.collection.mutable
class GameEngineLoadGameTest extends AnyFunSuite with Matchers: class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
test("loadGame with PgnParser: loads valid PGN and enables undo/redo"): 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 pgn = "[Event \"Test\"]\n\n1. e4 e5\n"
val result = engine.loadGame(PgnParser, pgn) val result = engine.loadGame(PgnParser, pgn)
result shouldBe Right(()) result shouldBe Right(())
@@ -19,7 +25,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
engine.canUndo shouldBe true engine.canUndo shouldBe true
test("loadGame with FenParser: loads position without replaying moves"): 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 fen = "8/4P3/4k3/8/8/8/8/8 w - - 0 1"
val result = engine.loadGame(FenParser, fen) val result = engine.loadGame(FenParser, fen)
result shouldBe Right(()) result shouldBe Right(())
@@ -27,7 +33,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
engine.canUndo shouldBe false engine.canUndo shouldBe false
test("exportGame with PgnExporter: exports current game as PGN"): test("exportGame with PgnExporter: exports current game as PGN"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
engine.processUserInput("e2e4") engine.processUserInput("e2e4")
engine.processUserInput("e7e5") engine.processUserInput("e7e5")
val pgn = engine.exportGame(PgnExporter) val pgn = engine.exportGame(PgnExporter)
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, File, Rank, Square} 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.api.game.GameContext
import de.nowchess.io.fen.FenParser import de.nowchess.io.fen.FenParser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
@@ -37,7 +38,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White) .withTurn(Color.White)
.withCastlingRights(castlingRights) .withCastlingRights(castlingRights)
val engine = new GameEngine(ctx) val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine) val events = captureEvents(engine)
// White castles queenside: e1c1 // White castles queenside: e1c1
@@ -65,7 +66,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withEnPassantSquare(epSquare) .withEnPassantSquare(epSquare)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) .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) val events = captureEvents(engine)
// White pawn on e5 captures en passant to d6 // White pawn on e5 captures en passant to d6
@@ -96,7 +97,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White) .withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) .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) val events = captureEvents(engine)
engine.processUserInput("e7e8b") engine.processUserInput("e7e8b")
@@ -117,7 +118,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White) .withTurn(Color.White)
.withCastlingRights(de.nowchess.api.board.CastlingRights(false, false, false, false)) .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) val events = captureEvents(engine)
// King moves e1 -> f1 // King moves e1 -> f1
@@ -14,7 +14,7 @@ import de.nowchess.chess.observer.{
MoveExecutedEvent, MoveExecutedEvent,
Observer, Observer,
} }
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers import org.scalatest.matchers.should.Matchers
@@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
events events
private def engineWith(board: Board, turn: Color = Color.White): GameEngine = 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") { 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 val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable import scala.collection.mutable
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.GameResult import de.nowchess.api.game.GameResult
@@ -10,7 +11,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineResignTest extends AnyFunSuite with Matchers: class GameEngineResignTest extends AnyFunSuite with Matchers:
test("White resigns"): test("White resigns"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver() val observer = new ResignMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -25,7 +26,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected ResignEvent, but got $other") fail(s"Expected ResignEvent, but got $other")
test("Black resigns"): test("Black resigns"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver() val observer = new ResignMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -40,7 +41,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected ResignEvent, but got $other") fail(s"Expected ResignEvent, but got $other")
test("Cannot resign when game is already over"): test("Cannot resign when game is already over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver() val observer = new ResignMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -64,7 +65,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other") fail(s"Expected InvalidMoveEvent, but got $other")
test("resign() without color resigns side to move"): test("resign() without color resigns side to move"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver() val observer = new ResignMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -73,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
engine.context.result shouldBe Some(GameResult.Win(Color.Black)) engine.context.result shouldBe Some(GameResult.Win(Color.Black))
test("resign() without color does nothing when game already over"): test("resign() without color does nothing when game already over"):
val engine = new GameEngine() val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver() val observer = new ResignMockObserver()
engine.subscribe(observer) engine.subscribe(observer)
@@ -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])
@@ -1,6 +1,7 @@
package de.nowchess.chess.registry package de.nowchess.chess.registry
import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
import io.quarkus.test.junit.QuarkusTest import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -20,14 +21,24 @@ class GameRegistryImplTest:
@Test @Test
@DisplayName("store saves entry") @DisplayName("store saves entry")
def testStore(): Unit = 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) registry.store(entry)
assertTrue(registry.get("g1").isDefined) assertTrue(registry.get("g1").isDefined)
@Test @Test
@DisplayName("get returns stored entry") @DisplayName("get returns stored entry")
def testGet(): Unit = 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) registry.store(entry)
val retrieved = registry.get("g2") val retrieved = registry.get("g2")
assertTrue(retrieved.isDefined) assertTrue(retrieved.isDefined)
@@ -41,7 +52,12 @@ class GameRegistryImplTest:
@Test @Test
@DisplayName("update modifies existing entry") @DisplayName("update modifies existing entry")
def testUpdate(): Unit = 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) registry.store(entry)
val updated = entry.copy(resigned = true) val updated = entry.copy(resigned = true)
registry.update(updated) registry.update(updated)
@@ -1,11 +1,13 @@
package de.nowchess.chess.resource package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.* import de.nowchess.api.dto.*
import de.nowchess.api.game.GameContext 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.chess.exception.BadRequestException
import de.nowchess.io.fen.FenExporter import de.nowchess.io.fen.FenExporter
import de.nowchess.io.pgn.PgnParser import de.nowchess.io.pgn.PgnParser
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -14,6 +16,7 @@ import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.when import org.mockito.Mockito.when
import org.mockito.invocation.InvocationOnMock
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@@ -29,6 +32,10 @@ class GameResourceIntegrationTest:
@RestClient @RestClient
var ioClient: IoServiceClient = uninitialized var ioClient: IoServiceClient = uninitialized
@InjectMock
@RestClient
var ruleClient: RuleServiceClient = uninitialized
@BeforeEach @BeforeEach
def setupMocks(): Unit = def setupMocks(): Unit =
when(ioClient.importFen(any())).thenReturn(GameContext.initial) when(ioClient.importFen(any())).thenReturn(GameContext.initial)
@@ -37,6 +44,32 @@ class GameResourceIntegrationTest:
) )
when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial)) when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
when(ioClient.exportPgn(any())).thenReturn("1. e4 c5") 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 @Test
@DisplayName("createGame returns 201") @DisplayName("createGame returns 201")
+47 -2
View File
@@ -1,6 +1,7 @@
plugins { plugins {
id("scala") id("scala")
id("org.scoverage") version "8.1" id("org.scoverage") version "8.1"
id("io.quarkus")
} }
group = "de.nowchess" group = "de.nowchess"
@@ -25,6 +26,10 @@ tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8") scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
} }
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
dependencies { dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") { compileOnly("org.scala-lang:scala3-compiler_3") {
@@ -40,20 +45,56 @@ dependencies {
implementation(project(":modules:api")) 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(project(":modules:io"))
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}") testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}") 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") testRuntimeOnly("org.junit.platform:junit-platform-launcher")
} }
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test { tasks.test {
useJUnitPlatform { useJUnitPlatform {
includeEngines("scalatest") includeEngines("scalatest", "junit-jupiter")
testLogging { testLogging {
events("skipped", "failed") events("passed", "skipped", "failed")
} }
} }
finalizedBy(tasks.reportScoverage) finalizedBy(tasks.reportScoverage)
@@ -61,3 +102,7 @@ tasks.test {
tasks.reportScoverage { tasks.reportScoverage {
dependsOn(tasks.test) dependsOn(tasks.test)
} }
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
+100
View File
@@ -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" ]
@@ -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" ]
@@ -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"]
@@ -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"]
@@ -0,0 +1,5 @@
quarkus:
http:
port: 8082
application:
name: rule-service
@@ -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)
@@ -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
@@ -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)
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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)
@@ -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)
@@ -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)
@@ -3,7 +3,7 @@ package de.nowchess.rules.sets
import de.nowchess.api.board.* import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.rules.RuleSet import de.nowchess.api.rules.RuleSet
import scala.annotation.tailrec import scala.annotation.tailrec
@@ -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
@@ -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])
@@ -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)
@@ -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