feat(bot): implement bot architecture with difficulty levels and game context handling

This commit is contained in:
2026-04-28 00:59:32 +02:00
parent 6b59e68e04
commit c10a4d7e64
121 changed files with 1010 additions and 1358 deletions
@@ -6,6 +6,8 @@ quarkus:
rest-client:
core-service:
url: http://localhost:8080
bot-platform-service:
url: http://localhost:8087
smallrye-openapi:
info-title: NowChess Account Service
path: /openapi
@@ -27,6 +29,8 @@ quarkus:
rest-client:
core-service:
url: ${CORE_SERVICE_URL}
bot-platform-service:
url: ${BOT_PLATFORM_SERVICE_URL}
datasource:
db-kind: postgresql
username: ${DB_USER}
@@ -0,0 +1,20 @@
package de.nowchess.account.client
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@Path("/api/bot")
@RegisterRestClient(configKey = "bot-platform-service")
trait BotPlatformClient:
@POST
@Path("/game/{gameId}/assign")
@Produces(Array(MediaType.APPLICATION_JSON))
def assignBot(
@PathParam("gameId") gameId: String,
@QueryParam("botId") botId: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("playingAs") playingAs: String,
@QueryParam("botAccountId") botAccountId: String,
): Unit
@@ -31,7 +31,6 @@ import io.quarkus.runtime.annotations.RegisterForReflection
classOf[BotAccountDto],
classOf[BotAccountWithTokenDto],
classOf[OfficialBotAccountDto],
classOf[OfficialBotAccountWithTokenDto],
classOf[CreateBotAccountRequest],
classOf[UpdateBotNameRequest],
classOf[RotatedTokenDto],
@@ -72,9 +72,6 @@ class OfficialBotAccount extends PanacheEntityBase:
@Column(nullable = false)
var name: String = uninitialized
@Column(unique = true, nullable = false, length = 256)
var token: String = uninitialized
var rating: Int = 1500
var createdAt: Instant = uninitialized
@@ -46,4 +46,5 @@ case class RotatedTokenDto(token: String)
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
case class OfficialBotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String)
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
@@ -67,7 +67,6 @@ class BotAccountRepository:
def delete(botId: UUID): Unit =
em.find(classOf[BotAccount], botId) match
case bot: BotAccount => em.remove(bot)
case _ => ()
def findByToken(token: String): Option[BotAccount] =
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
@@ -97,11 +96,4 @@ class OfficialBotAccountRepository:
def delete(botId: UUID): Unit =
em.find(classOf[OfficialBotAccount], botId) match
case bot: OfficialBotAccount => em.remove(bot)
case _ => ()
def findByToken(token: String): Option[OfficialBotAccount] =
em.createQuery("FROM OfficialBotAccount WHERE token = :token", classOf[OfficialBotAccount])
.setParameter("token", token)
.getResultList
.asScala
.headOption
@@ -170,7 +170,7 @@ class AccountResource:
@GET
@Path("/official-bots")
def getOfficialBots(): Response =
def getOfficialBots: Response =
val bots = accountService.getOfficialBotAccounts()
Response.ok(bots.map(toOfficialBotDto)).build()
@@ -180,7 +180,7 @@ class AccountResource:
def createOfficialBot(req: CreateBotAccountRequest): Response =
accountService.createOfficialBotAccount(req.name) match
case Right(bot) =>
Response.status(Response.Status.CREATED).entity(toOfficialBotDtoWithToken(bot)).build()
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
case Left(error) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
@@ -193,15 +193,6 @@ class AccountResource:
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
@POST
@Path("/official-bots/{botId}/rotate-token")
@RolesAllowed(Array("Admin"))
def rotateOfficialBotToken(@PathParam("botId") botId: String): Response =
accountService.rotateOfficialBotToken(UUID.fromString(botId)) match
case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build()
case Left(error) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
private def toOfficialBotDto(bot: OfficialBotAccount): OfficialBotAccountDto =
OfficialBotAccountDto(
id = bot.id.toString,
@@ -210,11 +201,3 @@ class AccountResource:
createdAt = bot.createdAt.toString,
)
private def toOfficialBotDtoWithToken(bot: OfficialBotAccount): OfficialBotAccountWithTokenDto =
OfficialBotAccountWithTokenDto(
id = bot.id.toString,
name = bot.name,
rating = bot.rating,
token = bot.token,
createdAt = bot.createdAt.toString,
)
@@ -0,0 +1,94 @@
package de.nowchess.account.resource
import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
import de.nowchess.account.service.AccountService
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.util.UUID
@Path("/api/challenge/official")
@ApplicationScoped
@RolesAllowed(Array("**"))
@Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON))
class OfficialChallengeResource:
// scalafix:off DisableSyntax.var
@Inject
var accountService: AccountService = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Inject
@RestClient
var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on
private val log = Logger.getLogger(classOf[OfficialChallengeResource])
@POST
@Path("/{botName}")
def challengeWithDifficulty(
@PathParam("botName") botName: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("color") color: String,
): Response =
if difficulty < 1000 || difficulty > 2800 then
return Response
.status(Response.Status.BAD_REQUEST)
.entity(ErrorDto("difficulty must be between 1000 and 2800"))
.build()
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
case other =>
return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build()
val userId = UUID.fromString(jwt.getSubject)
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
val userOpt = accountService.findById(userId)
(botOpt, userOpt) match
case (None, _) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(s"Official bot '$botName' not found")).build()
case (_, None) =>
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto("User not found")).build()
case (Some(bot), Some(user)) =>
val userIsWhite = playerColor match
case "white" => true
case "black" => false
case _ => scala.util.Random.nextBoolean()
val (white, black, botColor) =
if userIsWhite then
(CorePlayerInfo(user.id.toString, user.username), CorePlayerInfo(bot.id.toString, bot.name), "black")
else
(CorePlayerInfo(bot.id.toString, bot.name), CorePlayerInfo(user.id.toString, user.username), "white")
val req = CoreCreateGameRequest(Some(white), Some(black), None, Some("Authenticated"))
val gameId =
try Right(coreGameClient.createGame(req).gameId)
catch case _ => Left("Failed to create game")
gameId match
case Left(err) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
case Right(id) =>
try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id)
Response
.status(Response.Status.CREATED)
.entity(OfficialChallengeResponse(id, botName, difficulty))
.build()
@@ -121,7 +121,6 @@ class AccountService:
def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
val bot = new OfficialBotAccount()
bot.name = botName
bot.token = generateOfficialBotToken(bot.id)
bot.createdAt = Instant.now()
officialBotAccountRepository.persist(bot)
Right(bot)
@@ -137,15 +136,6 @@ class AccountService:
officialBotAccountRepository.delete(botId)
Right(())
@Transactional
def rotateOfficialBotToken(botId: UUID): Either[AccountError, OfficialBotAccount] =
officialBotAccountRepository.findById(botId) match
case None => Left(AccountError.BotNotFound)
case Some(bot) =>
bot.token = generateOfficialBotToken(botId)
officialBotAccountRepository.persist(bot)
Right(bot)
private def generateBotToken(botId: UUID): String =
Jwt
.issuer("nowchess")
@@ -174,10 +164,3 @@ class AccountService:
userAccountRepository.persist(user)
Right(user)
private def generateOfficialBotToken(botId: UUID): String =
Jwt
.issuer("nowchess")
.subject(botId.toString)
.expiresAt(Long.MaxValue)
.claim("type", "official-bot")
.sign()
@@ -1,6 +1,7 @@
package de.nowchess.account.service
import de.nowchess.account.client.{
BotPlatformClient,
CoreCreateGameRequest,
CoreGameClient,
CoreGameResponse,
@@ -22,6 +23,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import jakarta.transaction.Transactional
import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger
import scala.compiletime.uninitialized
import java.time.Instant
@@ -31,6 +33,8 @@ import java.util.UUID
@ApplicationScoped
class ChallengeService:
private val log = Logger.getLogger(classOf[ChallengeService])
// scalafix:off DisableSyntax.var
@Inject
var userAccountRepository: UserAccountRepository = uninitialized
@@ -41,6 +45,10 @@ class ChallengeService:
@Inject
@RestClient
var coreGameClient: CoreGameClient = uninitialized
@Inject
@RestClient
var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on
@Transactional
@@ -80,6 +88,7 @@ class ChallengeService:
challenge.status = ChallengeStatus.Accepted
challenge.gameId = gameId
challengeRepository.merge(challenge)
notifyBotIfNeeded(challenge, gameId)
challenge
@Transactional
@@ -111,6 +120,16 @@ class ChallengeService:
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
ChallengeListDto(in = incoming, out = outgoing)
private def notifyBotIfNeeded(challenge: Challenge, gameId: String): Unit =
val (white, black) = assignColors(challenge)
List(challenge.challenger, challenge.destUser).foreach { user =>
user.getBotAccounts.headOption.foreach { bot =>
val playingAs = if white.id == user.id.toString then "white" else "black"
try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId)
}
}
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
try
val (white, black) = assignColors(challenge)