feat(bot): implement bot architecture with difficulty levels and game context handling
This commit is contained in:
@@ -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,
|
||||
)
|
||||
|
||||
+94
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user