feat(core): core dependecy to rules

Added communication between core and rules via REST
This commit is contained in:
LQ63
2026-04-21 23:33:52 +02:00
committed by Janis
parent df76b53714
commit 48ce1fbdb2
31 changed files with 316 additions and 74 deletions
@@ -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.game.GameContext
import de.nowchess.api.move.Move
/** 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.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
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.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class HybridBot(
@@ -7,7 +7,7 @@ import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class NNUEBot(
@@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.util.ZobristHash
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
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.bots.classic.EvaluationClassic
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.matchers.should.Matchers
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.MoveType
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.matchers.should.Matchers
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.bots.HybridBot
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.matchers.should.Matchers
+1 -1
View File
@@ -48,7 +48,6 @@ dependencies {
}
implementation(project(":modules:api"))
implementation(project(":modules:rule"))
implementation(project(":modules:bot"))
@@ -69,6 +68,7 @@ dependencies {
testImplementation(project(":modules:io"))
testImplementation(project(":modules:rule"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
@@ -6,3 +6,5 @@ quarkus:
rest-client:
io-service:
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.module.scala.DefaultScalaModule
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 jakarta.inject.Singleton
@@ -17,7 +19,11 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
val squareModule = new SimpleModule()
squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
mapper.registerModule(squareModule)
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)
@@ -8,8 +8,7 @@ import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
import de.nowchess.api.io.{GameContextExport, GameContextImport}
import de.nowchess.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.api.rules.RuleSet
import scala.concurrent.{ExecutionContext, Future}
@@ -18,7 +17,7 @@ import scala.concurrent.{ExecutionContext, Future}
*/
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet = DefaultRules,
val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
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,11 @@
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]:
// scalafix:off DisableSyntax.null
override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
Square.fromAlgebraic(p.getText).orNull
// scalafix:on DisableSyntax.null
@@ -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.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.adapter.RuleSetRestAdapter
import de.nowchess.chess.client.IoServiceClient
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.engine.GameEngine
@@ -36,6 +37,9 @@ class GameResource:
@Inject
@RestClient
var ioClient: IoServiceClient = uninitialized
@Inject
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
// scalafix:on DisableSyntax.var
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
@@ -103,7 +107,7 @@ class GameResource:
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
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] =
val error = new AtomicReference[Option[String]](None)
@@ -13,7 +13,7 @@ object EngineTestHelpers:
new GameEngine(ruleSet = DefaultRules)
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 =
engine.loadGame(FenParser, fen)
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.{DrawReason, GameResult}
@@ -18,7 +19,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
test("White offers draw"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -32,7 +33,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferEvent, but got $other")
test("Black accepts White's draw offer"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -49,7 +50,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other")
test("Black declines White's draw offer"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -65,7 +66,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferDeclinedEvent, but got $other")
test("Black offers draw"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -79,7 +80,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawOfferEvent, but got $other")
test("White accepts Black's draw offer"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -96,7 +97,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other")
test("Cannot accept draw when no offer pending"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -110,7 +111,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline draw when no offer pending"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -124,7 +125,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot offer draw when game is already over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -147,7 +148,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot accept your own draw offer"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -163,7 +164,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("Cannot decline your own draw offer"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
@@ -179,7 +180,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
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()
engine.subscribe(observer)
@@ -195,7 +196,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
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()
engine.subscribe(observer)
@@ -215,7 +216,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
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()
engine.subscribe(observer)
@@ -235,22 +236,22 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("pendingDrawOfferBy returns None initially"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
engine.pendingDrawOfferBy shouldBe None
test("pendingDrawOfferBy returns White after White offers"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
engine.offerDraw(Color.White)
engine.pendingDrawOfferBy shouldBe Some(Color.White)
test("pendingDrawOfferBy returns None after draw is accepted"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
engine.offerDraw(Color.White)
engine.acceptDraw(Color.Black)
engine.pendingDrawOfferBy shouldBe None
test("applyDraw sets draw result when game not over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
engine.applyDraw(DrawReason.Agreement)
@@ -263,7 +264,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
fail(s"Expected DrawEvent, but got $other")
test("applyDraw does nothing when game already over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// End the game with checkmate
@@ -276,7 +277,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
observer.events should have length 0
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()
engine.subscribe(observer)
// 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
test("claimDraw when game already over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new DrawOfferMockObserver()
engine.subscribe(observer)
// End the game with checkmate
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.DrawReason
@@ -11,7 +12,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
test("GameEngine handles Checkmate (Fool's Mate)"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver()
engine.subscribe(observer)
@@ -31,7 +32,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
fail(s"Expected CheckmateEvent, but got $other")
test("GameEngine handles check detection"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver()
engine.subscribe(observer)
@@ -53,7 +54,7 @@ class GameEngineGameEndingTest extends AnyFunSuite with Matchers:
// 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
test("GameEngine handles Stalemate via 10-move known sequence"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new EndingMockObserver()
engine.subscribe(observer)
@@ -5,7 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
import de.nowchess.api.io.GameContextImport
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -21,7 +21,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
events
test("accessors expose redo availability and command history"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
engine.canRedo shouldBe false
engine.commandHistory shouldBe empty
@@ -30,7 +30,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.commandHistory.nonEmpty shouldBe true
test("processUserInput handles undo redo empty and malformed commands"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
engine.processUserInput("")
@@ -44,7 +44,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
} should be >= 3
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)
engine.processUserInput("e2e5")
@@ -56,14 +56,14 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
test("loadGame returns Left when importer fails"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val failingImporter = new GameContextImport:
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
test("loadPosition replaces context clears history and notifies reset"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val events = captureEvents(engine)
engine.processUserInput("e2e4")
@@ -78,7 +78,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
} shouldBe true
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)
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"):
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.context.moves.lastOption shouldBe Some(normalMove)
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 illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
val trailingMove = Move(sq("e2"), sq("e4"))
@@ -160,19 +160,19 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
engine.context shouldBe saved
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)
result shouldBe "e4"
test("pieceNotation default branch returns empty string"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val result = engine.pieceNotation(PieceType.Pawn)
result shouldBe ""
test("observerCount reflects subscribe and unsubscribe operations"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit = ()
@@ -1,5 +1,11 @@
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.io.fen.FenParser
import de.nowchess.io.pgn.{PgnExporter, PgnParser}
@@ -11,7 +17,7 @@ import scala.collection.mutable
class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
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 result = engine.loadGame(PgnParser, pgn)
result shouldBe Right(())
@@ -19,7 +25,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
engine.canUndo shouldBe true
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 result = engine.loadGame(FenParser, fen)
result shouldBe Right(())
@@ -27,7 +33,7 @@ class GameEngineLoadGameTest extends AnyFunSuite with Matchers:
engine.canUndo shouldBe false
test("exportGame with PgnExporter: exports current game as PGN"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
engine.processUserInput("e2e4")
engine.processUserInput("e7e5")
val pgn = engine.exportGame(PgnExporter)
@@ -1,6 +1,7 @@
package de.nowchess.chess.engine
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.io.fen.FenParser
import de.nowchess.chess.observer.*
@@ -37,7 +38,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White)
.withCastlingRights(castlingRights)
val engine = new GameEngine(ctx)
val engine = new GameEngine(ctx, DefaultRules)
val events = captureEvents(engine)
// White castles queenside: e1c1
@@ -65,7 +66,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withEnPassantSquare(epSquare)
.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)
// White pawn on e5 captures en passant to d6
@@ -96,7 +97,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White)
.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)
engine.processUserInput("e7e8b")
@@ -117,7 +118,7 @@ class GameEngineNotationTest extends AnyFunSuite with Matchers:
.withTurn(Color.White)
.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)
// King moves e1 -> f1
@@ -14,7 +14,7 @@ import de.nowchess.chess.observer.{
MoveExecutedEvent,
Observer,
}
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -29,7 +29,7 @@ class GameEnginePromotionTest extends AnyFunSuite with Matchers:
events
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") {
val promotionBoard = FenParser.parseBoard("8/4P3/4k3/8/8/8/8/8").get
@@ -1,5 +1,6 @@
package de.nowchess.chess.engine
import de.nowchess.rules.sets.DefaultRules
import scala.collection.mutable
import de.nowchess.api.board.Color
import de.nowchess.api.game.GameResult
@@ -10,7 +11,7 @@ import org.scalatest.matchers.should.Matchers
class GameEngineResignTest extends AnyFunSuite with Matchers:
test("White resigns"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver()
engine.subscribe(observer)
@@ -25,7 +26,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected ResignEvent, but got $other")
test("Black resigns"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver()
engine.subscribe(observer)
@@ -40,7 +41,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected ResignEvent, but got $other")
test("Cannot resign when game is already over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver()
engine.subscribe(observer)
@@ -64,7 +65,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
fail(s"Expected InvalidMoveEvent, but got $other")
test("resign() without color resigns side to move"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver()
engine.subscribe(observer)
@@ -73,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
test("resign() without color does nothing when game already over"):
val engine = new GameEngine()
val engine = new GameEngine(ruleSet = DefaultRules)
val observer = new ResignMockObserver()
engine.subscribe(observer)
@@ -1,6 +1,7 @@
package de.nowchess.chess.registry
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.rules.sets.DefaultRules
import de.nowchess.chess.engine.GameEngine
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
@@ -20,14 +21,14 @@ class GameRegistryImplTest:
@Test
@DisplayName("store saves entry")
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)
assertTrue(registry.get("g1").isDefined)
@Test
@DisplayName("get returns stored entry")
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)
val retrieved = registry.get("g2")
assertTrue(retrieved.isDefined)
@@ -41,7 +42,7 @@ class GameRegistryImplTest:
@Test
@DisplayName("update modifies existing entry")
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)
val updated = entry.copy(resigned = true)
registry.update(updated)
@@ -1,11 +1,13 @@
package de.nowchess.chess.resource
import de.nowchess.api.board.Square
import de.nowchess.api.dto.*
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.io.fen.FenExporter
import de.nowchess.io.pgn.PgnParser
import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
@@ -14,6 +16,7 @@ import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
import org.mockito.Mockito.when
import org.mockito.invocation.InvocationOnMock
import scala.compiletime.uninitialized
@@ -29,6 +32,10 @@ class GameResourceIntegrationTest:
@RestClient
var ioClient: IoServiceClient = uninitialized
@InjectMock
@RestClient
var ruleClient: RuleServiceClient = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(ioClient.importFen(any())).thenReturn(GameContext.initial)
@@ -37,6 +44,32 @@ class GameResourceIntegrationTest:
)
when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial))
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
@DisplayName("createGame returns 201")
@@ -1,5 +1,5 @@
quarkus:
http:
port: 8081
port: 8082
application:
name: rule-service
@@ -3,7 +3,7 @@ package de.nowchess.rules.sets
import de.nowchess.api.board.*
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.rules.RuleSet
import de.nowchess.api.rules.RuleSet
import scala.annotation.tailrec