feat(backcore): Quarkus compatible with GUI/TUI
Build & Test (NowChessSystems) TeamCity build failed

Added quarkus backcore run functionality so that it launches the TUI, GUI, Quarkus via modules: backcore:run
This commit is contained in:
LQ63
2026-04-14 23:24:16 +02:00
parent db955c08a5
commit 8fd6adc1f4
14 changed files with 776 additions and 394 deletions
+6 -1
View File
@@ -34,6 +34,7 @@ dependencies {
implementation(project(":modules:core")) implementation(project(":modules:core"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:ui"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest") implementation("io.quarkus:quarkus-rest")
@@ -90,7 +91,11 @@ tasks.jacocoTestReport {
dependsOn(tasks.test) dependsOn(tasks.test)
executionData.setFrom(layout.buildDirectory.file("jacoco-quarkus.exec")) executionData.setFrom(layout.buildDirectory.file("jacoco-quarkus.exec"))
sourceDirectories.setFrom(files("src/main/scala")) 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 { reports {
xml.required.set(true) xml.required.set(true)
xml.outputLocation.set( xml.outputLocation.set(
@@ -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
@@ -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)
@@ -3,6 +3,7 @@ package de.nowchess.backcore.game
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Color 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.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.backcore.dto.* import de.nowchess.backcore.dto.*
import de.nowchess.io.fen.FenExporter import de.nowchess.io.fen.FenExporter
@@ -12,54 +13,53 @@ import de.nowchess.rules.sets.DefaultRules
object GameMapper: object GameMapper:
private val mapper = new ObjectMapper().registerModule(DefaultScalaModule) private val mapper = new ObjectMapper().registerModule(DefaultScalaModule)
def toGameFullJson(session: GameSession): String = def toGameFullJson(snapshot: GameSnapshot): String =
mapper.writeValueAsString(toGameFull(session)) mapper.writeValueAsString(toGameFull(snapshot))
def toGameFull(session: GameSession): GameFullResponse = def toGameFull(snapshot: GameSnapshot): GameFullResponse =
GameFullResponse( GameFullResponse(
gameId = session.gameId, gameId = snapshot.gameId,
white = toPlayerInfo(session.white), white = toPlayerInfo(snapshot.white),
black = toPlayerInfo(session.black), black = toPlayerInfo(snapshot.black),
state = toGameState(session), state = toGameState(snapshot),
) )
def toGameState(session: GameSession): GameStateResponse = def toGameState(snapshot: GameSnapshot): GameStateResponse =
val (status, winner) = computeStatus(session) val (status, winner) = computeStatus(snapshot)
GameStateResponse( GameStateResponse(
fen = FenExporter.exportGameContext(session.context), fen = FenExporter.exportGameContext(snapshot.context),
pgn = buildPgn(session.context.moves), pgn = buildPgn(snapshot.context.moves),
turn = if session.context.turn == Color.White then "white" else "black", turn = if snapshot.context.turn == Color.White then "white" else "black",
status = status, status = status,
winner = winner, winner = winner,
moves = session.context.moves.map(moveToUci), moves = snapshot.context.moves.map(moveToUci),
undoAvailable = session.invoker.canUndo, undoAvailable = snapshot.canUndo,
redoAvailable = session.invoker.canRedo, redoAvailable = snapshot.canRedo,
) )
private def toPlayerInfo(p: de.nowchess.api.player.PlayerInfo): PlayerInfoDto = private def toPlayerInfo(p: de.nowchess.api.player.PlayerInfo): PlayerInfoDto =
PlayerInfoDto(id = p.id.value, displayName = p.displayName) PlayerInfoDto(id = p.id.value, displayName = p.displayName)
private def computeStatus(session: GameSession): (String, Option[String]) = private def colorStr(c: Color): String = if c == Color.White then "white" else "black"
session.result match
case Some(GameResult.Checkmate(winner)) => private def computeStatus(snapshot: GameSnapshot): (String, Option[String]) =
val w = if winner == Color.White then "white" else "black" snapshot.externalResult match
("checkmate", Some(w))
case Some(GameResult.Stalemate) =>
("stalemate", None)
case Some(GameResult.Resign(winner)) => case Some(GameResult.Resign(winner)) =>
val w = if winner == Color.White then "white" else "black" ("resign", Some(colorStr(winner)))
("resign", Some(w))
case Some(GameResult.AgreedDraw) | Some(GameResult.FiftyMoveDraw) => case Some(GameResult.AgreedDraw) | Some(GameResult.FiftyMoveDraw) =>
("draw", None) ("draw", None)
case Some(GameResult.InsufficientMaterial) => case _ =>
("insufficientMaterial", None) snapshot.context.result match
case None => case Some(ApiGameResult.Win(winner)) => ("checkmate", Some(colorStr(winner)))
computeLiveStatus(session) 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]) = private def computeLiveStatus(snapshot: GameSnapshot): (String, Option[String]) =
val ctx = session.context val ctx = snapshot.context
if DefaultRules.isCheck(ctx) then ("check", None) 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 if DefaultRules.isFiftyMoveRule(ctx) then ("fiftyMoveAvailable", None)
else ("started", None) else ("started", None)
@@ -76,5 +76,4 @@ object GameMapper:
case _ => base case _ => base
private def buildPgn(moves: List[Move]): String = private def buildPgn(moves: List[Move]): String =
// Use PgnExporter with no headers to get move-text only (SAN notation)
PgnExporter.exportGame(Map.empty, moves) PgnExporter.exportGame(Map.empty, moves)
@@ -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)
@@ -3,14 +3,14 @@ package de.nowchess.backcore.game
import de.nowchess.api.board.Color import de.nowchess.api.board.Color
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.player.PlayerInfo import de.nowchess.api.player.PlayerInfo
import de.nowchess.chess.command.CommandInvoker
case class GameSession( case class GameSnapshot(
gameId: String, gameId: String,
white: PlayerInfo, white: PlayerInfo,
black: PlayerInfo, black: PlayerInfo,
context: GameContext, context: GameContext,
invoker: CommandInvoker,
drawOfferedBy: Option[Color] = None, drawOfferedBy: Option[Color] = None,
result: Option[GameResult] = None, externalResult: Option[GameResult] = None,
canUndo: Boolean = false,
canRedo: Boolean = false,
) )
@@ -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))
@@ -1,7 +1,7 @@
package de.nowchess.backcore.resource package de.nowchess.backcore.resource
import de.nowchess.backcore.dto.* 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.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -10,50 +10,47 @@ import jakarta.ws.rs.core.{MediaType, Response}
@Path("/api/board/game") @Path("/api/board/game")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
@ApplicationScoped @ApplicationScoped
class GameResource @Inject() (store: GameStore): class GameResource @Inject() (service: GameService):
@POST @POST
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
def createGame(req: CreateGameRequest): Response = def createGame(req: CreateGameRequest): Response =
val session = store.create(Option(req).getOrElse(CreateGameRequest())) val snapshot = service.reset(Option(req).getOrElse(CreateGameRequest()))
Response.status(201).entity(GameMapper.toGameFull(session)).build() Response.status(201).entity(GameMapper.toGameFull(snapshot)).build()
@GET @GET
@Path("/{gameId}") @Path("/{gameId}")
def getGame(@PathParam("gameId") gameId: String): Response = def getGame(@PathParam("gameId") gameId: String): Response =
store.get(gameId) match if !service.isKnownId(gameId) then
case Some(session) => Response.ok(GameMapper.toGameFull(session)).build() Response
case None => .status(404)
Response .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
.status(404) .build()
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) else Response.ok(GameMapper.toGameFull(service.getSnapshot)).build()
.build()
@GET @GET
@Path("/{gameId}/stream") @Path("/{gameId}/stream")
@Produces(Array("application/x-ndjson")) @Produces(Array("application/x-ndjson"))
def streamGame(@PathParam("gameId") gameId: String): Response = def streamGame(@PathParam("gameId") gameId: String): Response =
store.get(gameId) match if !service.isKnownId(gameId) then
case None => Response
Response .status(404)
.status(404) .`type`(MediaType.APPLICATION_JSON)
.`type`(MediaType.APPLICATION_JSON) .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) .build()
.build() else
case Some(session) => val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(service.getSnapshot)}}"""
// Simplified: return a single-line NDJSON snapshot of the current game state Response.ok(event + "\n").build()
val event = s"""{"type":"gameFull","game":${GameMapper.toGameFullJson(session)}}"""
Response.ok(event + "\n").build()
@POST @POST
@Path("/{gameId}/resign") @Path("/{gameId}/resign")
def resignGame(@PathParam("gameId") gameId: String): Response = def resignGame(@PathParam("gameId") gameId: String): Response =
store.resign(gameId) match if !service.isKnownId(gameId) then
case Right(_) => Response.ok(OkResponse()).build() Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Left(err) if err.contains("not found") => else
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() service.resign() match
case Left(err) => case Right(_) => Response.ok(OkResponse()).build()
Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("RESIGN_ERROR", err)).build()
@POST @POST
@Path("/{gameId}/draw/{action}") @Path("/{gameId}/draw/{action}")
@@ -61,39 +58,33 @@ class GameResource @Inject() (store: GameStore):
@PathParam("gameId") gameId: String, @PathParam("gameId") gameId: String,
@PathParam("action") action: String, @PathParam("action") action: String,
): Response = ): Response =
store.drawAction(gameId, action) match if !service.isKnownId(gameId) then
case Right(_) => Response.ok(OkResponse()).build() Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Left(err) if err.contains("not found") => else
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() service.drawAction(action) match
case Left(err) => case Right(_) => Response.ok(OkResponse()).build()
Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("DRAW_ERROR", err)).build()
@GET @GET
@Path("/{gameId}/export/fen") @Path("/{gameId}/export/fen")
@Produces(Array(MediaType.TEXT_PLAIN)) @Produces(Array(MediaType.TEXT_PLAIN))
def exportFen(@PathParam("gameId") gameId: String): Response = def exportFen(@PathParam("gameId") gameId: String): Response =
store.get(gameId) match if !service.isKnownId(gameId) then
case None => Response
Response .status(404)
.status(404) .`type`(MediaType.APPLICATION_JSON)
.`type`(MediaType.APPLICATION_JSON) .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) .build()
.build() else Response.ok(service.exportFen()).build()
case Some(session) =>
import de.nowchess.io.fen.FenExporter
Response.ok(FenExporter.exportGameContext(session.context)).build()
@GET @GET
@Path("/{gameId}/export/pgn") @Path("/{gameId}/export/pgn")
@Produces(Array("application/x-chess-pgn")) @Produces(Array("application/x-chess-pgn"))
def exportPgn(@PathParam("gameId") gameId: String): Response = def exportPgn(@PathParam("gameId") gameId: String): Response =
store.get(gameId) match if !service.isKnownId(gameId) then
case None => Response
Response .status(404)
.status(404) .`type`(MediaType.APPLICATION_JSON)
.`type`(MediaType.APPLICATION_JSON) .entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found"))
.entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")) .build()
.build() else Response.ok(service.exportPgn()).build()
case Some(session) =>
import de.nowchess.io.pgn.PgnExporter
Response.ok(PgnExporter.exportGameContext(session.context)).build()
@@ -1,7 +1,7 @@
package de.nowchess.backcore.resource package de.nowchess.backcore.resource
import de.nowchess.backcore.dto.{ApiErrorResponse, ImportFenRequest, ImportPgnRequest} 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.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -11,19 +11,19 @@ import jakarta.ws.rs.core.{MediaType, Response}
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
@ApplicationScoped @ApplicationScoped
class ImportResource @Inject() (store: GameStore): class ImportResource @Inject() (service: GameService):
@POST @POST
@Path("/fen") @Path("/fen")
def importFen(req: ImportFenRequest): Response = def importFen(req: ImportFenRequest): Response =
store.importFen(Option(req).getOrElse(ImportFenRequest())) match service.importFen(Option(req).getOrElse(ImportFenRequest())) match
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() case Right(snap) => Response.status(201).entity(GameMapper.toGameFull(snap)).build()
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_FEN", err)).build()
@POST @POST
@Path("/pgn") @Path("/pgn")
def importPgn(req: ImportPgnRequest): Response = def importPgn(req: ImportPgnRequest): Response =
val body = Option(req).getOrElse(ImportPgnRequest()) val body = Option(req).getOrElse(ImportPgnRequest())
store.importPgn(body.pgn, None, None) match service.importPgn(body.pgn) match
case Right(session) => Response.status(201).entity(GameMapper.toGameFull(session)).build() case Right(snap) => Response.status(201).entity(GameMapper.toGameFull(snap)).build()
case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_PGN", err)).build()
@@ -3,7 +3,7 @@ package de.nowchess.backcore.resource
import de.nowchess.api.board.Square import de.nowchess.api.board.Square
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.backcore.dto.* 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.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -12,7 +12,7 @@ import jakarta.ws.rs.core.{MediaType, Response}
@Path("/api/board/game") @Path("/api/board/game")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
@ApplicationScoped @ApplicationScoped
class MoveResource @Inject() (store: GameStore): class MoveResource @Inject() (service: GameService):
@POST @POST
@Path("/{gameId}/move/{uci}") @Path("/{gameId}/move/{uci}")
@@ -20,12 +20,12 @@ class MoveResource @Inject() (store: GameStore):
@PathParam("gameId") gameId: String, @PathParam("gameId") gameId: String,
@PathParam("uci") uci: String, @PathParam("uci") uci: String,
): Response = ): Response =
store.applyMove(gameId, uci) match if !service.isKnownId(gameId) then
case Right(session) => Response.ok(GameMapper.toGameState(session)).build() Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Left(err) if err.contains("not found") => else
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() service.applyMove(uci) match
case Left(err) => case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build()
Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("INVALID_MOVE", err)).build()
@GET @GET
@Path("/{gameId}/moves") @Path("/{gameId}/moves")
@@ -33,35 +33,33 @@ class MoveResource @Inject() (store: GameStore):
@PathParam("gameId") gameId: String, @PathParam("gameId") gameId: String,
@QueryParam("square") squareParam: String, @QueryParam("square") squareParam: String,
): Response = ): Response =
val square = Option(squareParam).flatMap(Square.fromAlgebraic) if !service.isKnownId(gameId) then
store.legalMoves(gameId, square) match Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Right(moves) => else
val dtos = moves.map(toLegalMoveDto) val square = Option(squareParam).flatMap(Square.fromAlgebraic)
Response.ok(LegalMovesResponse(dtos)).build() val moves = service.legalMoves(square)
case Left(err) if err.contains("not found") => val dtos = moves.map(toLegalMoveDto)
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() Response.ok(LegalMovesResponse(dtos)).build()
case Left(err) =>
Response.status(400).entity(ApiErrorResponse("ERROR", err)).build()
@POST @POST
@Path("/{gameId}/undo") @Path("/{gameId}/undo")
def undoMove(@PathParam("gameId") gameId: String): Response = def undoMove(@PathParam("gameId") gameId: String): Response =
store.undo(gameId) match if !service.isKnownId(gameId) then
case Right(session) => Response.ok(GameMapper.toGameState(session)).build() Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Left(err) if err.contains("not found") => else
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() service.undo() match
case Left(err) => case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build()
Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("UNDO_NOT_AVAILABLE", err)).build()
@POST @POST
@Path("/{gameId}/redo") @Path("/{gameId}/redo")
def redoMove(@PathParam("gameId") gameId: String): Response = def redoMove(@PathParam("gameId") gameId: String): Response =
store.redo(gameId) match if !service.isKnownId(gameId) then
case Right(session) => Response.ok(GameMapper.toGameState(session)).build() Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", s"Game $gameId not found")).build()
case Left(err) if err.contains("not found") => else
Response.status(404).entity(ApiErrorResponse("GAME_NOT_FOUND", err)).build() service.redo() match
case Left(err) => case Right(snap) => Response.ok(GameMapper.toGameState(snap)).build()
Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build() case Left(err) => Response.status(400).entity(ApiErrorResponse("REDO_NOT_AVAILABLE", err)).build()
private def toLegalMoveDto(move: Move): LegalMoveDto = private def toLegalMoveDto(move: Move): LegalMoveDto =
val uci = GameMapper.moveToUci(move) val uci = GameMapper.moveToUci(move)
@@ -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)
@@ -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")
+8
View File
@@ -90,6 +90,14 @@ dependencies {
testRuntimeOnly("org.junit.platform:junit-platform-launcher") 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 { tasks.test {
useJUnitPlatform { useJUnitPlatform {
includeEngines("scalatest") includeEngines("scalatest")
@@ -1,20 +1,15 @@
package de.nowchess.ui package de.nowchess.ui
import de.nowchess.chess.engine.GameEngine import de.nowchess.chess.engine.GameEngine
import de.nowchess.ui.terminal.TerminalUI
import de.nowchess.ui.gui.ChessGUILauncher 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 /** Application entry point - starts both GUI and Terminal UI for the chess game. Both views subscribe to the same
* GameEngine via Observer pattern. * GameEngine via Observer pattern.
*/ */
object Main: object Main:
def main(args: Array[String]): Unit = def main(args: Array[String]): Unit =
// Create the core game engine (single source of truth)
val engine = new GameEngine() val engine = new GameEngine()
// Launch ScalaFX GUI in separate thread
ChessGUILauncher.launch(engine) ChessGUILauncher.launch(engine)
// Create and start the terminal UI (blocks on main thread)
val tui = new TerminalUI(engine) val tui = new TerminalUI(engine)
tui.start() tui.start()