From 8fd6adc1f4e8c83ed830a07f75aff3d15f076ad4 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Tue, 14 Apr 2026 23:24:16 +0200 Subject: [PATCH] feat(backcore): Quarkus compatible with GUI/TUI Added quarkus backcore run functionality so that it launches the TUI, GUI, Quarkus via modules: backcore:run --- modules/backcore/build.gradle.kts | 7 +- .../scala/de/nowchess/backcore/AppMain.scala | 17 ++ .../backcore/game/GameEngineHolder.scala | 28 ++ .../nowchess/backcore/game/GameMapper.scala | 63 +++-- .../nowchess/backcore/game/GameService.scala | 175 ++++++++++++ .../{GameSession.scala => GameSnapshot.scala} | 8 +- .../de/nowchess/backcore/game/GameStore.scala | 260 ------------------ .../backcore/resource/GameResource.scala | 99 +++---- .../backcore/resource/ImportResource.scala | 16 +- .../backcore/resource/MoveResource.scala | 56 ++-- .../backcore/game/GameMapperTest.scala | 181 ++++++++++++ .../backcore/game/GameServiceTest.scala | 245 +++++++++++++++++ modules/ui/build.gradle.kts | 8 + .../src/main/scala/de/nowchess/ui/Main.scala | 7 +- 14 files changed, 776 insertions(+), 394 deletions(-) create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/AppMain.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameEngineHolder.scala create mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameService.scala rename modules/backcore/src/main/scala/de/nowchess/backcore/game/{GameSession.scala => GameSnapshot.scala} (67%) delete mode 100644 modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/game/GameMapperTest.scala create mode 100644 modules/backcore/src/test/scala/de/nowchess/backcore/game/GameServiceTest.scala diff --git a/modules/backcore/build.gradle.kts b/modules/backcore/build.gradle.kts index 14f8eb5..b13d6df 100644 --- a/modules/backcore/build.gradle.kts +++ b/modules/backcore/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(project(":modules:core")) implementation(project(":modules:io")) implementation(project(":modules:rule")) + implementation(project(":modules:ui")) implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation("io.quarkus:quarkus-rest") @@ -90,7 +91,11 @@ tasks.jacocoTestReport { dependsOn(tasks.test) executionData.setFrom(layout.buildDirectory.file("jacoco-quarkus.exec")) sourceDirectories.setFrom(files("src/main/scala")) - classDirectories.setFrom(files(layout.buildDirectory.dir("classes/scala/main"))) + classDirectories.setFrom( + files(layout.buildDirectory.dir("classes/scala/main")).asFileTree.matching { + exclude("**/AppMain*.class", "**/AppMain\$*.class") + } + ) reports { xml.required.set(true) xml.outputLocation.set( diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/AppMain.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/AppMain.scala new file mode 100644 index 0000000..20bdf9c --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/AppMain.scala @@ -0,0 +1,17 @@ +package de.nowchess.backcore + +import de.nowchess.backcore.game.{GameEngineHolder, GameId} +import de.nowchess.ui.gui.ChessGUILauncher +import de.nowchess.ui.terminal.TerminalUI +import io.quarkus.runtime.annotations.QuarkusMain +import io.quarkus.runtime.{Quarkus, QuarkusApplication} + +@QuarkusMain +class AppMain extends QuarkusApplication: + override def run(args: String*): Int = + val engine = GameEngineHolder.engine + println(s"REST API -> http://localhost:8080/api/board/game/${GameEngineHolder.gameId}") + ChessGUILauncher.launch(engine) + new TerminalUI(engine).start() + Quarkus.asyncExit() + 0 diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameEngineHolder.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameEngineHolder.scala new file mode 100644 index 0000000..f3a6e8c --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameEngineHolder.scala @@ -0,0 +1,28 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.chess.engine.GameEngine + +import java.util.concurrent.atomic.AtomicReference + +/** Singleton holder that bridges an externally-created GameEngine into the CDI context. + * + * All fields are `val` wrapping an AtomicReference. Set engine / white / black before starting Quarkus. In + * standalone-server or test mode the defaults are used; GameService is created lazily (on first request), so the + * values are already set by the time CDI constructs it. + */ +object GameEngineHolder: + private val engineRef = new AtomicReference[GameEngine](new GameEngine()) + private val whiteRef = new AtomicReference[PlayerInfo](PlayerInfo(PlayerId("white"), "White")) + private val blackRef = new AtomicReference[PlayerInfo](PlayerInfo(PlayerId("black"), "Black")) + + val gameId: String = GameId.generate() + + def engine: GameEngine = engineRef.get() + def engine_=(e: GameEngine): Unit = engineRef.set(e) + + def white: PlayerInfo = whiteRef.get() + def white_=(p: PlayerInfo): Unit = whiteRef.set(p) + + def black: PlayerInfo = blackRef.get() + def black_=(p: PlayerInfo): Unit = blackRef.set(p) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala index 634b641..450e59d 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameMapper.scala @@ -3,6 +3,7 @@ package de.nowchess.backcore.game import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.scala.DefaultScalaModule import de.nowchess.api.board.Color +import de.nowchess.api.game.{DrawReason, GameResult as ApiGameResult} import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.backcore.dto.* import de.nowchess.io.fen.FenExporter @@ -12,54 +13,53 @@ import de.nowchess.rules.sets.DefaultRules object GameMapper: private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) - def toGameFullJson(session: GameSession): String = - mapper.writeValueAsString(toGameFull(session)) + def toGameFullJson(snapshot: GameSnapshot): String = + mapper.writeValueAsString(toGameFull(snapshot)) - def toGameFull(session: GameSession): GameFullResponse = + def toGameFull(snapshot: GameSnapshot): GameFullResponse = GameFullResponse( - gameId = session.gameId, - white = toPlayerInfo(session.white), - black = toPlayerInfo(session.black), - state = toGameState(session), + gameId = snapshot.gameId, + white = toPlayerInfo(snapshot.white), + black = toPlayerInfo(snapshot.black), + state = toGameState(snapshot), ) - def toGameState(session: GameSession): GameStateResponse = - val (status, winner) = computeStatus(session) + def toGameState(snapshot: GameSnapshot): GameStateResponse = + val (status, winner) = computeStatus(snapshot) GameStateResponse( - fen = FenExporter.exportGameContext(session.context), - pgn = buildPgn(session.context.moves), - turn = if session.context.turn == Color.White then "white" else "black", + fen = FenExporter.exportGameContext(snapshot.context), + pgn = buildPgn(snapshot.context.moves), + turn = if snapshot.context.turn == Color.White then "white" else "black", status = status, winner = winner, - moves = session.context.moves.map(moveToUci), - undoAvailable = session.invoker.canUndo, - redoAvailable = session.invoker.canRedo, + moves = snapshot.context.moves.map(moveToUci), + undoAvailable = snapshot.canUndo, + redoAvailable = snapshot.canRedo, ) private def toPlayerInfo(p: de.nowchess.api.player.PlayerInfo): PlayerInfoDto = PlayerInfoDto(id = p.id.value, displayName = p.displayName) - private def computeStatus(session: GameSession): (String, Option[String]) = - session.result match - case Some(GameResult.Checkmate(winner)) => - val w = if winner == Color.White then "white" else "black" - ("checkmate", Some(w)) - case Some(GameResult.Stalemate) => - ("stalemate", None) + private def colorStr(c: Color): String = if c == Color.White then "white" else "black" + + private def computeStatus(snapshot: GameSnapshot): (String, Option[String]) = + snapshot.externalResult match case Some(GameResult.Resign(winner)) => - val w = if winner == Color.White then "white" else "black" - ("resign", Some(w)) + ("resign", Some(colorStr(winner))) case Some(GameResult.AgreedDraw) | Some(GameResult.FiftyMoveDraw) => ("draw", None) - case Some(GameResult.InsufficientMaterial) => - ("insufficientMaterial", None) - case None => - computeLiveStatus(session) + case _ => + snapshot.context.result match + case Some(ApiGameResult.Win(winner)) => ("checkmate", Some(colorStr(winner))) + case Some(ApiGameResult.Draw(DrawReason.Stalemate)) => ("stalemate", None) + case Some(ApiGameResult.Draw(DrawReason.InsufficientMaterial)) => ("insufficientMaterial", None) + case Some(ApiGameResult.Draw(_)) => ("draw", None) + case None => computeLiveStatus(snapshot) - private def computeLiveStatus(session: GameSession): (String, Option[String]) = - val ctx = session.context + private def computeLiveStatus(snapshot: GameSnapshot): (String, Option[String]) = + val ctx = snapshot.context if DefaultRules.isCheck(ctx) then ("check", None) - else if session.drawOfferedBy.isDefined then ("drawOffered", None) + else if snapshot.drawOfferedBy.isDefined then ("drawOffered", None) else if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None) else ("started", None) @@ -76,5 +76,4 @@ object GameMapper: case _ => base private def buildPgn(moves: List[Move]): String = - // Use PgnExporter with no headers to get move-text only (SAN notation) PgnExporter.exportGame(Map.empty, moves) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameService.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameService.scala new file mode 100644 index 0000000..e06d7ad --- /dev/null +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameService.scala @@ -0,0 +1,175 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.{Color, Square} +import de.nowchess.api.game.GameContext +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.player.{PlayerId, PlayerInfo} +import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto} +import de.nowchess.io.fen.{FenExporter, FenParser} +import de.nowchess.io.pgn.{PgnExporter, PgnParser} +import de.nowchess.rules.sets.DefaultRules +import jakarta.enterprise.context.ApplicationScoped + +private case class ServiceState( + drawOfferedBy: Option[Color] = None, + externalResult: Option[GameResult] = None, +) + +@ApplicationScoped +class GameService: + private val engine = GameEngineHolder.engine + @SuppressWarnings(Array("DisableSyntax.var")) + private var state: ServiceState = ServiceState() + + def isKnownId(id: String): Boolean = id == GameEngineHolder.gameId + + def getSnapshot: GameSnapshot = synchronized: + GameSnapshot( + gameId = GameEngineHolder.gameId, + white = GameEngineHolder.white, + black = GameEngineHolder.black, + context = engine.context, + drawOfferedBy = state.drawOfferedBy, + externalResult = state.externalResult, + canUndo = engine.canUndo, + canRedo = engine.canRedo, + ) + + def reset(req: CreateGameRequest): GameSnapshot = synchronized: + engine.reset() + state = ServiceState() + GameEngineHolder.white = toPlayerInfo(req.white, "white", "White") + GameEngineHolder.black = toPlayerInfo(req.black, "black", "Black") + getSnapshot + + def applyMove(uci: String): Either[String, GameSnapshot] = synchronized: + if hasEnded then Left("Game is already over") + else + parseUci(uci) match + case None => Left(s"Invalid UCI notation: $uci") + case Some((from, to, promotion)) => + val candidates = engine.ruleSet.legalMoves(engine.context)(from) + findMatchingMove(candidates, to, promotion) match + case None => Left(s"$uci is not a legal move") + case Some(move) => + engine.processUserInput(s"$from$to") + promotion.foreach(engine.completePromotion) + Right(getSnapshot) + + def legalMoves(square: Option[Square]): List[Move] = synchronized: + val ctx = engine.context + square match + case Some(sq) => engine.ruleSet.legalMoves(ctx)(sq) + case None => engine.ruleSet.allLegalMoves(ctx) + + def undo(): Either[String, GameSnapshot] = synchronized: + if !engine.canUndo then Left("No moves to undo") + else + engine.undo() + state = state.copy(externalResult = None, drawOfferedBy = None) + Right(getSnapshot) + + def redo(): Either[String, GameSnapshot] = synchronized: + if !engine.canRedo then Left("No moves to redo") + else + engine.redo() + Right(getSnapshot) + + def resign(): Either[String, GameSnapshot] = synchronized: + if hasEnded then Left("Game is already over") + else + val winner = engine.context.turn.opposite + state = state.copy(externalResult = Some(GameResult.Resign(winner))) + Right(getSnapshot) + + def drawAction(action: String): Either[String, GameSnapshot] = synchronized: + if hasEnded then Left("Game is already over") + else + action match + case "offer" => + state = state.copy(drawOfferedBy = Some(engine.context.turn)) + Right(getSnapshot) + case "accept" => + state.drawOfferedBy match + case None => Left("No draw offer to accept") + case Some(offerer) if offerer == engine.context.turn => + Left("Cannot accept your own draw offer") + case Some(_) => + state = state.copy(externalResult = Some(GameResult.AgreedDraw), drawOfferedBy = None) + Right(getSnapshot) + case "decline" => + state.drawOfferedBy match + case None => Left("No draw offer to decline") + case Some(_) => + state = state.copy(drawOfferedBy = None) + Right(getSnapshot) + case "claim" => + if DefaultRules.isFiftyMoveRule(engine.context) then + state = state.copy(externalResult = Some(GameResult.FiftyMoveDraw)) + Right(getSnapshot) + else Left("Fifty-move rule has not been triggered") + case other => Left(s"Unknown draw action: $other") + + def importFen(req: ImportFenRequest): Either[String, GameSnapshot] = synchronized: + FenParser.parseFen(req.fen) match + case Left(err) => Left(err) + case Right(ctx) => + engine.loadPosition(ctx) + state = ServiceState() + GameEngineHolder.white = toPlayerInfo(req.white, "white", "White") + GameEngineHolder.black = toPlayerInfo(req.black, "black", "Black") + Right(getSnapshot) + + def importPgn(pgn: String): Either[String, GameSnapshot] = synchronized: + engine.loadGame(PgnParser, pgn) match + case Left(err) => Left(err) + case Right(_) => + state = ServiceState() + Right(getSnapshot) + + def exportFen(): String = FenExporter.exportGameContext(engine.context) + + def exportPgn(): String = PgnExporter.exportGameContext(engine.context) + + private def hasEnded: Boolean = + state.externalResult.isDefined || engine.context.result.isDefined + + private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo = + dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName)) + + private def parseUci(uci: String): Option[(Square, Square, Option[PromotionPiece])] = + if uci.length < 4 || uci.length > 5 then None + else + for + from <- Square.fromAlgebraic(uci.substring(0, 2)) + to <- Square.fromAlgebraic(uci.substring(2, 4)) + yield + val promotion = if uci.length == 5 then parsePromotionChar(uci.charAt(4)) else None + (from, to, promotion) + + private def parsePromotionChar(c: Char): Option[PromotionPiece] = + c match + case 'q' => Some(PromotionPiece.Queen) + case 'r' => Some(PromotionPiece.Rook) + case 'b' => Some(PromotionPiece.Bishop) + case 'n' => Some(PromotionPiece.Knight) + case _ => None + + private def findMatchingMove( + candidates: List[Move], + to: Square, + promotion: Option[PromotionPiece], + ): Option[Move] = + candidates.filter(_.to == to) match + case Nil => None + case moves => + promotion match + case Some(pp) => moves.find(_.moveType == MoveType.Promotion(pp)) + case None => + moves + .find(m => + m.moveType match + case _: MoveType.Promotion => false + case _ => true, + ) + .orElse(moves.headOption) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSnapshot.scala similarity index 67% rename from modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala rename to modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSnapshot.scala index 8d0b437..aaac46a 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSession.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameSnapshot.scala @@ -3,14 +3,14 @@ package de.nowchess.backcore.game import de.nowchess.api.board.Color import de.nowchess.api.game.GameContext import de.nowchess.api.player.PlayerInfo -import de.nowchess.chess.command.CommandInvoker -case class GameSession( +case class GameSnapshot( gameId: String, white: PlayerInfo, black: PlayerInfo, context: GameContext, - invoker: CommandInvoker, drawOfferedBy: Option[Color] = None, - result: Option[GameResult] = None, + externalResult: Option[GameResult] = None, + canUndo: Boolean = false, + canRedo: Boolean = false, ) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala deleted file mode 100644 index 3fc33a8..0000000 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/game/GameStore.scala +++ /dev/null @@ -1,260 +0,0 @@ -package de.nowchess.backcore.game - -import de.nowchess.api.board.{Color, Square} -import de.nowchess.api.game.GameContext -import de.nowchess.api.move.{Move, MoveType, PromotionPiece} -import de.nowchess.api.player.{PlayerId, PlayerInfo} -import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto} -import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult} -import de.nowchess.io.fen.FenParser -import de.nowchess.io.pgn.PgnParser -import de.nowchess.rules.sets.DefaultRules -import jakarta.enterprise.context.ApplicationScoped - -import scala.collection.mutable - -@ApplicationScoped -class GameStore: - private val games: mutable.Map[String, GameSession] = mutable.Map.empty - - // ─── Create / Get ──────────────────────────────────────────────── - - def create(req: CreateGameRequest): GameSession = synchronized: - val id = generateId() - val session = newSession(id, req.white, req.black, GameContext.initial) - games(id) = session - session - - def get(id: String): Option[GameSession] = synchronized: - games.get(id) - - // ─── Move-making ───────────────────────────────────────────────── - - def applyMove(id: String, uci: String): Either[String, GameSession] = synchronized: - withSession(id): session => - if session.result.isDefined then Left("Game is already over") - else - parseUci(uci) match - case None => Left(s"Invalid UCI notation: $uci") - case Some((from, to, promotion)) => - val legalCandidates = DefaultRules.legalMoves(session.context)(from) - findMatchingMove(legalCandidates, to, promotion) match - case None => Left(s"$uci is not a legal move") - case Some(move) => - val nextCtx = DefaultRules.applyMove(session.context)(move) - val prevCtx = session.context - val cmd = MoveCommand( - from = move.from, - to = move.to, - moveResult = Some(MoveResult.Successful(nextCtx, prevCtx.board.pieceAt(move.to))), - previousContext = Some(prevCtx), - ) - session.invoker.execute(cmd) - val result = detectGameOver(nextCtx) - val updated = session.copy(context = nextCtx, result = result) - games(id) = updated - Right(updated) - - def legalMoves(id: String, square: Option[Square]): Either[String, List[Move]] = synchronized: - withSession(id): session => - val moves = square match - case Some(sq) => DefaultRules.legalMoves(session.context)(sq) - case None => DefaultRules.allLegalMoves(session.context) - Right(moves) - - // ─── Undo / Redo ───────────────────────────────────────────────── - - def undo(id: String): Either[String, GameSession] = synchronized: - withSession(id): session => - if !session.invoker.canUndo then Left("No moves to undo") - else - val idx = session.invoker.getCurrentIndex - session.invoker.history(idx) match - case cmd: MoveCommand => - cmd.previousContext match - case None => Left("Cannot undo: no previous context stored") - case Some(prevCtx) => - session.invoker.undo() - val updated = session.copy(context = prevCtx, result = None, drawOfferedBy = None) - games(id) = updated - Right(updated) - case _ => Left("Cannot undo this command type") - - def redo(id: String): Either[String, GameSession] = synchronized: - withSession(id): session => - if !session.invoker.canRedo then Left("No moves to redo") - else - val idx = session.invoker.getCurrentIndex + 1 - session.invoker.history(idx) match - case cmd: MoveCommand => - cmd.moveResult match - case Some(MoveResult.Successful(nextCtx, _)) => - session.invoker.redo() - val result = detectGameOver(nextCtx) - val updated = session.copy(context = nextCtx, result = result) - games(id) = updated - Right(updated) - case _ => Left("Cannot redo: move result not available") - case _ => Left("Cannot redo this command type") - - // ─── Resign ────────────────────────────────────────────────────── - - def resign(id: String): Either[String, GameSession] = synchronized: - withSession(id): session => - if session.result.isDefined then Left("Game is already over") - else - val winner = session.context.turn.opposite - val updated = session.copy(result = Some(GameResult.Resign(winner))) - games(id) = updated - Right(updated) - - // ─── Draw actions ──────────────────────────────────────────────── - - def drawAction(id: String, action: String): Either[String, GameSession] = synchronized: - withSession(id): session => - if session.result.isDefined then Left("Game is already over") - else - action match - case "offer" => - val updated = session.copy(drawOfferedBy = Some(session.context.turn)) - games(id) = updated - Right(updated) - case "accept" => - session.drawOfferedBy match - case None => Left("No draw offer to accept") - case Some(offerer) if offerer == session.context.turn => - Left("Cannot accept your own draw offer") - case Some(_) => - val updated = session.copy(result = Some(GameResult.AgreedDraw), drawOfferedBy = None) - games(id) = updated - Right(updated) - case "decline" => - session.drawOfferedBy match - case None => Left("No draw offer to decline") - case Some(_) => - val updated = session.copy(drawOfferedBy = None) - games(id) = updated - Right(updated) - case "claim" => - if DefaultRules.isFiftyMoveRule(session.context) then - val updated = session.copy(result = Some(GameResult.FiftyMoveDraw)) - games(id) = updated - Right(updated) - else Left("Fifty-move rule has not been triggered") - case other => Left(s"Unknown draw action: $other") - - // ─── Import ────────────────────────────────────────────────────── - - def importFen(req: ImportFenRequest): Either[String, GameSession] = synchronized: - FenParser.parseFen(req.fen) match - case Left(err) => Left(err) - case Right(ctx) => - val id = generateId() - val session = newSession(id, req.white, req.black, ctx) - games(id) = session - Right(session) - - def importPgn(pgn: String, white: Option[PlayerInfoDto], black: Option[PlayerInfoDto]): Either[String, GameSession] = - synchronized: - PgnParser.validatePgn(pgn) match - case Left(err) => Left(err) - case Right(game) => - val id = generateId() - val session = newSession(id, white, black, GameContext.initial) - replayIntoSession(session, game.moves, GameContext.initial) match - case Left(err) => Left(err) - case Right(s) => - games(id) = s - Right(s) - - // ─── Private helpers ───────────────────────────────────────────── - - private def withSession[A](id: String)(f: GameSession => Either[String, A]): Either[String, A] = - games.get(id) match - case None => Left(s"Game $id not found") - case Some(session) => f(session) - - private def generateId(): String = - var id = GameId.generate() - while games.contains(id) do id = GameId.generate() - id - - private def newSession( - id: String, - white: Option[PlayerInfoDto], - black: Option[PlayerInfoDto], - ctx: GameContext, - ): GameSession = - GameSession( - gameId = id, - white = toPlayerInfo(white, "white", "White"), - black = toPlayerInfo(black, "black", "Black"), - context = ctx, - invoker = new CommandInvoker(), - ) - - private def toPlayerInfo(dto: Option[PlayerInfoDto], defaultId: String, defaultName: String): PlayerInfo = - dto.fold(PlayerInfo(PlayerId(defaultId), defaultName))(d => PlayerInfo(PlayerId(d.id), d.displayName)) - - private def parseUci(uci: String): Option[(Square, Square, Option[PromotionPiece])] = - if uci.length < 4 || uci.length > 5 then None - else - for - from <- Square.fromAlgebraic(uci.substring(0, 2)) - to <- Square.fromAlgebraic(uci.substring(2, 4)) - yield - val promotion = if uci.length == 5 then parsePromotionChar(uci.charAt(4)) else None - (from, to, promotion) - - private def parsePromotionChar(c: Char): Option[PromotionPiece] = - c match - case 'q' => Some(PromotionPiece.Queen) - case 'r' => Some(PromotionPiece.Rook) - case 'b' => Some(PromotionPiece.Bishop) - case 'n' => Some(PromotionPiece.Knight) - case _ => None - - private def findMatchingMove( - candidates: List[Move], - to: Square, - promotion: Option[PromotionPiece], - ): Option[Move] = - candidates.filter(_.to == to) match - case Nil => None - case moves => - promotion match - case Some(pp) => moves.find(_.moveType == MoveType.Promotion(pp)) - case None => - moves - .find(m => !m.moveType.isInstanceOf[MoveType.Promotion]) - .orElse(moves.headOption) - - private def detectGameOver(ctx: GameContext): Option[GameResult] = - if DefaultRules.isCheckmate(ctx) then Some(GameResult.Checkmate(ctx.turn.opposite)) - else if DefaultRules.isStalemate(ctx) then Some(GameResult.Stalemate) - else if DefaultRules.isInsufficientMaterial(ctx) then Some(GameResult.InsufficientMaterial) - else None - - private def replayIntoSession( - session: GameSession, - moves: List[Move], - startCtx: GameContext, - ): Either[String, GameSession] = - moves.foldLeft[Either[String, GameSession]](Right(session)): - case (Left(err), _) => Left(err) - case (Right(s), move) => - val legal = DefaultRules.legalMoves(s.context)(move.from) - legal - .find(m => m.from == move.from && m.to == move.to && m.moveType == move.moveType) - .orElse(legal.find(m => m.from == move.from && m.to == move.to)) match - case None => Left(s"Illegal move in PGN: $move") - case Some(legalMove) => - val nextCtx = DefaultRules.applyMove(s.context)(legalMove) - val cmd = MoveCommand( - from = legalMove.from, - to = legalMove.to, - moveResult = Some(MoveResult.Successful(nextCtx, s.context.board.pieceAt(legalMove.to))), - previousContext = Some(s.context), - ) - s.invoker.execute(cmd) - Right(s.copy(context = nextCtx)) diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala index 0c72855..44b7913 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/GameResource.scala @@ -1,7 +1,7 @@ package de.nowchess.backcore.resource import de.nowchess.backcore.dto.* -import de.nowchess.backcore.game.{GameMapper, GameStore} +import de.nowchess.backcore.game.{GameMapper, GameService} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.ws.rs.* @@ -10,50 +10,47 @@ import jakarta.ws.rs.core.{MediaType, Response} @Path("/api/board/game") @Produces(Array(MediaType.APPLICATION_JSON)) @ApplicationScoped -class GameResource @Inject() (store: GameStore): +class GameResource @Inject() (service: GameService): @POST @Consumes(Array(MediaType.APPLICATION_JSON)) def createGame(req: CreateGameRequest): Response = - val session = store.create(Option(req).getOrElse(CreateGameRequest())) - Response.status(201).entity(GameMapper.toGameFull(session)).build() + val snapshot = service.reset(Option(req).getOrElse(CreateGameRequest())) + Response.status(201).entity(GameMapper.toGameFull(snapshot)).build() @GET @Path("/{gameId}") def getGame(@PathParam("gameId") gameId: String): Response = - store.get(gameId) match - case Some(session) => Response.ok(GameMapper.toGameFull(session)).build() - case None => - Response - .status(404) - .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) - .build() + if !service.isKnownId(gameId) then + Response + .status(404) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + else Response.ok(GameMapper.toGameFull(service.getSnapshot)).build() @GET @Path("/{gameId}/stream") @Produces(Array("application/x-ndjson")) def streamGame(@PathParam("gameId") gameId: String): Response = - store.get(gameId) match - case None => - Response - .status(404) - .`type`(MediaType.APPLICATION_JSON) - .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) - .build() - case Some(session) => - // Simplified: return a single-line NDJSON snapshot of the current game state - val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(session)}}""" - Response.ok(event + "\n").build() + if !service.isKnownId(gameId) then + Response + .status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + else + val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(service.getSnapshot)}}""" + Response.ok(event + "\n").build() @POST @Path("/{gameId}/resign") def resignGame(@PathParam("gameId") gameId: String): Response = - store.resign(gameId) match - case Right(_) => Response.ok(OkResponse()).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + service.resign() match + case Right(_) => Response.ok(OkResponse()).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build() @POST @Path("/{gameId}/draw/{action}") @@ -61,39 +58,33 @@ class GameResource @Inject() (store: GameStore): @PathParam("gameId") gameId: String, @PathParam("action") action: String, ): Response = - store.drawAction(gameId, action) match - case Right(_) => Response.ok(OkResponse()).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + service.drawAction(action) match + case Right(_) => Response.ok(OkResponse()).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build() @GET @Path("/{gameId}/export/fen") @Produces(Array(MediaType.TEXT_PLAIN)) def exportFen(@PathParam("gameId") gameId: String): Response = - store.get(gameId) match - case None => - Response - .status(404) - .`type`(MediaType.APPLICATION_JSON) - .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) - .build() - case Some(session) => - import de.nowchess.io.fen.FenExporter - Response.ok(FenExporter.exportGameContext(session.context)).build() + if !service.isKnownId(gameId) then + Response + .status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + else Response.ok(service.exportFen()).build() @GET @Path("/{gameId}/export/pgn") @Produces(Array("application/x-chess-pgn")) def exportPgn(@PathParam("gameId") gameId: String): Response = - store.get(gameId) match - case None => - Response - .status(404) - .`type`(MediaType.APPLICATION_JSON) - .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) - .build() - case Some(session) => - import de.nowchess.io.pgn.PgnExporter - Response.ok(PgnExporter.exportGameContext(session.context)).build() + if !service.isKnownId(gameId) then + Response + .status(404) + .`type`(MediaType.APPLICATION_JSON) + .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) + .build() + else Response.ok(service.exportPgn()).build() diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala index 6291661..22ac794 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/ImportResource.scala @@ -1,7 +1,7 @@ package de.nowchess.backcore.resource import de.nowchess.backcore.dto.{ApiErrorResponse, ImportFenRequest, ImportPgnRequest} -import de.nowchess.backcore.game.{GameMapper, GameStore} +import de.nowchess.backcore.game.{GameMapper, GameService} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.ws.rs.* @@ -11,19 +11,19 @@ import jakarta.ws.rs.core.{MediaType, Response} @Produces(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON)) @ApplicationScoped -class ImportResource @Inject() (store: GameStore): +class ImportResource @Inject() (service: GameService): @POST @Path("/fen") def importFen(req: ImportFenRequest): Response = - store.importFen(Option(req).getOrElse(ImportFenRequest())) match - case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() - case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build() + service.importFen(Option(req).getOrElse(ImportFenRequest())) match + case Right(snap) => Response.status(201).entity(GameMapper.toGameFull(snap)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build() @POST @Path("/pgn") def importPgn(req: ImportPgnRequest): Response = val body = Option(req).getOrElse(ImportPgnRequest()) - store.importPgn(body.pgn, None, None) match - case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() - case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build() + service.importPgn(body.pgn) match + case Right(snap) => Response.status(201).entity(GameMapper.toGameFull(snap)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build() diff --git a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala index b95b435..2cb9cca 100644 --- a/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala +++ b/modules/backcore/src/main/scala/de/nowchess/backcore/resource/MoveResource.scala @@ -3,7 +3,7 @@ package de.nowchess.backcore.resource import de.nowchess.api.board.Square import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.backcore.dto.* -import de.nowchess.backcore.game.{GameMapper, GameStore} +import de.nowchess.backcore.game.{GameMapper, GameService} import jakarta.enterprise.context.ApplicationScoped import jakarta.inject.Inject import jakarta.ws.rs.* @@ -12,7 +12,7 @@ import jakarta.ws.rs.core.{MediaType, Response} @Path("/api/board/game") @Produces(Array(MediaType.APPLICATION_JSON)) @ApplicationScoped -class MoveResource @Inject() (store: GameStore): +class MoveResource @Inject() (service: GameService): @POST @Path("/{gameId}/move/{uci}") @@ -20,12 +20,12 @@ class MoveResource @Inject() (store: GameStore): @PathParam("gameId") gameId: String, @PathParam("uci") uci: String, ): Response = - store.applyMove(gameId, uci) match - case Right(session) => Response.ok(GameMapper.toGameState(session)).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + service.applyMove(uci) match + case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build() @GET @Path("/{gameId}/moves") @@ -33,35 +33,33 @@ class MoveResource @Inject() (store: GameStore): @PathParam("gameId") gameId: String, @QueryParam("square") squareParam: String, ): Response = - val square = Option(squareParam).flatMap(Square.fromAlgebraic) - store.legalMoves(gameId, square) match - case Right(moves) => - val dtos = moves.map(toLegalMoveDto) - Response.ok(LegalMovesResponse(dtos)).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("ERROR", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + val square = Option(squareParam).flatMap(Square.fromAlgebraic) + val moves = service.legalMoves(square) + val dtos = moves.map(toLegalMoveDto) + Response.ok(LegalMovesResponse(dtos)).build() @POST @Path("/{gameId}/undo") def undoMove(@PathParam("gameId") gameId: String): Response = - store.undo(gameId) match - case Right(session) => Response.ok(GameMapper.toGameState(session)).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + service.undo() match + case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build() @POST @Path("/{gameId}/redo") def redoMove(@PathParam("gameId") gameId: String): Response = - store.redo(gameId) match - case Right(session) => Response.ok(GameMapper.toGameState(session)).build() - case Left(err) if err.contains("not found") => - Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() - case Left(err) => - Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build() + if !service.isKnownId(gameId) then + Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build() + else + service.redo() match + case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build() + case Left(err) => Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build() private def toLegalMoveDto(move: Move): LegalMoveDto = val uci = GameMapper.moveToUci(move) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameMapperTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameMapperTest.scala new file mode 100644 index 0000000..14292e0 --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameMapperTest.scala @@ -0,0 +1,181 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.{Color, File, Rank, Square} +import de.nowchess.api.game.{DrawReason, GameContext, GameResult as ApiGameResult} +import de.nowchess.api.move.{Move, MoveType, PromotionPiece} +import de.nowchess.api.player.{PlayerId, PlayerInfo} +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class GameMapperTest: + + private val white = PlayerInfo(PlayerId("white"), "White") + private val black = PlayerInfo(PlayerId("black"), "Black") + + private def snap( + ctx: GameContext = GameContext.initial, + externalResult: Option[GameResult] = None, + drawOfferedBy: Option[Color] = None, + canUndo: Boolean = false, + canRedo: Boolean = false, + ): GameSnapshot = + GameSnapshot( + gameId = "testId1", + white = white, + black = black, + context = ctx, + drawOfferedBy = drawOfferedBy, + externalResult = externalResult, + canUndo = canUndo, + canRedo = canRedo, + ) + + @Test + def resignWhiteReturnsResignWithWhiteWinner(): Unit = + val state = GameMapper.toGameState(snap(externalResult = Some(GameResult.Resign(Color.White)))) + assertEquals("resign", state.status) + assertEquals(Some("white"), state.winner) + + @Test + def resignBlackReturnsResignWithBlackWinner(): Unit = + val state = GameMapper.toGameState(snap(externalResult = Some(GameResult.Resign(Color.Black)))) + assertEquals("resign", state.status) + assertEquals(Some("black"), state.winner) + + @Test + def agreedDrawReturnsDrawNoWinner(): Unit = + val state = GameMapper.toGameState(snap(externalResult = Some(GameResult.AgreedDraw))) + assertEquals("draw", state.status) + assertEquals(None, state.winner) + + @Test + def fiftyMoveDrawReturnsDrawNoWinner(): Unit = + val state = GameMapper.toGameState(snap(externalResult = Some(GameResult.FiftyMoveDraw))) + assertEquals("draw", state.status) + assertEquals(None, state.winner) + + @Test + def contextWinWhiteReturnsCheckmate(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Win(Color.White))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("checkmate", state.status) + assertEquals(Some("white"), state.winner) + + @Test + def contextWinBlackReturnsCheckmate(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Win(Color.Black))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("checkmate", state.status) + assertEquals(Some("black"), state.winner) + + @Test + def contextDrawStalemateReturnsStalemate(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Draw(DrawReason.Stalemate))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("stalemate", state.status) + assertEquals(None, state.winner) + + @Test + def contextDrawInsufficientMaterialReturnsInsufficientMaterial(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Draw(DrawReason.InsufficientMaterial))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("insufficientMaterial", state.status) + assertEquals(None, state.winner) + + @Test + def contextDrawFiftyMoveRuleReturnsDraw(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Draw(DrawReason.FiftyMoveRule))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("draw", state.status) + assertEquals(None, state.winner) + + @Test + def contextDrawAgreementReturnsDraw(): Unit = + val ctx = GameContext.initial.withResult(Some(ApiGameResult.Draw(DrawReason.Agreement))) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("draw", state.status) + assertEquals(None, state.winner) + + @Test + def liveDrawOfferedBySetReturnsDrawOffered(): Unit = + val state = GameMapper.toGameState(snap(drawOfferedBy = Some(Color.White))) + assertEquals("drawOffered", state.status) + + @Test + def liveFiftyMoveClockGe100ReturnsFiftyMoveAvailable(): Unit = + val ctx = GameContext.initial.withHalfMoveClock(100) + val state = GameMapper.toGameState(snap(ctx = ctx)) + assertEquals("fiftyMoveAvailable", state.status) + + @Test + def liveNormalPositionReturnsStarted(): Unit = + val state = GameMapper.toGameState(snap()) + assertEquals("started", state.status) + assertEquals(None, state.winner) + + @Test + def canUndoReflectedInToGameState(): Unit = + val state = GameMapper.toGameState(snap(canUndo = true, canRedo = false)) + assertTrue(state.undoAvailable) + assertFalse(state.redoAvailable) + + @Test + def canRedoReflectedInToGameState(): Unit = + val state = GameMapper.toGameState(snap(canUndo = false, canRedo = true)) + assertFalse(state.undoAvailable) + assertTrue(state.redoAvailable) + + @Test + def moveToUciNormalQuiet(): Unit = + val sq = Square(File.E, Rank.R2) + val sq2 = Square(File.E, Rank.R4) + val move = Move(sq, sq2, MoveType.Normal(false)) + assertEquals("e2e4", GameMapper.moveToUci(move)) + + @Test + def moveToUciNormalCapture(): Unit = + val move = Move(Square(File.D, Rank.R4), Square(File.E, Rank.R5), MoveType.Normal(true)) + assertEquals("d4e5", GameMapper.moveToUci(move)) + + @Test + def moveToUciCastleKingside(): Unit = + val move = Move(Square(File.E, Rank.R1), Square(File.G, Rank.R1), MoveType.CastleKingside) + assertEquals("e1g1", GameMapper.moveToUci(move)) + + @Test + def moveToUciCastleQueenside(): Unit = + val move = Move(Square(File.E, Rank.R1), Square(File.C, Rank.R1), MoveType.CastleQueenside) + assertEquals("e1c1", GameMapper.moveToUci(move)) + + @Test + def moveToUciEnPassant(): Unit = + val move = Move(Square(File.E, Rank.R5), Square(File.D, Rank.R6), MoveType.EnPassant) + assertEquals("e5d6", GameMapper.moveToUci(move)) + + @Test + def moveToUciPromotionQueen(): Unit = + val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Queen)) + assertEquals("e7e8q", GameMapper.moveToUci(move)) + + @Test + def moveToUciPromotionRook(): Unit = + val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Rook)) + assertEquals("e7e8r", GameMapper.moveToUci(move)) + + @Test + def moveToUciPromotionBishop(): Unit = + val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Bishop)) + assertEquals("e7e8b", GameMapper.moveToUci(move)) + + @Test + def moveToUciPromotionKnight(): Unit = + val move = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R8), MoveType.Promotion(PromotionPiece.Knight)) + assertEquals("e7e8n", GameMapper.moveToUci(move)) + + @Test + def toGameFullPopulatesAllFields(): Unit = + val full = GameMapper.toGameFull(snap()) + assertEquals("testId1", full.gameId) + assertEquals("white", full.white.id) + assertEquals("black", full.black.id) + assertNotNull(full.state) diff --git a/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameServiceTest.scala b/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameServiceTest.scala new file mode 100644 index 0000000..fe58c1e --- /dev/null +++ b/modules/backcore/src/test/scala/de/nowchess/backcore/game/GameServiceTest.scala @@ -0,0 +1,245 @@ +package de.nowchess.backcore.game + +import de.nowchess.api.board.{Color, File, Rank, Square} +import de.nowchess.backcore.dto.{CreateGameRequest, ImportFenRequest, PlayerInfoDto} +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.{BeforeEach, Test} + +class GameServiceTest: + + private val promotionFen = "8/4P3/8/8/8/8/8/4K2k w - - 0 1" + + private def freshService(): GameService = + val svc = new GameService() + svc.reset(CreateGameRequest()) + svc + + @Test + def resetClearsStateAndReturnsSnapshot(): Unit = + val svc = freshService() + svc.resign() + val snap = svc.reset(CreateGameRequest()) + assertEquals(GameEngineHolder.gameId, snap.gameId) + assertEquals(None, snap.externalResult) + assertEquals(None, snap.drawOfferedBy) + + @Test + def resetWithPlayersUpdatesHolderInfo(): Unit = + val svc = freshService() + val req = CreateGameRequest( + white = Some(PlayerInfoDto("p1", "Alice")), + black = Some(PlayerInfoDto("p2", "Bob")), + ) + val snap = svc.reset(req) + assertEquals("Alice", snap.white.displayName) + assertEquals("Bob", snap.black.displayName) + + @Test + def applyMoveInvalidUciReturnsLeft(): Unit = + val svc = freshService() + val result = svc.applyMove("zzzz") + assertTrue(result.isLeft) + + @Test + def applyMoveIllegalMoveReturnsLeft(): Unit = + val svc = freshService() + val result = svc.applyMove("e2e5") + assertTrue(result.isLeft) + + @Test + def applyMoveValidMoveReturnsRightWithUpdatedContext(): Unit = + val svc = freshService() + val result = svc.applyMove("e2e4") + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertEquals(Color.Black, snap.context.turn) + + @Test + def applyMoveWhenGameOverReturnsLeft(): Unit = + val svc = freshService() + svc.resign() + val result = svc.applyMove("e2e4") + assertTrue(result.isLeft) + assertTrue(result.left.getOrElse("").contains("Game is already over")) + + @Test + def legalMovesNoneReturnsNonEmptyListAtStart(): Unit = + val svc = freshService() + val moves = svc.legalMoves(None) + assertFalse(moves.isEmpty) + + @Test + def legalMovesForE2ReturnsE2e3AndE2e4(): Unit = + val svc = freshService() + val e2 = Square(File.E, Rank.R2) + val moves = svc.legalMoves(Some(e2)) + val ucis = moves.map(m => s"${m.from}${m.to}") + assertTrue(ucis.contains("e2e3"), s"Expected e2e3 in $ucis") + assertTrue(ucis.contains("e2e4"), s"Expected e2e4 in $ucis") + + @Test + def undoWithNoHistoryReturnsLeft(): Unit = + val svc = freshService() + val result = svc.undo() + assertTrue(result.isLeft) + + @Test + def undoAfterMoveReverts(): Unit = + val svc = freshService() + svc.applyMove("e2e4") + val result = svc.undo() + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertEquals(Color.White, snap.context.turn) + + @Test + def redoWithNoRedoStackReturnsLeft(): Unit = + val svc = freshService() + val result = svc.redo() + assertTrue(result.isLeft) + + @Test + def redoAfterUndoReturnsRight(): Unit = + val svc = freshService() + svc.applyMove("e2e4") + svc.undo() + val result = svc.redo() + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertEquals(Color.Black, snap.context.turn) + + @Test + def resignNormalReturnsRight(): Unit = + val svc = freshService() + val result = svc.resign() + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertTrue(snap.externalResult.isDefined) + snap.externalResult match + case Some(GameResult.Resign(_)) => () + case other => fail(s"Expected Resign but got $other") + + @Test + def resignWhenAlreadyResignedReturnsLeft(): Unit = + val svc = freshService() + svc.resign() + val result = svc.resign() + assertTrue(result.isLeft) + + @Test + def drawActionOfferSetsDrawOfferedBy(): Unit = + val svc = freshService() + val result = svc.drawAction("offer") + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertTrue(snap.drawOfferedBy.isDefined) + + @Test + def drawActionAcceptWithNoOfferReturnsLeft(): Unit = + val svc = freshService() + val result = svc.drawAction("accept") + assertTrue(result.isLeft) + + @Test + def drawActionAcceptOwnOfferReturnsLeft(): Unit = + val svc = freshService() + svc.drawAction("offer") + val result = svc.drawAction("accept") + assertTrue(result.isLeft) + assertTrue(result.left.getOrElse("").contains("Cannot accept your own draw offer")) + + @Test + def drawActionAcceptOpponentAcceptsReturnsAgreedDraw(): Unit = + val svc = freshService() + svc.drawAction("offer") + svc.applyMove("e2e4") + val result = svc.drawAction("accept") + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertEquals(Some(GameResult.AgreedDraw), snap.externalResult) + + @Test + def drawActionDeclineWithNoOfferReturnsLeft(): Unit = + val svc = freshService() + val result = svc.drawAction("decline") + assertTrue(result.isLeft) + + @Test + def drawActionDeclineAfterOfferClearsOffer(): Unit = + val svc = freshService() + svc.drawAction("offer") + val result = svc.drawAction("decline") + assertTrue(result.isRight) + val snap = result.getOrElse(fail("Expected Right")) + assertEquals(None, snap.drawOfferedBy) + + @Test + def drawActionClaimWhenFiftyMoveNotTriggeredReturnsLeft(): Unit = + val svc = freshService() + val result = svc.drawAction("claim") + assertTrue(result.isLeft) + + @Test + def drawActionUnknownReturnsLeft(): Unit = + val svc = freshService() + val result = svc.drawAction("unknown") + assertTrue(result.isLeft) + + @Test + def importFenValidReturnsRight(): Unit = + val svc = freshService() + val result = svc.importFen(ImportFenRequest(fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1")) + assertTrue(result.isRight) + + @Test + def importFenInvalidReturnsLeft(): Unit = + val svc = freshService() + val result = svc.importFen(ImportFenRequest(fen = "not-a-fen")) + assertTrue(result.isLeft) + + @Test + def importPgnValidReturnsRight(): Unit = + val svc = freshService() + val result = svc.importPgn("1. e4 e5 2. Nf3 Nc6 *") + assertTrue(result.isRight) + + @Test + def importPgnInvalidReturnsLeft(): Unit = + val svc = freshService() + val result = svc.importPgn("1. z9 *") + assertTrue(result.isLeft) + + @Test + def exportFenReturnsNonEmptyString(): Unit = + val svc = freshService() + val fen = svc.exportFen() + assertFalse(fen.isEmpty) + + @Test + def exportPgnReturnsString(): Unit = + val svc = freshService() + val pgn = svc.exportPgn() + assertNotNull(pgn) + + @Test + def isKnownIdTrueForHolderGameId(): Unit = + val svc = freshService() + assertTrue(svc.isKnownId(GameEngineHolder.gameId)) + + @Test + def isKnownIdFalseForOtherId(): Unit = + val svc = freshService() + assertFalse(svc.isKnownId("XXXXXXXX")) + + @Test + def applyMovePromotionQueenProducesQueen(): Unit = + val svc = freshService() + svc.importFen(ImportFenRequest(fen = promotionFen)) + val result = svc.applyMove("e7e8q") + assertTrue(result.isRight, s"Expected Right but got $result") + val snap = result.getOrElse(fail("Expected Right")) + val e8 = Square(File.E, Rank.R8) + snap.context.board.pieceAt(e8) match + case Some(piece) => + assertEquals(de.nowchess.api.board.PieceType.Queen, piece.pieceType) + case None => fail("Expected queen on e8") diff --git a/modules/ui/build.gradle.kts b/modules/ui/build.gradle.kts index 71e3a5f..231c92e 100644 --- a/modules/ui/build.gradle.kts +++ b/modules/ui/build.gradle.kts @@ -90,6 +90,14 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +configurations.scoverage { + resolutionStrategy.eachDependency { + if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) { + useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0") + } + } +} + tasks.test { useJUnitPlatform { includeEngines("scalatest") diff --git a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala index 14e0aea..42882d5 100644 --- a/modules/ui/src/main/scala/de/nowchess/ui/Main.scala +++ b/modules/ui/src/main/scala/de/nowchess/ui/Main.scala @@ -1,20 +1,15 @@ package de.nowchess.ui import de.nowchess.chess.engine.GameEngine -import de.nowchess.ui.terminal.TerminalUI import de.nowchess.ui.gui.ChessGUILauncher +import de.nowchess.ui.terminal.TerminalUI /** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same * GameEngine via Observer pattern. */ object Main: def main(args: Array[String]): Unit = - // Create the core game engine (single source of truth) val engine = new GameEngine() - - // Launch ScalaFX GUI in separate thread ChessGUILauncher.launch(engine) - - // Create and start the terminal UI (blocks on main thread) val tui = new TerminalUI(engine) tui.start()