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
+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 => ()