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

Co-authored-by: LQ63 <lkhermann@web.de>
Co-authored-by: TeamCity <teamcity@service.local>
Reviewed-on: #39
This commit was merged in pull request #39.
This commit is contained in:
2026-04-22 10:09:35 +02:00
parent ffeb3ce338
commit 093134d36c
54 changed files with 1655 additions and 76 deletions
@@ -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