feat: NCS-52 Rules as a microservice (#36)
Build & Test (NowChessSystems) TeamCity build finished
Build & Test (NowChessSystems) TeamCity build finished
Implemented module rules as a microservice. ## Summary - Adds Quarkus to the `rule` module, making it independently deployable as a standalone microservice on port 8081 - Exposes all `RuleSet` methods via a REST API at `/api/rules` (candidate moves, legal moves, check/checkmate/stalemate/draw detection, apply move) - Introduces DTOs and a `DtoMapper` for serializing/deserializing chess types (board, moves, game context) as flat JSON strings - Adds `JacksonConfig` for Scala module registration and an `application.yml` for the rule service - Includes Dockerfiles for JVM, legacy-jar, native, and native-micro targets - Full test coverage: 17 `@QuarkusTest` HTTP-level tests + 29 `DtoMapper` unit tests (100% condition coverage) ## Test plan - [ ] `./compile` — all modules build successfully - [ ] `./test` — all tests pass (rule module: 107 tests total) - [ ] `./coverage` — 100% condition coverage in `rule` module - [ ] Rule service runs standalone: `./gradlew :modules:rule:quarkusDev` starts on port 8081 - [ ] `GET /api/rules/candidate-moves` returns valid moves for initial position - [ ] `GET /api/rules/is-check` returns `false` for initial position Co-authored-by: LQ63 <lkhermann@web.de> Co-authored-by: Janis <janis-e@gmx.de> Reviewed-on: #36 Co-authored-by: Leon Hermann <lq@blackhole.local> Co-committed-by: Leon Hermann <lq@blackhole.local>
This commit was merged in pull request #36.
This commit is contained in:
@@ -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).
|
||||||
|
|||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+21
-20
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+12
-12
@@ -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)
|
||||||
|
|||||||
+34
-1
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user