feat(game): introduce game modes and time control features
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-23 21:56:21 +02:00
parent 21d3d87543
commit 3df199afa1
100 changed files with 1676 additions and 604 deletions
+2
View File
@@ -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()
@@ -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])
@@ -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)