@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user