feat: true-microservices #40

Merged
Janis merged 25 commits from bigchanges into main 2026-04-29 22:06:20 +02:00
26 changed files with 373 additions and 188 deletions
Showing only changes of commit 9a39cd6916 - Show all commits
@@ -7,10 +7,10 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String) case class CorePlayerInfo(id: String, displayName: String)
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int]) case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
case class CoreCreateGameRequest( case class CoreCreateGameRequest(
white: Option[CorePlayerInfo], white: Option[CorePlayerInfo],
black: Option[CorePlayerInfo], black: Option[CorePlayerInfo],
timeControl: Option[CoreTimeControl], timeControl: Option[CoreTimeControl],
mode: Option[String], mode: Option[String],
) )
case class CoreGameResponse(gameId: String) case class CoreGameResponse(gameId: String)
@@ -13,7 +13,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[ChallengeStatus], classOf[ChallengeStatus],
classOf[DeclineReason], classOf[DeclineReason],
classOf[TimeControl], classOf[TimeControl],
classOf[LoginRequest], classOf[LoginRequest],
classOf[TokenResponse], classOf[TokenResponse],
classOf[PlayerInfo], classOf[PlayerInfo],
@@ -27,7 +26,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[ChallengeStatus], classOf[ChallengeStatus],
classOf[ChallengeColor], classOf[ChallengeColor],
classOf[DeclineReason], classOf[DeclineReason],
classOf[CorePlayerInfo], classOf[CorePlayerInfo],
classOf[CoreTimeControl], classOf[CoreTimeControl],
classOf[CoreCreateGameRequest], classOf[CoreCreateGameRequest],
@@ -26,4 +26,4 @@ class Account extends PanacheEntityBase:
var rating: Int = 1500 var rating: Int = 1500
var createdAt: Instant = uninitialized var createdAt: Instant = uninitialized
// scalafix:on // scalafix:on
@@ -4,4 +4,4 @@ sealed trait TimeControl
object TimeControl: object TimeControl:
case class Clock(limit: Int, increment: Int) extends TimeControl case class Clock(limit: Int, increment: Int) extends TimeControl
case object Unlimited extends TimeControl case object Unlimited extends TimeControl
@@ -15,17 +15,17 @@ case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[
case class ChallengeRequest(color: String, timeControl: TimeControlDto) case class ChallengeRequest(color: String, timeControl: TimeControlDto)
case class ChallengeDto( case class ChallengeDto(
id: String, id: String,
challenger: PlayerInfo, challenger: PlayerInfo,
destUser: PlayerInfo, destUser: PlayerInfo,
variant: String, variant: String,
color: String, color: String,
timeControl: TimeControlDto, timeControl: TimeControlDto,
status: String, status: String,
declineReason: Option[String], declineReason: Option[String],
gameId: Option[String], gameId: Option[String],
createdAt: String, createdAt: String,
expiresAt: String, expiresAt: String,
) )
case class DeclineRequest(reason: Option[String]) case class DeclineRequest(reason: Option[String])
@@ -40,7 +40,4 @@ class AccountRepository:
.headOption .headOption
def findAll(): List[Account] = def findAll(): List[Account] =
em.createQuery("FROM Account", classOf[Account]) em.createQuery("FROM Account", classOf[Account]).getResultList.asScala.toList
.getResultList
.asScala
.toList
@@ -63,8 +63,8 @@ class AccountResource:
private def toPublicDto(account: Account): PublicAccountDto = private def toPublicDto(account: Account): PublicAccountDto =
PublicAccountDto( PublicAccountDto(
id = account.id.toString, id = account.id.toString,
username = account.username, username = account.username,
rating = account.rating, rating = account.rating,
createdAt = account.createdAt.toString, createdAt = account.createdAt.toString,
) )
@@ -38,8 +38,8 @@ class ChallengeResource:
case Left(error) => case Left(error) =>
val status = error match val status = error match
case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND
case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST
case _ => Response.Status.CONFLICT case _ => Response.Status.CONFLICT
Response.status(status).entity(ErrorDto(error.message)).build() Response.status(status).entity(ErrorDto(error.message)).build()
@GET @GET
@@ -24,16 +24,14 @@ class AccountService:
@Transactional @Transactional
def register(req: RegisterRequest): Either[AccountError, Account] = def register(req: RegisterRequest): Either[AccountError, Account] =
if accountRepository.findByUsername(req.username).isDefined then if accountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
Left(AccountError.UsernameTaken(req.username)) else if accountRepository.findByEmail(req.email).isDefined then Left(AccountError.EmailAlreadyRegistered(req.email))
else if accountRepository.findByEmail(req.email).isDefined then
Left(AccountError.EmailAlreadyRegistered(req.email))
else else
val account = new Account() val account = new Account()
account.username = req.username account.username = req.username
account.email = req.email account.email = req.email
account.passwordHash = BcryptUtil.bcryptHash(req.password) account.passwordHash = BcryptUtil.bcryptHash(req.password)
account.createdAt = Instant.now() account.createdAt = Instant.now()
accountRepository.persist(account) accountRepository.persist(account)
Right(account) Right(account)
@@ -1,8 +1,21 @@
package de.nowchess.account.service package de.nowchess.account.service
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CoreGameResponse, CorePlayerInfo, CoreTimeControl} import de.nowchess.account.client.{
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
CorePlayerInfo,
CoreTimeControl,
}
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason} import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
import de.nowchess.account.dto.{ChallengeDto, ChallengeListDto, ChallengeRequest, DeclineRequest, PlayerInfo, TimeControlDto} import de.nowchess.account.dto.{
ChallengeDto,
ChallengeListDto,
ChallengeRequest,
DeclineRequest,
PlayerInfo,
TimeControlDto,
}
import de.nowchess.account.error.ChallengeError import de.nowchess.account.error.ChallengeError
import de.nowchess.account.repository.{AccountRepository, ChallengeRepository} import de.nowchess.account.repository.{AccountRepository, ChallengeRepository}
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
@@ -36,23 +49,21 @@ class ChallengeService:
destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername)) destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound) challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf) _ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
_ <- Either.cond( _ <- Either.cond(
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty, challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
(), (),
ChallengeError.DuplicateChallenge, ChallengeError.DuplicateChallenge,
) )
color <- parseColor(req.color) color <- parseColor(req.color)
yield yield
val challenge = new Challenge() val challenge = new Challenge()
challenge.challenger = challenger challenge.challenger = challenger
challenge.destUser = destUser challenge.destUser = destUser
challenge.color = color challenge.color = color
challenge.status = ChallengeStatus.Created challenge.status = ChallengeStatus.Created
challenge.timeControlType = req.timeControl.`type` challenge.timeControlType = req.timeControl.`type`
challenge.timeControlLimit = challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
req.timeControl.limit.map(java.lang.Integer.valueOf).orNull challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
challenge.timeControlIncrement =
req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
challenge.createdAt = Instant.now() challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS) challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge) challengeRepository.persist(challenge)
@@ -79,7 +90,7 @@ class ChallengeService:
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized) _ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
reason <- parseDeclineReason(req.reason) reason <- parseDeclineReason(req.reason)
yield yield
challenge.status = ChallengeStatus.Declined challenge.status = ChallengeStatus.Declined
challenge.declineReason = reason.orNull challenge.declineReason = reason.orNull
challengeRepository.merge(challenge) challengeRepository.merge(challenge)
challenge challenge
@@ -112,8 +123,8 @@ class ChallengeService:
val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username) val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username)
val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username) val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username)
challenge.color match challenge.color match
case ChallengeColor.White => (challenger, destUser) case ChallengeColor.White => (challenger, destUser)
case ChallengeColor.Black => (destUser, challenger) case ChallengeColor.Black => (destUser, challenger)
case ChallengeColor.Random => case ChallengeColor.Random =>
if scala.util.Random.nextBoolean() then (challenger, destUser) else (destUser, challenger) if scala.util.Random.nextBoolean() then (challenger, destUser) else (destUser, challenger)
@@ -121,7 +132,7 @@ class ChallengeService:
challenge.timeControlType match challenge.timeControlType match
case "unlimited" => None case "unlimited" => None
case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt)) case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None)) case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] = private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
raw.toLowerCase match raw.toLowerCase match
@@ -140,15 +151,15 @@ class ChallengeService:
def toDto(c: Challenge): ChallengeDto = def toDto(c: Challenge): ChallengeDto =
ChallengeDto( ChallengeDto(
id = c.id.toString, id = c.id.toString,
challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating), challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating),
destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating), destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
variant = "standard", variant = "standard",
color = c.color.toString.toLowerCase, color = c.color.toString.toLowerCase,
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt), timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
status = c.status.toString.toLowerCase, status = c.status.toString.toLowerCase,
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase), declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
gameId = c.gameIdOpt, gameId = c.gameIdOpt,
createdAt = c.createdAt.toString, createdAt = c.createdAt.toString,
expiresAt = c.expiresAt.toString, expiresAt = c.expiresAt.toString,
) )
@@ -156,8 +156,20 @@ class ChallengeResourceTest:
val t1 = registerAndLogin("listUser1") val t1 = registerAndLogin("listUser1")
registerAndLogin("listUser2") registerAndLogin("listUser2")
registerAndLogin("listUser3") registerAndLogin("listUser3")
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser2").`then`().statusCode(201) authed(t1)
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/listUser3").`then`().statusCode(201) .contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/listUser2")
.`then`()
.statusCode(201)
authed(t1)
.contentType(ContentType.JSON)
.body(clockBody)
.when()
.post("/api/challenge/listUser3")
.`then`()
.statusCode(201)
authed(t1) authed(t1)
.when() .when()
.get("/api/challenge") .get("/api/challenge")
@@ -0,0 +1,9 @@
package de.nowchess.api.rules
final case class PostMoveStatus(
isCheckmate: Boolean,
isStalemate: Boolean,
isInsufficientMaterial: Boolean,
isCheck: Boolean,
isThreefoldRepetition: Boolean,
)
@@ -39,3 +39,15 @@ trait RuleSet:
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history. * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
*/ */
def applyMove(context: GameContext)(move: Move): GameContext def applyMove(context: GameContext)(move: Move): GameContext
/** Batch status check after a move is applied. Replaces individual isCheckmate/isStalemate/isInsufficientMaterial/
* isCheck/isThreefoldRepetition calls with a single round-trip. Override for remote implementations.
*/
def postMoveStatus(context: GameContext): PostMoveStatus =
PostMoveStatus(
isCheckmate = isCheckmate(context),
isStalemate = isStalemate(context),
isInsufficientMaterial = isInsufficientMaterial(context),
isCheck = isCheck(context),
isThreefoldRepetition = isThreefoldRepetition(context),
)
@@ -4,7 +4,7 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest} import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
import de.nowchess.api.rules.RuleSet import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import org.eclipse.microprofile.rest.client.inject.RestClient import org.eclipse.microprofile.rest.client.inject.RestClient
@@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet:
def applyMove(ctx: GameContext)(move: Move): GameContext = def applyMove(ctx: GameContext)(move: Move): GameContext =
client.applyMove(RuleMoveRequest(ctx, move)) client.applyMove(RuleMoveRequest(ctx, move))
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
client.postMoveStatus(ctx)
@@ -6,6 +6,8 @@ import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CombinedExportResponse(fen: String, pgn: String)
@Path("/io") @Path("/io")
@RegisterRestClient(configKey = "io-service") @RegisterRestClient(configKey = "io-service")
trait IoServiceClient: trait IoServiceClient:
@@ -33,3 +35,9 @@ trait IoServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array("application/x-chess-pgn")) @Produces(Array("application/x-chess-pgn"))
def exportPgn(ctx: GameContext): String def exportPgn(ctx: GameContext): String
@POST
@Path("/export/combined")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def exportCombined(ctx: GameContext): CombinedExportResponse
@@ -2,6 +2,7 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.api.rules.PostMoveStatus
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@@ -72,3 +73,9 @@ trait RuleServiceClient:
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: RuleMoveRequest): GameContext def applyMove(req: RuleMoveRequest): GameContext
@POST
@Path("/post-move-status")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def postMoveStatus(ctx: GameContext): PostMoveStatus
@@ -2,7 +2,19 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square} import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{BotParticipant, ClockState, CorrespondenceClockState, DrawReason, GameContext, GameResult, Human, LiveClockState, Participant, TimeControl, WinReason} import de.nowchess.api.game.{
BotParticipant,
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
Human,
LiveClockState,
Participant,
TimeControl,
WinReason,
}
import de.nowchess.api.player.{PlayerId, PlayerInfo} import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
@@ -378,28 +390,28 @@ class GameEngine(
), ),
) )
val status = ruleSet.postMoveStatus(currentContext)
if currentContext.result.isEmpty then if currentContext.result.isEmpty then
if ruleSet.isCheckmate(currentContext) then if status.isCheckmate then
val winner = currentContext.turn.opposite val winner = currentContext.turn.opposite
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate))) currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
cancelScheduled() cancelScheduled()
notifyObservers(CheckmateEvent(currentContext, winner)) notifyObservers(CheckmateEvent(currentContext, winner))
invoker.clear() invoker.clear()
else if ruleSet.isStalemate(currentContext) then else if status.isStalemate then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
cancelScheduled() cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate)) notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
invoker.clear() invoker.clear()
else if ruleSet.isInsufficientMaterial(currentContext) then else if status.isInsufficientMaterial then
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial))) currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
cancelScheduled() cancelScheduled()
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial)) notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
invoker.clear() invoker.clear()
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext)) else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext)) if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if ruleSet.isThreefoldRepetition(currentContext) then if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
else requestBotMoveIfNeeded() else requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String = private def translateMoveToNotation(move: Move, boardBefore: Board): String =
@@ -3,7 +3,16 @@ package de.nowchess.chess.resource
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.board.{Color, Square} import de.nowchess.api.board.{Color, Square}
import de.nowchess.api.dto.* import de.nowchess.api.dto.*
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameContext, GameMode, GameResult, LiveClockState, TimeControl} import de.nowchess.api.game.{
CorrespondenceClockState,
DrawReason,
GameContext,
GameMode,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
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 java.time.Instant import java.time.Instant
@@ -56,7 +65,8 @@ class GameResource:
entry.mode match entry.mode match
case GameMode.Open => entry.engine.context.turn case GameMode.Open => entry.engine.context.turn
case GameMode.Authenticated => case GameMode.Authenticated =>
val subject = Option(jwt).flatMap(j => Option(j.getSubject)) val subject = Option(jwt)
.flatMap(j => Option(j.getSubject))
.getOrElse(throw ForbiddenException("Authentication required")) .getOrElse(throw ForbiddenException("Authentication required"))
if entry.white.id.value == subject then Color.White if entry.white.id.value == subject then Color.White
else if entry.black.id.value == subject then Color.Black else if entry.black.id.value == subject then Color.Black
@@ -65,8 +75,7 @@ class GameResource:
private def assertIsCurrentPlayer(entry: GameEntry): Unit = private def assertIsCurrentPlayer(entry: GameEntry): Unit =
if entry.mode == GameMode.Authenticated then if entry.mode == GameMode.Authenticated then
val color = colorOf(entry) val color = colorOf(entry)
if color != entry.engine.context.turn then if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
throw ForbiddenException("Not your turn")
// scalafix:on DisableSyntax.throw // scalafix:on DisableSyntax.throw
@@ -77,10 +86,9 @@ class GameResource:
else else
val ctx = entry.engine.context val ctx = entry.engine.context
ctx.result match ctx.result match
case Some(GameResult.Win(_, _)) => case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
if entry.resigned then "resign" case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate" case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
else "timeout"
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate" case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial" case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
case Some(GameResult.Draw(_)) => "draw" case Some(GameResult.Draw(_)) => "draw"
@@ -128,10 +136,11 @@ class GameResource:
} }
private def toGameStateDto(entry: GameEntry): GameStateDto = private def toGameStateDto(entry: GameEntry): GameStateDto =
val ctx = entry.engine.context val ctx = entry.engine.context
val exported = ioClient.exportCombined(ctx)
GameStateDto( GameStateDto(
fen = ioClient.exportFen(ctx), fen = exported.fen,
pgn = ioClient.exportPgn(ctx), pgn = exported.pgn,
turn = ctx.turn.label.toLowerCase, turn = ctx.turn.label.toLowerCase,
status = statusOf(entry), status = statusOf(entry),
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase }, winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
@@ -234,13 +243,8 @@ class GameResource:
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId)) val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry) assertGameNotOver(entry)
assertIsCurrentPlayer(entry) assertIsCurrentPlayer(entry)
val (from, to, promoOpt) = Parser if Parser.parseMove(uci).isEmpty then
.parseMove(uci) throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
.getOrElse(throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")))
val candidates = entry.engine.ruleSet.legalMoves(entry.engine.context)(from).filter(_.to == to)
val isPromotion = candidates.exists { case Move(_, _, MoveType.Promotion(_)) => true; case _ => false }
if candidates.isEmpty || (isPromotion && promoOpt.isEmpty) then
throw BadRequestException("INVALID_MOVE", s"$uci is not a legal move", Some("uci"))
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci"))) applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
ok(toGameStateDto(entry)) ok(toGameStateDto(entry))
@@ -1,7 +1,15 @@
package de.nowchess.chess.engine package de.nowchess.chess.engine
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.{ClockState, CorrespondenceClockState, DrawReason, GameResult, LiveClockState, TimeControl, WinReason} import de.nowchess.api.game.{
ClockState,
CorrespondenceClockState,
DrawReason,
GameResult,
LiveClockState,
TimeControl,
WinReason,
}
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite import org.scalatest.funsuite.AnyFunSuite
@@ -3,7 +3,8 @@ package de.nowchess.chess.resource
import de.nowchess.api.board.Square 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, RuleMoveRequest, RuleServiceClient, RuleSquareRequest} import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.chess.client.{CombinedExportResponse, 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
@@ -44,6 +45,10 @@ 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(ioClient.exportCombined(any())).thenAnswer((inv: InvocationOnMock) =>
val ctx = inv.getArgument[GameContext](0)
CombinedExportResponse(FenExporter.exportGameContext(ctx), ""),
)
when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) => when(ruleClient.legalMoves(any())).thenAnswer((inv: InvocationOnMock) =>
val req = inv.getArgument[RuleSquareRequest](0) val req = inv.getArgument[RuleSquareRequest](0)
DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get), DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get),
@@ -70,6 +75,9 @@ class GameResourceIntegrationTest:
when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) => when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)), DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)),
) )
when(ruleClient.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) =>
DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)),
)
@Test @Test
@DisplayName("createGame returns 201") @DisplayName("createGame returns 201")
@@ -11,16 +11,27 @@ object FenParser extends GameContextImport:
*/ */
def parseFen(fen: String): Either[GameError, GameContext] = def parseFen(fen: String): Either[GameError, GameContext] =
val parts = fen.trim.split("\\s+") val parts = fen.trim.split("\\s+")
if parts.length != 6 then Left(GameError.ParseError(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")) if parts.length != 6 then
Left(GameError.ParseError(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}"))
else else
for for
board <- parseBoard(parts(0)).toRight(GameError.ParseError("Invalid FEN: invalid board position")) board <- parseBoard(parts(0)).toRight(GameError.ParseError("Invalid FEN: invalid board position"))
activeColor <- parseColor(parts(1)).toRight(GameError.ParseError("Invalid FEN: invalid active color (expected 'w' or 'b')")) activeColor <- parseColor(parts(1)).toRight(
GameError.ParseError("Invalid FEN: invalid active color (expected 'w' or 'b')"),
)
castlingRights <- parseCastling(parts(2)).toRight(GameError.ParseError("Invalid FEN: invalid castling rights")) castlingRights <- parseCastling(parts(2)).toRight(GameError.ParseError("Invalid FEN: invalid castling rights"))
enPassant <- parseEnPassant(parts(3)).toRight(GameError.ParseError("Invalid FEN: invalid en passant square")) enPassant <- parseEnPassant(parts(3)).toRight(GameError.ParseError("Invalid FEN: invalid en passant square"))
halfMoveClock <- parts(4).toIntOption.toRight(GameError.ParseError("Invalid FEN: invalid half-move clock (expected integer)")) halfMoveClock <- parts(4).toIntOption.toRight(
fullMoveNumber <- parts(5).toIntOption.toRight(GameError.ParseError("Invalid FEN: invalid full move number (expected integer)")) GameError.ParseError("Invalid FEN: invalid half-move clock (expected integer)"),
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), GameError.ParseError("Invalid FEN: invalid move counts")) )
fullMoveNumber <- parts(5).toIntOption.toRight(
GameError.ParseError("Invalid FEN: invalid full move number (expected integer)"),
)
_ <- Either.cond(
halfMoveClock >= 0 && fullMoveNumber >= 1,
(),
GameError.ParseError("Invalid FEN: invalid move counts"),
)
yield GameContext( yield GameContext(
board = board, board = board,
turn = activeColor, turn = activeColor,
@@ -0,0 +1,3 @@
package de.nowchess.io.service.dto
final case class CombinedExportResponse(fen: String, pgn: String)
@@ -3,7 +3,7 @@ package de.nowchess.io.service.resource
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.service.dto.{ImportFenRequest, ImportPgnRequest, IoErrorDto} import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
import io.smallrye.mutiny.Uni import io.smallrye.mutiny.Uni
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -75,3 +75,18 @@ class IoResource:
@APIResponse(responseCode = "200", description = "PGN text") @APIResponse(responseCode = "200", description = "PGN text")
def exportPgn(ctx: GameContext): Uni[Response] = def exportPgn(ctx: GameContext): Uni[Response] =
Uni.createFrom().item(Response.ok(PgnExporter.exportGameContext(ctx)).build()) Uni.createFrom().item(Response.ok(PgnExporter.exportGameContext(ctx)).build())
@POST
@Path("/export/combined")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
@Operation(summary = "Export FEN and PGN", description = "Serialize a GameContext to both FEN and PGN in one call")
@APIResponse(responseCode = "200", description = "FEN and PGN")
def exportCombined(ctx: GameContext): Uni[Response] =
Uni
.createFrom()
.item(
Response
.ok(CombinedExportResponse(FenExporter.exportGameContext(ctx), PgnExporter.exportGameContext(ctx)))
.build(),
)
@@ -11,7 +11,7 @@ class GameResultDeserializer extends JsonDeserializer[GameResult]:
override def deserialize(p: JsonParser, ctx: DeserializationContext): GameResult = override def deserialize(p: JsonParser, ctx: DeserializationContext): GameResult =
val node = p.getCodec.readTree[ObjectNode](p) val node = p.getCodec.readTree[ObjectNode](p)
node.get("type").asText() match node.get("type").asText() match
case "win" => case "win" =>
GameResult.Win( GameResult.Win(
Color.valueOf(node.get("color").asText()), Color.valueOf(node.get("color").asText()),
WinReason.valueOf(node.get("winReason").asText()), WinReason.valueOf(node.get("winReason").asText()),
@@ -4,6 +4,7 @@ import de.nowchess.api.board.Square
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.rules.dto.* import de.nowchess.rules.dto.*
import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -88,3 +89,10 @@ class RuleSetResource:
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def applyMove(req: ContextMoveRequest): GameContext = def applyMove(req: ContextMoveRequest): GameContext =
rules.applyMove(req.context)(req.move) rules.applyMove(req.context)(req.move)
@POST
@Path("/post-move-status")
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def postMoveStatus(ctx: GameContext): PostMoveStatus =
rules.postMoveStatus(ctx)
@@ -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.api.rules.RuleSet import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
/** Standard chess rules — optimized hot path. /** Standard chess rules — optimized hot path.
* *
@@ -12,52 +12,54 @@ import de.nowchess.api.rules.RuleSet
* avoid heap allocation in tight loops. Check detection uses make/unmake on the mutable array instead of copying the * avoid heap allocation in tight loops. Check detection uses make/unmake on the mutable array instead of copying the
* immutable Board map. * immutable Board map.
*/ */
// scalafix:off DisableSyntax.var
// scalafix:off DisableSyntax.return
object DefaultRules extends RuleSet: object DefaultRules extends RuleSet:
// ─── Piece constants ────────────────────────────────────────────────────── // ─── Piece constants ──────────────────────────────────────────────────────
private val PAWN = 1; private val KNIGHT = 2; private val BISHOP = 3 private val PAWN = 1; private val KNIGHT = 2; private val BISHOP = 3
private val ROOK = 4; private val QUEEN = 5; private val KING = 6 private val ROOK = 4; private val QUEEN = 5; private val KING = 6
private inline def idx(f: Int, r: Int): Int = f + (r << 3) private inline def idx(f: Int, r: Int): Int = f + (r << 3)
private inline def fileOf(sq: Int): Int = sq & 7 private inline def fileOf(sq: Int): Int = sq & 7
private inline def rankOf(sq: Int): Int = sq >> 3 private inline def rankOf(sq: Int): Int = sq >> 3
private inline def isEmpty(p: Int): Boolean = p == 0 private inline def isEmpty(p: Int): Boolean = p == 0
private inline def isWhitePiece(p: Int): Boolean = p > 0 private inline def isWhitePiece(p: Int): Boolean = p > 0
private inline def pieceType(p: Int): Int = if p > 0 then p else -p private inline def pieceType(p: Int): Int = if p > 0 then p else -p
private def encodePiece(c: Color, pt: PieceType): Int = private def encodePiece(c: Color, pt: PieceType): Int =
val raw = pt match val raw = pt match
case PieceType.Pawn => PAWN; case PieceType.Knight => KNIGHT case PieceType.Pawn => PAWN; case PieceType.Knight => KNIGHT
case PieceType.Bishop => BISHOP; case PieceType.Rook => ROOK case PieceType.Bishop => BISHOP; case PieceType.Rook => ROOK
case PieceType.Queen => QUEEN; case PieceType.King => KING case PieceType.Queen => QUEEN; case PieceType.King => KING
if c == Color.White then raw else -raw if c == Color.White then raw else -raw
// ─── Pre-computed tables ────────────────────────────────────────────────── // ─── Pre-computed tables ──────────────────────────────────────────────────
private val KNIGHT_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq => private val KNIGHT_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq)) val (f, r) = (fileOf(sq), rankOf(sq))
Array((2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)).collect { Array((2, 1), (2, -1), (-2, 1), (-2, -1), (1, 2), (1, -2), (-1, 2), (-1, -2)).collect {
case (df, dr) if f+df >= 0 && f+df < 8 && r+dr >= 0 && r+dr < 8 => idx(f+df, r+dr) case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
} }
} }
private val KING_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq => private val KING_TARGETS: Array[Array[Int]] = Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq)) val (f, r) = (fileOf(sq), rankOf(sq))
Array((-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1)).collect { Array((-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)).collect {
case (df, dr) if f+df >= 0 && f+df < 8 && r+dr >= 0 && r+dr < 8 => idx(f+df, r+dr) case (df, dr) if f + df >= 0 && f + df < 8 && r + dr >= 0 && r + dr < 8 => idx(f + df, r + dr)
} }
} }
// Directions 0-3: rook (N,S,E,W); 4-7: bishop (NE,NW,SE,SW) // Directions 0-3: rook (N,S,E,W); 4-7: bishop (NE,NW,SE,SW)
private val DIR_VECS: Array[(Int, Int)] = private val DIR_VECS: Array[(Int, Int)] =
Array((0,1),(0,-1),(1,0),(-1,0),(1,1),(-1,1),(1,-1),(-1,-1)) Array((0, 1), (0, -1), (1, 0), (-1, 0), (1, 1), (-1, 1), (1, -1), (-1, -1))
// RAY_TABLES(sq)(d) = squares along direction d from sq, nearest first // RAY_TABLES(sq)(d) = squares along direction d from sq, nearest first
private val RAY_TABLES: Array[Array[Array[Int]]] = Array.tabulate(64, 8) { (sq, d) => private val RAY_TABLES: Array[Array[Array[Int]]] = Array.tabulate(64, 8) { (sq, d) =>
val (df, dr) = DIR_VECS(d) val (df, dr) = DIR_VECS(d)
val (f, r) = (fileOf(sq), rankOf(sq)) val (f, r) = (fileOf(sq), rankOf(sq))
val buf = new scala.collection.mutable.ArrayBuffer[Int](7) val buf = new scala.collection.mutable.ArrayBuffer[Int](7)
var nf = f + df; var nr = r + dr var nf = f + df; var nr = r + dr
while nf >= 0 && nf < 8 && nr >= 0 && nr < 8 do while nf >= 0 && nf < 8 && nr >= 0 && nr < 8 do
buf += idx(nf, nr); nf += df; nr += dr buf += idx(nf, nr); nf += df; nr += dr
buf.toArray buf.toArray
@@ -71,18 +73,18 @@ object DefaultRules extends RuleSet:
Array.tabulate(64) { sq => Array.tabulate(64) { sq =>
val (f, r) = (fileOf(sq), rankOf(sq)) val (f, r) = (fileOf(sq), rankOf(sq))
Array(-1, 1).collect { Array(-1, 1).collect {
case df if f+df >= 0 && f+df < 8 && r-fwd >= 0 && r-fwd < 8 => idx(f+df, r-fwd) case df if f + df >= 0 && f + df < 8 && r - fwd >= 0 && r - fwd < 8 => idx(f + df, r - fwd)
} }
} }
} }
// Pre-computed castling square indices (no runtime string parsing) // Pre-computed castling square indices (no runtime string parsing)
private val A1 = idx(0,0); private val B1 = idx(1,0); private val C1 = idx(2,0) private val A1 = idx(0, 0); private val B1 = idx(1, 0); private val C1 = idx(2, 0)
private val D1 = idx(3,0); private val E1 = idx(4,0); private val F1 = idx(5,0) private val D1 = idx(3, 0); private val E1 = idx(4, 0); private val F1 = idx(5, 0)
private val G1 = idx(6,0); private val H1 = idx(7,0) private val G1 = idx(6, 0); private val H1 = idx(7, 0)
private val A8 = idx(0,7); private val B8 = idx(1,7); private val C8 = idx(2,7) private val A8 = idx(0, 7); private val B8 = idx(1, 7); private val C8 = idx(2, 7)
private val D8 = idx(3,7); private val E8 = idx(4,7); private val F8 = idx(5,7) private val D8 = idx(3, 7); private val E8 = idx(4, 7); private val F8 = idx(5, 7)
private val G8 = idx(6,7); private val H8 = idx(7,7) private val G8 = idx(6, 7); private val H8 = idx(7, 7)
// Thread-local mutable board and move buffer — zero heap allocation in hot loops // Thread-local mutable board and move buffer — zero heap allocation in hot loops
private val tlBoard = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](64)) private val tlBoard = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](64))
@@ -91,15 +93,15 @@ object DefaultRules extends RuleSet:
// ─── Move word encoding ─────────────────────────────────────────────────── // ─── Move word encoding ───────────────────────────────────────────────────
// bits 0-5: from square, bits 6-11: to square, bits 12-15: move kind // bits 0-5: from square, bits 6-11: to square, bits 12-15: move kind
private val KIND_QUIET = 0; private val KIND_CAPTURE = 1; private val KIND_EP = 2 private val KIND_QUIET = 0; private val KIND_CAPTURE = 1; private val KIND_EP = 2
private val KIND_CASTLEK = 3; private val KIND_CASTLEQ = 4 private val KIND_CASTLEK = 3; private val KIND_CASTLEQ = 4
private val KIND_PROMO_Q = 5; private val KIND_PROMO_R = 6 private val KIND_PROMO_Q = 5; private val KIND_PROMO_R = 6
private val KIND_PROMO_B = 7; private val KIND_PROMO_N = 8 private val KIND_PROMO_B = 7; private val KIND_PROMO_N = 8
private inline def encMove(from: Int, to: Int, kind: Int): Int = from | (to << 6) | (kind << 12) private inline def encMove(from: Int, to: Int, kind: Int): Int = from | (to << 6) | (kind << 12)
private inline def moveFrom(m: Int): Int = m & 63 private inline def moveFrom(m: Int): Int = m & 63
private inline def moveTo(m: Int): Int = (m >> 6) & 63 private inline def moveTo(m: Int): Int = (m >> 6) & 63
private inline def moveKind(m: Int): Int = m >> 12 private inline def moveKind(m: Int): Int = m >> 12
// ─── Board ↔ Array[Int] ────────────────────────────────────────────────── // ─── Board ↔ Array[Int] ──────────────────────────────────────────────────
@@ -174,10 +176,10 @@ object DefaultRules extends RuleSet:
// Applies move on mutable arr, tests check, undoes — no Map copy. // Applies move on mutable arr, tests check, undoes — no Map copy.
private def leavesKingInCheck(arr: Array[Int], move: Int, whiteMoved: Boolean): Boolean = private def leavesKingInCheck(arr: Array[Int], move: Int, whiteMoved: Boolean): Boolean =
val from = moveFrom(move); val to = moveTo(move); val kind = moveKind(move) val from = moveFrom(move); val to = moveTo(move); val kind = moveKind(move)
val savedFrom = arr(from); val savedTo = arr(to) val savedFrom = arr(from); val savedTo = arr(to)
var epSq = -1 var epSq = -1
var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1 var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1
kind match kind match
case KIND_EP => case KIND_EP =>
@@ -186,20 +188,20 @@ object DefaultRules extends RuleSet:
case KIND_CASTLEK => case KIND_CASTLEK =>
rookFrom = if whiteMoved then H1 else H8 rookFrom = if whiteMoved then H1 else H8
rookTo = if whiteMoved then F1 else F8 rookTo = if whiteMoved then F1 else F8
savedRookPiece = arr(rookFrom) savedRookPiece = arr(rookFrom)
arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0 arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
case KIND_CASTLEQ => case KIND_CASTLEQ =>
rookFrom = if whiteMoved then A1 else A8 rookFrom = if whiteMoved then A1 else A8
rookTo = if whiteMoved then D1 else D8 rookTo = if whiteMoved then D1 else D8
savedRookPiece = arr(rookFrom) savedRookPiece = arr(rookFrom)
arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0 arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0
case k if k >= KIND_PROMO_Q => case k if k >= KIND_PROMO_Q =>
val promoted = k match val promoted = k match
case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN
case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK
case KIND_PROMO_B => if whiteMoved then BISHOP else -BISHOP case KIND_PROMO_B => if whiteMoved then BISHOP else -BISHOP
case _ => if whiteMoved then KNIGHT else -KNIGHT case _ => if whiteMoved then KNIGHT else -KNIGHT
arr(to) = promoted; arr(from) = 0 arr(to) = promoted; arr(from) = 0
@@ -225,22 +227,36 @@ object DefaultRules extends RuleSet:
var n = 0; var sq = 0 var n = 0; var sq = 0
while sq < 64 do while sq < 64 do
val p = arr(sq) val p = arr(sq)
if !isEmpty(p) && isWhitePiece(p) == isWhite then if !isEmpty(p) && isWhitePiece(p) == isWhite then n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n)
n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n)
sq += 1 sq += 1
n n
private def generatePiece(arr: Array[Int], sq: Int, pt: Int, isWhite: Boolean, ctx: GameContext, buf: Array[Int], n: Int): Int = private def generatePiece(
if pt == PAWN then generatePawnMoves(arr, sq, isWhite, ctx, buf, n) arr: Array[Int],
sq: Int,
pt: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
n: Int,
): Int =
if pt == PAWN then generatePawnMoves(arr, sq, isWhite, ctx, buf, n)
else if pt == KNIGHT then generateJumps(arr, sq, isWhite, KNIGHT_TARGETS(sq), buf, n) else if pt == KNIGHT then generateJumps(arr, sq, isWhite, KNIGHT_TARGETS(sq), buf, n)
else if pt == BISHOP then generateRays(arr, sq, isWhite, buf, n, rookRays = false) else if pt == BISHOP then generateRays(arr, sq, isWhite, buf, n, rookRays = false)
else if pt == ROOK then generateRays(arr, sq, isWhite, buf, n, rookRays = true) else if pt == ROOK then generateRays(arr, sq, isWhite, buf, n, rookRays = true)
else if pt == QUEEN then else if pt == QUEEN then
val n2 = generateRays(arr, sq, isWhite, buf, n, rookRays = true) val n2 = generateRays(arr, sq, isWhite, buf, n, rookRays = true)
generateRays(arr, sq, isWhite, buf, n2, rookRays = false) generateRays(arr, sq, isWhite, buf, n2, rookRays = false)
else generateKingMoves(arr, sq, isWhite, ctx, buf, n) else generateKingMoves(arr, sq, isWhite, ctx, buf, n)
private def generateJumps(arr: Array[Int], from: Int, isWhite: Boolean, targets: Array[Int], buf: Array[Int], start: Int): Int = private def generateJumps(
arr: Array[Int],
from: Int,
isWhite: Boolean,
targets: Array[Int],
buf: Array[Int],
start: Int,
): Int =
var n = start; var i = 0 var n = start; var i = 0
while i < targets.length do while i < targets.length do
val to = targets(i); val tgt = arr(to) val to = targets(i); val tgt = arr(to)
@@ -251,8 +267,15 @@ object DefaultRules extends RuleSet:
i += 1 i += 1
n n
private def generateRays(arr: Array[Int], from: Int, isWhite: Boolean, buf: Array[Int], start: Int, rookRays: Boolean): Int = private def generateRays(
var n = start arr: Array[Int],
from: Int,
isWhite: Boolean,
buf: Array[Int],
start: Int,
rookRays: Boolean,
): Int =
var n = start
val rays = RAY_TABLES(from) val rays = RAY_TABLES(from)
val d0 = if rookRays then 0 else 4 val d0 = if rookRays then 0 else 4
val d1 = if rookRays then 4 else 8 val d1 = if rookRays then 4 else 8
@@ -271,42 +294,67 @@ object DefaultRules extends RuleSet:
d += 1 d += 1
n n
private def generateKingMoves(arr: Array[Int], from: Int, isWhite: Boolean, ctx: GameContext, buf: Array[Int], start: Int): Int = private def generateKingMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
val n = generateJumps(arr, from, isWhite, KING_TARGETS(from), buf, start) val n = generateJumps(arr, from, isWhite, KING_TARGETS(from), buf, start)
generateCastlingMoves(arr, from, isWhite, ctx, buf, n) generateCastlingMoves(arr, from, isWhite, ctx, buf, n)
private def generateCastlingMoves(arr: Array[Int], from: Int, isWhite: Boolean, ctx: GameContext, buf: Array[Int], start: Int): Int = private def generateCastlingMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
var n = start var n = start
val cr = ctx.castlingRights val cr = ctx.castlingRights
if isWhite && from == E1 then if isWhite && from == E1 then
if cr.whiteKingSide && isEmpty(arr(F1)) && isEmpty(arr(G1)) && if cr.whiteKingSide && isEmpty(arr(F1)) && isEmpty(arr(G1)) &&
arr(E1) == KING && arr(H1) == ROOK && arr(E1) == KING && arr(H1) == ROOK &&
!isAttackedByColor(arr, E1, false) && !isAttackedByColor(arr, E1, false) &&
!isAttackedByColor(arr, F1, false) && !isAttackedByColor(arr, F1, false) &&
!isAttackedByColor(arr, G1, false) then !isAttackedByColor(arr, G1, false)
then
buf(n) = encMove(E1, G1, KIND_CASTLEK); n += 1 buf(n) = encMove(E1, G1, KIND_CASTLEK); n += 1
if cr.whiteQueenSide && isEmpty(arr(D1)) && isEmpty(arr(C1)) && isEmpty(arr(B1)) && if cr.whiteQueenSide && isEmpty(arr(D1)) && isEmpty(arr(C1)) && isEmpty(arr(B1)) &&
arr(E1) == KING && arr(A1) == ROOK && arr(E1) == KING && arr(A1) == ROOK &&
!isAttackedByColor(arr, E1, false) && !isAttackedByColor(arr, E1, false) &&
!isAttackedByColor(arr, D1, false) && !isAttackedByColor(arr, D1, false) &&
!isAttackedByColor(arr, C1, false) then !isAttackedByColor(arr, C1, false)
then
buf(n) = encMove(E1, C1, KIND_CASTLEQ); n += 1 buf(n) = encMove(E1, C1, KIND_CASTLEQ); n += 1
else if !isWhite && from == E8 then else if !isWhite && from == E8 then
if cr.blackKingSide && isEmpty(arr(F8)) && isEmpty(arr(G8)) && if cr.blackKingSide && isEmpty(arr(F8)) && isEmpty(arr(G8)) &&
arr(E8) == -KING && arr(H8) == -ROOK && arr(E8) == -KING && arr(H8) == -ROOK &&
!isAttackedByColor(arr, E8, true) && !isAttackedByColor(arr, E8, true) &&
!isAttackedByColor(arr, F8, true) && !isAttackedByColor(arr, F8, true) &&
!isAttackedByColor(arr, G8, true) then !isAttackedByColor(arr, G8, true)
then
buf(n) = encMove(E8, G8, KIND_CASTLEK); n += 1 buf(n) = encMove(E8, G8, KIND_CASTLEK); n += 1
if cr.blackQueenSide && isEmpty(arr(D8)) && isEmpty(arr(C8)) && isEmpty(arr(B8)) && if cr.blackQueenSide && isEmpty(arr(D8)) && isEmpty(arr(C8)) && isEmpty(arr(B8)) &&
arr(E8) == -KING && arr(A8) == -ROOK && arr(E8) == -KING && arr(A8) == -ROOK &&
!isAttackedByColor(arr, E8, true) && !isAttackedByColor(arr, E8, true) &&
!isAttackedByColor(arr, D8, true) && !isAttackedByColor(arr, D8, true) &&
!isAttackedByColor(arr, C8, true) then !isAttackedByColor(arr, C8, true)
then
buf(n) = encMove(E8, C8, KIND_CASTLEQ); n += 1 buf(n) = encMove(E8, C8, KIND_CASTLEQ); n += 1
n n
private def generatePawnMoves(arr: Array[Int], from: Int, isWhite: Boolean, ctx: GameContext, buf: Array[Int], start: Int): Int = private def generatePawnMoves(
arr: Array[Int],
from: Int,
isWhite: Boolean,
ctx: GameContext,
buf: Array[Int],
start: Int,
): Int =
var n = start var n = start
val f = fileOf(from); val r = rankOf(from) val f = fileOf(from); val r = rankOf(from)
val fwd = if isWhite then 1 else -1 val fwd = if isWhite then 1 else -1
@@ -376,27 +424,27 @@ object DefaultRules extends RuleSet:
val sqI = idx(square.file.ordinal, square.rank.ordinal) val sqI = idx(square.file.ordinal, square.rank.ordinal)
val piece = arr(sqI) val piece = arr(sqI)
if isEmpty(piece) || isWhitePiece(piece) != (context.turn == Color.White) then return Nil if isEmpty(piece) || isWhitePiece(piece) != (context.turn == Color.White) then return Nil
val buf = new Array[Int](64) val buf = new Array[Int](64)
val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0) val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0)
(0 until n).map(i => decodeMoveToApi(buf(i))).toList (0 until n).map(i => decodeMoveToApi(buf(i))).toList
override def legalMoves(context: GameContext)(square: Square): List[Move] = override def legalMoves(context: GameContext)(square: Square): List[Move] =
val arr = tlBoard.get(); fillBoard(context.board, arr) val arr = tlBoard.get(); fillBoard(context.board, arr)
val sqI = idx(square.file.ordinal, square.rank.ordinal) val sqI = idx(square.file.ordinal, square.rank.ordinal)
val piece = arr(sqI) val piece = arr(sqI)
val isWhite = context.turn == Color.White val isWhite = context.turn == Color.White
if isEmpty(piece) || isWhitePiece(piece) != isWhite then return Nil if isEmpty(piece) || isWhitePiece(piece) != isWhite then return Nil
val buf = tlMoves.get() val buf = tlMoves.get()
val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0) val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0)
val result = new scala.collection.mutable.ListBuffer[Move]() val result = new scala.collection.mutable.ListBuffer[Move]()
var i = 0 var i = 0
while i < n do while i < n do
if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i)) if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i))
i += 1 i += 1
result.toList result.toList
override def allLegalMoves(context: GameContext): List[Move] = override def allLegalMoves(context: GameContext): List[Move] =
val arr = tlBoard.get(); fillBoard(context.board, arr) val arr = tlBoard.get(); fillBoard(context.board, arr)
val isWhite = context.turn == Color.White val isWhite = context.turn == Color.White
val buf = tlMoves.get() val buf = tlMoves.get()
val n = generateAll(arr, isWhite, context, buf) val n = generateAll(arr, isWhite, context, buf)
@@ -408,7 +456,7 @@ object DefaultRules extends RuleSet:
result.toList result.toList
override def isCheck(context: GameContext): Boolean = override def isCheck(context: GameContext): Boolean =
val arr = tlBoard.get(); fillBoard(context.board, arr) val arr = tlBoard.get(); fillBoard(context.board, arr)
val isWhite = context.turn == Color.White val isWhite = context.turn == Color.White
val kingSq = findKing(arr, isWhite) val kingSq = findKing(arr, isWhite)
kingSq >= 0 && isAttackedByColor(arr, kingSq, !isWhite) kingSq >= 0 && isAttackedByColor(arr, kingSq, !isWhite)
@@ -433,7 +481,7 @@ object DefaultRules extends RuleSet:
override def applyMove(context: GameContext)(move: Move): GameContext = override def applyMove(context: GameContext)(move: Move): GameContext =
val color = context.turn val color = context.turn
val board = context.board val board = context.board
val newBoard = move.moveType match val newBoard = move.moveType match
case MoveType.CastleKingside => applyCastle(board, color, kingside = true) case MoveType.CastleKingside => applyCastle(board, color, kingside = true)
@@ -487,8 +535,8 @@ object DefaultRules extends RuleSet:
val isKingMove = piece.exists(_.pieceType == PieceType.King) val isKingMove = piece.exists(_.pieceType == PieceType.King)
val isRookMove = piece.exists(_.pieceType == PieceType.Rook) val isRookMove = piece.exists(_.pieceType == PieceType.Rook)
val whiteKingsideRook = Square(File.H, Rank.R1); val whiteQueensideRook = Square(File.A, Rank.R1) val whiteKingsideRook = Square(File.H, Rank.R1); val whiteQueensideRook = Square(File.A, Rank.R1)
val blackKingsideRook = Square(File.H, Rank.R8); val blackQueensideRook = Square(File.A, Rank.R8) val blackKingsideRook = Square(File.H, Rank.R8); val blackQueensideRook = Square(File.A, Rank.R8)
val afterKingMove = if isKingMove then rights.revokeColor(color) else rights val afterKingMove = if isKingMove then rights.revokeColor(color) else rights
val afterRookMove = val afterRookMove =
@@ -523,7 +571,7 @@ object DefaultRules extends RuleSet:
private def insufficientMaterial(board: Board): Boolean = private def insufficientMaterial(board: Board): Boolean =
val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King } val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King }
nonKings match nonKings match
case Nil => true case Nil => true
case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true case List((_, p)) if p.pieceType == PieceType.Bishop || p.pieceType == PieceType.Knight => true
case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } => case bishops if bishops.forall { case (_, p) => p.pieceType == PieceType.Bishop } =>
bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1 bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1
@@ -531,18 +579,23 @@ object DefaultRules extends RuleSet:
// ─── Threefold repetition ───────────────────────────────────────────────── // ─── Threefold repetition ─────────────────────────────────────────────────
private case class Position(board: Board, turn: Color, castlingRights: CastlingRights, enPassantSquare: Option[Square]) private case class Position(
board: Board,
turn: Color,
castlingRights: CastlingRights,
enPassantSquare: Option[Square],
)
private def countPositionOccurrences(context: GameContext, target: Position): Int = private def countPositionOccurrences(context: GameContext, target: Position): Int =
try try
val initialCtx = GameContext( val initialCtx = GameContext(
board = context.initialBoard, board = context.initialBoard,
turn = Color.White, turn = Color.White,
castlingRights = CastlingRights.Initial, castlingRights = CastlingRights.Initial,
enPassantSquare = None, enPassantSquare = None,
halfMoveClock = 0, halfMoveClock = 0,
moves = List.empty, moves = List.empty,
initialBoard = context.initialBoard, initialBoard = context.initialBoard,
) )
def positionOf(ctx: GameContext): Position = def positionOf(ctx: GameContext): Position =
@@ -557,5 +610,13 @@ object DefaultRules extends RuleSet:
(nextCtx, nextCount) (nextCtx, nextCount)
} }
._2 ._2
catch catch case _: Exception => 1
case _: Exception => 1
override def postMoveStatus(context: GameContext): PostMoveStatus =
PostMoveStatus(
isCheckmate = isCheckmate(context),
isStalemate = isStalemate(context),
isInsufficientMaterial = isInsufficientMaterial(context),
isCheck = isCheck(context),
isThreefoldRepetition = isThreefoldRepetition(context),
)