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
+4
View File
@@ -32,6 +32,8 @@ val quarkusPlatformVersion: String by project
dependencies {
runtimeOnly("io.quarkus:quarkus-jdbc-h2")
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
@@ -46,6 +48,7 @@ dependencies {
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-rest-client-jackson")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-arc")
implementation("io.quarkus:quarkus-hibernate-orm-panache")
@@ -66,6 +69,7 @@ dependencies {
testImplementation("io.rest-assured:rest-assured")
testImplementation("io.quarkus:quarkus-jdbc-h2")
testImplementation("io.quarkus:quarkus-test-security")
testImplementation("io.quarkus:quarkus-junit5-mockito")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -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[]" }
]
}
@@ -3,6 +3,9 @@ quarkus:
port: 8083
application:
name: nowchess-account
rest-client:
core-service:
url: http://localhost:8080
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
@@ -10,17 +13,29 @@ quarkus:
always-include: true
path: /swagger-ui
datasource:
postgres:
db-kind: postgresql
username: ${DB_USER:nowchess}
password: ${DB_PASSWORD:nowchess}
jdbc:
url: ${DB_URL:jdbc:postgresql://localhost:5432/nowchess_account}
db-kind: h2
username: sa
password: ""
jdbc:
url: jdbc:h2:mem:nowchess;DB_CLOSE_DELAY=-1
hibernate-orm:
schema-management:
strategy: update
strategy: drop-and-create
"%prod":
"%live":
quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
datasource:
db-kind: postgresql
username: ${DB_USER}
password: ${DB_PASSWORD}
jdbc:
url: ${DB_URL}
hibernate-orm:
schema-management:
strategy: update
mp:
jwt:
verify:
@@ -31,11 +46,4 @@ quarkus:
jwt:
sign:
key:
location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
quarkus:
datasource:
postgres:
active: true
hibernate-orm:
postgres:
active: true
location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
@@ -0,0 +1,24 @@
package de.nowchess.account.client
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String)
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
case class CoreCreateGameRequest(
white: Option[CorePlayerInfo],
black: Option[CorePlayerInfo],
timeControl: Option[CoreTimeControl],
mode: Option[String],
)
case class CoreGameResponse(gameId: String)
@Path("/api/board/game")
@RegisterRestClient(configKey = "core-service")
trait CoreGameClient:
@POST
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
def createGame(req: CoreCreateGameRequest): CoreGameResponse
@@ -1,12 +1,19 @@
package de.nowchess.account.config
import de.nowchess.account.domain.{ChallengeColor, ChallengeStatus, DeclineReason}
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
import de.nowchess.account.domain.{Account, Challenge, ChallengeColor, ChallengeStatus, DeclineReason, TimeControl}
import de.nowchess.account.dto.*
import io.quarkus.runtime.annotations.RegisterForReflection
@RegisterForReflection(
targets = Array(
classOf[RegisterRequest],
classOf[Account],
classOf[Challenge],
classOf[ChallengeColor],
classOf[ChallengeStatus],
classOf[DeclineReason],
classOf[TimeControl],
classOf[LoginRequest],
classOf[TokenResponse],
classOf[PlayerInfo],
@@ -20,6 +27,11 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[ChallengeStatus],
classOf[ChallengeColor],
classOf[DeclineReason],
classOf[CorePlayerInfo],
classOf[CoreTimeControl],
classOf[CoreCreateGameRequest],
classOf[CoreGameResponse],
),
)
class NativeReflectionConfig
@@ -45,6 +45,10 @@ class Challenge extends PanacheEntityBase:
var expiresAt: Instant = uninitialized
@Column(nullable = true)
var gameId: String = uninitialized
def gameIdOpt: Option[String] = Option(gameId)
def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
@@ -23,6 +23,7 @@ case class ChallengeDto(
timeControl: TimeControlDto,
status: String,
declineReason: Option[String],
gameId: Option[String],
createdAt: String,
expiresAt: String,
)
@@ -0,0 +1,11 @@
package de.nowchess.account.error
enum AccountError:
case UsernameTaken(username: String)
case EmailAlreadyRegistered(email: String)
case InvalidCredentials
def message: String = this match
case UsernameTaken(u) => s"Username '$u' is already taken"
case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
case InvalidCredentials => "Invalid credentials"
@@ -0,0 +1,25 @@
package de.nowchess.account.error
enum ChallengeError:
case UserNotFound(username: String)
case ChallengerNotFound
case CannotChallengeSelf
case DuplicateChallenge
case InvalidColor(color: String)
case InvalidDeclineReason(reason: String)
case ChallengeNotFound
case ChallengeNotActive
case NotAuthorized
case GameCreationFailed
def message: String = this match
case UserNotFound(u) => s"User '$u' not found"
case ChallengerNotFound => "Challenger not found"
case CannotChallengeSelf => "Cannot challenge yourself"
case DuplicateChallenge => "Active challenge to this user already exists"
case InvalidColor(c) => s"Unknown color: $c"
case InvalidDeclineReason(r) => s"Unknown decline reason: $r"
case ChallengeNotFound => "Challenge not found"
case ChallengeNotActive => "Challenge is not active"
case NotAuthorized => "Not authorized"
case GameCreationFailed => "Failed to create game"
@@ -2,6 +2,7 @@ package de.nowchess.account.resource
import de.nowchess.account.domain.Account
import de.nowchess.account.dto.*
import de.nowchess.account.error.AccountError
import de.nowchess.account.service.AccountService
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
@@ -31,7 +32,7 @@ class AccountResource:
case Right(account) =>
Response.ok(toPublicDto(account)).build()
case Left(error) =>
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error)).build()
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error.message)).build()
@POST
@Path("/login")
@@ -40,7 +41,7 @@ class AccountResource:
case Right(token) =>
Response.ok(TokenResponse(token)).build()
case Left(error) =>
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error)).build()
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
@GET
@Path("/me")
@@ -1,6 +1,7 @@
package de.nowchess.account.resource
import de.nowchess.account.dto.*
import de.nowchess.account.error.ChallengeError
import de.nowchess.account.service.ChallengeService
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
@@ -33,10 +34,11 @@ class ChallengeResource:
case Right(challenge) =>
Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
case Left(error) =>
val status = if error.contains("not found") then Response.Status.NOT_FOUND
else if error.contains("yourself") then Response.Status.BAD_REQUEST
else Response.Status.CONFLICT
Response.status(status).entity(ErrorDto(error)).build()
val status = error match
case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND
case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST
case _ => Response.Status.CONFLICT
Response.status(status).entity(ErrorDto(error.message)).build()
@GET
def list(): Response =
@@ -67,9 +69,10 @@ class ChallengeResource:
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
private def errorResponse(error: String): Response =
val status =
if error.contains("not found") then Response.Status.NOT_FOUND
else if error.contains("authorized") then Response.Status.FORBIDDEN
else Response.Status.BAD_REQUEST
Response.status(status).entity(ErrorDto(error)).build()
private def errorResponse(error: ChallengeError): Response =
val status = error match
case ChallengeError.ChallengeNotFound => Response.Status.NOT_FOUND
case ChallengeError.NotAuthorized => Response.Status.FORBIDDEN
case ChallengeError.GameCreationFailed => Response.Status.INTERNAL_SERVER_ERROR
case _ => Response.Status.BAD_REQUEST
Response.status(status).entity(ErrorDto(error.message)).build()
@@ -2,6 +2,7 @@ package de.nowchess.account.service
import de.nowchess.account.domain.Account
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
import de.nowchess.account.error.AccountError
import de.nowchess.account.repository.AccountRepository
import io.quarkus.elytron.security.common.BcryptUtil
import io.smallrye.jwt.build.Jwt
@@ -20,11 +21,11 @@ class AccountService:
var accountRepository: AccountRepository = uninitialized
@Transactional
def register(req: RegisterRequest): Either[String, Account] =
def register(req: RegisterRequest): Either[AccountError, Account] =
if accountRepository.findByUsername(req.username).isDefined then
Left(s"Username '${req.username}' is already taken")
Left(AccountError.UsernameTaken(req.username))
else if accountRepository.findByEmail(req.email).isDefined then
Left(s"Email '${req.email}' is already registered")
Left(AccountError.EmailAlreadyRegistered(req.email))
else
val account = new Account()
account.username = req.username
@@ -34,7 +35,7 @@ class AccountService:
accountRepository.persist(account)
Right(account)
def login(req: LoginRequest): Either[String, String] =
def login(req: LoginRequest): Either[AccountError, String] =
accountRepository
.findByUsername(req.username)
.filter(a => BcryptUtil.matches(req.password, a.passwordHash))
@@ -45,7 +46,7 @@ class AccountService:
.claim("username", account.username)
.sign()
}
.toRight("Invalid credentials")
.toRight(AccountError.InvalidCredentials)
def findByUsername(username: String): Option[Account] =
accountRepository.findByUsername(username)
@@ -1,11 +1,14 @@
package de.nowchess.account.service
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
import de.nowchess.account.dto.{ChallengeDto, ChallengeListDto, ChallengeRequest, DeclineRequest, PlayerInfo, TimeControlDto}
import de.nowchess.account.error.ChallengeError
import de.nowchess.account.repository.{AccountRepository, ChallengeRepository}
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import org.eclipse.microprofile.rest.client.inject.RestClient
import scala.compiletime.uninitialized
import java.time.Instant
@@ -21,16 +24,20 @@ class ChallengeService:
@Inject
var challengeRepository: ChallengeRepository = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Transactional
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[String, Challenge] =
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
for
destUser <- accountRepository.findByUsername(destUsername).toRight(s"User '$destUsername' not found")
challenger <- accountRepository.findById(challengerId).toRight("Challenger not found")
_ <- Either.cond(challenger.id != destUser.id, (), "Cannot challenge yourself")
destUser <- accountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
challenger <- accountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
_ <- Either.cond(
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
(),
"Active challenge to this user already exists",
ChallengeError.DuplicateChallenge,
)
color <- parseColor(req.color)
yield
@@ -50,22 +57,24 @@ class ChallengeService:
challenge
@Transactional
def accept(challengeId: UUID, userId: UUID): Either[String, Challenge] =
def accept(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to accept this challenge")
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
gameId <- createGame(challenge)
yield
challenge.status = ChallengeStatus.Accepted
challenge.gameId = gameId
challengeRepository.merge(challenge)
challenge
@Transactional
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[String, Challenge] =
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
_ <- Either.cond(challenge.destUser.id == userId, (), "Not authorized to decline this challenge")
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
reason <- parseDeclineReason(req.reason)
yield
challenge.status = ChallengeStatus.Declined
@@ -74,11 +83,11 @@ class ChallengeService:
challenge
@Transactional
def cancel(challengeId: UUID, userId: UUID): Either[String, Challenge] =
def cancel(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight("Challenge not found")
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), "Challenge is not active")
_ <- Either.cond(challenge.challenger.id == userId, (), "Not authorized to cancel this challenge")
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
_ <- Either.cond(challenge.challenger.id == userId, (), ChallengeError.NotAuthorized)
yield
challenge.status = ChallengeStatus.Canceled
challengeRepository.merge(challenge)
@@ -89,20 +98,45 @@ class ChallengeService:
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
ChallengeListDto(in = incoming, out = outgoing)
private def parseColor(raw: String): Either[String, ChallengeColor] =
// scalafix:off DisableSyntax.null
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
try
val (white, black) = assignColors(challenge)
val tc = buildTimeControl(challenge)
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
Right(coreGameClient.createGame(req).gameId)
catch case _ => Left(ChallengeError.GameCreationFailed)
// scalafix:on DisableSyntax.null
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username)
val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username)
challenge.color match
case ChallengeColor.White => (challenger, destUser)
case ChallengeColor.Black => (destUser, challenger)
case ChallengeColor.Random =>
if scala.util.Random.nextBoolean() then (challenger, destUser) else (destUser, challenger)
private def buildTimeControl(challenge: Challenge): Option[CoreTimeControl] =
challenge.timeControlType match
case "unlimited" => None
case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
raw.toLowerCase match
case "white" => Right(ChallengeColor.White)
case "black" => Right(ChallengeColor.Black)
case "random" => Right(ChallengeColor.Random)
case _ => Left(s"Unknown color: $raw")
case _ => Left(ChallengeError.InvalidColor(raw))
private def parseDeclineReason(raw: Option[String]): Either[String, Option[DeclineReason]] =
private def parseDeclineReason(raw: Option[String]): Either[ChallengeError, Option[DeclineReason]] =
raw match
case None => Right(None)
case Some(r) =>
DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match
case Some(reason) => Right(Some(reason))
case None => Left(s"Unknown decline reason: $r")
case None => Left(ChallengeError.InvalidDeclineReason(r))
def toDto(c: Challenge): ChallengeDto =
ChallengeDto(
@@ -114,6 +148,7 @@ class ChallengeService:
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
status = c.status.toString.toLowerCase,
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
gameId = c.gameIdOpt,
createdAt = c.createdAt.toString,
expiresAt = c.expiresAt.toString,
)
@@ -1,14 +1,26 @@
package de.nowchess.account.resource
import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import io.restassured.RestAssured
import io.restassured.http.ContentType
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.hamcrest.Matchers.*
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.{BeforeEach, Test}
import org.mockito.{ArgumentMatchers, Mockito}
@QuarkusTest
class ChallengeResourceTest:
@InjectMock
@RestClient
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
@BeforeEach
def setup(): Unit =
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
private def registerBody(username: String, suffix: String = "") =
@@ -92,6 +104,7 @@ class ChallengeResourceTest:
.`then`()
.statusCode(200)
.body("status", is("accepted"))
.body("gameId", is("test-game-id"))
@Test
def declineChallengeReturns200(): Unit =