feat(game): introduce game modes and time control features
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -48,6 +48,7 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:bot"))
|
||||
|
||||
|
||||
@@ -63,6 +64,7 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("io.quarkus:quarkus-websockets-next")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
|
||||
|
||||
@@ -3,8 +3,43 @@ quarkus:
|
||||
port: 8080
|
||||
application:
|
||||
name: nowchess-core
|
||||
rest-client:
|
||||
io-service:
|
||||
url: http://localhost:8081
|
||||
rule-service:
|
||||
url: http://localhost:8082
|
||||
|
||||
"%dev":
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: keys/public.pem
|
||||
issuer: nowchess
|
||||
quarkus:
|
||||
http:
|
||||
cors:
|
||||
~: true
|
||||
origins: http://localhost:4200
|
||||
methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
headers: Content-Type,Accept,Authorization
|
||||
rest-client:
|
||||
io-service:
|
||||
url: http://localhost:8081
|
||||
rule-service:
|
||||
url: http://localhost:8082
|
||||
|
||||
"%prod":
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
|
||||
issuer: nowchess
|
||||
quarkus:
|
||||
http:
|
||||
cors:
|
||||
~: true
|
||||
origins: ${CORS_ORIGINS}
|
||||
methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
headers: Content-Type,Accept,Authorization
|
||||
rest-client:
|
||||
io-service:
|
||||
url: ${IO_SERVICE_URL}
|
||||
rule-service:
|
||||
url: ${RULE_SERVICE_URL}
|
||||
|
||||
@@ -2,11 +2,8 @@ package de.nowchess.chess.config
|
||||
|
||||
import com.fasterxml.jackson.core.Version
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.move.MoveType
|
||||
import de.nowchess.chess.json.{MoveTypeDeserializer, MoveTypeSerializer, SquareDeserializer, SquareSerializer}
|
||||
import de.nowchess.json.ChessJacksonModule
|
||||
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
@@ -19,11 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
|
||||
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
||||
// scalafix:on DisableSyntax.null
|
||||
})
|
||||
val mod = new SimpleModule()
|
||||
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
|
||||
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
|
||||
mod.addSerializer(classOf[Square], new SquareSerializer())
|
||||
mod.addDeserializer(classOf[Square], new SquareDeserializer())
|
||||
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
|
||||
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
|
||||
mapper.registerModule(mod)
|
||||
mapper.registerModule(new ChessJacksonModule())
|
||||
|
||||
@@ -2,13 +2,14 @@ package de.nowchess.chess.config
|
||||
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[ApiErrorDto],
|
||||
classOf[ClockDto],
|
||||
classOf[CreateGameRequestDto],
|
||||
classOf[ErrorEventDto],
|
||||
classOf[GameFullDto],
|
||||
@@ -21,6 +22,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[LegalMovesResponseDto],
|
||||
classOf[OkResponseDto],
|
||||
classOf[PlayerInfoDto],
|
||||
classOf[TimeControlDto],
|
||||
classOf[GameContext],
|
||||
classOf[Color],
|
||||
classOf[Piece],
|
||||
@@ -34,6 +36,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[PromotionPiece],
|
||||
classOf[GameResult],
|
||||
classOf[DrawReason],
|
||||
classOf[GameMode],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
package de.nowchess.chess.config
|
||||
|
||||
import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
|
||||
import de.nowchess.api.board.Square
|
||||
|
||||
class SquareKeyDeserializer extends KeyDeserializer:
|
||||
override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
|
||||
Square.fromAlgebraic(key).orNull
|
||||
@@ -1,9 +0,0 @@
|
||||
package de.nowchess.chess.config
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
|
||||
import de.nowchess.api.board.Square
|
||||
|
||||
class SquareKeySerializer extends JsonSerializer[Square]:
|
||||
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
|
||||
gen.writeFieldName(value.toString)
|
||||
@@ -2,14 +2,18 @@ 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, DrawReason, GameContext, GameResult, Human, Participant}
|
||||
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.*
|
||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
||||
import de.nowchess.api.error.GameError
|
||||
import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
|
||||
import de.nowchess.api.io.{GameContextExport, GameContextImport}
|
||||
import de.nowchess.api.rules.RuleSet
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
|
||||
import scala.concurrent.{ExecutionContext, Future}
|
||||
|
||||
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
|
||||
@@ -22,6 +26,7 @@ class GameEngine(
|
||||
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
|
||||
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
|
||||
),
|
||||
val timeControl: TimeControl = TimeControl.Unlimited,
|
||||
) extends Observable:
|
||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||
private val contextWithInitialBoard =
|
||||
@@ -32,15 +37,26 @@ class GameEngine(
|
||||
private var currentContext: GameContext = contextWithInitialBoard
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var pendingDrawOffer: Option[Color] = None
|
||||
private val invoker = new CommandInvoker()
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var clockState: Option[ClockState] =
|
||||
ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now())
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var scheduledCheck: Option[ScheduledFuture[?]] = None
|
||||
// One shared scheduler per engine; shut down with the game.
|
||||
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||
private val invoker = new CommandInvoker()
|
||||
|
||||
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
|
||||
clockState.foreach(scheduleExpiryCheck)
|
||||
|
||||
private implicit val ec: ExecutionContext = ExecutionContext.global
|
||||
|
||||
// Synchronized accessors for current state
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
|
||||
def board: Board = synchronized(currentContext.board)
|
||||
def turn: Color = synchronized(currentContext.turn)
|
||||
def context: GameContext = synchronized(currentContext)
|
||||
def pendingDrawOfferBy: Option[Color] = synchronized(pendingDrawOffer)
|
||||
def currentClockState: Option[ClockState] = synchronized(clockState)
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized(invoker.canUndo)
|
||||
@@ -130,10 +146,11 @@ class GameEngine(
|
||||
if currentContext.result.isDefined then
|
||||
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||
else
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite)))
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(ResignEvent(currentContext, color))
|
||||
notifyObservers(ResignEvent(currentContext, color.opposite))
|
||||
}
|
||||
|
||||
/** Offer a draw. */
|
||||
@@ -162,6 +179,7 @@ class GameEngine(
|
||||
case Some(_) =>
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
|
||||
}
|
||||
@@ -187,10 +205,12 @@ class GameEngine(
|
||||
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||
else if currentContext.halfMoveClock >= 100 then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||
else if ruleSet.isThreefoldRepetition(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
||||
@@ -199,17 +219,19 @@ class GameEngine(
|
||||
/** Load a game using the provided importer. If the imported context has moves, they are replayed through the command
|
||||
* system. Otherwise, the position is set directly. Notifies observers with PgnLoadedEvent on success.
|
||||
*/
|
||||
def loadGame(importer: GameContextImport, input: String): Either[String, Unit] = synchronized {
|
||||
def loadGame(importer: GameContextImport, input: String): Either[GameError, Unit] = synchronized {
|
||||
importer.importGameContext(input) match
|
||||
case Left(err) => Left(err)
|
||||
case Right(ctx) =>
|
||||
replayGame(ctx).map { _ =>
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||
notifyObservers(PgnLoadedEvent(currentContext))
|
||||
}
|
||||
}
|
||||
|
||||
private def replayGame(ctx: GameContext): Either[String, Unit] =
|
||||
private def replayGame(ctx: GameContext): Either[GameError, Unit] =
|
||||
val savedContext = currentContext
|
||||
currentContext = GameContext.initial
|
||||
invoker.clear()
|
||||
@@ -219,20 +241,20 @@ class GameEngine(
|
||||
Right(())
|
||||
else replayMoves(ctx.moves, savedContext)
|
||||
|
||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[String, Unit] =
|
||||
val result = moves.foldLeft[Either[String, Unit]](Right(())) { (acc, move) =>
|
||||
private[engine] def replayMoves(moves: List[Move], savedContext: GameContext): Either[GameError, Unit] =
|
||||
val result = moves.foldLeft[Either[GameError, Unit]](Right(())) { (acc, move) =>
|
||||
acc.flatMap(_ => applyReplayMove(move))
|
||||
}
|
||||
result.left.foreach(_ => currentContext = savedContext)
|
||||
result
|
||||
|
||||
private def applyReplayMove(move: Move): Either[String, Unit] =
|
||||
private def applyReplayMove(move: Move): Either[GameError, Unit] =
|
||||
val legal = ruleSet.legalMoves(currentContext)(move.from)
|
||||
val candidate = move.moveType match
|
||||
case MoveType.Promotion(pp) => legal.find(m => m.to == move.to && m.moveType == MoveType.Promotion(pp))
|
||||
case _ => legal.find(_.to == move.to)
|
||||
candidate match
|
||||
case None => Left("Illegal move.")
|
||||
case None => Left(GameError.IllegalMove)
|
||||
case Some(lm) => executeMove(lm); Right(())
|
||||
|
||||
/** Export the current game context using the provided exporter. */
|
||||
@@ -247,6 +269,8 @@ class GameEngine(
|
||||
else newContext
|
||||
currentContext = contextWithInitialBoard
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
@@ -255,22 +279,17 @@ class GameEngine(
|
||||
def reset(): Unit = synchronized {
|
||||
currentContext = GameContext.initial
|
||||
pendingDrawOffer = None
|
||||
stopClock()
|
||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||
invoker.clear()
|
||||
notifyObservers(BoardResetEvent(currentContext))
|
||||
}
|
||||
|
||||
/** Resign the game on behalf of the side to move. */
|
||||
def resign(): Unit = synchronized {
|
||||
if currentContext.result.isEmpty then
|
||||
val winner = currentContext.turn.opposite
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
|
||||
invoker.clear()
|
||||
}
|
||||
|
||||
/** Apply a draw result directly (for agreement, fifty-move claim, etc.). */
|
||||
def applyDraw(reason: DrawReason): Unit = synchronized {
|
||||
if currentContext.result.isEmpty then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
|
||||
stopClock()
|
||||
invoker.clear()
|
||||
notifyObservers(DrawEvent(currentContext, reason))
|
||||
}
|
||||
@@ -278,6 +297,57 @@ class GameEngine(
|
||||
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
|
||||
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
|
||||
|
||||
/** Inject clock state directly (for testing). */
|
||||
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
|
||||
|
||||
// ──── Clock helpers ────
|
||||
|
||||
private def advanceClock(movedColor: Color): Unit =
|
||||
clockState.foreach { cs =>
|
||||
cs.afterMove(movedColor, Instant.now()) match
|
||||
case Left(flagged) => clockState = None; cancelScheduled(); handleTimeFlag(flagged)
|
||||
case Right(updated) => clockState = Some(updated); scheduleExpiryCheck(updated)
|
||||
}
|
||||
|
||||
private def handleTimeFlag(flagged: Color): Unit =
|
||||
val result =
|
||||
if ruleSet.isInsufficientMaterial(currentContext) then GameResult.Draw(DrawReason.InsufficientMaterial)
|
||||
else GameResult.Win(flagged.opposite, WinReason.TimeControl)
|
||||
currentContext = currentContext.withResult(Some(result))
|
||||
pendingDrawOffer = None
|
||||
invoker.clear()
|
||||
notifyObservers(TimeFlagEvent(currentContext, flagged))
|
||||
|
||||
private def scheduleExpiryCheck(cs: ClockState): Unit =
|
||||
cancelScheduled()
|
||||
cs match
|
||||
case live: LiveClockState =>
|
||||
val delayMs = math.max(0L, live.remainingMs(live.activeColor, Instant.now()))
|
||||
val future = scheduler.schedule(
|
||||
new Runnable { def run(): Unit = checkClockExpiry() },
|
||||
delayMs,
|
||||
TimeUnit.MILLISECONDS,
|
||||
)
|
||||
scheduledCheck = Some(future)
|
||||
case _ => ()
|
||||
|
||||
private def cancelScheduled(): Unit =
|
||||
scheduledCheck.foreach(_.cancel(false))
|
||||
scheduledCheck = None
|
||||
|
||||
private def stopClock(): Unit =
|
||||
cancelScheduled()
|
||||
clockState = None
|
||||
|
||||
private def checkClockExpiry(): Unit = synchronized {
|
||||
if currentContext.result.isEmpty then
|
||||
clockState.foreach { cs =>
|
||||
if cs.remainingMs(cs.activeColor, Instant.now()) <= 0 then
|
||||
clockState = None
|
||||
handleTimeFlag(cs.activeColor)
|
||||
}
|
||||
}
|
||||
|
||||
// ──── Private helpers ────
|
||||
|
||||
private def executeMove(move: Move): Unit =
|
||||
@@ -295,6 +365,8 @@ class GameEngine(
|
||||
invoker.execute(cmd)
|
||||
currentContext = nextContext
|
||||
|
||||
advanceClock(contextBefore.turn)
|
||||
|
||||
notifyObservers(
|
||||
MoveExecutedEvent(
|
||||
currentContext,
|
||||
@@ -304,20 +376,24 @@ class GameEngine(
|
||||
),
|
||||
)
|
||||
|
||||
if ruleSet.isCheckmate(currentContext) then
|
||||
val winner = currentContext.turn.opposite
|
||||
currentContext = currentContext.withResult(Some(GameResult.Win(winner)))
|
||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||
invoker.clear()
|
||||
else if ruleSet.isStalemate(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||
invoker.clear()
|
||||
else if ruleSet.isInsufficientMaterial(currentContext) then
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
|
||||
invoker.clear()
|
||||
else if ruleSet.isCheck(currentContext) then notifyObservers(CheckDetectedEvent(currentContext))
|
||||
if currentContext.result.isEmpty then
|
||||
if ruleSet.isCheckmate(currentContext) 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
|
||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||
cancelScheduled()
|
||||
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||
invoker.clear()
|
||||
else if ruleSet.isInsufficientMaterial(currentContext) 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))
|
||||
|
||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||
if ruleSet.isThreefoldRepetition(currentContext) then
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
package de.nowchess.chess.json
|
||||
|
||||
import com.fasterxml.jackson.core.{JsonParseException, JsonParser}
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode
|
||||
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
|
||||
import de.nowchess.api.move.{MoveType, PromotionPiece}
|
||||
|
||||
class MoveTypeDeserializer extends JsonDeserializer[MoveType]:
|
||||
// scalafix:off DisableSyntax.throw
|
||||
override def deserialize(p: JsonParser, ctx: DeserializationContext): MoveType =
|
||||
val node = p.getCodec.readTree[ObjectNode](p)
|
||||
node.get("type").asText() match
|
||||
case "normal" => MoveType.Normal(node.get("isCapture").asBoolean(false))
|
||||
case "castleKingside" => MoveType.CastleKingside
|
||||
case "castleQueenside" => MoveType.CastleQueenside
|
||||
case "enPassant" => MoveType.EnPassant
|
||||
case "promotion" => MoveType.Promotion(PromotionPiece.valueOf(node.get("piece").asText()))
|
||||
case t => throw new JsonParseException(p, s"Unknown move type: $t")
|
||||
// scalafix:on DisableSyntax.throw
|
||||
@@ -1,23 +0,0 @@
|
||||
package de.nowchess.chess.json
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
|
||||
import de.nowchess.api.move.MoveType
|
||||
|
||||
class MoveTypeSerializer extends JsonSerializer[MoveType]:
|
||||
override def serialize(value: MoveType, gen: JsonGenerator, provider: SerializerProvider): Unit =
|
||||
gen.writeStartObject()
|
||||
value match
|
||||
case MoveType.Normal(isCapture) =>
|
||||
gen.writeStringField("type", "normal")
|
||||
gen.writeBooleanField("isCapture", isCapture)
|
||||
case MoveType.CastleKingside =>
|
||||
gen.writeStringField("type", "castleKingside")
|
||||
case MoveType.CastleQueenside =>
|
||||
gen.writeStringField("type", "castleQueenside")
|
||||
case MoveType.EnPassant =>
|
||||
gen.writeStringField("type", "enPassant")
|
||||
case MoveType.Promotion(piece) =>
|
||||
gen.writeStringField("type", "promotion")
|
||||
gen.writeStringField("piece", piece.toString)
|
||||
gen.writeEndObject()
|
||||
@@ -1,9 +0,0 @@
|
||||
package de.nowchess.chess.json
|
||||
|
||||
import com.fasterxml.jackson.core.JsonParser
|
||||
import com.fasterxml.jackson.databind.{DeserializationContext, JsonDeserializer}
|
||||
import de.nowchess.api.board.Square
|
||||
|
||||
class SquareDeserializer extends JsonDeserializer[Square]:
|
||||
override def deserialize(p: JsonParser, ctx: DeserializationContext): Square =
|
||||
Square.fromAlgebraic(p.getText).orNull
|
||||
@@ -1,9 +0,0 @@
|
||||
package de.nowchess.chess.json
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator
|
||||
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
|
||||
import de.nowchess.api.board.Square
|
||||
|
||||
class SquareSerializer extends JsonSerializer[Square]:
|
||||
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
|
||||
gen.writeString(value.toString)
|
||||
@@ -92,6 +92,12 @@ case class DrawOfferDeclinedEvent(
|
||||
declinedBy: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Fired when a player's clock expires. */
|
||||
case class TimeFlagEvent(
|
||||
context: GameContext,
|
||||
flaggedColor: Color,
|
||||
) extends GameEvent
|
||||
|
||||
/** Observer trait: implement to receive game state updates. */
|
||||
trait Observer:
|
||||
def onGameEvent(event: GameEvent): Unit
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.registry
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.GameMode
|
||||
import de.nowchess.api.player.PlayerInfo
|
||||
import de.nowchess.chess.engine.GameEngine
|
||||
|
||||
@@ -10,4 +11,5 @@ final case class GameEntry(
|
||||
white: PlayerInfo,
|
||||
black: PlayerInfo,
|
||||
resigned: Boolean = false,
|
||||
mode: GameMode = GameMode.Open,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
package de.nowchess.chess.resource
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.board.{Color, Square}
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameContext, GameMode, GameResult, LiveClockState, TimeControl}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||
import java.time.Instant
|
||||
import de.nowchess.chess.adapter.RuleSetRestAdapter
|
||||
import de.nowchess.chess.client.IoServiceClient
|
||||
import de.nowchess.chess.controller.Parser
|
||||
@@ -13,11 +14,11 @@ import de.nowchess.chess.engine.GameEngine
|
||||
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import io.smallrye.mutiny.Multi
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.{MediaType, Response}
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
@@ -40,11 +41,35 @@ class GameResource:
|
||||
|
||||
@Inject
|
||||
var ruleSetAdapter: RuleSetRestAdapter = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
|
||||
private val DefaultBlack = PlayerInfo(PlayerId("p2"), "Player 2")
|
||||
|
||||
// ── auth helpers ─────────────────────────────────────────────────────────
|
||||
// scalafix:off DisableSyntax.throw
|
||||
|
||||
private def colorOf(entry: GameEntry): Color =
|
||||
entry.mode match
|
||||
case GameMode.Open => entry.engine.context.turn
|
||||
case GameMode.Authenticated =>
|
||||
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
|
||||
else throw ForbiddenException("You are not a player in this game")
|
||||
|
||||
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")
|
||||
|
||||
// scalafix:on DisableSyntax.throw
|
||||
|
||||
// ── mapping ──────────────────────────────────────────────────────────────
|
||||
|
||||
private def statusOf(entry: GameEntry): String =
|
||||
@@ -52,8 +77,10 @@ class GameResource:
|
||||
else
|
||||
val ctx = entry.engine.context
|
||||
ctx.result match
|
||||
case Some(GameResult.Win(_)) =>
|
||||
if entry.resigned then "resign" else "checkmate"
|
||||
case Some(GameResult.Win(_, _)) =>
|
||||
if entry.resigned then "resign"
|
||||
else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
|
||||
else "timeout"
|
||||
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
||||
case Some(GameResult.Draw(_)) => "draw"
|
||||
@@ -87,6 +114,19 @@ class GameResource:
|
||||
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||
PlayerInfoDto(info.id.value, info.displayName)
|
||||
|
||||
private def toClockDto(entry: GameEntry): Option[ClockDto] =
|
||||
val now = Instant.now()
|
||||
entry.engine.currentClockState.map {
|
||||
case cs: LiveClockState =>
|
||||
ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
|
||||
case cs: CorrespondenceClockState =>
|
||||
val remaining = cs.remainingMs(cs.activeColor, now)
|
||||
ClockDto(
|
||||
whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
|
||||
blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
|
||||
)
|
||||
}
|
||||
|
||||
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
||||
val ctx = entry.engine.context
|
||||
GameStateDto(
|
||||
@@ -94,10 +134,11 @@ class GameResource:
|
||||
pgn = ioClient.exportPgn(ctx),
|
||||
turn = ctx.turn.label.toLowerCase,
|
||||
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 },
|
||||
moves = ctx.moves.map(moveToUci),
|
||||
undoAvailable = entry.engine.canUndo,
|
||||
redoAvailable = entry.engine.canRedo,
|
||||
clock = toClockDto(entry),
|
||||
)
|
||||
|
||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
||||
@@ -106,8 +147,29 @@ class GameResource:
|
||||
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
|
||||
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||
|
||||
private def newEntry(ctx: GameContext, white: PlayerInfo, black: PlayerInfo): GameEntry =
|
||||
GameEntry(registry.generateId(), GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter), white, black)
|
||||
private def toTimeControl(dto: Option[TimeControlDto]): TimeControl =
|
||||
dto match
|
||||
case None => TimeControl.Unlimited
|
||||
case Some(tc) =>
|
||||
tc.daysPerMove match
|
||||
case Some(d) => TimeControl.Correspondence(d)
|
||||
case None =>
|
||||
tc.limitSeconds.fold(TimeControl.Unlimited)(l => TimeControl.Clock(l, tc.incrementSeconds.getOrElse(0)))
|
||||
|
||||
private def newEntry(
|
||||
ctx: GameContext,
|
||||
white: PlayerInfo,
|
||||
black: PlayerInfo,
|
||||
tc: TimeControl = TimeControl.Unlimited,
|
||||
mode: GameMode = GameMode.Open,
|
||||
): GameEntry =
|
||||
GameEntry(
|
||||
registry.generateId(),
|
||||
GameEngine(initialContext = ctx, ruleSet = ruleSetAdapter, timeControl = tc),
|
||||
white,
|
||||
black,
|
||||
mode = mode,
|
||||
)
|
||||
|
||||
private def applyMoveInput(engine: GameEngine, uci: String): Option[String] =
|
||||
val error = new AtomicReference[Option[String]](None)
|
||||
@@ -137,10 +199,12 @@ class GameResource:
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(body: CreateGameRequestDto): Response =
|
||||
val req = Option(body).getOrElse(CreateGameRequestDto(None, None))
|
||||
val req = Option(body).getOrElse(CreateGameRequestDto(None, None, None, None))
|
||||
val white = playerInfoFrom(req.white, DefaultWhite)
|
||||
val black = playerInfoFrom(req.black, DefaultBlack)
|
||||
val entry = newEntry(GameContext.initial, white, black)
|
||||
val tc = toTimeControl(req.timeControl)
|
||||
val mode = req.mode.getOrElse(GameMode.Open)
|
||||
val entry = newEntry(GameContext.initial, white, black, tc, mode)
|
||||
registry.store(entry)
|
||||
println(s"Created game ${entry.gameId}")
|
||||
created(toGameFullDto(entry))
|
||||
@@ -152,33 +216,14 @@ class GameResource:
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
ok(toGameFullDto(entry))
|
||||
|
||||
@GET
|
||||
@Path("/{gameId}/stream")
|
||||
@Produces(Array("application/x-ndjson"))
|
||||
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
Multi
|
||||
.createFrom()
|
||||
.emitter[String] { emitter =>
|
||||
emitter.emit(objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry))) + "\n")
|
||||
val obs = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
registry.get(gameId).foreach { updated =>
|
||||
emitter.emit(
|
||||
objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))) + "\n",
|
||||
)
|
||||
}
|
||||
entry.engine.subscribe(obs)
|
||||
emitter.onTermination(() => entry.engine.unsubscribe(obs))
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/{gameId}/resign")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def resignGame(@PathParam("gameId") gameId: String): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
assertGameNotOver(entry)
|
||||
entry.engine.resign()
|
||||
val color = colorOf(entry)
|
||||
entry.engine.resign(color)
|
||||
registry.update(entry.copy(resigned = true))
|
||||
ok(OkResponseDto())
|
||||
|
||||
@@ -188,6 +233,7 @@ class GameResource:
|
||||
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
|
||||
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")))
|
||||
@@ -243,21 +289,13 @@ class GameResource:
|
||||
): Response =
|
||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||
assertGameNotOver(entry)
|
||||
val color = colorOf(entry)
|
||||
action match
|
||||
case "offer" =>
|
||||
entry.engine.offerDraw(entry.engine.context.turn)
|
||||
ok(OkResponseDto())
|
||||
case "accept" =>
|
||||
entry.engine.acceptDraw(entry.engine.context.turn)
|
||||
ok(OkResponseDto())
|
||||
case "decline" =>
|
||||
entry.engine.declineDraw(entry.engine.context.turn)
|
||||
ok(OkResponseDto())
|
||||
case "claim" =>
|
||||
entry.engine.claimDraw()
|
||||
ok(OkResponseDto())
|
||||
case _ =>
|
||||
throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
|
||||
case "offer" => entry.engine.offerDraw(color); ok(OkResponseDto())
|
||||
case "accept" => entry.engine.acceptDraw(color); ok(OkResponseDto())
|
||||
case "decline" => entry.engine.declineDraw(color); ok(OkResponseDto())
|
||||
case "claim" => entry.engine.claimDraw(); ok(OkResponseDto())
|
||||
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
|
||||
|
||||
@POST
|
||||
@Path("/import/fen")
|
||||
@@ -267,7 +305,8 @@ class GameResource:
|
||||
val ctx = ioClient.importFen(ImportFenRequest(body.fen))
|
||||
val white = playerInfoFrom(body.white, DefaultWhite)
|
||||
val black = playerInfoFrom(body.black, DefaultBlack)
|
||||
val entry = newEntry(ctx, white, black)
|
||||
val tc = toTimeControl(body.timeControl)
|
||||
val entry = newEntry(ctx, white, black, tc)
|
||||
registry.store(entry)
|
||||
created(toGameFullDto(entry))
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package de.nowchess.chess.resource
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.api.player.PlayerInfo
|
||||
import de.nowchess.chess.client.IoServiceClient
|
||||
import de.nowchess.chess.observer.*
|
||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||
import io.quarkus.websockets.next.*
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@WebSocket(path = "/api/board/game/{gameId}/ws")
|
||||
class GameWebSocketResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var registry: GameRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
var objectMapper: ObjectMapper = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var ioClient: IoServiceClient = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val connectionObservers = new ConcurrentHashMap[String, (String, Observer)]()
|
||||
|
||||
@OnOpen
|
||||
def onOpen(connection: WebSocketConnection): Unit =
|
||||
val gameId = connection.pathParam("gameId")
|
||||
registry.get(gameId) match
|
||||
case None =>
|
||||
val err = ErrorEventDto(ApiErrorDto("GAME_NOT_FOUND", s"Game $gameId not found", None))
|
||||
connection
|
||||
.sendText(objectMapper.writeValueAsString(err))
|
||||
.flatMap(_ => connection.close())
|
||||
.subscribe()
|
||||
.`with`(_ => (), _ => ())
|
||||
case Some(entry) =>
|
||||
val initial = objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry)))
|
||||
val obs = new Observer:
|
||||
def onGameEvent(event: GameEvent): Unit =
|
||||
registry.get(gameId).foreach { updated =>
|
||||
connection
|
||||
.sendText(objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))))
|
||||
.subscribe()
|
||||
.`with`(_ => (), _ => ())
|
||||
}
|
||||
connection
|
||||
.sendText(initial)
|
||||
.subscribe()
|
||||
.`with`(
|
||||
_ => {
|
||||
connectionObservers.put(connection.id(), (gameId, obs))
|
||||
entry.engine.subscribe(obs)
|
||||
},
|
||||
_ => (),
|
||||
)
|
||||
|
||||
@OnClose
|
||||
def onClose(connection: WebSocketConnection): Unit =
|
||||
Option(connectionObservers.remove(connection.id())).foreach { case (gameId, obs) =>
|
||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||
}
|
||||
|
||||
private def statusOf(entry: GameEntry): String =
|
||||
if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
|
||||
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.Draw(DrawReason.Stalemate)) => "stalemate"
|
||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
||||
case Some(GameResult.Draw(_)) => "draw"
|
||||
case None =>
|
||||
if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
|
||||
else if entry.engine.ruleSet.isCheck(ctx) then "check"
|
||||
else "started"
|
||||
|
||||
private def moveToUci(move: Move): String =
|
||||
val base = s"${move.from}${move.to}"
|
||||
move.moveType match
|
||||
case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
|
||||
case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
|
||||
case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
|
||||
case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
|
||||
case _ => base
|
||||
|
||||
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||
PlayerInfoDto(info.id.value, info.displayName)
|
||||
|
||||
private def toClockDto(entry: GameEntry): Option[ClockDto] =
|
||||
val now = Instant.now()
|
||||
entry.engine.currentClockState.map {
|
||||
case cs: LiveClockState =>
|
||||
ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
|
||||
case cs: CorrespondenceClockState =>
|
||||
val remaining = cs.remainingMs(cs.activeColor, now)
|
||||
ClockDto(
|
||||
whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
|
||||
blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
|
||||
)
|
||||
}
|
||||
|
||||
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
||||
val ctx = entry.engine.context
|
||||
GameStateDto(
|
||||
fen = ioClient.exportFen(ctx),
|
||||
pgn = ioClient.exportPgn(ctx),
|
||||
turn = ctx.turn.label.toLowerCase,
|
||||
status = statusOf(entry),
|
||||
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
|
||||
moves = ctx.moves.map(moveToUci),
|
||||
undoAvailable = entry.engine.canUndo,
|
||||
redoAvailable = entry.engine.canRedo,
|
||||
clock = toClockDto(entry),
|
||||
)
|
||||
|
||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
||||
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
|
||||
@@ -0,0 +1,147 @@
|
||||
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.chess.observer.*
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class GameEngineClockTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private def makeClockEngine(tc: TimeControl): GameEngine =
|
||||
new GameEngine(ruleSet = DefaultRules, timeControl = tc)
|
||||
|
||||
// ── Unlimited ─────────────────────────────────────────────────────────────
|
||||
|
||||
test("Unlimited time control: no clock state"):
|
||||
val engine = makeClockEngine(TimeControl.Unlimited)
|
||||
engine.currentClockState shouldBe None
|
||||
|
||||
// ── Live clock initialisation ─────────────────────────────────────────────
|
||||
|
||||
test("Clock(300,3) initialises both sides to 300,000ms"):
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 3))
|
||||
engine.currentClockState match
|
||||
case Some(cs: LiveClockState) =>
|
||||
cs.whiteRemainingMs shouldBe 300_000L
|
||||
cs.blackRemainingMs shouldBe 300_000L
|
||||
cs.incrementMs shouldBe 3_000L
|
||||
cs.activeColor shouldBe Color.White
|
||||
case other => fail(s"Expected Some(LiveClockState), got $other")
|
||||
|
||||
// ── Clock advances after move ─────────────────────────────────────────────
|
||||
|
||||
test("After White move, activeColor flips to Black and white time decreases"):
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 3))
|
||||
engine.processUserInput("e2e4")
|
||||
engine.currentClockState match
|
||||
case Some(cs: LiveClockState) =>
|
||||
cs.activeColor shouldBe Color.Black
|
||||
cs.whiteRemainingMs should be < 300_000L + 3_000L
|
||||
cs.blackRemainingMs shouldBe 300_000L
|
||||
case other => fail(s"Expected Some(LiveClockState), got $other")
|
||||
|
||||
// ── Time flag via injection ───────────────────────────────────────────────
|
||||
|
||||
test("TimeFlagEvent fires and result is Win(opponent) when White flags on move"):
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 0))
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
// Inject nearly-exhausted clock: White has 1ms, will flag on move
|
||||
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
|
||||
engine.injectClockState(Some(expiredClock))
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
observer.hasEvent[TimeFlagEvent] shouldBe true
|
||||
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black, WinReason.TimeControl))
|
||||
|
||||
test("TimeFlagEvent fires and result is Win(Black) when Black flags on move"):
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 0))
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
engine.processUserInput("e2e4")
|
||||
observer.clear()
|
||||
|
||||
val expiredClock = LiveClockState(300_000L, 1L, 0L, Instant.now().minusSeconds(10), Color.Black)
|
||||
engine.injectClockState(Some(expiredClock))
|
||||
|
||||
engine.processUserInput("e7e5")
|
||||
|
||||
observer.hasEvent[TimeFlagEvent] shouldBe true
|
||||
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.Black)
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.White, WinReason.TimeControl))
|
||||
|
||||
test("Flag with insufficient material gives Draw(InsufficientMaterial)"):
|
||||
// King vs King — White flags but Black can't mate
|
||||
// White king e4, Black king e6: e4d3 is a legal move (not adjacent to e6)
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 0))
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
EngineTestHelpers.loadFen(engine, "8/8/4k3/8/4K3/8/8/8 w - - 0 1")
|
||||
observer.clear()
|
||||
|
||||
val expiredClock = LiveClockState(1L, 300_000L, 0L, Instant.now().minusSeconds(10), Color.White)
|
||||
engine.injectClockState(Some(expiredClock))
|
||||
|
||||
engine.processUserInput("e4d3")
|
||||
|
||||
observer.hasEvent[TimeFlagEvent] shouldBe true
|
||||
engine.context.result shouldBe Some(GameResult.Draw(DrawReason.InsufficientMaterial))
|
||||
|
||||
// ── Correspondence clock ──────────────────────────────────────────────────
|
||||
|
||||
test("Correspondence(3): after move, deadline is ~3 days from move time"):
|
||||
val engine = makeClockEngine(TimeControl.Correspondence(3))
|
||||
val before = Instant.now()
|
||||
engine.processUserInput("e2e4")
|
||||
val after = Instant.now()
|
||||
engine.currentClockState match
|
||||
case Some(cs: CorrespondenceClockState) =>
|
||||
val expectedMin = before.plus(3L, ChronoUnit.DAYS)
|
||||
val expectedMax = after.plus(3L, ChronoUnit.DAYS)
|
||||
cs.moveDeadline.isAfter(expectedMin.minusSeconds(1)) shouldBe true
|
||||
cs.moveDeadline.isBefore(expectedMax.plusSeconds(1)) shouldBe true
|
||||
cs.activeColor shouldBe Color.Black
|
||||
case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
|
||||
|
||||
test("Correspondence flag fires TimeFlagEvent when move past deadline"):
|
||||
val engine = makeClockEngine(TimeControl.Correspondence(3))
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
// Inject expired deadline
|
||||
val expired = CorrespondenceClockState(Instant.now().minusSeconds(60), 3, Color.White)
|
||||
engine.injectClockState(Some(expired))
|
||||
|
||||
engine.processUserInput("e2e4")
|
||||
|
||||
observer.hasEvent[TimeFlagEvent] shouldBe true
|
||||
observer.getEvent[TimeFlagEvent].map(_.flaggedColor) shouldBe Some(Color.White)
|
||||
|
||||
// ── reset() restarts clock ────────────────────────────────────────────────
|
||||
|
||||
test("reset() restarts clock to full time"):
|
||||
val engine = makeClockEngine(TimeControl.Clock(300, 3))
|
||||
engine.processUserInput("e2e4")
|
||||
engine.reset()
|
||||
engine.currentClockState match
|
||||
case Some(cs: LiveClockState) =>
|
||||
cs.whiteRemainingMs shouldBe 300_000L
|
||||
cs.blackRemainingMs shouldBe 300_000L
|
||||
cs.activeColor shouldBe Color.White
|
||||
case other => fail(s"Expected Some(LiveClockState), got $other")
|
||||
|
||||
// ── Passive expiry via scheduler ──────────────────────────────────────────
|
||||
|
||||
test("Scheduler fires TimeFlagEvent when active player's clock expires passively"):
|
||||
// Scheduler starts on engine creation, so TimeFlagEvent fires without a move being made
|
||||
val engine = new GameEngine(ruleSet = DefaultRules, timeControl = TimeControl.Clock(1, 0))
|
||||
val observer = new EngineTestHelpers.MockObserver()
|
||||
engine.subscribe(observer)
|
||||
Thread.sleep(1500)
|
||||
observer.hasEvent[TimeFlagEvent] shouldBe true
|
||||
@@ -202,7 +202,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.offerDraw(Color.White)
|
||||
observer.events.clear()
|
||||
engine.resign(Color.Black)
|
||||
engine.resign()
|
||||
|
||||
// Try to accept the now-cleared draw offer
|
||||
observer.events.clear()
|
||||
@@ -222,7 +222,7 @@ class GameEngineDrawOfferTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.offerDraw(Color.White)
|
||||
observer.events.clear()
|
||||
engine.resign(Color.Black)
|
||||
engine.resign()
|
||||
|
||||
// Try to accept the now-cleared draw offer
|
||||
observer.events.clear()
|
||||
|
||||
+7
-7
@@ -4,6 +4,7 @@ import de.nowchess.api.board.{Board, Color, File, PieceType, Rank, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, MoveRedoneEvent, Observer}
|
||||
import de.nowchess.api.error.GameError
|
||||
import de.nowchess.api.io.GameContextImport
|
||||
import de.nowchess.api.rules.RuleSet
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
@@ -58,9 +59,9 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
||||
val failingImporter = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] = Left("boom")
|
||||
def importGameContext(input: String): Either[GameError, GameContext] = Left(GameError.ParseError("boom"))
|
||||
|
||||
engine.loadGame(failingImporter, "ignored") shouldBe Left("boom")
|
||||
engine.loadGame(failingImporter, "ignored") shouldBe Left(GameError.ParseError("boom"))
|
||||
|
||||
test("loadPosition replaces context clears history and notifies reset"):
|
||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
||||
@@ -109,7 +110,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
|
||||
val engine = new GameEngine(ruleSet = permissiveRules)
|
||||
val importer = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
def importGameContext(input: String): Either[GameError, GameContext] =
|
||||
Right(GameContext.initial.copy(moves = List(promotionMove)))
|
||||
|
||||
engine.loadGame(importer, "ignored") shouldBe Right(())
|
||||
@@ -134,13 +135,12 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val saved = engine.context
|
||||
|
||||
val importer = new GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext] =
|
||||
def importGameContext(input: String): Either[GameError, GameContext] =
|
||||
Right(GameContext.initial.copy(moves = List(promotionMove)))
|
||||
|
||||
val result = engine.loadGame(importer, "ignored")
|
||||
|
||||
result.isLeft shouldBe true
|
||||
result.left.toOption.get should include("Illegal move")
|
||||
result shouldBe Left(GameError.IllegalMove)
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("loadGame replay executes non-promotion moves through default replay branch"):
|
||||
@@ -156,7 +156,7 @@ class GameEngineIntegrationTest extends AnyFunSuite with Matchers:
|
||||
val illegalPromotion = Move(sq("e2"), sq("e1"), MoveType.Promotion(PromotionPiece.Queen))
|
||||
val trailingMove = Move(sq("e2"), sq("e4"))
|
||||
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left("Illegal move.")
|
||||
engine.replayMoves(List(illegalPromotion, trailingMove), saved) shouldBe Left(GameError.IllegalMove)
|
||||
engine.context shouldBe saved
|
||||
|
||||
test("normalMoveNotation handles missing source piece"):
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.game.WinReason.Checkmate
|
||||
import de.nowchess.api.game.{DrawReason, GameResult}
|
||||
import de.nowchess.chess.observer.*
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
@@ -23,7 +24,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
engine.processUserInput("d8h4")
|
||||
|
||||
observer.hasEvent[CheckmateEvent] shouldBe true
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Checkmate))
|
||||
|
||||
test("checkmate with white winner"):
|
||||
val engine = EngineTestHelpers.makeEngine()
|
||||
@@ -43,7 +44,7 @@ class GameEngineOutcomesTest extends AnyFunSuite with Matchers:
|
||||
val evt = observer.getEvent[CheckmateEvent]
|
||||
evt.isDefined shouldBe true
|
||||
evt.get.winner shouldBe Color.White
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.White))
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.White, Checkmate))
|
||||
|
||||
// ── Stalemate ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package de.nowchess.chess.engine
|
||||
|
||||
import de.nowchess.rules.sets.DefaultRules
|
||||
|
||||
import scala.collection.mutable
|
||||
import de.nowchess.api.board.Color
|
||||
import de.nowchess.api.board.Color.{Black, White}
|
||||
import de.nowchess.api.game.GameResult
|
||||
import de.nowchess.api.game.WinReason.Resignation
|
||||
import de.nowchess.chess.observer.{GameEvent, InvalidMoveEvent, InvalidMoveReason, Observer, ResignEvent}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
@@ -15,13 +18,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
|
||||
val observer = new ResignMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.resign(Color.White)
|
||||
engine.resign(White)
|
||||
|
||||
observer.events should have length 1
|
||||
observer.events.head match
|
||||
case event: ResignEvent =>
|
||||
event.resignedColor shouldBe Color.White
|
||||
event.context.result shouldBe Some(GameResult.Win(Color.Black))
|
||||
event.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
|
||||
case other =>
|
||||
fail(s"Expected ResignEvent, but got $other")
|
||||
|
||||
@@ -30,13 +33,13 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
|
||||
val observer = new ResignMockObserver()
|
||||
engine.subscribe(observer)
|
||||
|
||||
engine.resign(Color.Black)
|
||||
engine.resign(Black)
|
||||
|
||||
observer.events should have length 1
|
||||
observer.events.head match
|
||||
case event: ResignEvent =>
|
||||
event.resignedColor shouldBe Color.Black
|
||||
event.context.result shouldBe Some(GameResult.Win(Color.White))
|
||||
event.context.result shouldBe Some(GameResult.Win(Color.White, Resignation))
|
||||
case other =>
|
||||
fail(s"Expected ResignEvent, but got $other")
|
||||
|
||||
@@ -54,7 +57,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
|
||||
|
||||
// Try to resign
|
||||
observer.events.clear()
|
||||
engine.resign(Color.White)
|
||||
engine.resign()
|
||||
|
||||
// Should get InvalidMoveEvent with GameAlreadyOver reason
|
||||
observer.events.length shouldBe 1
|
||||
@@ -71,7 +74,7 @@ class GameEngineResignTest extends AnyFunSuite with Matchers:
|
||||
|
||||
engine.resign()
|
||||
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black))
|
||||
engine.context.result shouldBe Some(GameResult.Win(Color.Black, Resignation))
|
||||
|
||||
test("resign() without color does nothing when game already over"):
|
||||
val engine = new GameEngine(ruleSet = DefaultRules)
|
||||
|
||||
@@ -1,87 +1,31 @@
|
||||
package de.nowchess.chess.json
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.{File, Rank, Square}
|
||||
import de.nowchess.api.move.{MoveType, PromotionPiece}
|
||||
import de.nowchess.api.move.MoveType
|
||||
import de.nowchess.chess.config.JacksonConfig
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
|
||||
class JsonSerializersTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val mapper: ObjectMapper =
|
||||
val m = new ObjectMapper()
|
||||
val mod = new SimpleModule()
|
||||
m.registerModule(DefaultScalaModule)
|
||||
mod.addSerializer(classOf[Square], new SquareSerializer())
|
||||
mod.addDeserializer(classOf[Square], new SquareDeserializer())
|
||||
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
|
||||
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
|
||||
m.registerModule(mod)
|
||||
val m = new ObjectMapper()
|
||||
new JacksonConfig().customize(m)
|
||||
m
|
||||
|
||||
private val e4 = Square(File.E, Rank.R4)
|
||||
test("customize enables Option serialization via DefaultScalaModule"):
|
||||
mapper.writeValueAsString(None) shouldBe "null"
|
||||
mapper.writeValueAsString(Some("hello")) shouldBe """"hello""""
|
||||
|
||||
// ── SquareSerializer ──────────────────────────────────────────────
|
||||
test("customize registers SquareSerializer"):
|
||||
mapper.writeValueAsString(Square(File.E, Rank.R4)) shouldBe """"e4""""
|
||||
|
||||
test("SquareSerializer writes square as string"):
|
||||
mapper.writeValueAsString(e4) shouldBe """"e4""""
|
||||
test("customize registers SquareDeserializer"):
|
||||
mapper.readValue(""""e4"""", classOf[Square]) shouldBe Square(File.E, Rank.R4)
|
||||
|
||||
// ── SquareDeserializer ────────────────────────────────────────────
|
||||
|
||||
test("SquareDeserializer reads valid square string"):
|
||||
mapper.readValue(""""e4"""", classOf[Square]) shouldBe e4
|
||||
|
||||
// scalafix:off DisableSyntax.null
|
||||
test("SquareDeserializer returns null for invalid square string"):
|
||||
mapper.readValue(""""z9"""", classOf[Square]) shouldBe null
|
||||
// scalafix:on DisableSyntax.null
|
||||
|
||||
// ── MoveTypeSerializer ────────────────────────────────────────────
|
||||
|
||||
test("MoveTypeSerializer serializes Normal non-capture"):
|
||||
mapper.writeValueAsString(MoveType.Normal(false)) shouldBe """{"type":"normal","isCapture":false}"""
|
||||
|
||||
test("MoveTypeSerializer serializes Normal capture"):
|
||||
mapper.writeValueAsString(MoveType.Normal(true)) shouldBe """{"type":"normal","isCapture":true}"""
|
||||
|
||||
test("MoveTypeSerializer serializes CastleKingside"):
|
||||
test("customize registers MoveTypeSerializer"):
|
||||
mapper.writeValueAsString(MoveType.CastleKingside) shouldBe """{"type":"castleKingside"}"""
|
||||
|
||||
test("MoveTypeSerializer serializes CastleQueenside"):
|
||||
mapper.writeValueAsString(MoveType.CastleQueenside) shouldBe """{"type":"castleQueenside"}"""
|
||||
|
||||
test("MoveTypeSerializer serializes EnPassant"):
|
||||
mapper.writeValueAsString(MoveType.EnPassant) shouldBe """{"type":"enPassant"}"""
|
||||
|
||||
test("MoveTypeSerializer serializes Promotion"):
|
||||
mapper.writeValueAsString(MoveType.Promotion(PromotionPiece.Queen)) shouldBe
|
||||
"""{"type":"promotion","piece":"Queen"}"""
|
||||
|
||||
// ── MoveTypeDeserializer ──────────────────────────────────────────
|
||||
|
||||
test("MoveTypeDeserializer deserializes normal non-capture"):
|
||||
mapper.readValue("""{"type":"normal","isCapture":false}""", classOf[MoveType]) shouldBe
|
||||
MoveType.Normal(false)
|
||||
|
||||
test("MoveTypeDeserializer deserializes normal capture"):
|
||||
mapper.readValue("""{"type":"normal","isCapture":true}""", classOf[MoveType]) shouldBe
|
||||
MoveType.Normal(true)
|
||||
|
||||
test("MoveTypeDeserializer deserializes castleKingside"):
|
||||
mapper.readValue("""{"type":"castleKingside"}""", classOf[MoveType]) shouldBe MoveType.CastleKingside
|
||||
|
||||
test("MoveTypeDeserializer deserializes castleQueenside"):
|
||||
mapper.readValue("""{"type":"castleQueenside"}""", classOf[MoveType]) shouldBe MoveType.CastleQueenside
|
||||
|
||||
test("MoveTypeDeserializer deserializes enPassant"):
|
||||
test("customize registers MoveTypeDeserializer"):
|
||||
mapper.readValue("""{"type":"enPassant"}""", classOf[MoveType]) shouldBe MoveType.EnPassant
|
||||
|
||||
test("MoveTypeDeserializer deserializes promotion"):
|
||||
mapper.readValue("""{"type":"promotion","piece":"Rook"}""", classOf[MoveType]) shouldBe
|
||||
MoveType.Promotion(PromotionPiece.Rook)
|
||||
|
||||
test("MoveTypeDeserializer throws for unknown type"):
|
||||
an[Exception] should be thrownBy
|
||||
mapper.readValue("""{"type":"unknown"}""", classOf[MoveType])
|
||||
|
||||
+13
-13
@@ -74,7 +74,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("createGame returns 201")
|
||||
def testCreateGame(): Unit =
|
||||
val req = CreateGameRequestDto(None, None)
|
||||
val req = CreateGameRequestDto(None, None, None)
|
||||
val resp = resource.createGame(req)
|
||||
assertEquals(201, resp.getStatus)
|
||||
val dto = resp.getEntity.asInstanceOf[GameFullDto]
|
||||
@@ -83,7 +83,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("getGame returns 200")
|
||||
def testGetGame(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val getResp = resource.getGame(gameId)
|
||||
assertEquals(200, getResp.getStatus)
|
||||
@@ -93,7 +93,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("makeMove advances game")
|
||||
def testMakeMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val moveResp = resource.makeMove(gameId, "e2e4")
|
||||
assertEquals(200, moveResp.getStatus)
|
||||
@@ -103,14 +103,14 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("makeMove with invalid UCI throws")
|
||||
def testMakeMoveInvalid(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
assertThrows(classOf[BadRequestException], () => resource.makeMove(gameId, "invalid"))
|
||||
|
||||
@Test
|
||||
@DisplayName("getLegalMoves returns moves")
|
||||
def testGetLegalMoves(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val movesResp = resource.getLegalMoves(gameId, "")
|
||||
assertEquals(200, movesResp.getStatus)
|
||||
@@ -120,7 +120,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("resignGame updates state")
|
||||
def testResignGame(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resignResp = resource.resignGame(gameId)
|
||||
assertEquals(200, resignResp.getStatus)
|
||||
@@ -131,7 +131,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("undoMove reverts")
|
||||
def testUndoMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
val undoResp = resource.undoMove(gameId)
|
||||
@@ -142,7 +142,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("redoMove restores")
|
||||
def testRedoMove(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
resource.undoMove(gameId)
|
||||
@@ -154,7 +154,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("drawAction offer")
|
||||
def testDrawActionOffer(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resp = resource.drawAction(gameId, "offer")
|
||||
assertEquals(200, resp.getStatus)
|
||||
@@ -162,7 +162,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("drawAction accept")
|
||||
def testDrawActionAccept(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.drawAction(gameId, "offer")
|
||||
val resp = resource.drawAction(gameId, "accept")
|
||||
@@ -172,7 +172,7 @@ class GameResourceIntegrationTest:
|
||||
@DisplayName("importFen creates game")
|
||||
def testImportFen(): Unit =
|
||||
val fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
val req = ImportFenRequestDto(fen, None, None)
|
||||
val req = ImportFenRequestDto(fen, None, None, None)
|
||||
val resp = resource.importFen(req)
|
||||
assertEquals(201, resp.getStatus)
|
||||
val dto = resp.getEntity.asInstanceOf[GameFullDto]
|
||||
@@ -190,7 +190,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("exportFen returns FEN")
|
||||
def testExportFen(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
val resp = resource.exportFen(gameId)
|
||||
assertEquals(200, resp.getStatus)
|
||||
@@ -199,7 +199,7 @@ class GameResourceIntegrationTest:
|
||||
@Test
|
||||
@DisplayName("exportPgn returns PGN")
|
||||
def testExportPgn(): Unit =
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None))
|
||||
val createResp = resource.createGame(CreateGameRequestDto(None, None, None))
|
||||
val gameId = createResp.getEntity.asInstanceOf[GameFullDto].gameId
|
||||
resource.makeMove(gameId, "e2e4")
|
||||
val resp = resource.exportPgn(gameId)
|
||||
|
||||
Reference in New Issue
Block a user