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,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],
)
@@ -9,4 +9,5 @@ final case class GameStateDto(
moves: List[String],
undoAvailable: Boolean,
redoAvailable: Boolean,
clock: Option[ClockDto],
)
@@ -4,4 +4,5 @@ final case class ImportFenRequestDto(
fen: String,
white: Option[PlayerInfoDto],
black: Option[PlayerInfoDto],
timeControl: Option[TimeControlDto],
)
@@ -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
@@ -0,0 +1,6 @@
package de.nowchess.api.game
enum TimeControl:
case Clock(limitSeconds: Int, incrementSeconds: Int)
case Correspondence(daysPerMove: Int)
case Unlimited
@@ -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]
@@ -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"):