feat(game): introduce game modes and time control features
Build & Test (NowChessSystems) TeamCity build failed
Build & Test (NowChessSystems) TeamCity build failed
This commit is contained in:
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user