diff --git a/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala index 8a62a1c..4f06560 100644 --- a/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala +++ b/modules/account/src/main/scala/de/nowchess/account/client/CoreGameClient.scala @@ -7,10 +7,10 @@ import org.eclipse.microprofile.rest.client.inject.RegisterRestClient case class CorePlayerInfo(id: String, displayName: String) case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int]) case class CoreCreateGameRequest( - white: Option[CorePlayerInfo], - black: Option[CorePlayerInfo], - timeControl: Option[CoreTimeControl], - mode: Option[String], + white: Option[CorePlayerInfo], + black: Option[CorePlayerInfo], + timeControl: Option[CoreTimeControl], + mode: Option[String], ) case class CoreGameResponse(gameId: String) diff --git a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala index c21023e..0dcf188 100644 --- a/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala +++ b/modules/account/src/main/scala/de/nowchess/account/config/NativeReflectionConfig.scala @@ -13,7 +13,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection classOf[ChallengeStatus], classOf[DeclineReason], classOf[TimeControl], - classOf[LoginRequest], classOf[TokenResponse], classOf[PlayerInfo], @@ -27,7 +26,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection classOf[ChallengeStatus], classOf[ChallengeColor], classOf[DeclineReason], - classOf[CorePlayerInfo], classOf[CoreTimeControl], classOf[CoreCreateGameRequest], diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala b/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala index af3c431..27c7b3a 100644 --- a/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala +++ b/modules/account/src/main/scala/de/nowchess/account/domain/Account.scala @@ -26,4 +26,4 @@ class Account extends PanacheEntityBase: var rating: Int = 1500 var createdAt: Instant = uninitialized - // scalafix:on \ No newline at end of file + // scalafix:on diff --git a/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala index eceaef8..5bc2fd0 100644 --- a/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala +++ b/modules/account/src/main/scala/de/nowchess/account/domain/TimeControl.scala @@ -4,4 +4,4 @@ sealed trait TimeControl object TimeControl: case class Clock(limit: Int, increment: Int) extends TimeControl - case object Unlimited extends TimeControl + case object Unlimited extends TimeControl diff --git a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala index aec4597..d6c8bfc 100644 --- a/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala +++ b/modules/account/src/main/scala/de/nowchess/account/dto/Dtos.scala @@ -15,17 +15,17 @@ case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[ case class ChallengeRequest(color: String, timeControl: TimeControlDto) case class ChallengeDto( - id: String, - challenger: PlayerInfo, - destUser: PlayerInfo, - variant: String, - color: String, - timeControl: TimeControlDto, - status: String, - declineReason: Option[String], - gameId: Option[String], - createdAt: String, - expiresAt: String, + id: String, + challenger: PlayerInfo, + destUser: PlayerInfo, + variant: String, + color: String, + timeControl: TimeControlDto, + status: String, + declineReason: Option[String], + gameId: Option[String], + createdAt: String, + expiresAt: String, ) case class DeclineRequest(reason: Option[String]) diff --git a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala index c9261e9..0a4c3fd 100644 --- a/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala +++ b/modules/account/src/main/scala/de/nowchess/account/repository/AccountRepository.scala @@ -40,7 +40,4 @@ class AccountRepository: .headOption def findAll(): List[Account] = - em.createQuery("FROM Account", classOf[Account]) - .getResultList - .asScala - .toList + em.createQuery("FROM Account", classOf[Account]).getResultList.asScala.toList diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala index 5eaba85..92c1994 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/AccountResource.scala @@ -63,8 +63,8 @@ class AccountResource: private def toPublicDto(account: Account): PublicAccountDto = PublicAccountDto( - id = account.id.toString, - username = account.username, - rating = account.rating, + id = account.id.toString, + username = account.username, + rating = account.rating, createdAt = account.createdAt.toString, ) diff --git a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala index 7a096d9..9a28cb5 100644 --- a/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala +++ b/modules/account/src/main/scala/de/nowchess/account/resource/ChallengeResource.scala @@ -38,8 +38,8 @@ class ChallengeResource: case Left(error) => val status = error match case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND - case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST - case _ => Response.Status.CONFLICT + case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST + case _ => Response.Status.CONFLICT Response.status(status).entity(ErrorDto(error.message)).build() @GET diff --git a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala index fca0cec..2955989 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/AccountService.scala @@ -24,16 +24,14 @@ class AccountService: @Transactional def register(req: RegisterRequest): Either[AccountError, Account] = - if accountRepository.findByUsername(req.username).isDefined then - Left(AccountError.UsernameTaken(req.username)) - else if accountRepository.findByEmail(req.email).isDefined then - Left(AccountError.EmailAlreadyRegistered(req.email)) + if accountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username)) + else if accountRepository.findByEmail(req.email).isDefined then Left(AccountError.EmailAlreadyRegistered(req.email)) else val account = new Account() - account.username = req.username - account.email = req.email + account.username = req.username + account.email = req.email account.passwordHash = BcryptUtil.bcryptHash(req.password) - account.createdAt = Instant.now() + account.createdAt = Instant.now() accountRepository.persist(account) Right(account) diff --git a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala index e35cf49..1dbb3bd 100644 --- a/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala +++ b/modules/account/src/main/scala/de/nowchess/account/service/ChallengeService.scala @@ -1,8 +1,21 @@ 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.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.repository.{AccountRepository, ChallengeRepository} import jakarta.enterprise.context.ApplicationScoped @@ -36,23 +49,21 @@ class ChallengeService: destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername)) challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound) _ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf) - _ <- Either.cond( - challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty, - (), - ChallengeError.DuplicateChallenge, - ) - color <- parseColor(req.color) + _ <- Either.cond( + challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty, + (), + ChallengeError.DuplicateChallenge, + ) + color <- parseColor(req.color) yield - val challenge = new Challenge() - challenge.challenger = challenger - challenge.destUser = destUser - challenge.color = color - challenge.status = ChallengeStatus.Created - challenge.timeControlType = req.timeControl.`type` - challenge.timeControlLimit = - req.timeControl.limit.map(java.lang.Integer.valueOf).orNull - challenge.timeControlIncrement = - req.timeControl.increment.map(java.lang.Integer.valueOf).orNull + val challenge = new Challenge() + challenge.challenger = challenger + challenge.destUser = destUser + challenge.color = color + challenge.status = ChallengeStatus.Created + challenge.timeControlType = req.timeControl.`type` + challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull + challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull challenge.createdAt = Instant.now() challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS) challengeRepository.persist(challenge) @@ -79,7 +90,7 @@ class ChallengeService: _ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized) reason <- parseDeclineReason(req.reason) yield - challenge.status = ChallengeStatus.Declined + challenge.status = ChallengeStatus.Declined challenge.declineReason = reason.orNull challengeRepository.merge(challenge) challenge @@ -112,8 +123,8 @@ class ChallengeService: val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username) val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username) challenge.color match - case ChallengeColor.White => (challenger, destUser) - case ChallengeColor.Black => (destUser, challenger) + case ChallengeColor.White => (challenger, destUser) + case ChallengeColor.Black => (destUser, challenger) case ChallengeColor.Random => if scala.util.Random.nextBoolean() then (challenger, destUser) else (destUser, challenger) @@ -121,7 +132,7 @@ class ChallengeService: challenge.timeControlType match case "unlimited" => None 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] = raw.toLowerCase match @@ -140,15 +151,15 @@ class ChallengeService: def toDto(c: Challenge): ChallengeDto = ChallengeDto( - id = c.id.toString, - challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating), - destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating), - variant = "standard", - color = c.color.toString.toLowerCase, + id = c.id.toString, + challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating), + destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating), + variant = "standard", + color = c.color.toString.toLowerCase, timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt), - status = c.status.toString.toLowerCase, + status = c.status.toString.toLowerCase, declineReason = c.declineReasonOpt.map(_.toString.toLowerCase), - gameId = c.gameIdOpt, - createdAt = c.createdAt.toString, - expiresAt = c.expiresAt.toString, + gameId = c.gameIdOpt, + createdAt = c.createdAt.toString, + expiresAt = c.expiresAt.toString, ) diff --git a/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala index 59e33c3..b793d6b 100644 --- a/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala +++ b/modules/account/src/test/scala/de/nowchess/account/resource/ChallengeResourceTest.scala @@ -156,8 +156,20 @@ class ChallengeResourceTest: val t1 = registerAndLogin("listUser1") registerAndLogin("listUser2") registerAndLogin("listUser3") - authed(t1).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) + .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) .when() .get("/api/challenge") diff --git a/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala b/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala new file mode 100644 index 0000000..c1ba30c --- /dev/null +++ b/modules/api/src/main/scala/de/nowchess/api/rules/PostMoveStatus.scala @@ -0,0 +1,9 @@ +package de.nowchess.api.rules + +final case class PostMoveStatus( + isCheckmate: Boolean, + isStalemate: Boolean, + isInsufficientMaterial: Boolean, + isCheck: Boolean, + isThreefoldRepetition: Boolean, +) diff --git a/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala index 535b655..52bd3cf 100644 --- a/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala +++ b/modules/api/src/main/scala/de/nowchess/api/rules/RuleSet.scala @@ -39,3 +39,15 @@ trait RuleSet: * promotion. Updates castling rights, en passant square, half-move clock, turn, and move history. */ 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), + ) diff --git a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala index a281095..fa81949 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/adapter/RuleSetRestAdapter.scala @@ -4,7 +4,7 @@ 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 de.nowchess.api.rules.{PostMoveStatus, RuleSet} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import org.eclipse.microprofile.rest.client.inject.RestClient @@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet: def applyMove(ctx: GameContext)(move: Move): GameContext = client.applyMove(RuleMoveRequest(ctx, move)) + + override def postMoveStatus(ctx: GameContext): PostMoveStatus = + client.postMoveStatus(ctx) diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala index bc41b1b..bf32d22 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/client/IoServiceClient.scala @@ -6,6 +6,8 @@ import jakarta.ws.rs.* import jakarta.ws.rs.core.MediaType import org.eclipse.microprofile.rest.client.inject.RegisterRestClient +case class CombinedExportResponse(fen: String, pgn: String) + @Path("/io") @RegisterRestClient(configKey = "io-service") trait IoServiceClient: @@ -33,3 +35,9 @@ trait IoServiceClient: @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array("application/x-chess-pgn")) def exportPgn(ctx: GameContext): String + + @POST + @Path("/export/combined") + @Consumes(Array(MediaType.APPLICATION_JSON)) + @Produces(Array(MediaType.APPLICATION_JSON)) + def exportCombined(ctx: GameContext): CombinedExportResponse diff --git a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala index b213ef4..1f39fdb 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/client/RuleServiceClient.scala @@ -2,6 +2,7 @@ package de.nowchess.chess.client import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move +import de.nowchess.api.rules.PostMoveStatus import jakarta.ws.rs.* import jakarta.ws.rs.core.MediaType import org.eclipse.microprofile.rest.client.inject.RegisterRestClient @@ -72,3 +73,9 @@ trait RuleServiceClient: @Consumes(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON)) 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 diff --git a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala index 9a11758..10be40f 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/engine/GameEngine.scala @@ -2,7 +2,19 @@ package de.nowchess.chess.engine import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square} 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.chess.controller.Parser import de.nowchess.chess.observer.* @@ -378,28 +390,28 @@ class GameEngine( ), ) + val status = ruleSet.postMoveStatus(currentContext) if currentContext.result.isEmpty then - if ruleSet.isCheckmate(currentContext) then + if status.isCheckmate then val winner = currentContext.turn.opposite currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate))) cancelScheduled() notifyObservers(CheckmateEvent(currentContext, winner)) invoker.clear() - else if ruleSet.isStalemate(currentContext) then + else if status.isStalemate then currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate))) cancelScheduled() notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate)) invoker.clear() - else if ruleSet.isInsufficientMaterial(currentContext) then + else if status.isInsufficientMaterial then currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial))) cancelScheduled() notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial)) 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 ruleSet.isThreefoldRepetition(currentContext) then - notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext)) + if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext)) else requestBotMoveIfNeeded() private def translateMoveToNotation(move: Move, boardBefore: Board): String = diff --git a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala index b5f77d7..a65e4b6 100644 --- a/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala +++ b/modules/core/src/main/scala/de/nowchess/chess/resource/GameResource.scala @@ -3,7 +3,16 @@ package de.nowchess.chess.resource import com.fasterxml.jackson.databind.ObjectMapper import de.nowchess.api.board.{Color, Square} 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.player.{PlayerId, PlayerInfo} import java.time.Instant @@ -56,7 +65,8 @@ class GameResource: entry.mode match case GameMode.Open => entry.engine.context.turn 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")) if entry.white.id.value == subject then Color.White else if entry.black.id.value == subject then Color.Black @@ -65,8 +75,7 @@ class GameResource: private def assertIsCurrentPlayer(entry: GameEntry): Unit = if entry.mode == GameMode.Authenticated then val color = colorOf(entry) - if color != entry.engine.context.turn then - throw ForbiddenException("Not your turn") + if color != entry.engine.context.turn then throw ForbiddenException("Not your turn") // scalafix:on DisableSyntax.throw @@ -77,10 +86,9 @@ class GameResource: else val ctx = entry.engine.context ctx.result match - case Some(GameResult.Win(_, _)) => - if entry.resigned then "resign" - else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate" - else "timeout" + case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate" + case Some(GameResult.Win(_, WinReason.Resignation)) => "resign" + case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout" case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate" case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial" case Some(GameResult.Draw(_)) => "draw" @@ -128,10 +136,11 @@ class GameResource: } private def toGameStateDto(entry: GameEntry): GameStateDto = - val ctx = entry.engine.context + val ctx = entry.engine.context + val exported = ioClient.exportCombined(ctx) GameStateDto( - fen = ioClient.exportFen(ctx), - pgn = ioClient.exportPgn(ctx), + fen = exported.fen, + pgn = exported.pgn, turn = ctx.turn.label.toLowerCase, status = statusOf(entry), 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)) assertGameNotOver(entry) assertIsCurrentPlayer(entry) - val (from, to, promoOpt) = Parser - .parseMove(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")) + if Parser.parseMove(uci).isEmpty then + throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci")) applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci"))) ok(toGameStateDto(entry)) diff --git a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala index e4f4362..6c4bb0e 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/engine/GameEngineClockTest.scala @@ -1,7 +1,15 @@ package de.nowchess.chess.engine 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.rules.sets.DefaultRules import org.scalatest.funsuite.AnyFunSuite diff --git a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala index cca51a7..340a547 100644 --- a/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala +++ b/modules/core/src/test/scala/de/nowchess/chess/resource/GameResourceIntegrationTest.scala @@ -3,7 +3,8 @@ 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, 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.io.fen.FenExporter import de.nowchess.io.pgn.PgnParser @@ -44,6 +45,10 @@ class GameResourceIntegrationTest: ) when(ioClient.exportFen(any())).thenReturn(FenExporter.exportGameContext(GameContext.initial)) 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) => val req = inv.getArgument[RuleSquareRequest](0) DefaultRules.legalMoves(req.context)(Square.fromAlgebraic(req.square).get), @@ -70,6 +75,9 @@ class GameResourceIntegrationTest: when(ruleClient.isThreefoldRepetition(any())).thenAnswer((inv: InvocationOnMock) => DefaultRules.isThreefoldRepetition(inv.getArgument[GameContext](0)), ) + when(ruleClient.postMoveStatus(any())).thenAnswer((inv: InvocationOnMock) => + DefaultRules.postMoveStatus(inv.getArgument[GameContext](0)), + ) @Test @DisplayName("createGame returns 201") diff --git a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala index f956745..9078ec5 100644 --- a/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala +++ b/modules/io/src/main/scala/de/nowchess/io/fen/FenParser.scala @@ -11,16 +11,27 @@ object FenParser extends GameContextImport: */ def parseFen(fen: String): Either[GameError, GameContext] = 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 for - 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')")) + 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')"), + ) 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")) - halfMoveClock <- parts(4).toIntOption.toRight(GameError.ParseError("Invalid FEN: invalid half-move clock (expected integer)")) - 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")) + 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)"), + ) + 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( board = board, turn = activeColor, diff --git a/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala b/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala new file mode 100644 index 0000000..3a7bf1f --- /dev/null +++ b/modules/io/src/main/scala/de/nowchess/io/service/dto/CombinedExportResponse.scala @@ -0,0 +1,3 @@ +package de.nowchess.io.service.dto + +final case class CombinedExportResponse(fen: String, pgn: String) diff --git a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala index 24f12ff..bc653bd 100644 --- a/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala +++ b/modules/io/src/main/scala/de/nowchess/io/service/resource/IoResource.scala @@ -3,7 +3,7 @@ package de.nowchess.io.service.resource import de.nowchess.api.game.GameContext import de.nowchess.io.fen.{FenExporter, FenParser} 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 jakarta.enterprise.context.ApplicationScoped import jakarta.ws.rs.* @@ -75,3 +75,18 @@ class IoResource: @APIResponse(responseCode = "200", description = "PGN text") def exportPgn(ctx: GameContext): Uni[Response] = 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(), + ) diff --git a/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala b/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala index de0aca0..1c36f25 100644 --- a/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala +++ b/modules/json/src/main/scala/de/nowchess/json/GameResultDeserializer.scala @@ -11,7 +11,7 @@ class GameResultDeserializer extends JsonDeserializer[GameResult]: override def deserialize(p: JsonParser, ctx: DeserializationContext): GameResult = val node = p.getCodec.readTree[ObjectNode](p) node.get("type").asText() match - case "win" => + case "win" => GameResult.Win( Color.valueOf(node.get("color").asText()), WinReason.valueOf(node.get("winReason").asText()), diff --git a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala index d128519..4027603 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/resource/RuleSetResource.scala @@ -4,6 +4,7 @@ import de.nowchess.api.board.Square import de.nowchess.api.game.GameContext import de.nowchess.api.move.Move import de.nowchess.rules.dto.* +import de.nowchess.api.rules.PostMoveStatus import de.nowchess.rules.sets.DefaultRules import jakarta.enterprise.context.ApplicationScoped import jakarta.ws.rs.* @@ -88,3 +89,10 @@ class RuleSetResource: @Produces(Array(MediaType.APPLICATION_JSON)) def applyMove(req: ContextMoveRequest): GameContext = 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) diff --git a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala index 99cf5ff..8965e57 100644 --- a/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala +++ b/modules/rule/src/main/scala/de/nowchess/rules/sets/DefaultRules.scala @@ -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.api.rules.RuleSet +import de.nowchess.api.rules.{PostMoveStatus, RuleSet} /** 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 * immutable Board map. */ +// scalafix:off DisableSyntax.var +// scalafix:off DisableSyntax.return object DefaultRules extends RuleSet: // ─── Piece constants ────────────────────────────────────────────────────── 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 inline def idx(f: Int, r: Int): Int = f + (r << 3) - private inline def fileOf(sq: Int): Int = sq & 7 - private inline def rankOf(sq: Int): Int = sq >> 3 - private inline def isEmpty(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 idx(f: Int, r: Int): Int = f + (r << 3) + private inline def fileOf(sq: Int): Int = sq & 7 + private inline def rankOf(sq: Int): Int = sq >> 3 + private inline def isEmpty(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 def encodePiece(c: Color, pt: PieceType): Int = 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.Queen => QUEEN; case PieceType.King => KING + case PieceType.Queen => QUEEN; case PieceType.King => KING if c == Color.White then raw else -raw // ─── Pre-computed tables ────────────────────────────────────────────────── private val KNIGHT_TARGETS: Array[Array[Int]] = Array.tabulate(64) { 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 { - case (df, dr) if f+df >= 0 && f+df < 8 && r+dr >= 0 && r+dr < 8 => idx(f+df, r+dr) + 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) } } private val KING_TARGETS: Array[Array[Int]] = Array.tabulate(64) { 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 { - case (df, dr) if f+df >= 0 && f+df < 8 && r+dr >= 0 && r+dr < 8 => idx(f+df, r+dr) + 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) } } // Directions 0-3: rook (N,S,E,W); 4-7: bishop (NE,NW,SE,SW) 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 private val RAY_TABLES: Array[Array[Array[Int]]] = Array.tabulate(64, 8) { (sq, d) => val (df, dr) = DIR_VECS(d) val (f, r) = (fileOf(sq), rankOf(sq)) - val buf = new scala.collection.mutable.ArrayBuffer[Int](7) - var nf = f + df; var nr = r + dr + val buf = new scala.collection.mutable.ArrayBuffer[Int](7) + var nf = f + df; var nr = r + dr while nf >= 0 && nf < 8 && nr >= 0 && nr < 8 do buf += idx(nf, nr); nf += df; nr += dr buf.toArray @@ -71,18 +73,18 @@ object DefaultRules extends RuleSet: Array.tabulate(64) { sq => val (f, r) = (fileOf(sq), rankOf(sq)) 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) - 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 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 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 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 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 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) // Thread-local mutable board and move buffer — zero heap allocation in hot loops private val tlBoard = ThreadLocal.withInitial[Array[Int]](() => new Array[Int](64)) @@ -91,15 +93,15 @@ object DefaultRules extends RuleSet: // ─── Move word encoding ─────────────────────────────────────────────────── // 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_PROMO_Q = 5; private val KIND_PROMO_R = 6 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 moveFrom(m: Int): Int = m & 63 - private inline def moveTo(m: Int): Int = (m >> 6) & 63 - private inline def moveKind(m: Int): Int = m >> 12 + private inline def moveFrom(m: Int): Int = m & 63 + private inline def moveTo(m: Int): Int = (m >> 6) & 63 + private inline def moveKind(m: Int): Int = m >> 12 // ─── Board ↔ Array[Int] ────────────────────────────────────────────────── @@ -174,10 +176,10 @@ object DefaultRules extends RuleSet: // Applies move on mutable arr, tests check, undoes — no Map copy. 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) var epSq = -1 - var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1 + var rookFrom = -1; var savedRookPiece = 0; var rookTo = -1 kind match case KIND_EP => @@ -186,20 +188,20 @@ object DefaultRules extends RuleSet: case KIND_CASTLEK => rookFrom = if whiteMoved then H1 else H8 - rookTo = if whiteMoved then F1 else F8 + rookTo = if whiteMoved then F1 else F8 savedRookPiece = arr(rookFrom) arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0 case KIND_CASTLEQ => rookFrom = if whiteMoved then A1 else A8 - rookTo = if whiteMoved then D1 else D8 + rookTo = if whiteMoved then D1 else D8 savedRookPiece = arr(rookFrom) arr(to) = savedFrom; arr(from) = 0; arr(rookTo) = savedRookPiece; arr(rookFrom) = 0 case k if k >= KIND_PROMO_Q => val promoted = k match - case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN - case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK + case KIND_PROMO_Q => if whiteMoved then QUEEN else -QUEEN + case KIND_PROMO_R => if whiteMoved then ROOK else -ROOK case KIND_PROMO_B => if whiteMoved then BISHOP else -BISHOP case _ => if whiteMoved then KNIGHT else -KNIGHT arr(to) = promoted; arr(from) = 0 @@ -225,22 +227,36 @@ object DefaultRules extends RuleSet: var n = 0; var sq = 0 while sq < 64 do val p = arr(sq) - if !isEmpty(p) && isWhitePiece(p) == isWhite then - n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n) + if !isEmpty(p) && isWhitePiece(p) == isWhite then n = generatePiece(arr, sq, pieceType(p), isWhite, ctx, buf, n) sq += 1 n - private def generatePiece(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) + private def generatePiece( + 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 == 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 == QUEEN then + else if pt == ROOK then generateRays(arr, sq, isWhite, buf, n, rookRays = true) + else if pt == QUEEN then val n2 = generateRays(arr, sq, isWhite, buf, n, rookRays = true) generateRays(arr, sq, isWhite, buf, n2, rookRays = false) 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 while i < targets.length do val to = targets(i); val tgt = arr(to) @@ -251,8 +267,15 @@ object DefaultRules extends RuleSet: i += 1 n - private def generateRays(arr: Array[Int], from: Int, isWhite: Boolean, buf: Array[Int], start: Int, rookRays: Boolean): Int = - var n = start + private def generateRays( + arr: Array[Int], + from: Int, + isWhite: Boolean, + buf: Array[Int], + start: Int, + rookRays: Boolean, + ): Int = + var n = start val rays = RAY_TABLES(from) val d0 = if rookRays then 0 else 4 val d1 = if rookRays then 4 else 8 @@ -271,42 +294,67 @@ object DefaultRules extends RuleSet: d += 1 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) 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 val cr = ctx.castlingRights if isWhite && from == E1 then if cr.whiteKingSide && isEmpty(arr(F1)) && isEmpty(arr(G1)) && - arr(E1) == KING && arr(H1) == ROOK && - !isAttackedByColor(arr, E1, false) && - !isAttackedByColor(arr, F1, false) && - !isAttackedByColor(arr, G1, false) then + arr(E1) == KING && arr(H1) == ROOK && + !isAttackedByColor(arr, E1, false) && + !isAttackedByColor(arr, F1, false) && + !isAttackedByColor(arr, G1, false) + then buf(n) = encMove(E1, G1, KIND_CASTLEK); n += 1 if cr.whiteQueenSide && isEmpty(arr(D1)) && isEmpty(arr(C1)) && isEmpty(arr(B1)) && - arr(E1) == KING && arr(A1) == ROOK && - !isAttackedByColor(arr, E1, false) && - !isAttackedByColor(arr, D1, false) && - !isAttackedByColor(arr, C1, false) then + arr(E1) == KING && arr(A1) == ROOK && + !isAttackedByColor(arr, E1, false) && + !isAttackedByColor(arr, D1, false) && + !isAttackedByColor(arr, C1, false) + then buf(n) = encMove(E1, C1, KIND_CASTLEQ); n += 1 else if !isWhite && from == E8 then if cr.blackKingSide && isEmpty(arr(F8)) && isEmpty(arr(G8)) && - arr(E8) == -KING && arr(H8) == -ROOK && - !isAttackedByColor(arr, E8, true) && - !isAttackedByColor(arr, F8, true) && - !isAttackedByColor(arr, G8, true) then + arr(E8) == -KING && arr(H8) == -ROOK && + !isAttackedByColor(arr, E8, true) && + !isAttackedByColor(arr, F8, true) && + !isAttackedByColor(arr, G8, true) + then buf(n) = encMove(E8, G8, KIND_CASTLEK); n += 1 if cr.blackQueenSide && isEmpty(arr(D8)) && isEmpty(arr(C8)) && isEmpty(arr(B8)) && - arr(E8) == -KING && arr(A8) == -ROOK && - !isAttackedByColor(arr, E8, true) && - !isAttackedByColor(arr, D8, true) && - !isAttackedByColor(arr, C8, true) then + arr(E8) == -KING && arr(A8) == -ROOK && + !isAttackedByColor(arr, E8, true) && + !isAttackedByColor(arr, D8, true) && + !isAttackedByColor(arr, C8, true) + then buf(n) = encMove(E8, C8, KIND_CASTLEQ); n += 1 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 val f = fileOf(from); val r = rankOf(from) 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 piece = arr(sqI) if isEmpty(piece) || isWhitePiece(piece) != (context.turn == Color.White) then return Nil - val buf = new Array[Int](64) - val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0) + val buf = new Array[Int](64) + val n = generatePiece(arr, sqI, pieceType(piece), context.turn == Color.White, context, buf, 0) (0 until n).map(i => decodeMoveToApi(buf(i))).toList 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 piece = arr(sqI) val isWhite = context.turn == Color.White if isEmpty(piece) || isWhitePiece(piece) != isWhite then return Nil - val buf = tlMoves.get() - val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0) - val result = new scala.collection.mutable.ListBuffer[Move]() - var i = 0 + val buf = tlMoves.get() + val n = generatePiece(arr, sqI, pieceType(piece), isWhite, context, buf, 0) + val result = new scala.collection.mutable.ListBuffer[Move]() + var i = 0 while i < n do if !leavesKingInCheck(arr, buf(i), isWhite) then result += decodeMoveToApi(buf(i)) i += 1 result.toList 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 buf = tlMoves.get() val n = generateAll(arr, isWhite, context, buf) @@ -408,7 +456,7 @@ object DefaultRules extends RuleSet: result.toList 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 kingSq = findKing(arr, isWhite) kingSq >= 0 && isAttackedByColor(arr, kingSq, !isWhite) @@ -433,7 +481,7 @@ object DefaultRules extends RuleSet: override def applyMove(context: GameContext)(move: Move): GameContext = val color = context.turn - val board = context.board + val board = context.board val newBoard = move.moveType match case MoveType.CastleKingside => applyCastle(board, color, kingside = true) @@ -487,8 +535,8 @@ object DefaultRules extends RuleSet: val isKingMove = piece.exists(_.pieceType == PieceType.King) val isRookMove = piece.exists(_.pieceType == PieceType.Rook) - 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 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 afterKingMove = if isKingMove then rights.revokeColor(color) else rights val afterRookMove = @@ -523,7 +571,7 @@ object DefaultRules extends RuleSet: private def insufficientMaterial(board: Board): Boolean = val nonKings = board.pieces.toList.filter { case (_, p) => p.pieceType != PieceType.King } nonKings match - case Nil => true + case Nil => 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 } => bishops.map { case (sq, _) => squareColor(sq) }.distinct.sizeIs == 1 @@ -531,18 +579,23 @@ object DefaultRules extends RuleSet: // ─── 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 = try val initialCtx = GameContext( - board = context.initialBoard, - turn = Color.White, - castlingRights = CastlingRights.Initial, + board = context.initialBoard, + turn = Color.White, + castlingRights = CastlingRights.Initial, enPassantSquare = None, - halfMoveClock = 0, - moves = List.empty, - initialBoard = context.initialBoard, + halfMoveClock = 0, + moves = List.empty, + initialBoard = context.initialBoard, ) def positionOf(ctx: GameContext): Position = @@ -557,5 +610,13 @@ object DefaultRules extends RuleSet: (nextCtx, nextCount) } ._2 - catch - case _: Exception => 1 + catch 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), + )