feat(game): introduce game modes and time control features
Build & Test (NowChessSystems) TeamCity build failed

This commit is contained in:
2026-04-23 21:56:21 +02:00
parent 21d3d87543
commit 3df199afa1
100 changed files with 1676 additions and 604 deletions
@@ -0,0 +1,27 @@
{
"reflection": [
{ "type": "scala.Tuple1[]" },
{ "type": "scala.Tuple2[]" },
{ "type": "scala.Tuple3[]" },
{ "type": "scala.Tuple4[]" },
{ "type": "scala.Tuple5[]" },
{ "type": "scala.Tuple6[]" },
{ "type": "scala.Tuple7[]" },
{ "type": "scala.Tuple8[]" },
{ "type": "scala.Tuple9[]" },
{ "type": "scala.Tuple10[]" },
{ "type": "scala.Tuple11[]" },
{ "type": "scala.Tuple12[]" },
{ "type": "scala.Tuple13[]" },
{ "type": "scala.Tuple14[]" },
{ "type": "scala.Tuple15[]" },
{ "type": "scala.Tuple16[]" },
{ "type": "scala.Tuple17[]" },
{ "type": "scala.Tuple18[]" },
{ "type": "scala.Tuple19[]" },
{ "type": "scala.Tuple20[]" },
{ "type": "scala.Tuple21[]" },
{ "type": "scala.Tuple22[]" },
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
]
}
@@ -1,5 +1,6 @@
package de.nowchess.io
import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.{GameContextExport, GameContextImport}
@@ -12,29 +13,29 @@ import scala.util.Try
* Abstracts file I/O operations away from the UI layer. Handles both reading and writing game files.
*/
trait GameFileService:
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit]
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext]
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[GameError, Unit]
def loadGameFromFile(path: Path, importer: GameContextImport): Either[GameError, GameContext]
/** Default implementation using the file system. */
object FileSystemGameService extends GameFileService:
/** Save a game context to a file using the specified exporter. */
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[String, Unit] =
def saveGameToFile(context: GameContext, path: Path, exporter: GameContextExport): Either[GameError, Unit] =
Try {
val json = exporter.exportGameContext(context)
Files.write(path, json.getBytes(StandardCharsets.UTF_8))
()
}.fold(
ex => Left(s"Failed to save file: ${ex.getMessage}"),
ex => Left(GameError.FileWriteError(s"Failed to save file: ${ex.getMessage}")),
_ => Right(()),
)
/** Load a game context from a file using the specified importer. */
def loadGameFromFile(path: Path, importer: GameContextImport): Either[String, GameContext] =
def loadGameFromFile(path: Path, importer: GameContextImport): Either[GameError, GameContext] =
Try {
val json = new String(Files.readAllBytes(path), StandardCharsets.UTF_8)
importer.importGameContext(json)
}.fold(
ex => Left(s"Failed to load file: ${ex.getMessage}"),
ex => Left(GameError.FileReadError(s"Failed to load file: ${ex.getMessage}")),
result => result,
)
@@ -1,6 +1,7 @@
package de.nowchess.io.fen
import de.nowchess.api.board.*
import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -8,18 +9,18 @@ object FenParser extends GameContextImport:
/** Parse a complete FEN string into a GameContext. Returns Left with error message if the format is invalid.
*/
def parseFen(fen: String): Either[String, GameContext] =
def parseFen(fen: String): Either[GameError, GameContext] =
val parts = fen.trim.split("\\s+")
if parts.length != 6 then Left(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}")
if parts.length != 6 then Left(GameError.ParseError(s"Invalid FEN: expected 6 space-separated fields, got ${parts.length}"))
else
for
board <- parseBoard(parts(0)).toRight("Invalid FEN: invalid board position")
activeColor <- parseColor(parts(1)).toRight("Invalid FEN: invalid active color (expected 'w' or 'b')")
castlingRights <- parseCastling(parts(2)).toRight("Invalid FEN: invalid castling rights")
enPassant <- parseEnPassant(parts(3)).toRight("Invalid FEN: invalid en passant square")
halfMoveClock <- parts(4).toIntOption.toRight("Invalid FEN: invalid half-move clock (expected integer)")
fullMoveNumber <- parts(5).toIntOption.toRight("Invalid FEN: invalid full move number (expected integer)")
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), "Invalid FEN: invalid move counts")
board <- parseBoard(parts(0)).toRight(GameError.ParseError("Invalid FEN: invalid board position"))
activeColor <- parseColor(parts(1)).toRight(GameError.ParseError("Invalid FEN: invalid active color (expected 'w' or 'b')"))
castlingRights <- parseCastling(parts(2)).toRight(GameError.ParseError("Invalid FEN: invalid castling rights"))
enPassant <- parseEnPassant(parts(3)).toRight(GameError.ParseError("Invalid FEN: invalid en passant square"))
halfMoveClock <- parts(4).toIntOption.toRight(GameError.ParseError("Invalid FEN: invalid half-move clock (expected integer)"))
fullMoveNumber <- parts(5).toIntOption.toRight(GameError.ParseError("Invalid FEN: invalid full move number (expected integer)"))
_ <- Either.cond(halfMoveClock >= 0 && fullMoveNumber >= 1, (), GameError.ParseError("Invalid FEN: invalid move counts"))
yield GameContext(
board = board,
turn = activeColor,
@@ -29,7 +30,7 @@ object FenParser extends GameContextImport:
moves = List.empty,
)
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
/** Parse active color ("w" or "b"). */
@@ -1,6 +1,7 @@
package de.nowchess.io.fen
import de.nowchess.api.board.*
import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import scala.util.parsing.combinator.RegexParsers
@@ -107,15 +108,15 @@ object FenParserCombinators extends RegexParsers with GameContextImport:
// ── Public API ───────────────────────────────────────────────────────────
def parseFen(fen: String): Either[String, GameContext] =
def parseFen(fen: String): Either[GameError, GameContext] =
parseAll(fenParser, fen) match
case Success(ctx, _) => Right(ctx)
case other => Left(s"Invalid FEN: ${other.toString}")
case other => Left(GameError.ParseError(s"Invalid FEN: ${other.toString}"))
def parseBoard(fen: String): Option[Board] =
parseAll(boardParser, fen) match
case Success(board, _) => Some(board)
case _ => None
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
@@ -3,6 +3,7 @@ package de.nowchess.io.fen
import fastparse.*
import fastparse.NoWhitespace.*
import de.nowchess.api.board.*
import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
import FenParserSupport.*
import de.nowchess.api.io.GameContextImport
@@ -103,10 +104,10 @@ object FenParserFastParse extends GameContextImport:
// ── Public API ───────────────────────────────────────────────────────────
def parseFen(fen: String): Either[String, GameContext] =
def parseFen(fen: String): Either[GameError, GameContext] =
parse(fen, fenParser(using _)) match
case Parsed.Success(ctx, _) => Right(ctx)
case f: Parsed.Failure => Left(s"Invalid FEN: ${f.msg}")
case f: Parsed.Failure => Left(GameError.ParseError(s"Invalid FEN: ${f.msg}"))
private def boardParserFull(using P[Any]): P[Board] =
boardParser ~ End
@@ -116,5 +117,5 @@ object FenParserFastParse extends GameContextImport:
case Parsed.Success(board, _) => Some(board)
case _ => None
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
parseFen(input)
@@ -3,6 +3,7 @@ package de.nowchess.io.json
import com.fasterxml.jackson.databind.{DeserializationFeature, ObjectMapper}
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.*
import de.nowchess.api.error.GameError
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -27,9 +28,9 @@ object JsonParser extends GameContextImport:
.registerModule(DefaultScalaModule)
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
Try(mapper.readValue(input, classOf[JsonGameRecord])).toEither.left
.map(e => "JSON parsing error: " + e.getMessage)
.map(e => GameError.ParseError("JSON parsing error: " + e.getMessage))
.flatMap { data =>
val gs = data.gameState.getOrElse(JsonGameState())
val rawBoard = gs.board.getOrElse(Nil)
@@ -54,7 +55,7 @@ object JsonParser extends GameContextImport:
)
}
private def parseBoard(pieces: List[JsonPiece]): Either[String, Board] =
private def parseBoard(pieces: List[JsonPiece]): Either[GameError, Board] =
val parsedPieces = pieces.flatMap { p =>
for
sq <- p.square.flatMap(Square.fromAlgebraic)
@@ -64,8 +65,8 @@ object JsonParser extends GameContextImport:
}
Right(Board(parsedPieces.toMap))
private def parseTurn(color: String): Either[String, Color] =
parseColor(color).toRight(s"Invalid turn color: $color")
private def parseTurn(color: String): Either[GameError, Color] =
parseColor(color).toRight(GameError.ParseError(s"Invalid turn color: $color"))
private def parseColor(color: String): Option[Color] =
if color == "White" then Some(Color.White)
@@ -90,7 +91,7 @@ object JsonParser extends GameContextImport:
cr.blackQueenSide.getOrElse(false),
)
private def parseMoves(moves: List[JsonMove]): Either[String, List[Move]] =
private def parseMoves(moves: List[JsonMove]): Either[GameError, List[Move]] =
Right(moves.flatMap { m =>
for
from <- m.from.flatMap(Square.fromAlgebraic)
@@ -1,8 +0,0 @@
package de.nowchess.io.json
import com.fasterxml.jackson.databind.{DeserializationContext, KeyDeserializer}
import de.nowchess.api.board.Square
class SquareKeyDeserializer extends KeyDeserializer:
override def deserializeKey(key: String, ctx: DeserializationContext): AnyRef =
Square.fromAlgebraic(key).orNull
@@ -1,9 +0,0 @@
package de.nowchess.io.json
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.{JsonSerializer, SerializerProvider}
import de.nowchess.api.board.Square
class SquareKeySerializer extends JsonSerializer[Square]:
override def serialize(value: Square, gen: JsonGenerator, provider: SerializerProvider): Unit =
gen.writeFieldName(value.toString)
@@ -1,6 +1,7 @@
package de.nowchess.io.pgn
import de.nowchess.api.board.*
import de.nowchess.api.error.GameError
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextImport
@@ -17,7 +18,7 @@ object PgnParser extends GameContextImport:
/** Strictly validate a PGN text. Returns Right(PgnGame) if every move token is a legal move in the evolving position.
* Returns Left(error message) on the first illegal or impossible move, or any unrecognised token.
*/
def validatePgn(pgn: String): Either[String, PgnGame] =
def validatePgn(pgn: String): Either[GameError, PgnGame] =
val lines = pgn.split("\n").map(_.trim)
val (headerLines, rest) = lines.span(_.startsWith("["))
val headers = parseHeaders(headerLines)
@@ -28,7 +29,7 @@ object PgnParser extends GameContextImport:
* moves applied and .moves populated. Returns Left(error message) if validation fails or move replay encounters an
* issue.
*/
def importGameContext(input: String): Either[String, GameContext] =
def importGameContext(input: String): Either[GameError, GameContext] =
validatePgn(input).flatMap { game =>
Right(game.moves.foldLeft(GameContext.initial)((ctx, move) => DefaultRules.applyMove(ctx)(move)))
}
@@ -173,17 +174,17 @@ object PgnParser extends GameContextImport:
// ── Strict validation helpers ─────────────────────────────────────────────
/** Walk all move tokens, failing immediately on any unresolvable or illegal move. */
private def validateMovesText(moveText: String): Either[String, List[Move]] =
private def validateMovesText(moveText: String): Either[GameError, List[Move]] =
val tokens = moveText.split("\\s+").filter(_.nonEmpty)
tokens
.foldLeft(
Right((GameContext.initial, Color.White, List.empty[Move])): Either[String, (GameContext, Color, List[Move])],
Right((GameContext.initial, Color.White, List.empty[Move])): Either[GameError, (GameContext, Color, List[Move])],
) { case (acc, token) =>
acc.flatMap { case (ctx, color, moves) =>
if isMoveNumberOrResult(token) then Right((ctx, color, moves))
else
parseAlgebraicMove(token, ctx, color) match
case None => Left(s"Illegal or impossible move: '$token'")
case None => Left(GameError.ParseError(s"Illegal or impossible move: '$token'"))
case Some(move) =>
val nextCtx = DefaultRules.applyMove(ctx)(move)
Right((nextCtx, color.opposite, moves :+ move))
@@ -2,10 +2,8 @@ package de.nowchess.io.service.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.Square
import de.nowchess.io.json.{SquareKeyDeserializer, SquareKeySerializer}
import de.nowchess.json.ChessJacksonModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@@ -18,7 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
val squareModule = new SimpleModule()
squareModule.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
squareModule.addKeySerializer(classOf[Square], new SquareKeySerializer())
mapper.registerModule(squareModule)
mapper.registerModule(new ChessJacksonModule())
@@ -33,7 +33,7 @@ class IoResource:
Uni.createFrom().item {
FenParser.parseFen(body.fen) match
case Left(err) =>
Response.status(400).entity(IoErrorDto("INVALID_FEN", err)).build()
Response.status(400).entity(IoErrorDto("INVALID_FEN", err.message)).build()
case Right(ctx) =>
Response.ok(ctx).build()
}
@@ -53,7 +53,7 @@ class IoResource:
Uni.createFrom().item {
PgnParser.importGameContext(body.pgn) match
case Left(err) =>
Response.status(400).entity(IoErrorDto("INVALID_PGN", err)).build()
Response.status(400).entity(IoErrorDto("INVALID_PGN", err.message)).build()
case Right(ctx) =>
Response.ok(ctx).build()
}
@@ -5,6 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.io.GameContextExport
import de.nowchess.api.move.Move
import de.nowchess.io.json.{JsonExporter, JsonParser}
import org.scalactic.Prettifier.default
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
@@ -128,6 +129,6 @@ class GameFileServiceSuite extends AnyFunSuite with Matchers:
val result = FileSystemGameService.saveGameToFile(context, tmpFile, faultyExporter)
assert(result.isLeft)
assert(result.left.toOption.get.contains("Failed to save file"))
assert(result.left.toOption.get.message.contains("Failed to save file"))
finally Files.deleteIfExists(tmpFile)
}
@@ -97,7 +97,7 @@ class FenExporterTest extends AnyFunSuite with Matchers:
val fen = FenExporter.gameContextToFen(gameContext)
FenParser.parseFen(fen) match
case Right(ctx) => ctx.halfMoveClock shouldBe 42
case Left(err) => fail(s"FEN parsing failed: $err")
case Left(err) => fail(s"FEN parsing failed: ${err.message}")
test("exportGameContext forwards to gameContextToFen"):
val ctx = GameContext.initial
@@ -95,13 +95,13 @@ class JsonParserTest extends AnyFunSuite with Matchers:
val invalidJson = "{ this is not valid json at all }"
val result = JsonParser.importGameContext(invalidJson)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse empty string returns error") {
val result = JsonParser.importGameContext("")
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse number value returns error") {
@@ -113,7 +113,7 @@ class JsonParserTest extends AnyFunSuite with Matchers:
val malformed = """{"metadata": {"unclosed": """
val result = JsonParser.importGameContext(malformed)
assert(result.isLeft)
assert(result.left.toOption.get.contains("JSON parsing error"))
assert(result.left.toOption.get.message.contains("JSON parsing error"))
}
test("parse invalid JSON array returns error") {
@@ -137,7 +137,7 @@ class JsonParserTest extends AnyFunSuite with Matchers:
}"""
val result = JsonParser.importGameContext(json)
assert(result.isLeft)
assert(result.left.toOption.get.contains("Invalid turn color"))
assert(result.left.toOption.get.message.contains("Invalid turn color"))
}
test("parse invalid piece type filters it out") {
@@ -2,61 +2,50 @@ package de.nowchess.io.json
import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.io.service.config.JacksonConfig
import de.nowchess.json.SquareKeyDeserializer
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class SquareKeyDeserializerTest extends AnyFunSuite with Matchers:
private def mapper: ObjectMapper =
val m = new ObjectMapper()
val mod = new SimpleModule()
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
m.registerModule(DefaultScalaModule)
m.registerModule(mod)
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
private def readMap(json: String): Map[Square, Int] =
mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
test("deserializes valid algebraic key") {
test("deserializes valid algebraic key"):
val result = readMap("""{"e4":1}""")
result(Square(File.E, Rank.R4)) shouldBe 1
}
test("deserializes a1 corner") {
test("deserializes a1 corner"):
val result = readMap("""{"a1":1}""")
result(Square(File.A, Rank.R1)) shouldBe 1
}
test("deserializes h8 corner") {
test("deserializes h8 corner"):
val result = readMap("""{"h8":1}""")
result(Square(File.H, Rank.R8)) shouldBe 1
}
test("deserializes multiple squares") {
test("deserializes multiple squares"):
val result = readMap("""{"a1":1,"h8":2,"e4":3}""")
result(Square(File.A, Rank.R1)) shouldBe 1
result(Square(File.H, Rank.R8)) shouldBe 2
result(Square(File.E, Rank.R4)) shouldBe 3
}
// scalafix:off DisableSyntax.null
test("deserializeKey returns null for invalid square") {
test("deserializeKey returns null for invalid square"):
new SquareKeyDeserializer().deserializeKey("invalid", null) shouldBe null
}
test("deserializeKey returns null for wrong-length key") {
test("deserializeKey returns null for wrong-length key"):
new SquareKeyDeserializer().deserializeKey("e44", null) shouldBe null
}
test("deserializeKey returns null for bad file") {
test("deserializeKey returns null for bad file"):
new SquareKeyDeserializer().deserializeKey("z4", null) shouldBe null
}
test("deserializeKey returns null for bad rank") {
test("deserializeKey returns null for bad rank"):
new SquareKeyDeserializer().deserializeKey("e9", null) shouldBe null
}
// scalafix:on DisableSyntax.null
@@ -2,49 +2,32 @@ package de.nowchess.io.json
import com.fasterxml.jackson.core.`type`.TypeReference
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import de.nowchess.api.board.{File, Rank, Square}
import de.nowchess.io.service.config.JacksonConfig
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
class SquareKeySerializerTest extends AnyFunSuite with Matchers:
private def mapper: ObjectMapper =
val m = new ObjectMapper()
val mod = new SimpleModule()
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
m.registerModule(DefaultScalaModule)
m.registerModule(mod)
val m = new ObjectMapper()
new JacksonConfig().customize(m)
m
test("serializes square as algebraic notation") {
test("serializes square as algebraic notation"):
val json = mapper.writeValueAsString(Map(Square(File.E, Rank.R4) -> 1))
json should include("\"e4\"")
}
test("serializes a1 corner") {
test("serializes a1 corner"):
val json = mapper.writeValueAsString(Map(Square(File.A, Rank.R1) -> 1))
json should include("\"a1\"")
}
test("serializes h8 corner") {
test("serializes h8 corner"):
val json = mapper.writeValueAsString(Map(Square(File.H, Rank.R8) -> 1))
json should include("\"h8\"")
}
test("round-trips with SquareKeyDeserializer") {
val rt = {
val m = new ObjectMapper()
val mod = new SimpleModule()
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
m.registerModule(DefaultScalaModule)
m.registerModule(mod)
m
}
test("round-trips with SquareKeyDeserializer"):
val original = Map(Square(File.D, Rank.R5) -> 99)
val json = rt.writeValueAsString(original)
val result = rt.readValue(json, new TypeReference[Map[Square, Int]] {})
val json = mapper.writeValueAsString(original)
val result = mapper.readValue(json, new TypeReference[Map[Square, Int]] {})
result shouldBe original
}