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)
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
+17
@@ -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
|
||||
+35
@@ -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,
|
||||
)
|
||||
+30
@@ -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
|
||||
+67
@@ -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()
|
||||
+56
@@ -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 => ()
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
+6
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
+7
-8
@@ -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
|
||||
}
|
||||
}
|
||||
+20
-27
@@ -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())
|
||||
+31
@@ -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
Reference in New Issue
Block a user