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: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(
@@ -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.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)
@@ -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.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,
)
@@ -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
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()
@@ -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()
@@ -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)
@@ -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")
}
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")
@@ -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()