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