feat: true-microservices (#40)

Reviewed-on: #40
This commit is contained in:
2026-04-29 22:06:01 +02:00
parent 67511fc649
commit 590924254e
328 changed files with 10672 additions and 2939 deletions
@@ -1,11 +0,0 @@
package de.nowchess.api.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
trait Bot {
def name: String
def nextMove(context: GameContext): Option[Move]
}
@@ -0,0 +1,9 @@
package de.nowchess.api.dto
/** Snapshot of remaining clock time for both players in milliseconds. -1 indicates the value is not applicable (e.g.
* inactive player in correspondence chess).
*/
final case class ClockDto(
whiteRemainingMs: Long,
blackRemainingMs: Long,
)
@@ -1,6 +1,10 @@
package de.nowchess.api.dto
import de.nowchess.api.game.GameMode
final case class CreateGameRequestDto(
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
timeControl: Option[TimeControlDto],
mode: Option[GameMode] = None,
)
@@ -9,4 +9,6 @@ final case class GameStateDto(
moves: List[String],
undoAvailable: Boolean,
redoAvailable: Boolean,
clock: Option[ClockDto],
takebackRequestedBy: Option[String] = None,
)
@@ -0,0 +1,28 @@
package de.nowchess.api.dto
case class GameWritebackEventDto(
gameId: String,
fen: String,
pgn: String,
moveCount: Int,
whiteId: String,
whiteName: String,
blackId: String,
blackName: String,
mode: String,
resigned: Boolean,
limitSeconds: Option[Int],
incrementSeconds: Option[Int],
daysPerMove: Option[Int],
whiteRemainingMs: Option[Long],
blackRemainingMs: Option[Long],
incrementMs: Option[Long],
clockLastTickAt: Option[Long],
clockMoveDeadline: Option[Long],
clockActiveColor: Option[String],
pendingDrawOffer: Option[String],
result: Option[String] = None,
terminationReason: Option[String] = None,
redoStack: List[String] = Nil,
pendingTakebackRequest: Option[String] = None,
)
@@ -4,4 +4,5 @@ final case class ImportFenRequestDto(
fen: String,
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
timeControl: Option[TimeControlDto],
)
@@ -1,3 +1,5 @@
package de.nowchess.api.dto
final case class PlayerInfoDto(id: String, displayName: String)
import de.nowchess.api.player.PlayerType
final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
@@ -0,0 +1,7 @@
package de.nowchess.api.dto
final case class TimeControlDto(
limitSeconds: Option[Int],
incrementSeconds: Option[Int],
daysPerMove: Option[Int],
)
@@ -0,0 +1,13 @@
package de.nowchess.api.error
enum GameError:
case ParseError(details: String)
case FileReadError(details: String)
case FileWriteError(details: String)
case IllegalMove
def message: String = this match
case ParseError(d) => d
case FileReadError(d) => d
case FileWriteError(d) => d
case IllegalMove => "Illegal move"
@@ -0,0 +1,55 @@
package de.nowchess.api.game
import de.nowchess.api.board.Color
import java.time.Instant
import java.time.temporal.ChronoUnit
sealed trait ClockState:
def activeColor: Color
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState]
def remainingMs(color: Color, now: Instant): Long
final case class LiveClockState(
whiteRemainingMs: Long,
blackRemainingMs: Long,
incrementMs: Long,
lastTickAt: Instant,
activeColor: Color,
) extends ClockState:
def remainingMs(color: Color, now: Instant): Long =
val stored = if color == Color.White then whiteRemainingMs else blackRemainingMs
if color == activeColor then math.max(0L, stored - (now.toEpochMilli - lastTickAt.toEpochMilli))
else stored
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
val elapsed = at.toEpochMilli - lastTickAt.toEpochMilli
val newRemaining =
(if movedColor == Color.White then whiteRemainingMs else blackRemainingMs) - elapsed + incrementMs
if newRemaining <= 0 then Left(movedColor)
else
val (w, b) =
if movedColor == Color.White then (newRemaining, blackRemainingMs)
else (whiteRemainingMs, newRemaining)
Right(copy(whiteRemainingMs = w, blackRemainingMs = b, lastTickAt = at, activeColor = movedColor.opposite))
final case class CorrespondenceClockState(
moveDeadline: Instant,
daysPerMove: Int,
activeColor: Color,
) extends ClockState:
def remainingMs(color: Color, now: Instant): Long =
math.max(0L, moveDeadline.toEpochMilli - now.toEpochMilli)
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
if at.isAfter(moveDeadline) then Left(movedColor)
else Right(copy(moveDeadline = at.plus(daysPerMove.toLong, ChronoUnit.DAYS), activeColor = movedColor.opposite))
object ClockState:
def fromTimeControl(tc: TimeControl, activeColor: Color, now: Instant): Option[ClockState] =
tc match
case TimeControl.Clock(limit, inc) =>
val ms = limit * 1000L
Some(LiveClockState(ms, ms, inc * 1000L, now, activeColor))
case TimeControl.Correspondence(days) =>
Some(CorrespondenceClockState(now.plus(days.toLong, ChronoUnit.DAYS), days, activeColor))
case TimeControl.Unlimited => None
@@ -0,0 +1,4 @@
package de.nowchess.api.game
enum GameMode:
case Open, Authenticated
@@ -4,5 +4,10 @@ import de.nowchess.api.board.Color
/** Outcome of a finished game. */
enum GameResult:
case Win(color: Color)
case Win(color: Color, winReason: WinReason)
case Draw(reason: DrawReason)
enum WinReason:
case Checkmate
case Resignation
case TimeControl
@@ -1,8 +0,0 @@
package de.nowchess.api.game
import de.nowchess.api.bot.Bot
import de.nowchess.api.player.PlayerInfo
sealed trait Participant
final case class Human(playerInfo: PlayerInfo) extends Participant
final case class BotParticipant(bot: Bot) extends Participant
@@ -0,0 +1,6 @@
package de.nowchess.api.game
enum TimeControl:
case Clock(limitSeconds: Int, incrementSeconds: Int)
case Correspondence(daysPerMove: Int)
case Unlimited
@@ -0,0 +1,107 @@
package de.nowchess.api.grpc
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
import scala.jdk.CollectionConverters.*
trait ProtoMapperBase[PC, PPT, PMK, PM, PSP, PBoard, PCR, PRK, PGC]:
def toProtoColor(c: Color): PC
def fromProtoColor(c: PC): Color
def toProtoPieceType(pt: PieceType): PPT
def fromProtoPieceType(pt: PPT): PieceType
def toProtoMoveKind(mt: MoveType): PMK
def fromProtoMoveKind(k: PMK): MoveType
def toProtoMove(m: DomainMove): PM
def fromProtoMove(m: PM): Option[DomainMove]
def toProtoSquarePiece(sq: Square, piece: Piece): PSP
def fromProtoSquarePiece(sp: PSP): Option[(Square, Piece)]
def toProtoBoard(board: Board): java.util.List[PSP]
def fromProtoBoard(pieces: java.util.List[PSP]): Board
def toProtoResultKind(r: Option[GameResult]): PRK
def fromProtoResultKind(k: PRK): Option[GameResult]
def toProtoCastlingRights(cr: DomainCastlingRights): PCR
def fromProtoCastlingRights(pcr: PCR): DomainCastlingRights
def toProtoGameContext(ctx: GameContext): PGC
def fromProtoGameContext(p: PGC): GameContext
object ProtoMapperBase:
def colorConversions[PC](white: PC, black: PC): (Color => PC, PC => Color) =
(
(c: Color) =>
c match
case Color.White => white
case Color.Black => black,
(pc: PC) =>
if pc == white then Color.White
else Color.Black,
)
def pieceTypeConversions[PPT](
pawn: PPT,
knight: PPT,
bishop: PPT,
rook: PPT,
queen: PPT,
king: PPT,
): (PieceType => PPT, PPT => PieceType) =
(
(pt: PieceType) =>
pt match
case PieceType.Pawn => pawn
case PieceType.Knight => knight
case PieceType.Bishop => bishop
case PieceType.Rook => rook
case PieceType.Queen => queen
case PieceType.King => king,
(ppt: PPT) =>
if ppt == pawn then PieceType.Pawn
else if ppt == knight then PieceType.Knight
else if ppt == bishop then PieceType.Bishop
else if ppt == rook then PieceType.Rook
else if ppt == queen then PieceType.Queen
else PieceType.King,
)
def moveKindConversions[PMK](
quiet: PMK,
capture: PMK,
castleKingside: PMK,
castleQueenside: PMK,
enPassant: PMK,
promoQueen: PMK,
promoRook: PMK,
promoBishop: PMK,
promoKnight: PMK,
): (MoveType => PMK, PMK => MoveType) =
(
(mt: MoveType) =>
mt match
case MoveType.Normal(false) => quiet
case MoveType.Normal(true) => capture
case MoveType.CastleKingside => castleKingside
case MoveType.CastleQueenside => castleQueenside
case MoveType.EnPassant => enPassant
case MoveType.Promotion(PromotionPiece.Queen) => promoQueen
case MoveType.Promotion(PromotionPiece.Rook) => promoRook
case MoveType.Promotion(PromotionPiece.Bishop) => promoBishop
case MoveType.Promotion(PromotionPiece.Knight) => promoKnight,
(pmk: PMK) =>
if pmk == quiet then MoveType.Normal(false)
else if pmk == capture then MoveType.Normal(true)
else if pmk == castleKingside then MoveType.CastleKingside
else if pmk == castleQueenside then MoveType.CastleQueenside
else if pmk == enPassant then MoveType.EnPassant
else if pmk == promoQueen then MoveType.Promotion(PromotionPiece.Queen)
else if pmk == promoRook then MoveType.Promotion(PromotionPiece.Rook)
else if pmk == promoBishop then MoveType.Promotion(PromotionPiece.Bishop)
else if pmk == promoKnight then MoveType.Promotion(PromotionPiece.Knight)
else MoveType.Normal(false),
)
@@ -1,6 +1,7 @@
package de.nowchess.api.io
import de.nowchess.api.error.GameError
import de.nowchess.api.game.GameContext
trait GameContextImport:
def importGameContext(input: String): Either[String, GameContext]
def importGameContext(input: String): Either[GameError, GameContext]
@@ -23,4 +23,10 @@ object PlayerId:
final case class PlayerInfo(
id: PlayerId,
displayName: String,
playerType: PlayerType = PlayerType.Human,
)
enum PlayerType:
case Human
case OfficialBot
case Bot
@@ -0,0 +1,9 @@
package de.nowchess.api.rules
final case class PostMoveStatus(
isCheckmate: Boolean,
isStalemate: Boolean,
isInsufficientMaterial: Boolean,
isCheck: Boolean,
isThreefoldRepetition: Boolean,
)
@@ -39,3 +39,15 @@ trait RuleSet:
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
*/
def applyMove(context: GameContext)(move: Move): GameContext
/** Batch status check after a move is applied. Replaces individual isCheckmate/isStalemate/isInsufficientMaterial/
* isCheck/isThreefoldRepetition calls with a single round-trip. Override for remote implementations.
*/
def postMoveStatus(context: GameContext): PostMoveStatus =
PostMoveStatus(
isCheckmate = isCheckmate(context),
isStalemate = isStalemate(context),
isInsufficientMaterial = isInsufficientMaterial(context),
isCheck = isCheck(context),
isThreefoldRepetition = isThreefoldRepetition(context),
)
@@ -0,0 +1,109 @@
package de.nowchess.api.game
import de.nowchess.api.board.Color
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.time.Instant
import java.time.temporal.ChronoUnit
class ClockStateTest extends AnyFunSuite with Matchers:
private val t0 = Instant.parse("2024-01-01T00:00:00Z")
private val t1s = t0.plusSeconds(1)
private val t5s = t0.plusSeconds(5)
// ── LiveClockState ────────────────────────────────────────────────────────
test("LiveClockState.afterMove deducts elapsed and adds increment on valid move"):
val cs = LiveClockState(300_000L, 300_000L, 3_000L, t0, Color.White)
cs.afterMove(Color.White, t5s) match
case Right(updated: LiveClockState) =>
updated.whiteRemainingMs shouldBe (300_000L - 5_000L + 3_000L)
updated.blackRemainingMs shouldBe 300_000L
updated.activeColor shouldBe Color.Black
updated.lastTickAt shouldBe t5s
case other => fail(s"Expected Right(LiveClockState), got $other")
test("LiveClockState.afterMove returns Left when time exhausted"):
val cs = LiveClockState(2_000L, 300_000L, 0L, t0, Color.White)
cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
test("LiveClockState.afterMove returns Left when time exactly zero"):
val cs = LiveClockState(5_000L, 300_000L, 0L, t0, Color.White)
cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
test("LiveClockState.remainingMs for active color deducts live elapsed"):
val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.White)
val now = t5s
cs.remainingMs(Color.White, now) shouldBe (300_000L - 5_000L)
test("LiveClockState.remainingMs for inactive color returns stored value"):
val cs = LiveClockState(200_000L, 300_000L, 0L, t0, Color.White)
cs.remainingMs(Color.Black, t5s) shouldBe 300_000L
test("LiveClockState.remainingMs clamps to zero when overdue"):
val cs = LiveClockState(1_000L, 300_000L, 0L, t0, Color.White)
cs.remainingMs(Color.White, t5s) shouldBe 0L
test("LiveClockState.afterMove advances activeColor to opponent"):
val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.Black)
cs.afterMove(Color.Black, t1s) match
case Right(updated: LiveClockState) => updated.activeColor shouldBe Color.White
case other => fail(s"Expected Right, got $other")
// ── CorrespondenceClockState ──────────────────────────────────────────────
test("CorrespondenceClockState.afterMove advances deadline on valid move"):
val deadline = t0.plus(3L, ChronoUnit.DAYS)
val cs = CorrespondenceClockState(deadline, 3, Color.White)
cs.afterMove(Color.White, t1s) match
case Right(updated: CorrespondenceClockState) =>
updated.moveDeadline shouldBe t1s.plus(3L, ChronoUnit.DAYS)
updated.activeColor shouldBe Color.Black
case other => fail(s"Expected Right(CorrespondenceClockState), got $other")
test("CorrespondenceClockState.afterMove returns Left when move is past deadline"):
val deadline = t0.plus(1L, ChronoUnit.DAYS)
val cs = CorrespondenceClockState(deadline, 3, Color.White)
val lateMove = t0.plus(2L, ChronoUnit.DAYS)
cs.afterMove(Color.White, lateMove) shouldBe Left(Color.White)
test("CorrespondenceClockState.remainingMs returns time until deadline"):
val deadline = t0.plus(3L, ChronoUnit.DAYS)
val cs = CorrespondenceClockState(deadline, 3, Color.White)
val expected = deadline.toEpochMilli - t1s.toEpochMilli
cs.remainingMs(Color.White, t1s) shouldBe expected
test("CorrespondenceClockState.remainingMs clamps to zero when overdue"):
val deadline = t0.plus(1L, ChronoUnit.DAYS)
val cs = CorrespondenceClockState(deadline, 3, Color.White)
val overdue = t0.plus(2L, ChronoUnit.DAYS)
cs.remainingMs(Color.White, overdue) shouldBe 0L
// ── ClockState.fromTimeControl ────────────────────────────────────────────
test("fromTimeControl with Clock returns LiveClockState with correct initial values"):
ClockState.fromTimeControl(TimeControl.Clock(300, 3), Color.White, t0) match
case Some(cs: LiveClockState) =>
cs.whiteRemainingMs shouldBe 300_000L
cs.blackRemainingMs shouldBe 300_000L
cs.incrementMs shouldBe 3_000L
cs.activeColor shouldBe Color.White
cs.lastTickAt shouldBe t0
case other => fail(s"Expected Some(LiveClockState), got $other")
test("fromTimeControl with Correspondence returns CorrespondenceClockState"):
ClockState.fromTimeControl(TimeControl.Correspondence(3), Color.White, t0) match
case Some(cs: CorrespondenceClockState) =>
cs.moveDeadline shouldBe t0.plus(3L, ChronoUnit.DAYS)
cs.daysPerMove shouldBe 3
cs.activeColor shouldBe Color.White
case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
test("fromTimeControl with Unlimited returns None"):
ClockState.fromTimeControl(TimeControl.Unlimited, Color.White, t0) shouldBe None
test("fromTimeControl with Black as starting color sets activeColor correctly"):
ClockState.fromTimeControl(TimeControl.Clock(300, 0), Color.Black, t0) match
case Some(cs: LiveClockState) => cs.activeColor shouldBe Color.Black
case other => fail(s"Expected Some(LiveClockState), got $other")
@@ -1,6 +1,7 @@
package de.nowchess.api.game
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
import de.nowchess.api.game.WinReason.Checkmate
import de.nowchess.api.move.Move
import de.nowchess.api.game.{DrawReason, GameResult}
import org.scalatest.funsuite.AnyFunSuite
@@ -61,7 +62,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
GameContext.initial.withMove(move).moves shouldBe List(move)
test("withResult sets Win result"):
val win = Some(GameResult.Win(Color.White))
val win = Some(GameResult.Win(Color.White, Checkmate))
GameContext.initial.withResult(win).result shouldBe win
test("withResult sets Draw result"):
@@ -69,7 +70,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
GameContext.initial.withResult(draw).result shouldBe draw
test("withResult clears result"):
val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black)))
val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black, Checkmate)))
ctx.withResult(None).result shouldBe None
test("kingSquare returns white king position"):