feat(backcore): Quarkus compatible with GUI/TUI
Build & Test (NowChessSystems) TeamCity build failed
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:
@@ -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)
|
||||
+4
-4
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user