feat(rule): Rules as a microservice

Added rules as a microservice
This commit is contained in:
LQ63
2026-04-20 19:31:44 +02:00
parent b4d197a9b2
commit 0be2ad43e8
12 changed files with 1041 additions and 2 deletions
@@ -0,0 +1,168 @@
package de.nowchess.rules.dto
import de.nowchess.api.board.{Color, File, Piece, PieceType, Rank, Square}
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class DtoMapperTest extends AnyFunSuite with Matchers:
// ── toColor ────────────────────────────────────────────────────────
test("toColor converts White"):
DtoMapper.toColor("White") shouldBe Right(Color.White)
test("toColor converts Black"):
DtoMapper.toColor("Black") shouldBe Right(Color.Black)
test("toColor rejects unknown"):
DtoMapper.toColor("Red") shouldBe a[Left[?, ?]]
// ── toPieceType ────────────────────────────────────────────────────
test("toPieceType converts all piece types"):
DtoMapper.toPieceType("Pawn") shouldBe Right(PieceType.Pawn)
DtoMapper.toPieceType("Knight") shouldBe Right(PieceType.Knight)
DtoMapper.toPieceType("Bishop") shouldBe Right(PieceType.Bishop)
DtoMapper.toPieceType("Rook") shouldBe Right(PieceType.Rook)
DtoMapper.toPieceType("Queen") shouldBe Right(PieceType.Queen)
DtoMapper.toPieceType("King") shouldBe Right(PieceType.King)
test("toPieceType rejects unknown"):
DtoMapper.toPieceType("Dragon") shouldBe a[Left[?, ?]]
// ── toSquare ───────────────────────────────────────────────────────
test("toSquare converts valid algebraic"):
DtoMapper.toSquare("e4") shouldBe Right(Square(File.E, Rank.R4))
test("toSquare rejects invalid"):
DtoMapper.toSquare("z9") shouldBe a[Left[?, ?]]
// ── toMoveType ────────────────────────────────────────────────────
test("toMoveType converts normal non-capture"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "normal", None)) shouldBe Right(MoveType.Normal(false))
test("toMoveType converts capture"):
DtoMapper.toMoveType(MoveDto("e2", "d3", "capture", None)) shouldBe Right(MoveType.Normal(true))
test("toMoveType converts castleKingside"):
DtoMapper.toMoveType(MoveDto("e1", "g1", "castleKingside", None)) shouldBe Right(MoveType.CastleKingside)
test("toMoveType converts castleQueenside"):
DtoMapper.toMoveType(MoveDto("e1", "c1", "castleQueenside", None)) shouldBe Right(MoveType.CastleQueenside)
test("toMoveType converts enPassant"):
DtoMapper.toMoveType(MoveDto("e5", "d6", "enPassant", None)) shouldBe Right(MoveType.EnPassant)
test("toMoveType converts all promotion pieces"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Queen"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Queen),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Rook"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Rook),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Bishop"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Bishop),
)
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Knight"))) shouldBe Right(
MoveType.Promotion(PromotionPiece.Knight),
)
test("toMoveType rejects promotion without piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", None)) shouldBe a[Left[?, ?]]
test("toMoveType rejects promotion with unknown piece"):
DtoMapper.toMoveType(MoveDto("e7", "e8", "promotion", Some("Pawn"))) shouldBe a[Left[?, ?]]
test("toMoveType rejects unknown type"):
DtoMapper.toMoveType(MoveDto("e2", "e4", "unknown", None)) shouldBe a[Left[?, ?]]
// ── toBoard ───────────────────────────────────────────────────────
test("toBoard builds valid board"):
val pieces = List(
PieceOnSquareDto("e1", "White", "King"),
PieceOnSquareDto("e8", "Black", "King"),
)
val result = DtoMapper.toBoard(pieces)
result.isRight shouldBe true
result.map(_.pieceAt(Square(File.E, Rank.R1))) shouldBe Right(Some(Piece(Color.White, PieceType.King)))
test("toBoard rejects invalid square"):
DtoMapper.toBoard(List(PieceOnSquareDto("z9", "White", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid color"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "Red", "King"))) shouldBe a[Left[?, ?]]
test("toBoard rejects invalid piece type"):
DtoMapper.toBoard(List(PieceOnSquareDto("e1", "White", "Dragon"))) shouldBe a[Left[?, ?]]
test("toBoard with multiple invalid pieces covers all sequenceList branches"):
val pieces = List(
PieceOnSquareDto("z9", "White", "King"),
PieceOnSquareDto("z8", "White", "Queen"),
)
DtoMapper.toBoard(pieces) shouldBe a[Left[?, ?]]
// ── toGameContext ─────────────────────────────────────────────────
test("toGameContext round-trips initial position"):
val ctx = GameContext.initial
val dto = DtoMapper.fromGameContext(ctx)
DtoMapper.toGameContext(dto) shouldBe Right(ctx)
test("toGameContext rejects invalid turn"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(turn = "Red")
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid en passant square"):
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(enPassantSquare = Some("z9"))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid initial board"):
val badBoard = List(PieceOnSquareDto("z9", "White", "King"))
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(initialBoard = badBoard)
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
test("toGameContext rejects invalid move"):
val badMove = MoveDto("z9", "e4", "normal", None)
val dto = DtoMapper.fromGameContext(GameContext.initial).copy(moves = List(badMove))
DtoMapper.toGameContext(dto) shouldBe a[Left[?, ?]]
// ── fromGameContext ───────────────────────────────────────────────
test("fromGameContext includes en passant square when present"):
val ctx = GameContext.initial.copy(enPassantSquare = Some(Square(File.E, Rank.R3)))
DtoMapper.fromGameContext(ctx).enPassantSquare shouldBe Some("e3")
// ── fromMove ──────────────────────────────────────────────────────
test("fromMove converts all move types"):
DtoMapper.fromMove(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4))).moveType shouldBe "normal"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R2), Square(File.D, Rank.R3), MoveType.Normal(true)))
.moveType shouldBe "capture"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside))
.moveType shouldBe "castleKingside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside))
.moveType shouldBe "castleQueenside"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant))
.moveType shouldBe "enPassant"
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)))
.promotionPiece shouldBe Some("Queen")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)))
.promotionPiece shouldBe Some("Rook")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)))
.promotionPiece shouldBe Some("Bishop")
DtoMapper
.fromMove(Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)))
.promotionPiece shouldBe Some("Knight")
@@ -0,0 +1,294 @@
package de.nowchess.rules.resource
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
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, ContextRequest, ContextSquareRequest, DtoMapper}
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 = new ObjectMapper().registerModule(DefaultScalaModule)
private val rules = DefaultRules
private def request() = RestAssured.`given`()
private def toJson(value: AnyRef): String = mapper.writeValueAsString(value)
private def contextBody(ctx: GameContext): String =
toJson(ContextRequest(DtoMapper.fromGameContext(ctx)))
private def contextSquareBody(ctx: GameContext, square: String): String =
toJson(ContextSquareRequest(DtoMapper.fromGameContext(ctx), square))
private def contextMoveBody(ctx: GameContext, move: Move): String =
toJson(ContextMoveRequest(DtoMapper.fromGameContext(ctx), DtoMapper.fromMove(move)))
// ── all-legal-moves ───────────────────────────────────────────────
@Test
def allLegalMoves_initialPositionHas20Moves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/all-legal-moves")
.`then`()
.statusCode(200)
.body("moves.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("moves.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("moves.size()", is(2))
// ── is-check ──────────────────────────────────────────────────────
@Test
def isCheck_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isCheck_trueWhenKingAttacked(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildCheckContext()))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-checkmate ──────────────────────────────────────────────────
@Test
def isCheckmate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isCheckmate_trueForFoolsMate(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildFoolsMate()))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-stalemate ──────────────────────────────────────────────────
@Test
def isStalemate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isStalemate_trueForStalematePosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildStalemateContext()))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-insufficient-material ──────────────────────────────────────
@Test
def isInsufficientMaterial_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isInsufficientMaterial_trueForKingsOnly(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildKingsOnlyContext()))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-fifty-move-rule ────────────────────────────────────────────
@Test
def isFiftyMoveRule_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isFiftyMoveRule_trueWhenClockAt100(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial.copy(halfMoveClock = 100)))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(true))
// ── is-threefold-repetition ───────────────────────────────────────
@Test
def isThreefoldRepetition_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", is(false))
@Test
def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildThreefoldContext()))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", 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(DtoMapper.fromGameContext(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)