feat(rule): Rules as a microservice #39

Merged
Janis merged 15 commits from feat/NCS-52 into main 2026-04-22 10:09:35 +02:00
4 changed files with 64 additions and 73 deletions
Showing only changes of commit 934568e716 - Show all commits
@@ -3,12 +3,6 @@ package de.nowchess.rules.dto
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
case class ContextRequest(context: GameContext)
case class ContextSquareRequest(context: GameContext, square: String)
case class ContextMoveRequest(context: GameContext, move: Move)
case class MovesResponse(moves: List[Move])
case class BooleanResponse(result: Boolean)
@@ -2,6 +2,7 @@ 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
@@ -22,64 +23,64 @@ class RuleSetResource:
@Path("/candidate-moves")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def candidateMoves(req: ContextSquareRequest): MovesResponse =
MovesResponse(rules.candidateMoves(req.context)(parseSquare(req.square)))
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): MovesResponse =
MovesResponse(rules.legalMoves(req.context)(parseSquare(req.square)))
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(req: ContextRequest): MovesResponse =
MovesResponse(rules.allLegalMoves(req.context))
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(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheck(req.context))
def isCheck(ctx: GameContext): Boolean =
rules.isCheck(ctx)
@POST
@Path("/is-checkmate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isCheckmate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isCheckmate(req.context))
def isCheckmate(ctx: GameContext): Boolean =
rules.isCheckmate(ctx)
@POST
@Path("/is-stalemate")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def isStalemate(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isStalemate(req.context))
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(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isInsufficientMaterial(req.context))
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(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isFiftyMoveRule(req.context))
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(req: ContextRequest): BooleanResponse =
BooleanResponse(rules.isThreefoldRepetition(req.context))
def isThreefoldRepetition(ctx: GameContext): Boolean =
rules.isThreefoldRepetition(ctx)
@POST
@Path("/apply-move")
@@ -5,7 +5,7 @@ import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, PieceTy
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.rules.config.JacksonConfig
import de.nowchess.rules.dto.{ContextMoveRequest, ContextRequest, ContextSquareRequest}
import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest}
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
@@ -27,9 +27,6 @@ class RuleSetResourceTest:
private def toJson(value: AnyRef): String = mapper.writeValueAsString(value)
private def contextBody(ctx: GameContext): String =
toJson(ContextRequest(ctx))
private def contextSquareBody(ctx: GameContext, square: String): String =
toJson(ContextSquareRequest(ctx, square))
@@ -42,12 +39,12 @@ class RuleSetResourceTest:
def allLegalMoves_initialPositionHas20Moves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/all-legal-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(20))
.body("size()", is(20))
// ── legal-moves ───────────────────────────────────────────────────
@@ -60,7 +57,7 @@ class RuleSetResourceTest:
.post("/api/rules/legal-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(2))
.body("size()", is(2))
// ── candidate-moves ───────────────────────────────────────────────
@@ -73,7 +70,7 @@ class RuleSetResourceTest:
.post("/api/rules/candidate-moves")
.`then`()
.statusCode(200)
.body("moves.size()", is(2))
.body("size()", is(2))
// ── is-check ──────────────────────────────────────────────────────
@@ -81,23 +78,23 @@ class RuleSetResourceTest:
def isCheck_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isCheck_trueWhenKingAttacked(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildCheckContext()))
.body(toJson(buildCheckContext()))
.when()
.post("/api/rules/is-check")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── is-checkmate ──────────────────────────────────────────────────
@@ -105,23 +102,23 @@ class RuleSetResourceTest:
def isCheckmate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isCheckmate_trueForFoolsMate(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildFoolsMate()))
.body(toJson(buildFoolsMate()))
.when()
.post("/api/rules/is-checkmate")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── is-stalemate ──────────────────────────────────────────────────
@@ -129,23 +126,23 @@ class RuleSetResourceTest:
def isStalemate_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isStalemate_trueForStalematePosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildStalemateContext()))
.body(toJson(buildStalemateContext()))
.when()
.post("/api/rules/is-stalemate")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── is-insufficient-material ──────────────────────────────────────
@@ -153,23 +150,23 @@ class RuleSetResourceTest:
def isInsufficientMaterial_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isInsufficientMaterial_trueForKingsOnly(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildKingsOnlyContext()))
.body(toJson(buildKingsOnlyContext()))
.when()
.post("/api/rules/is-insufficient-material")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── is-fifty-move-rule ────────────────────────────────────────────
@@ -177,23 +174,23 @@ class RuleSetResourceTest:
def isFiftyMoveRule_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isFiftyMoveRule_trueWhenClockAt100(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial.copy(halfMoveClock = 100)))
.body(toJson(GameContext.initial.copy(halfMoveClock = 100)))
.when()
.post("/api/rules/is-fifty-move-rule")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── is-threefold-repetition ───────────────────────────────────────
@@ -201,23 +198,23 @@ class RuleSetResourceTest:
def isThreefoldRepetition_falseForInitialPosition(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(GameContext.initial))
.body(toJson(GameContext.initial))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", is(false))
.body(is("false"))
@Test
def isThreefoldRepetition_trueAfterRepeatedMoves(): Unit =
request()
.contentType(ContentType.JSON)
.body(contextBody(buildThreefoldContext()))
.body(toJson(buildThreefoldContext()))
.when()
.post("/api/rules/is-threefold-repetition")
.`then`()
.statusCode(200)
.body("result", is(true))
.body(is("true"))
// ── apply-move ────────────────────────────────────────────────────
@@ -3,7 +3,7 @@ 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, ContextRequest, ContextSquareRequest}
import de.nowchess.rules.dto.{ContextMoveRequest, ContextSquareRequest}
import de.nowchess.rules.sets.DefaultRules
import jakarta.ws.rs.BadRequestException
import org.scalatest.funsuite.AnyFunSuite
@@ -14,7 +14,6 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
private val resource = new RuleSetResource()
private val rules = DefaultRules
private def ctx(g: GameContext) = ContextRequest(g)
private def ctxSq(g: GameContext, sq: String) = ContextSquareRequest(g, sq)
private def ctxMv(g: GameContext, m: Move) = ContextMoveRequest(g, m)
@@ -76,12 +75,12 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
// ── allLegalMoves ─────────────────────────────────────────────────
test("allLegalMoves returns 20 moves for initial position"):
resource.allLegalMoves(ctx(GameContext.initial)).moves should have size 20
resource.allLegalMoves(GameContext.initial) should have size 20
// ── legalMoves ────────────────────────────────────────────────────
test("legalMoves returns 2 moves for e2 pawn"):
resource.legalMoves(ctxSq(GameContext.initial, "e2")).moves should have size 2
resource.legalMoves(ctxSq(GameContext.initial, "e2")) should have size 2
test("legalMoves throws BadRequestException for invalid square"):
an[BadRequestException] should be thrownBy
@@ -90,7 +89,7 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
// ── candidateMoves ────────────────────────────────────────────────
test("candidateMoves returns moves for e2 pawn"):
resource.candidateMoves(ctxSq(GameContext.initial, "e2")).moves should not be empty
resource.candidateMoves(ctxSq(GameContext.initial, "e2")) should not be empty
test("candidateMoves throws BadRequestException for invalid square"):
an[BadRequestException] should be thrownBy
@@ -99,50 +98,50 @@ class RuleSetResourceUnitTest extends AnyFunSuite with Matchers:
// ── isCheck ───────────────────────────────────────────────────────
test("isCheck returns false for initial position"):
resource.isCheck(ctx(GameContext.initial)).result shouldBe false
resource.isCheck(GameContext.initial) shouldBe false
test("isCheck returns true when king is attacked"):
resource.isCheck(ctx(checkContext())).result shouldBe true
resource.isCheck(checkContext()) shouldBe true
// ── isCheckmate ───────────────────────────────────────────────────
test("isCheckmate returns false for initial position"):
resource.isCheckmate(ctx(GameContext.initial)).result shouldBe false
resource.isCheckmate(GameContext.initial) shouldBe false
test("isCheckmate returns true for Fool's mate"):
resource.isCheckmate(ctx(foolsMate())).result shouldBe true
resource.isCheckmate(foolsMate()) shouldBe true
// ── isStalemate ───────────────────────────────────────────────────
test("isStalemate returns false for initial position"):
resource.isStalemate(ctx(GameContext.initial)).result shouldBe false
resource.isStalemate(GameContext.initial) shouldBe false
test("isStalemate returns true for stalemate position"):
resource.isStalemate(ctx(stalemateContext())).result shouldBe true
resource.isStalemate(stalemateContext()) shouldBe true
// ── isInsufficientMaterial ────────────────────────────────────────
test("isInsufficientMaterial returns false for initial position"):
resource.isInsufficientMaterial(ctx(GameContext.initial)).result shouldBe false
resource.isInsufficientMaterial(GameContext.initial) shouldBe false
test("isInsufficientMaterial returns true for kings only"):
resource.isInsufficientMaterial(ctx(kingsOnlyContext())).result shouldBe true
resource.isInsufficientMaterial(kingsOnlyContext()) shouldBe true
// ── isFiftyMoveRule ───────────────────────────────────────────────
test("isFiftyMoveRule returns false for initial position"):
resource.isFiftyMoveRule(ctx(GameContext.initial)).result shouldBe false
resource.isFiftyMoveRule(GameContext.initial) shouldBe false
test("isFiftyMoveRule returns true when halfMoveClock is 100"):
resource.isFiftyMoveRule(ctx(GameContext.initial.copy(halfMoveClock = 100))).result shouldBe true
resource.isFiftyMoveRule(GameContext.initial.copy(halfMoveClock = 100)) shouldBe true
// ── isThreefoldRepetition ─────────────────────────────────────────
test("isThreefoldRepetition returns false for initial position"):
resource.isThreefoldRepetition(ctx(GameContext.initial)).result shouldBe false
resource.isThreefoldRepetition(GameContext.initial) shouldBe false
test("isThreefoldRepetition returns true after repeated moves"):
resource.isThreefoldRepetition(ctx(threefoldContext())).result shouldBe true
resource.isThreefoldRepetition(threefoldContext()) shouldBe true
// ── applyMove ─────────────────────────────────────────────────────