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)
@@ -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]
}
@@ -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
+115
View File
@@ -0,0 +1,115 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(
listOf(
"de\\.nowchess\\.botplatform\\.registry",
"de\\.nowchess\\.botplatform\\.resource",
)
)
excludedFiles.set(scoverageExcluded)
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
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-arc")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
implementation(project(":modules:api"))
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.quarkus:quarkus-junit")
testImplementation("io.rest-assured:rest-assured")
testImplementation("io.quarkus:quarkus-test-security")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("junit-jupiter")
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
@@ -0,0 +1,22 @@
quarkus:
http:
port: 8087
application:
name: nowchess-bot-platform
smallrye-jwt:
enabled: true
log:
level: INFO
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
"%deployed":
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
@@ -0,0 +1,17 @@
package de.nowchess.botplatform.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
@@ -0,0 +1,18 @@
package de.nowchess.botplatform.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,35 @@
package de.nowchess.botplatform.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -0,0 +1,8 @@
package de.nowchess.botplatform.registry
case class BotGameInfo(
botId: String,
difficulty: Int,
playingAs: String,
botAccountId: String,
)
@@ -0,0 +1,30 @@
package de.nowchess.botplatform.registry
import io.smallrye.mutiny.subscription.MultiEmitter
import jakarta.enterprise.context.ApplicationScoped
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class BotRegistry:
private val connections = ConcurrentHashMap[String, MultiEmitter[?]]()
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
connections.put(botId, emitter)
()
def unregister(botId: String): Unit =
connections.remove(botId)
()
def dispatch(botId: String, event: String): Boolean =
Option(connections.get(botId)) match
case Some(emitter) =>
emitter.asInstanceOf[MultiEmitter[String]].emit(event)
true
case None => false
def registeredBots: List[String] =
import scala.jdk.CollectionConverters.*
connections.keys().asScala.toList
@@ -0,0 +1,67 @@
package de.nowchess.botplatform.resource
import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry}
import de.nowchess.botplatform.service.GameBotMonitor
import io.smallrye.mutiny.Multi
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 scala.compiletime.uninitialized
@Path("/api/bot")
@ApplicationScoped
@RolesAllowed(Array("**"))
class BotEventResource:
// scalafix:off DisableSyntax.var
@Inject
var registry: BotRegistry = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject
var gameMonitor: GameBotMonitor = uninitialized
// scalafix:on DisableSyntax.var
@GET
@Path("/stream/events")
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamEvents(@QueryParam("botId") botId: String): Multi[String] =
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
val subject = Option(jwt.getSubject).getOrElse("")
if tokenType != "bot" || subject != botId then
Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot"))
else
Multi.createFrom().emitter[String] { emitter =>
registry.register(botId, emitter)
emitter.onTermination(() => registry.unregister(botId))
}
@GET
@Path("/game/stream/{gameId}")
@Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter =>
registry.register(s"game-$gameId", emitter)
emitter.onTermination(() => registry.unregister(s"game-$gameId"))
}
@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,
): Response =
val info = BotGameInfo(botId, difficulty, playingAs, botAccountId)
gameMonitor.watchGame(gameId, info)
val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}"""
registry.dispatch(botId, event)
Response.ok().build()
@@ -0,0 +1,56 @@
package de.nowchess.botplatform.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotGameInfo
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.{RedissonClient, RBlockingQueue}
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class GameBotMonitor:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
// scalafix:on DisableSyntax.var
private val listeners = ConcurrentHashMap[String, Int]()
def watchGame(gameId: String, info: BotGameInfo): Unit =
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleS2cEvent(gameId, msg, info),
)
listeners.put(gameId, listenerId)
def unwatchGame(gameId: String): Unit =
Option(listeners.remove(gameId)).foreach { listenerId =>
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
redisson.getTopic(topicName).removeListener(listenerId)
}
private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
try
val node = objectMapper.readTree(msg)
val status = Option(node.path("state").path("status").asText()).getOrElse("")
if terminalStatuses.contains(status) then
unwatchGame(gameId)
else
val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
if turn == info.playingAs then
val fen = node.path("state").path("fen").asText()
val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
queue.put(req)
catch case _: Exception => ()
-79
View File
@@ -1,79 +0,0 @@
plugins {
id("scala")
id("org.scoverage")
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(
listOf(
"de\\.nowchess\\.bot\\.bots\\.NNUEBot",
"de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
)
)
excludedFiles.set(scoverageExcluded)
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest")
testLogging {
events("skipped", "failed")
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
@@ -1,29 +0,0 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class ClassicalBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
) extends Bot:
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationClassic)
private val TIME_BUDGET_MS = 1000L
override val name: String = s"ClassicalBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse(search.bestMoveWithTime(context, TIME_BUDGET_MS, blockedMoves))
@@ -1,43 +0,0 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class HybridBot(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
nnueEvaluation: Evaluation = EvaluationNNUE,
classicalEvaluation: Evaluation = EvaluationClassic,
vetoReporter: String => Unit = println(_),
) extends Bot:
private val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
override val name: String = s"HybridBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse(searchWithVeto(context, blockedMoves))
private def searchWithVeto(context: GameContext, blockedMoves: Set[Move]): Option[Move] =
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
val next = rules.applyMove(context)(move)
val staticNnue = nnueEvaluation.evaluate(next)
val classical = classicalEvaluation.evaluate(next)
val diff = (classical - staticNnue).abs
if diff > Config.VETO_THRESHOLD then
vetoReporter(
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
)
move
}
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies {
implementation(project(":modules:json"))
implementation(project(":modules:rule"))
implementation(project(":modules:io"))
implementation(project(":modules:bot"))
implementation(project(":modules:official-bots"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
@@ -3,19 +3,15 @@ package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, Color, Piece, PieceType, Square}
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.api.game.{
BotParticipant,
ClockState,
CorrespondenceClockState,
DrawReason,
GameContext,
GameResult,
Human,
LiveClockState,
Participant,
TimeControl,
WinReason,
}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.chess.controller.Parser
import de.nowchess.chess.observer.*
import de.nowchess.api.error.GameError
@@ -25,7 +21,6 @@ import de.nowchess.api.rules.RuleSet
import java.time.Instant
import java.util.concurrent.{Executors, ScheduledExecutorService, ScheduledFuture, TimeUnit}
import scala.concurrent.{ExecutionContext, Future}
/** Pure game engine that manages game state and notifies observers of state changes. All rule queries delegate to the
* injected RuleSet. All user interactions go through Commands; state changes are broadcast via GameEvents.
@@ -33,10 +28,6 @@ import scala.concurrent.{ExecutionContext, Future}
class GameEngine(
val initialContext: GameContext = GameContext.initial,
val ruleSet: RuleSet,
val participants: Map[Color, Participant] = Map(
Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")),
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
),
val timeControl: TimeControl = TimeControl.Unlimited,
initialClockState: Option[ClockState] = None,
initialDrawOffer: Option[Color] = None,
@@ -69,8 +60,6 @@ class GameEngine(
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
clockState.foreach(scheduleExpiryCheck)
private implicit val ec: ExecutionContext = ExecutionContext.global
// Synchronized accessors for current state
def board: Board = synchronized(currentContext.board)
def turn: Color = synchronized(currentContext.turn)
@@ -325,9 +314,6 @@ class GameEngine(
notifyObservers(DrawEvent(currentContext, reason))
}
/** Kick off play when the side to move is a bot (e.g. bot-vs-bot from initial position). */
def startGame(): Unit = synchronized(requestBotMoveIfNeeded())
/** Inject clock state directly (for testing). */
private[engine] def injectClockState(cs: Option[ClockState]): Unit = synchronized { clockState = cs }
@@ -426,7 +412,6 @@ class GameEngine(
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
if status.isThreefoldRepetition then notifyObservers(ThreefoldRepetitionAvailableEvent(currentContext))
requestBotMoveIfNeeded()
private def translateMoveToNotation(move: Move, boardBefore: Board): String =
move.moveType match
@@ -477,47 +462,6 @@ class GameEngine(
case _ =>
context.board.pieceAt(move.to)
/** Request a move from the opponent bot if it's their turn. Spawns an async task to avoid blocking the engine.
*/
private def requestBotMoveIfNeeded(): Unit =
val pendingBotMove = synchronized {
participants.get(currentContext.turn) match
case Some(BotParticipant(bot)) => Some((bot, currentContext))
case _ => None
}
pendingBotMove.foreach { case (bot, contextAtRequest) =>
Future {
bot.nextMove(contextAtRequest) match
case Some(move) => applyBotMove(move)
case None => handleBotNoMove()
}
}
private def applyBotMove(move: Move): Unit =
synchronized {
val color = currentContext.turn
val from = move.from
val to = move.to
currentContext.board.pieceAt(from) match
case Some(piece) if piece.color == color =>
val legal = ruleSet.legalMoves(currentContext)(from)
legal.find(m => m.to == to && m.moveType == move.moveType) match
case Some(legalMove) => executeMove(legalMove)
case None =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveIllegal))
case _ =>
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.BotMoveInvalidSource))
}
private def handleBotNoMove(): Unit =
synchronized {
if ruleSet.isCheckmate(currentContext) then
val winner = currentContext.turn.opposite
notifyObservers(CheckmateEvent(currentContext, winner))
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
}
private def replayContextFromMoves(moves: List[Move]): GameContext =
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
@@ -79,6 +79,11 @@ class GameResource:
val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
private def assertIsBot(): Unit =
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
if !Set("bot", "official-bot").contains(botType) then
throw ForbiddenException("Only bots can make moves")
// scalafix:on DisableSyntax.throw
// ── mapping ──────────────────────────────────────────────────────────────
@@ -184,6 +189,7 @@ class GameResource:
@Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
assertIsBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry)
assertIsCurrentPlayer(entry)
@@ -1,266 +0,0 @@
package de.nowchess.chess.engine
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Piece, Rank, Square}
import de.nowchess.api.bot.Bot
import de.nowchess.api.game.{BotParticipant, GameContext, Human}
import de.nowchess.api.move.{Move, MoveType}
import de.nowchess.api.player.{PlayerId, PlayerInfo}
import de.nowchess.bot.bots.ClassicalBot
import de.nowchess.bot.{BotController, BotDifficulty}
import de.nowchess.chess.observer.*
import de.nowchess.rules.sets.DefaultRules
import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger}
private class NoMoveBot extends Bot:
def name: String = "nomove"
def nextMove(context: GameContext): Option[Move] = None
private class FixedMoveBot(move: Move) extends Bot:
def name: String = "fixed"
def nextMove(context: GameContext): Option[Move] = Some(move)
class GameEngineWithBotTest extends AnyFunSuite with Matchers:
test("GameEngine can play against a ClassicalBot"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
// Collect events
val moveCount = new AtomicInteger(0)
val checkmateDetected = new AtomicBoolean(false)
val gameEnded = new AtomicBoolean(false)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent =>
moveCount.incrementAndGet()
case _: CheckmateEvent =>
checkmateDetected.set(true)
gameEnded.set(true)
case _: DrawEvent =>
gameEnded.set(true)
case _ => ()
engine.subscribe(observer)
// Play a few moves: e2e4, then let the bot respond
engine.processUserInput("e2e4")
// Wait a bit for the bot to respond asynchronously
Thread.sleep(5000)
// White should have moved, then Black (bot) should have responded
moveCount.get() should be >= 2
test("BotController can list and retrieve bots"):
val bots = BotController.listBots
bots should contain("easy")
bots should contain("medium")
bots should contain("hard")
bots should contain("expert")
BotController.getBot("easy") should not be None
BotController.getBot("medium") should not be None
BotController.getBot("hard") should not be None
BotController.getBot("expert") should not be None
BotController.getBot("unknown") should be(None)
test("GameEngine handles bot with different difficulty"):
val hardBot = BotController.getBot("hard").get
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(hardBot)),
)
engine.turn should equal(Color.White)
val movesMade = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// White moves
engine.processUserInput("d2d4")
Thread.sleep(500) // Wait for bot response
// At least white moved, possibly black also responded
movesMade.get() should be >= 1
test("GameEngine plays valid bot moves"):
val bot = ClassicalBot(BotDifficulty.Easy)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val moveCount = new AtomicInteger(0)
val observer = new Observer:
def onGameEvent(event: GameEvent): Unit =
event match
case _: MoveExecutedEvent => moveCount.incrementAndGet()
case _ => ()
engine.subscribe(observer)
// Play a normal move
engine.processUserInput("e2e4")
Thread.sleep(1000)
// The game should have progressed with at least one move
moveCount.get() should be >= 1
// Game should not be ended (checkmate/stalemate)
engine.context.moves.nonEmpty should be(true)
test("startGame triggers bot when the starting player is a bot"):
val bot = new FixedMoveBot(Move(Square(File.E, Rank.R2), Square(File.E, Rank.R4)))
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(bot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val movesMade = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: MoveExecutedEvent => movesMade.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
movesMade.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move destination is illegal"):
val illegalMove = Move(Square(File.E, Rank.R7), Square(File.E, Rank.R3), MoveType.Normal())
val bot = new FixedMoveBot(illegalMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("applyBotMove fires InvalidMoveEvent when bot move source square is invalid"):
val invalidMove = Move(Square(File.E, Rank.R5), Square(File.E, Rank.R6), MoveType.Normal())
val bot = new FixedMoveBot(invalidMove)
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> Human(PlayerInfo(PlayerId("p1"), "Player 1")), Color.Black -> BotParticipant(bot)),
)
val invalidCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: InvalidMoveEvent => invalidCount.incrementAndGet()
case _ => (),
)
engine.processUserInput("e2e4")
Thread.sleep(1000)
invalidCount.get() should be >= 1
test("handleBotNoMove fires CheckmateEvent when position is checkmate"):
// White king at A1 in check from Qb2; Rb8 protects queen so king can't capture it
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R2) -> Piece.BlackQueen,
Square(File.B, Rank.R8) -> Piece.BlackRook,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val checkmateCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => checkmateCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
checkmateCount.get() should be >= 1
test("handleBotNoMove fires DrawEvent when position is stalemate"):
// White king at A1 not in check but has no legal moves (queen at B3 covers A2, B1, B2)
val board = Board(
Map(
Square(File.A, Rank.R1) -> Piece.WhiteKing,
Square(File.B, Rank.R3) -> Piece.BlackQueen,
Square(File.H, Rank.R8) -> Piece.BlackKing,
),
)
val ctx = GameContext.initial.copy(
board = board,
turn = Color.White,
castlingRights = CastlingRights(false, false, false, false),
enPassantSquare = None,
halfMoveClock = 0,
moves = List.empty,
)
val engine = GameEngine(
ctx,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val drawCount = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: DrawEvent => drawCount.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(1000)
drawCount.get() should be >= 1
test("handleBotNoMove does nothing when position is neither checkmate nor stalemate"):
val engine = GameEngine(
GameContext.initial,
DefaultRules,
Map(Color.White -> BotParticipant(new NoMoveBot), Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2"))),
)
val unexpectedEvents = new AtomicInteger(0)
engine.subscribe(
new Observer:
def onGameEvent(event: GameEvent): Unit = event match
case _: CheckmateEvent => unexpectedEvents.incrementAndGet()
case _: DrawEvent => unexpectedEvents.incrementAndGet()
case _ => (),
)
engine.startGame()
Thread.sleep(500)
unexpectedEvents.get() shouldBe 0
@@ -12,6 +12,7 @@ import de.nowchess.rules.sets.DefaultRules
import io.quarkus.test.InjectMock
import io.quarkus.test.junit.QuarkusTest
import jakarta.inject.Inject
import org.eclipse.microprofile.jwt.JsonWebToken
import org.junit.jupiter.api.{BeforeEach, DisplayName, Test}
import org.junit.jupiter.api.Assertions.*
import org.mockito.ArgumentMatchers.any
@@ -34,8 +35,13 @@ class GameResourceIntegrationTest:
@InjectMock
var ioWrapper: IoGrpcClientWrapper = uninitialized
@InjectMock
var jwt: JsonWebToken = uninitialized
@BeforeEach
def setupMocks(): Unit =
when(jwt.getClaim[AnyRef]("type")).thenReturn("bot")
when(ioWrapper.importFen(any[String]())).thenReturn(GameContext.initial)
when(ioWrapper.importPgn(any[String]())).thenAnswer((inv: InvocationOnMock) =>
PgnParser.importGameContext(inv.getArgument[String](0)).getOrElse(GameContext.initial),
+118
View File
@@ -0,0 +1,118 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
id("io.quarkus")
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
@Suppress("UNCHECKED_CAST")
val scoverageExcluded = rootProject.extra["SCOVERAGE_EXCLUDED"] as List<String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
excludedPackages.set(
listOf(
"de\\.nowchess\\.bot\\.bots\\.NNUEBot",
"de\\.nowchess\\.bot\\.bots\\.nnue\\..*",
"de\\.nowchess\\.bot\\.util\\.PolyglotBook",
"de\\.nowchess\\.bot\\.resource\\..*",
"de\\.nowchess\\.bot\\.config\\..*",
)
)
excludedFiles.set(scoverageExcluded)
}
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation("org.scala-lang:scala3-library_3") {
version {
strictly(versions["SCALA3"]!!)
}
}
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson")
implementation("io.quarkus:quarkus-arc")
implementation("io.quarkus:quarkus-config-yaml")
implementation("io.quarkus:quarkus-smallrye-jwt")
implementation("io.quarkus:quarkus-smallrye-health")
implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation(project(":modules:api"))
implementation(project(":modules:io"))
implementation(project(":modules:rule"))
implementation("com.microsoft.onnxruntime:onnxruntime:${versions["ONNXRUNTIME"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
testImplementation("io.quarkus:quarkus-junit")
testImplementation("io.rest-assured:rest-assured")
testImplementation("io.quarkus:quarkus-test-security")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
tasks.test {
useJUnitPlatform {
includeEngines("scalatest", "junit-jupiter")
testLogging {
events("passed", "skipped", "failed")
showStandardStreams = true
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
finalizedBy(tasks.reportScoverage)
}
tasks.reportScoverage {
dependsOn(tasks.test)
}
@@ -0,0 +1,22 @@
quarkus:
http:
port: 8088
application:
name: nowchess-official-bots
smallrye-jwt:
enabled: true
log:
level: INFO
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
"%deployed":
nowchess:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
@@ -0,0 +1,6 @@
package de.nowchess.bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
type Bot = GameContext => Option[Move]
@@ -1,10 +1,9 @@
package de.nowchess.bot
import de.nowchess.api.bot.Bot
import de.nowchess.bot.bots.ClassicalBot
import jakarta.enterprise.context.ApplicationScoped
object BotController {
object BotController:
private val bots: Map[String, Bot] = Map(
"easy" -> ClassicalBot(BotDifficulty.Easy),
"medium" -> ClassicalBot(BotDifficulty.Medium),
@@ -12,10 +11,10 @@ object BotController {
"expert" -> ClassicalBot(BotDifficulty.Expert),
)
/** Get a bot by name. */
def getBot(name: String): Option[Bot] = bots.get(name.toLowerCase)
def listBots: List[String] = bots.keys.toList.sorted
/** List all available bot names. */
def listBots: List[String] = bots.keys.toList.sorted
}
@ApplicationScoped
class BotController:
def getBot(name: String): Option[Bot] = BotController.getBot(name)
def listBots: List[String] = BotController.listBots
@@ -0,0 +1,25 @@
package de.nowchess.bot.bots
import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.rules.sets.DefaultRules
object ClassicalBot:
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
): Bot =
val search = AlphaBetaSearch(rules, weights = EvaluationClassic)
val timeBudgetMs = 1000L
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse(search.bestMoveWithTime(context, timeBudgetMs, blockedMoves))
@@ -0,0 +1,39 @@
package de.nowchess.bot.bots
import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.ai.Evaluation
import de.nowchess.bot.bots.classic.EvaluationClassic
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.{AlphaBetaSearch, TranspositionTable}
import de.nowchess.bot.util.PolyglotBook
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition, Config}
import de.nowchess.rules.sets.DefaultRules
object HybridBot:
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
nnueEvaluation: Evaluation = EvaluationNNUE,
classicalEvaluation: Evaluation = EvaluationClassic,
vetoReporter: String => Unit = println(_),
): Bot =
val search = AlphaBetaSearch(rules, TranspositionTable(), classicalEvaluation)
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book.flatMap(_.probe(context)).filterNot(blockedMoves.contains).orElse {
search.bestMoveWithTime(context, Config.TIME_LIMIT_MS, blockedMoves).map { move =>
val next = rules.applyMove(context)(move)
val staticNnue = nnueEvaluation.evaluate(next)
val classical = classicalEvaluation.evaluate(next)
val diff = (classical - staticNnue).abs
if diff > Config.VETO_THRESHOLD then
vetoReporter(
f"[Veto] ${move.from}->${move.to}: nnue=$staticNnue classical=$classical diff=$diff — flagged but trusted (deep search)",
)
move
}
}
@@ -1,43 +1,37 @@
package de.nowchess.bot.bots
import de.nowchess.api.bot.Bot
import de.nowchess.bot.Bot
import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move
import de.nowchess.api.rules.RuleSet
import de.nowchess.bot.bots.nnue.EvaluationNNUE
import de.nowchess.bot.logic.AlphaBetaSearch
import de.nowchess.bot.util.{PolyglotBook, ZobristHash}
import de.nowchess.bot.{BotDifficulty, BotMoveRepetition}
import de.nowchess.api.rules.RuleSet
import de.nowchess.rules.sets.DefaultRules
final class NNUEBot(
object NNUEBot:
def apply(
difficulty: BotDifficulty,
rules: RuleSet = DefaultRules,
book: Option[PolyglotBook] = None,
) extends Bot:
): Bot =
val search = AlphaBetaSearch(rules, weights = EvaluationNNUE)
context =>
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse {
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(rules, context, moves)
val bestMove = scored.maxBy(_._2)._1
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
}
private val search: AlphaBetaSearch = AlphaBetaSearch(rules, weights = EvaluationNNUE)
override val name: String = s"NNUEBot(${difficulty.toString})"
override def nextMove(context: GameContext): Option[Move] =
val blockedMoves = BotMoveRepetition.blockedMoves(context)
book
.flatMap(_.probe(context))
.filterNot(blockedMoves.contains)
.orElse {
val moves = BotMoveRepetition.filterAllowed(context, rules.allLegalMoves(context))
if moves.isEmpty then None
else
val scored = batchEvaluateRoot(context, moves)
val bestMove = scored.maxBy(_._2)._1
search.bestMoveWithTime(context, allocateTime(scored), blockedMoves).orElse(Some(bestMove))
}
/** Evaluate all root moves shallowly via incremental NNUE accumulator updates. Returns (move, score) pairs with score
* from the root player's perspective.
*/
private def batchEvaluateRoot(context: GameContext, moves: List[Move]): List[(Move, Int)] =
private def batchEvaluateRoot(rules: RuleSet, context: GameContext, moves: List[Move]): List[(Move, Int)] =
EvaluationNNUE.initAccumulator(context)
val rootHash = ZobristHash.hash(context)
moves.map { move =>
@@ -48,7 +42,6 @@ final class NNUEBot(
(move, score)
}
/** Allocate more time for complex positions; less when one move clearly dominates. */
private def allocateTime(scored: List[(Move, Int)]): Long =
val moveCount = scored.length
if moveCount > 30 then 1500L
@@ -0,0 +1,17 @@
package de.nowchess.bot.config
import com.fasterxml.jackson.core.Version
import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.scala.DefaultScalaModule
import io.quarkus.jackson.ObjectMapperCustomizer
import jakarta.inject.Singleton
@Singleton
class JacksonConfig extends ObjectMapperCustomizer:
def customize(mapper: ObjectMapper): Unit =
mapper.registerModule(new DefaultScalaModule() {
override def version(): Version =
// scalafix:off DisableSyntax.null
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
// scalafix:on DisableSyntax.null
})
@@ -0,0 +1,18 @@
package de.nowchess.bot.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,35 @@
package de.nowchess.bot.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject
var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -0,0 +1,31 @@
package de.nowchess.bot.resource
import de.nowchess.bot.service.DifficultyMapper
import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response}
@Path("/api/challenge/official")
@ApplicationScoped
@RolesAllowed(Array("**"))
@Produces(Array(MediaType.APPLICATION_JSON))
@Consumes(Array(MediaType.APPLICATION_JSON))
class OfficialBotChallengeResource:
@POST
@Path("/{botId}")
def challengeWithDifficulty(
@PathParam("botId") botId: String,
@QueryParam("difficulty") difficulty: Int
): Response =
DifficultyMapper.fromElo(difficulty) match
case None =>
Response.status(Response.Status.BAD_REQUEST)
.entity(s"""{"error":"difficulty must be between 1000 and 2800"}""")
.build()
case Some(botDifficulty) =>
// TODO: wire to account service challenge creation + bot routing
Response.status(Response.Status.CREATED)
.entity(s"""{"botId":"$botId","difficulty":$difficulty,"status":"pending"}""")
.build()
@@ -0,0 +1,12 @@
package de.nowchess.bot.service
import de.nowchess.bot.BotDifficulty
object DifficultyMapper:
def fromElo(elo: Int): Option[BotDifficulty] =
elo match
case e if e >= 1000 && e <= 1400 => Some(BotDifficulty.Easy)
case e if e >= 1401 && e <= 1800 => Some(BotDifficulty.Medium)
case e if e >= 1801 && e <= 2300 => Some(BotDifficulty.Hard)
case e if e >= 2301 && e <= 2800 => Some(BotDifficulty.Expert)
case _ => None
@@ -0,0 +1,26 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
case class MoveRequest(
gameId: String,
fen: String,
turn: String,
playingAs: String,
difficulty: Int,
botAccountId: String,
)
object MoveRequestParser:
def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
scala.util.Try {
val node = mapper.readTree(json)
MoveRequest(
gameId = node.get("gameId").asText(),
fen = node.get("fen").asText(),
turn = node.get("turn").asText(),
playingAs = node.get("playingAs").asText(),
difficulty = node.get("difficulty").asInt(1400),
botAccountId = node.get("botAccountId").asText(),
)
}.toOption
@@ -0,0 +1,70 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.Bot
import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.config.RedisConfig
import de.nowchess.io.fen.FenParser
import io.quarkus.runtime.StartupEvent
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
@ApplicationScoped
class OfficialBotService:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
@Inject var botController: BotController = uninitialized
// scalafix:on DisableSyntax.var
def onStart(@Observes event: StartupEvent): Unit =
Thread.ofVirtual().start(() => runWorker())
()
private def runWorker(): Unit =
val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
while true do
try
val json = queue.take()
MoveRequestParser.parse(json, objectMapper).foreach(processRequest)
catch case _: InterruptedException => Thread.currentThread().interrupt()
private def processRequest(req: MoveRequest): Unit =
val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium)
val botName = difficulty match
case BotDifficulty.Easy => "easy"
case BotDifficulty.Medium => "medium"
case BotDifficulty.Hard => "hard"
case BotDifficulty.Expert => "expert"
botController.getBot(botName).foreach(bot => parseAndMove(req, bot))
private def parseAndMove(req: MoveRequest, bot: Bot): Unit =
FenParser.parseFen(req.fen).toOption.foreach { context =>
bot(context).foreach { move =>
val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}"""
redisson.getTopic(c2sTopic).publish(moveMsg)
()
}
}
private def toUci(move: Move): String =
val base = s"${move.from}${move.to}"
move.moveType match
case MoveType.Promotion(piece) => base + promotionChar(piece)
case _ => base
private def promotionChar(piece: PromotionPiece): String =
piece match
case PromotionPiece.Knight => "n"
case PromotionPiece.Bishop => "b"
case PromotionPiece.Rook => "r"
case PromotionPiece.Queen => "q"

Some files were not shown because too many files have changed in this diff Show More