@@ -0,0 +1,117 @@
|
||||
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>
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
scala {
|
||||
scalaVersion = versions["SCALA3"]!!
|
||||
}
|
||||
|
||||
scoverage {
|
||||
scoverageVersion.set(versions["SCOVERAGE"]!!)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
runtimeOnly("io.quarkus:quarkus-jdbc-h2")
|
||||
|
||||
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:security"))
|
||||
|
||||
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-config-yaml")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("io.quarkus:quarkus-hibernate-orm-panache")
|
||||
implementation("io.quarkus:quarkus-jdbc-postgresql")
|
||||
implementation("io.quarkus:quarkus-smallrye-jwt")
|
||||
implementation("io.quarkus:quarkus-smallrye-jwt-build")
|
||||
implementation("io.quarkus:quarkus-elytron-security-common")
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
implementation("io.quarkus:quarkus-redis-client")
|
||||
|
||||
testImplementation(platform("org.junit:junit-bom:5.13.4"))
|
||||
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-jdbc-h2")
|
||||
testImplementation("io.quarkus:quarkus-test-security")
|
||||
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||
|
||||
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("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)
|
||||
}
|
||||
|
||||
tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.INCLUDE
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"reflection": [
|
||||
{ "type": "scala.Tuple1[]" },
|
||||
{ "type": "scala.Tuple2[]" },
|
||||
{ "type": "scala.Tuple3[]" },
|
||||
{ "type": "scala.Tuple4[]" },
|
||||
{ "type": "scala.Tuple5[]" },
|
||||
{ "type": "scala.Tuple6[]" },
|
||||
{ "type": "scala.Tuple7[]" },
|
||||
{ "type": "scala.Tuple8[]" },
|
||||
{ "type": "scala.Tuple9[]" },
|
||||
{ "type": "scala.Tuple10[]" },
|
||||
{ "type": "scala.Tuple11[]" },
|
||||
{ "type": "scala.Tuple12[]" },
|
||||
{ "type": "scala.Tuple13[]" },
|
||||
{ "type": "scala.Tuple14[]" },
|
||||
{ "type": "scala.Tuple15[]" },
|
||||
{ "type": "scala.Tuple16[]" },
|
||||
{ "type": "scala.Tuple17[]" },
|
||||
{ "type": "scala.Tuple18[]" },
|
||||
{ "type": "scala.Tuple19[]" },
|
||||
{ "type": "scala.Tuple20[]" },
|
||||
{ "type": "scala.Tuple21[]" },
|
||||
{ "type": "scala.Tuple22[]" },
|
||||
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8083
|
||||
application:
|
||||
name: nowchess-account
|
||||
redis:
|
||||
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||
rest-client:
|
||||
core-service:
|
||||
url: http://localhost:8080
|
||||
smallrye-openapi:
|
||||
info-title: NowChess Account Service
|
||||
path: /openapi
|
||||
swagger-ui:
|
||||
always-include: true
|
||||
path: /swagger-ui
|
||||
datasource:
|
||||
db-kind: h2
|
||||
username: sa
|
||||
password: ""
|
||||
jdbc:
|
||||
url: jdbc:h2:mem:nowchess;DB_CLOSE_DELAY=-1
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
prefix: nowchess
|
||||
internal:
|
||||
secret: 123abc
|
||||
|
||||
"%deployed":
|
||||
quarkus:
|
||||
rest-client:
|
||||
core-service:
|
||||
url: ${CORE_SERVICE_URL}
|
||||
nowchess:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
prefix: ${REDIS_PREFIX:nowchess}
|
||||
datasource:
|
||||
db-kind: postgresql
|
||||
username: ${DB_USER}
|
||||
password: ${DB_PASSWORD}
|
||||
jdbc:
|
||||
url: ${DB_URL}
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: update
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
|
||||
issuer: nowchess
|
||||
smallrye:
|
||||
jwt:
|
||||
sign:
|
||||
key:
|
||||
location: ${JWT_PRIVATE_KEY_PATH:keys/private.pem}
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
|
||||
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
|
||||
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
|
||||
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
|
||||
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
|
||||
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
|
||||
WQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.nowchess.account.client
|
||||
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class CorePlayerInfo(id: String, displayName: String)
|
||||
case class CoreTimeControl(limitSeconds: Option[Int], incrementSeconds: Option[Int], daysPerMove: Option[Int])
|
||||
case class CoreCreateGameRequest(
|
||||
white: Option[CorePlayerInfo],
|
||||
black: Option[CorePlayerInfo],
|
||||
timeControl: Option[CoreTimeControl],
|
||||
mode: Option[String],
|
||||
)
|
||||
case class CoreGameResponse(gameId: String)
|
||||
|
||||
@Path("/api/board/game")
|
||||
@RegisterRestClient(configKey = "core-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait CoreGameClient:
|
||||
|
||||
@POST
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def createGame(req: CoreCreateGameRequest): CoreGameResponse
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.nowchess.account.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
|
||||
})
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package de.nowchess.account.config
|
||||
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameResponse, CorePlayerInfo, CoreTimeControl}
|
||||
import de.nowchess.account.domain.{
|
||||
BotAccount,
|
||||
Challenge,
|
||||
ChallengeColor,
|
||||
ChallengeStatus,
|
||||
DeclineReason,
|
||||
OfficialBotAccount,
|
||||
TimeControl,
|
||||
UserAccount,
|
||||
}
|
||||
import de.nowchess.account.dto.*
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[UserAccount],
|
||||
classOf[BotAccount],
|
||||
classOf[OfficialBotAccount],
|
||||
classOf[Challenge],
|
||||
classOf[ChallengeColor],
|
||||
classOf[ChallengeStatus],
|
||||
classOf[DeclineReason],
|
||||
classOf[TimeControl],
|
||||
classOf[LoginRequest],
|
||||
classOf[TokenResponse],
|
||||
classOf[PlayerInfo],
|
||||
classOf[PublicAccountDto],
|
||||
classOf[BotAccountDto],
|
||||
classOf[BotAccountWithTokenDto],
|
||||
classOf[OfficialBotAccountDto],
|
||||
classOf[CreateBotAccountRequest],
|
||||
classOf[UpdateBotNameRequest],
|
||||
classOf[RotatedTokenDto],
|
||||
classOf[TimeControlDto],
|
||||
classOf[ChallengeRequest],
|
||||
classOf[ChallengeDto],
|
||||
classOf[DeclineRequest],
|
||||
classOf[ChallengeListDto],
|
||||
classOf[ErrorDto],
|
||||
classOf[CorePlayerInfo],
|
||||
classOf[CoreTimeControl],
|
||||
classOf[CoreCreateGameRequest],
|
||||
classOf[CoreGameResponse],
|
||||
classOf[OfficialChallengeResponse],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.account.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.prefix", defaultValue = "nowchess")
|
||||
var prefix: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
|
||||
import jakarta.persistence.*
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import scala.Conversion
|
||||
|
||||
@Entity
|
||||
@Table(name = "challenges")
|
||||
class Challenge extends PanacheEntityBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@ManyToOne
|
||||
var challenger: UserAccount = uninitialized
|
||||
|
||||
@ManyToOne
|
||||
var destUser: UserAccount = uninitialized
|
||||
|
||||
@Convert(converter = classOf[ChallengeColorConverter])
|
||||
@Column(columnDefinition = "varchar(255)")
|
||||
var color: ChallengeColor = uninitialized
|
||||
|
||||
@Convert(converter = classOf[ChallengeStatusConverter])
|
||||
@Column(columnDefinition = "varchar(255)")
|
||||
var status: ChallengeStatus = uninitialized
|
||||
|
||||
@Convert(converter = classOf[DeclineReasonConverter])
|
||||
@Column(nullable = true, columnDefinition = "varchar(255)")
|
||||
var declineReason: DeclineReason = uninitialized
|
||||
|
||||
var timeControlType: String = uninitialized
|
||||
|
||||
@Column(nullable = true)
|
||||
var timeControlLimit: java.lang.Integer = uninitialized
|
||||
|
||||
@Column(nullable = true)
|
||||
var timeControlIncrement: java.lang.Integer = uninitialized
|
||||
|
||||
var createdAt: Instant = uninitialized
|
||||
|
||||
var expiresAt: Instant = uninitialized
|
||||
|
||||
@Column(nullable = true)
|
||||
var gameId: String = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def gameIdOpt: Option[String] = Option(gameId)
|
||||
def declineReasonOpt: Option[DeclineReason] = Option(declineReason)
|
||||
def timeControlLimitOpt: Option[Int] = Option(timeControlLimit).map(_.intValue())
|
||||
def timeControlIncrementOpt: Option[Int] = Option(timeControlIncrement).map(_.intValue())
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
enum ChallengeColor:
|
||||
case White, Black, Random
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
@Converter(autoApply = true)
|
||||
class ChallengeColorConverter extends AttributeConverter[ChallengeColor, String]:
|
||||
override def convertToDatabaseColumn(attribute: ChallengeColor): String =
|
||||
Option(attribute).map(_.toString).orNull
|
||||
|
||||
override def convertToEntityAttribute(dbData: String): ChallengeColor =
|
||||
Option(dbData).map(ChallengeColor.valueOf).orNull
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
enum ChallengeStatus:
|
||||
case Created, Canceled, Declined, Accepted
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
@Converter(autoApply = true)
|
||||
class ChallengeStatusConverter extends AttributeConverter[ChallengeStatus, String]:
|
||||
override def convertToDatabaseColumn(attribute: ChallengeStatus): String =
|
||||
Option(attribute).map(_.toString).orNull
|
||||
|
||||
override def convertToEntityAttribute(dbData: String): ChallengeStatus =
|
||||
Option(dbData).map(ChallengeStatus.valueOf).orNull
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
enum DeclineReason:
|
||||
case Generic, Later, TooFast, TooSlow, TimeControl, Rated, Casual, Standard, Variant, NoBot, OnlyBot
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
import jakarta.persistence.AttributeConverter
|
||||
import jakarta.persistence.Converter
|
||||
|
||||
@Converter(autoApply = true)
|
||||
class DeclineReasonConverter extends AttributeConverter[DeclineReason, String]:
|
||||
override def convertToDatabaseColumn(attribute: DeclineReason): String =
|
||||
Option(attribute).map(_.toString).orNull
|
||||
|
||||
override def convertToEntityAttribute(dbData: String): DeclineReason =
|
||||
Option(dbData).map(DeclineReason.valueOf).orNull
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
sealed trait TimeControl
|
||||
|
||||
object TimeControl:
|
||||
case class Clock(limit: Int, increment: Int) extends TimeControl
|
||||
case object Unlimited extends TimeControl
|
||||
@@ -0,0 +1,78 @@
|
||||
package de.nowchess.account.domain
|
||||
|
||||
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
|
||||
import jakarta.persistence.*
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@Entity
|
||||
@Table(name = "user_accounts")
|
||||
class UserAccount extends PanacheEntityBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
var username: String = uninitialized
|
||||
|
||||
@Column(unique = true, nullable = false)
|
||||
var email: String = uninitialized
|
||||
|
||||
var passwordHash: String = uninitialized
|
||||
|
||||
var rating: Int = 1500
|
||||
|
||||
var createdAt: Instant = uninitialized
|
||||
|
||||
var banned: Boolean = false
|
||||
|
||||
@OneToMany(mappedBy = "owner", cascade = Array(CascadeType.ALL), orphanRemoval = true)
|
||||
var botAccounts: java.util.List[BotAccount] = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def getBotAccounts: List[BotAccount] = Option(botAccounts).map(_.asScala.toList).getOrElse(Nil)
|
||||
|
||||
@Entity
|
||||
@Table(name = "bot_accounts")
|
||||
class BotAccount extends PanacheEntityBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var name: String = uninitialized
|
||||
|
||||
@ManyToOne(optional = false)
|
||||
@JoinColumn(name = "owner_id", nullable = false)
|
||||
var owner: UserAccount = uninitialized
|
||||
|
||||
@Column(unique = true, nullable = false, length = 256)
|
||||
var token: String = uninitialized
|
||||
|
||||
var rating: Int = 1500
|
||||
|
||||
var createdAt: Instant = uninitialized
|
||||
|
||||
var banned: Boolean = false
|
||||
// scalafix:on
|
||||
|
||||
@Entity
|
||||
@Table(name = "official_bot_accounts")
|
||||
class OfficialBotAccount extends PanacheEntityBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
var id: UUID = uninitialized
|
||||
|
||||
@Column(nullable = false)
|
||||
var name: String = uninitialized
|
||||
|
||||
var rating: Int = 1500
|
||||
|
||||
var createdAt: Instant = uninitialized
|
||||
// scalafix:on
|
||||
@@ -0,0 +1,49 @@
|
||||
package de.nowchess.account.dto
|
||||
|
||||
case class RegisterRequest(username: String, email: String, password: String)
|
||||
|
||||
case class LoginRequest(username: String, password: String)
|
||||
|
||||
case class TokenResponse(token: String)
|
||||
|
||||
case class PlayerInfo(id: String, name: String, rating: Int)
|
||||
|
||||
case class PublicAccountDto(id: String, username: String, rating: Int, createdAt: String)
|
||||
|
||||
case class TimeControlDto(`type`: String, limit: Option[Int], increment: Option[Int])
|
||||
|
||||
case class ChallengeRequest(color: String, timeControl: TimeControlDto)
|
||||
|
||||
case class ChallengeDto(
|
||||
id: String,
|
||||
challenger: PlayerInfo,
|
||||
destUser: PlayerInfo,
|
||||
variant: String,
|
||||
color: String,
|
||||
timeControl: TimeControlDto,
|
||||
status: String,
|
||||
declineReason: Option[String],
|
||||
gameId: Option[String],
|
||||
createdAt: String,
|
||||
expiresAt: String,
|
||||
)
|
||||
|
||||
case class DeclineRequest(reason: Option[String])
|
||||
|
||||
case class ChallengeListDto(in: List[ChallengeDto], out: List[ChallengeDto])
|
||||
|
||||
case class ErrorDto(error: String)
|
||||
|
||||
case class CreateBotAccountRequest(name: String)
|
||||
|
||||
case class UpdateBotNameRequest(name: String)
|
||||
|
||||
case class BotAccountDto(id: String, name: String, rating: Int, createdAt: String)
|
||||
|
||||
case class BotAccountWithTokenDto(id: String, name: String, rating: Int, token: String, createdAt: String)
|
||||
|
||||
case class RotatedTokenDto(token: String)
|
||||
|
||||
case class OfficialBotAccountDto(id: String, name: String, rating: Int, createdAt: String)
|
||||
|
||||
case class OfficialChallengeResponse(gameId: String, botName: String, difficulty: Int)
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.account.error
|
||||
|
||||
enum AccountError:
|
||||
case UsernameTaken(username: String)
|
||||
case EmailAlreadyRegistered(email: String)
|
||||
case InvalidCredentials
|
||||
case UserNotFound
|
||||
case BotNotFound
|
||||
case BotLimitExceeded
|
||||
case NotAuthorized
|
||||
case UserBanned
|
||||
case BotBanned
|
||||
|
||||
def message: String = this match
|
||||
case UsernameTaken(u) => s"Username '$u' is already taken"
|
||||
case EmailAlreadyRegistered(e) => s"Email '$e' is already registered"
|
||||
case InvalidCredentials => "Invalid credentials"
|
||||
case UserNotFound => "User not found"
|
||||
case BotNotFound => "Bot account not found"
|
||||
case BotLimitExceeded => "Maximum of 5 bot accounts per user exceeded"
|
||||
case NotAuthorized => "Not authorized to perform this action"
|
||||
case UserBanned => "User account is banned"
|
||||
case BotBanned => "Bot account is banned"
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.nowchess.account.error
|
||||
|
||||
enum ChallengeError:
|
||||
case UserNotFound(username: String)
|
||||
case ChallengerNotFound
|
||||
case CannotChallengeSelf
|
||||
case DuplicateChallenge
|
||||
case InvalidColor(color: String)
|
||||
case InvalidDeclineReason(reason: String)
|
||||
case ChallengeNotFound
|
||||
case ChallengeNotActive
|
||||
case NotAuthorized
|
||||
case GameCreationFailed
|
||||
|
||||
def message: String = this match
|
||||
case UserNotFound(u) => s"User '$u' not found"
|
||||
case ChallengerNotFound => "Challenger not found"
|
||||
case CannotChallengeSelf => "Cannot challenge yourself"
|
||||
case DuplicateChallenge => "Active challenge to this user already exists"
|
||||
case InvalidColor(c) => s"Unknown color: $c"
|
||||
case InvalidDeclineReason(r) => s"Unknown decline reason: $r"
|
||||
case ChallengeNotFound => "Challenge not found"
|
||||
case ChallengeNotActive => "Challenge is not active"
|
||||
case NotAuthorized => "Not authorized"
|
||||
case GameCreationFailed => "Failed to create game"
|
||||
@@ -0,0 +1,43 @@
|
||||
package de.nowchess.account.filter
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
|
||||
import jakarta.ws.rs.core.Response
|
||||
import jakarta.ws.rs.ext.Provider
|
||||
import org.eclipse.microprofile.jwt.JsonWebToken
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@Provider
|
||||
@ApplicationScoped
|
||||
class AlreadyLoggedInFilter extends ContainerRequestFilter:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
override def filter(context: ContainerRequestContext): Unit =
|
||||
val path = context.getUriInfo.getPath
|
||||
val method = context.getMethod
|
||||
|
||||
if isProtectedEndpoint(path, method) && isAuthenticated then
|
||||
context.abortWith(
|
||||
Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.entity("""{"error":"Already logged in"}""")
|
||||
.build(),
|
||||
)
|
||||
|
||||
private def isAuthenticated: Boolean =
|
||||
// scalafix:off DisableSyntax.null
|
||||
try jwt.getName != null
|
||||
catch
|
||||
case _ => false
|
||||
// scalafix:on DisableSyntax.null
|
||||
|
||||
private def isProtectedEndpoint(path: String, method: String): Boolean =
|
||||
(path.contains("/api/account") || path.contains("/account")) &&
|
||||
((path.endsWith("/api/account") && method == "POST") ||
|
||||
(path.endsWith("/account") && method == "POST") ||
|
||||
(path.contains("/login") && method == "POST"))
|
||||
@@ -0,0 +1,98 @@
|
||||
package de.nowchess.account.repository
|
||||
|
||||
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
|
||||
import java.util.UUID
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class UserAccountRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findByUsername(username: String): Option[UserAccount] =
|
||||
em.createQuery("FROM UserAccount WHERE username = :username", classOf[UserAccount])
|
||||
.setParameter("username", username)
|
||||
.getResultList
|
||||
.stream()
|
||||
.findFirst()
|
||||
.map(Option(_))
|
||||
.orElse(None)
|
||||
|
||||
def findById(id: UUID): Option[UserAccount] =
|
||||
Option(em.find(classOf[UserAccount], id))
|
||||
|
||||
def persist(account: UserAccount): UserAccount =
|
||||
em.persist(account)
|
||||
account
|
||||
|
||||
def findByEmail(email: String): Option[UserAccount] =
|
||||
em.createQuery("FROM UserAccount WHERE email = :email", classOf[UserAccount])
|
||||
.setParameter("email", email)
|
||||
.getResultList
|
||||
.asScala
|
||||
.headOption
|
||||
|
||||
def findAll(): List[UserAccount] =
|
||||
em.createQuery("FROM UserAccount", classOf[UserAccount]).getResultList.asScala.toList
|
||||
|
||||
@ApplicationScoped
|
||||
class BotAccountRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findById(id: UUID): Option[BotAccount] =
|
||||
Option(em.find(classOf[BotAccount], id))
|
||||
|
||||
def findByOwner(ownerId: UUID): List[BotAccount] =
|
||||
em.createQuery("FROM BotAccount WHERE owner.id = :ownerId", classOf[BotAccount])
|
||||
.setParameter("ownerId", ownerId)
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def persist(bot: BotAccount): BotAccount =
|
||||
em.persist(bot)
|
||||
bot
|
||||
|
||||
def delete(botId: UUID): Unit =
|
||||
em.find(classOf[BotAccount], botId) match
|
||||
case bot: BotAccount => em.remove(bot)
|
||||
|
||||
def findByToken(token: String): Option[BotAccount] =
|
||||
em.createQuery("FROM BotAccount WHERE token = :token", classOf[BotAccount])
|
||||
.setParameter("token", token)
|
||||
.getResultList
|
||||
.asScala
|
||||
.headOption
|
||||
|
||||
@ApplicationScoped
|
||||
class OfficialBotAccountRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findById(id: UUID): Option[OfficialBotAccount] =
|
||||
Option(em.find(classOf[OfficialBotAccount], id))
|
||||
|
||||
def findAll(): List[OfficialBotAccount] =
|
||||
em.createQuery("FROM OfficialBotAccount", classOf[OfficialBotAccount]).getResultList.asScala.toList
|
||||
|
||||
def persist(bot: OfficialBotAccount): OfficialBotAccount =
|
||||
em.persist(bot)
|
||||
bot
|
||||
|
||||
def delete(botId: UUID): Unit =
|
||||
em.find(classOf[OfficialBotAccount], botId) match
|
||||
case bot: OfficialBotAccount => em.remove(bot)
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package de.nowchess.account.repository
|
||||
|
||||
import de.nowchess.account.domain.{Challenge, ChallengeStatus}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.persistence.EntityManager
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
@ApplicationScoped
|
||||
class ChallengeRepository:
|
||||
|
||||
@Inject
|
||||
// scalafix:off DisableSyntax.var
|
||||
var em: EntityManager = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
def findActiveByChallengerId(challengerId: UUID): List[Challenge] =
|
||||
em.createQuery(
|
||||
"FROM Challenge WHERE challenger.id = :cid AND status = :status AND expiresAt > :now",
|
||||
classOf[Challenge],
|
||||
).setParameter("cid", challengerId)
|
||||
.setParameter("status", ChallengeStatus.Created)
|
||||
.setParameter("now", Instant.now())
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findActiveByDestUserId(destUserId: UUID): List[Challenge] =
|
||||
em.createQuery(
|
||||
"FROM Challenge WHERE destUser.id = :uid AND status = :status AND expiresAt > :now",
|
||||
classOf[Challenge],
|
||||
).setParameter("uid", destUserId)
|
||||
.setParameter("status", ChallengeStatus.Created)
|
||||
.setParameter("now", Instant.now())
|
||||
.getResultList
|
||||
.asScala
|
||||
.toList
|
||||
|
||||
def findDuplicateChallenge(challengerId: UUID, destUserId: UUID): Option[Challenge] =
|
||||
em.createQuery(
|
||||
"FROM Challenge WHERE challenger.id = :cid AND destUser.id = :uid AND status = :status AND expiresAt > :now",
|
||||
classOf[Challenge],
|
||||
).setParameter("cid", challengerId)
|
||||
.setParameter("uid", destUserId)
|
||||
.setParameter("status", ChallengeStatus.Created)
|
||||
.setParameter("now", Instant.now())
|
||||
.getResultList
|
||||
.asScala
|
||||
.headOption
|
||||
|
||||
def findById(id: UUID): Option[Challenge] =
|
||||
Option(em.find(classOf[Challenge], id))
|
||||
|
||||
def persist(challenge: Challenge): Challenge =
|
||||
em.persist(challenge)
|
||||
challenge
|
||||
|
||||
def merge(challenge: Challenge): Challenge =
|
||||
em.merge(challenge)
|
||||
@@ -0,0 +1,202 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||
import de.nowchess.account.dto.*
|
||||
import de.nowchess.account.error.AccountError
|
||||
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 scala.compiletime.uninitialized
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
@Path("/api/account")
|
||||
@ApplicationScoped
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
class AccountResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var accountService: AccountService = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@POST
|
||||
def register(req: RegisterRequest): Response =
|
||||
accountService.register(req) match
|
||||
case Right(account) =>
|
||||
Response.ok(toPublicDto(account)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.CONFLICT).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@POST
|
||||
@Path("/login")
|
||||
def login(req: LoginRequest): Response =
|
||||
accountService.login(req) match
|
||||
case Right(token) =>
|
||||
Response.ok(TokenResponse(token)).build()
|
||||
case Left(AccountError.UserBanned) =>
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.UserBanned.message)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.UNAUTHORIZED).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@GET
|
||||
@Path("/me")
|
||||
@RolesAllowed(Array("**"))
|
||||
def me(): Response =
|
||||
val id = UUID.fromString(jwt.getSubject)
|
||||
accountService.findById(id) match
|
||||
case Some(account) => Response.ok(toPublicDto(account)).build()
|
||||
case None => Response.status(Response.Status.NOT_FOUND).build()
|
||||
|
||||
@GET
|
||||
@Path("/{username}")
|
||||
def publicProfile(@PathParam("username") username: String): Response =
|
||||
accountService.findByUsername(username) match
|
||||
case Some(account) => Response.ok(toPublicDto(account)).build()
|
||||
case None => Response.status(Response.Status.NOT_FOUND).build()
|
||||
|
||||
@POST
|
||||
@Path("/{userId}/ban")
|
||||
@RolesAllowed(Array("Admin"))
|
||||
def banUser(@PathParam("userId") userId: String): Response =
|
||||
accountService.banUser(UUID.fromString(userId)) match
|
||||
case Right(user) => Response.ok(toPublicDto(user)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@POST
|
||||
@Path("/{userId}/unban")
|
||||
@RolesAllowed(Array("Admin"))
|
||||
def unbanUser(@PathParam("userId") userId: String): Response =
|
||||
accountService.unbanUser(UUID.fromString(userId)) match
|
||||
case Right(user) => Response.ok(toPublicDto(user)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@POST
|
||||
@Path("/bots")
|
||||
@RolesAllowed(Array("**"))
|
||||
def createBotAccount(req: CreateBotAccountRequest): Response =
|
||||
val ownerId = UUID.fromString(jwt.getSubject)
|
||||
accountService.createBotAccount(ownerId, req.name) match
|
||||
case Right(bot) =>
|
||||
Response.status(Response.Status.CREATED).entity(toBotDtoWithToken(bot)).build()
|
||||
case Left(error) =>
|
||||
val status = error match
|
||||
case AccountError.BotLimitExceeded => Response.Status.BAD_REQUEST
|
||||
case _ => Response.Status.INTERNAL_SERVER_ERROR
|
||||
Response.status(status).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@GET
|
||||
@Path("/bots")
|
||||
@RolesAllowed(Array("**"))
|
||||
def listBotAccounts(): Response =
|
||||
val ownerId = UUID.fromString(jwt.getSubject)
|
||||
val bots = accountService.getBotAccounts(ownerId)
|
||||
Response.ok(bots.map(toBotDto)).build()
|
||||
|
||||
@PUT
|
||||
@Path("/bots/{botId}")
|
||||
@RolesAllowed(Array("**"))
|
||||
def updateBotName(@PathParam("botId") botId: String, req: UpdateBotNameRequest): Response =
|
||||
val ownerId = UUID.fromString(jwt.getSubject)
|
||||
accountService.updateBotName(UUID.fromString(botId), ownerId, req.name) match
|
||||
case Right(bot) => Response.ok(toBotDto(bot)).build()
|
||||
case Left(AccountError.NotAuthorized) =>
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@POST
|
||||
@Path("/bots/{botId}/rotate-token")
|
||||
@RolesAllowed(Array("**"))
|
||||
def rotateBotToken(@PathParam("botId") botId: String): Response =
|
||||
val ownerId = UUID.fromString(jwt.getSubject)
|
||||
accountService.rotateBotToken(UUID.fromString(botId), ownerId) match
|
||||
case Right(bot) => Response.ok(RotatedTokenDto(bot.token)).build()
|
||||
case Left(AccountError.NotAuthorized) =>
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@DELETE
|
||||
@Path("/bots/{botId}")
|
||||
@RolesAllowed(Array("**"))
|
||||
def deleteBotAccount(@PathParam("botId") botId: String): Response =
|
||||
val ownerId = UUID.fromString(jwt.getSubject)
|
||||
val botUuid = UUID.fromString(botId)
|
||||
accountService.getBotAccountWithOwnerCheck(botUuid, ownerId) match
|
||||
case None => Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(AccountError.BotNotFound.message)).build()
|
||||
case Some(None) =>
|
||||
Response.status(Response.Status.FORBIDDEN).entity(ErrorDto(AccountError.NotAuthorized.message)).build()
|
||||
case Some(Some(_)) =>
|
||||
accountService.deleteBotAccount(botUuid) match
|
||||
case Right(_) => Response.noContent().build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.NOT_FOUND).entity(ErrorDto(error.message)).build()
|
||||
|
||||
private def toPublicDto(account: UserAccount): PublicAccountDto =
|
||||
PublicAccountDto(
|
||||
id = account.id.toString,
|
||||
username = account.username,
|
||||
rating = account.rating,
|
||||
createdAt = account.createdAt.toString,
|
||||
)
|
||||
|
||||
private def toBotDto(bot: BotAccount): BotAccountDto =
|
||||
BotAccountDto(
|
||||
id = bot.id.toString,
|
||||
name = bot.name,
|
||||
rating = bot.rating,
|
||||
createdAt = bot.createdAt.toString,
|
||||
)
|
||||
|
||||
private def toBotDtoWithToken(bot: BotAccount): BotAccountWithTokenDto =
|
||||
BotAccountWithTokenDto(
|
||||
id = bot.id.toString,
|
||||
name = bot.name,
|
||||
rating = bot.rating,
|
||||
token = bot.token,
|
||||
createdAt = bot.createdAt.toString,
|
||||
)
|
||||
|
||||
@GET
|
||||
@Path("/official-bots")
|
||||
def getOfficialBots: Response =
|
||||
val bots = accountService.getOfficialBotAccounts()
|
||||
Response.ok(bots.map(toOfficialBotDto)).build()
|
||||
|
||||
@POST
|
||||
@Path("/official-bots")
|
||||
@RolesAllowed(Array("Admin"))
|
||||
def createOfficialBot(req: CreateBotAccountRequest): Response =
|
||||
accountService.createOfficialBotAccount(req.name) match
|
||||
case Right(bot) =>
|
||||
Response.status(Response.Status.CREATED).entity(toOfficialBotDto(bot)).build()
|
||||
case Left(error) =>
|
||||
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@DELETE
|
||||
@Path("/official-bots/{botId}")
|
||||
@RolesAllowed(Array("Admin"))
|
||||
def deleteOfficialBot(@PathParam("botId") botId: String): Response =
|
||||
accountService.deleteOfficialBotAccount(UUID.fromString(botId)) match
|
||||
case Right(_) => Response.noContent().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,
|
||||
name = bot.name,
|
||||
rating = bot.rating,
|
||||
createdAt = bot.createdAt.toString,
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.dto.*
|
||||
import de.nowchess.account.error.ChallengeError
|
||||
import de.nowchess.account.service.ChallengeService
|
||||
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
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
@Path("/api/challenge")
|
||||
@ApplicationScoped
|
||||
@RolesAllowed(Array("**"))
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
class ChallengeResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var challengeService: ChallengeService = uninitialized
|
||||
|
||||
@Inject
|
||||
var jwt: JsonWebToken = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@POST
|
||||
@Path("/{username}")
|
||||
def create(@PathParam("username") username: String, req: ChallengeRequest): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.create(userId, username, req) match
|
||||
case Right(challenge) =>
|
||||
Response.status(Response.Status.CREATED).entity(challengeService.toDto(challenge)).build()
|
||||
case Left(error) =>
|
||||
val status = error match
|
||||
case ChallengeError.UserNotFound(_) | ChallengeError.ChallengerNotFound => Response.Status.NOT_FOUND
|
||||
case ChallengeError.CannotChallengeSelf => Response.Status.BAD_REQUEST
|
||||
case _ => Response.Status.CONFLICT
|
||||
Response.status(status).entity(ErrorDto(error.message)).build()
|
||||
|
||||
@GET
|
||||
def list(): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
Response.ok(challengeService.listForUser(userId)).build()
|
||||
|
||||
@GET
|
||||
@Path("/{id}")
|
||||
def get(@PathParam("id") id: UUID): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.findById(id, userId) match
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/accept")
|
||||
def accept(@PathParam("id") id: UUID): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.accept(id, userId) match
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/decline")
|
||||
def decline(@PathParam("id") id: UUID, req: DeclineRequest): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.decline(id, userId, req) match
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
@POST
|
||||
@Path("/{id}/cancel")
|
||||
def cancel(@PathParam("id") id: UUID): Response =
|
||||
val userId = UUID.fromString(jwt.getSubject)
|
||||
challengeService.cancel(id, userId) match
|
||||
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
|
||||
case Left(error) => errorResponse(error)
|
||||
|
||||
private def errorResponse(error: ChallengeError): Response =
|
||||
val status = error match
|
||||
case ChallengeError.ChallengeNotFound => Response.Status.NOT_FOUND
|
||||
case ChallengeError.NotAuthorized => Response.Status.FORBIDDEN
|
||||
case ChallengeError.GameCreationFailed => Response.Status.INTERNAL_SERVER_ERROR
|
||||
case _ => Response.Status.BAD_REQUEST
|
||||
Response.status(status).entity(ErrorDto(error.message)).build()
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
|
||||
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
|
||||
import de.nowchess.account.service.{AccountService, EventPublisher}
|
||||
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
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
|
||||
@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 var botEventPublisher: EventPublisher = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = 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
|
||||
Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.entity(ErrorDto("difficulty must be between 1000 and 2800"))
|
||||
.build()
|
||||
else
|
||||
val normalizedColor = Option(color).map(_.toLowerCase).getOrElse("random")
|
||||
normalizedColor match
|
||||
case "white" | "black" | "random" =>
|
||||
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 = normalizedColor match
|
||||
case "white" => true
|
||||
case "black" => false
|
||||
case _ => ThreadLocalRandom.current().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 botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
|
||||
Response
|
||||
.status(Response.Status.CREATED)
|
||||
.entity(OfficialChallengeResponse(id, botName, difficulty))
|
||||
.build()
|
||||
case other =>
|
||||
Response
|
||||
.status(Response.Status.BAD_REQUEST)
|
||||
.entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
|
||||
.build()
|
||||
@@ -0,0 +1,165 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.domain.{BotAccount, OfficialBotAccount, UserAccount}
|
||||
import de.nowchess.account.dto.{LoginRequest, RegisterRequest}
|
||||
import de.nowchess.account.error.AccountError
|
||||
import de.nowchess.account.repository.{BotAccountRepository, OfficialBotAccountRepository, UserAccountRepository}
|
||||
import io.quarkus.elytron.security.common.BcryptUtil
|
||||
import io.smallrye.jwt.build.Jwt
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.transaction.Transactional
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@ApplicationScoped
|
||||
class AccountService:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var userAccountRepository: UserAccountRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
var botAccountRepository: BotAccountRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
var officialBotAccountRepository: OfficialBotAccountRepository = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@Transactional
|
||||
def register(req: RegisterRequest): Either[AccountError, UserAccount] =
|
||||
if userAccountRepository.findByUsername(req.username).isDefined then Left(AccountError.UsernameTaken(req.username))
|
||||
else if userAccountRepository.findByEmail(req.email).isDefined then
|
||||
Left(AccountError.EmailAlreadyRegistered(req.email))
|
||||
else
|
||||
val account = new UserAccount()
|
||||
account.username = req.username
|
||||
account.email = req.email
|
||||
account.passwordHash = BcryptUtil.bcryptHash(req.password)
|
||||
account.createdAt = Instant.now()
|
||||
userAccountRepository.persist(account)
|
||||
Right(account)
|
||||
|
||||
def login(req: LoginRequest): Either[AccountError, String] =
|
||||
userAccountRepository.findByUsername(req.username) match
|
||||
case None => Left(AccountError.InvalidCredentials)
|
||||
case Some(account) =>
|
||||
if !BcryptUtil.matches(req.password, account.passwordHash) then Left(AccountError.InvalidCredentials)
|
||||
else if account.banned then Left(AccountError.UserBanned)
|
||||
else
|
||||
Right(
|
||||
Jwt
|
||||
.issuer("nowchess")
|
||||
.subject(account.id.toString)
|
||||
.claim("username", account.username)
|
||||
.sign(),
|
||||
)
|
||||
|
||||
def findByUsername(username: String): Option[UserAccount] =
|
||||
userAccountRepository.findByUsername(username)
|
||||
|
||||
def findById(id: UUID): Option[UserAccount] =
|
||||
userAccountRepository.findById(id)
|
||||
|
||||
@Transactional
|
||||
def createBotAccount(ownerId: UUID, botName: String): Either[AccountError, BotAccount] =
|
||||
userAccountRepository.findById(ownerId) match
|
||||
case None => Left(AccountError.UserNotFound)
|
||||
case Some(owner) =>
|
||||
val botAccounts = botAccountRepository.findByOwner(ownerId)
|
||||
if botAccounts.length >= 5 then Left(AccountError.BotLimitExceeded)
|
||||
else
|
||||
val bot = new BotAccount()
|
||||
bot.name = botName
|
||||
bot.owner = owner
|
||||
bot.token = generateBotToken(bot.id)
|
||||
bot.createdAt = Instant.now()
|
||||
botAccountRepository.persist(bot)
|
||||
Right(bot)
|
||||
|
||||
def getBotAccounts(ownerId: UUID): List[BotAccount] =
|
||||
botAccountRepository.findByOwner(ownerId)
|
||||
|
||||
def getBotAccountWithOwnerCheck(botId: UUID, ownerId: UUID): Option[Option[BotAccount]] =
|
||||
botAccountRepository.findById(botId) match
|
||||
case None => Some(None)
|
||||
case Some(bot) => Some(Option(bot).filter(_.owner.id == ownerId))
|
||||
|
||||
@Transactional
|
||||
def deleteBotAccount(botId: UUID): Either[AccountError, Unit] =
|
||||
botAccountRepository.findById(botId) match
|
||||
case None => Left(AccountError.BotNotFound)
|
||||
case Some(_) =>
|
||||
botAccountRepository.delete(botId)
|
||||
Right(())
|
||||
|
||||
@Transactional
|
||||
def updateBotName(botId: UUID, ownerId: UUID, newName: String): Either[AccountError, BotAccount] =
|
||||
botAccountRepository.findById(botId) match
|
||||
case None => Left(AccountError.BotNotFound)
|
||||
case Some(bot) =>
|
||||
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
|
||||
else
|
||||
bot.name = newName
|
||||
botAccountRepository.persist(bot)
|
||||
Right(bot)
|
||||
|
||||
@Transactional
|
||||
def rotateBotToken(botId: UUID, ownerId: UUID): Either[AccountError, BotAccount] =
|
||||
botAccountRepository.findById(botId) match
|
||||
case None => Left(AccountError.BotNotFound)
|
||||
case Some(bot) =>
|
||||
if bot.owner.id != ownerId then Left(AccountError.NotAuthorized)
|
||||
else
|
||||
bot.token = generateBotToken(botId)
|
||||
botAccountRepository.persist(bot)
|
||||
Right(bot)
|
||||
|
||||
@Transactional
|
||||
def createOfficialBotAccount(botName: String): Either[AccountError, OfficialBotAccount] =
|
||||
val bot = new OfficialBotAccount()
|
||||
bot.name = botName
|
||||
bot.createdAt = Instant.now()
|
||||
officialBotAccountRepository.persist(bot)
|
||||
Right(bot)
|
||||
|
||||
def getOfficialBotAccounts(): List[OfficialBotAccount] =
|
||||
officialBotAccountRepository.findAll()
|
||||
|
||||
@Transactional
|
||||
def deleteOfficialBotAccount(botId: UUID): Either[AccountError, Unit] =
|
||||
officialBotAccountRepository.findById(botId) match
|
||||
case None => Left(AccountError.BotNotFound)
|
||||
case Some(_) =>
|
||||
officialBotAccountRepository.delete(botId)
|
||||
Right(())
|
||||
|
||||
private def generateBotToken(botId: UUID): String =
|
||||
Jwt
|
||||
.issuer("nowchess")
|
||||
.subject(botId.toString)
|
||||
.expiresAt(Long.MaxValue)
|
||||
.claim("type", "bot")
|
||||
.sign()
|
||||
|
||||
@Transactional
|
||||
def banUser(userId: UUID): Either[AccountError, UserAccount] =
|
||||
userAccountRepository.findById(userId) match
|
||||
case None => Left(AccountError.UserNotFound)
|
||||
case Some(user) =>
|
||||
user.banned = true
|
||||
user.botAccounts.forEach(_.banned = true)
|
||||
userAccountRepository.persist(user)
|
||||
Right(user)
|
||||
|
||||
@Transactional
|
||||
def unbanUser(userId: UUID): Either[AccountError, UserAccount] =
|
||||
userAccountRepository.findById(userId) match
|
||||
case None => Left(AccountError.UserNotFound)
|
||||
case Some(user) =>
|
||||
user.banned = false
|
||||
user.botAccounts.forEach(_.banned = false)
|
||||
userAccountRepository.persist(user)
|
||||
Right(user)
|
||||
@@ -0,0 +1,197 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.client.{
|
||||
CoreCreateGameRequest,
|
||||
CoreGameClient,
|
||||
CoreGameResponse,
|
||||
CorePlayerInfo,
|
||||
CoreTimeControl,
|
||||
}
|
||||
import de.nowchess.account.domain.{Challenge, ChallengeColor, ChallengeStatus, DeclineReason}
|
||||
import de.nowchess.account.dto.{
|
||||
ChallengeDto,
|
||||
ChallengeListDto,
|
||||
ChallengeRequest,
|
||||
DeclineRequest,
|
||||
PlayerInfo,
|
||||
TimeControlDto,
|
||||
}
|
||||
import de.nowchess.account.error.ChallengeError
|
||||
import de.nowchess.account.repository.{ChallengeRepository, UserAccountRepository}
|
||||
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
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ThreadLocalRandom
|
||||
|
||||
@ApplicationScoped
|
||||
class ChallengeService:
|
||||
|
||||
private val log = Logger.getLogger(classOf[ChallengeService])
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
var userAccountRepository: UserAccountRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
var challengeRepository: ChallengeRepository = uninitialized
|
||||
|
||||
@Inject
|
||||
@RestClient
|
||||
var coreGameClient: CoreGameClient = uninitialized
|
||||
|
||||
@Inject
|
||||
var eventPublisher: EventPublisher = uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@Transactional
|
||||
def create(challengerId: UUID, destUsername: String, req: ChallengeRequest): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
destUser <- userAccountRepository.findByUsername(destUsername).toRight(ChallengeError.UserNotFound(destUsername))
|
||||
challenger <- userAccountRepository.findById(challengerId).toRight(ChallengeError.ChallengerNotFound)
|
||||
_ <- Either.cond(challenger.id != destUser.id, (), ChallengeError.CannotChallengeSelf)
|
||||
_ <- Either.cond(
|
||||
challengeRepository.findDuplicateChallenge(challengerId, destUser.id).isEmpty,
|
||||
(),
|
||||
ChallengeError.DuplicateChallenge,
|
||||
)
|
||||
color <- parseColor(req.color)
|
||||
yield
|
||||
val challenge = new Challenge()
|
||||
challenge.challenger = challenger
|
||||
challenge.destUser = destUser
|
||||
challenge.color = color
|
||||
challenge.status = ChallengeStatus.Created
|
||||
challenge.timeControlType = req.timeControl.`type`
|
||||
challenge.timeControlLimit = req.timeControl.limit.map(java.lang.Integer.valueOf).orNull
|
||||
challenge.timeControlIncrement = req.timeControl.increment.map(java.lang.Integer.valueOf).orNull
|
||||
challenge.createdAt = Instant.now()
|
||||
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
|
||||
challengeRepository.persist(challenge)
|
||||
try eventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def accept(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
|
||||
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
|
||||
gameId <- createGame(challenge)
|
||||
yield
|
||||
challenge.status = ChallengeStatus.Accepted
|
||||
challenge.gameId = gameId
|
||||
challengeRepository.merge(challenge)
|
||||
notifyBotIfNeeded(challenge, gameId)
|
||||
try eventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def decline(challengeId: UUID, userId: UUID, req: DeclineRequest): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
|
||||
_ <- Either.cond(challenge.destUser.id == userId, (), ChallengeError.NotAuthorized)
|
||||
reason <- parseDeclineReason(req.reason)
|
||||
yield
|
||||
challenge.status = ChallengeStatus.Declined
|
||||
challenge.declineReason = reason.orNull
|
||||
challengeRepository.merge(challenge)
|
||||
challenge
|
||||
|
||||
@Transactional
|
||||
def cancel(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
|
||||
_ <- Either.cond(challenge.status == ChallengeStatus.Created, (), ChallengeError.ChallengeNotActive)
|
||||
_ <- Either.cond(challenge.challenger.id == userId, (), ChallengeError.NotAuthorized)
|
||||
yield
|
||||
challenge.status = ChallengeStatus.Canceled
|
||||
challengeRepository.merge(challenge)
|
||||
challenge
|
||||
|
||||
def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
|
||||
for
|
||||
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
|
||||
_ <- Either.cond(
|
||||
challenge.challenger.id == userId || challenge.destUser.id == userId,
|
||||
(),
|
||||
ChallengeError.NotAuthorized,
|
||||
)
|
||||
yield challenge
|
||||
|
||||
def listForUser(userId: UUID): ChallengeListDto =
|
||||
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
|
||||
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 eventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
|
||||
catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
|
||||
}
|
||||
}
|
||||
|
||||
private def createGame(challenge: Challenge): Either[ChallengeError, String] =
|
||||
try
|
||||
val (white, black) = assignColors(challenge)
|
||||
val tc = buildTimeControl(challenge)
|
||||
val req = CoreCreateGameRequest(Some(white), Some(black), tc, Some("Authenticated"))
|
||||
Right(coreGameClient.createGame(req).gameId)
|
||||
catch case _ => Left(ChallengeError.GameCreationFailed)
|
||||
|
||||
private def assignColors(challenge: Challenge): (CorePlayerInfo, CorePlayerInfo) =
|
||||
val challenger = CorePlayerInfo(challenge.challenger.id.toString, challenge.challenger.username)
|
||||
val destUser = CorePlayerInfo(challenge.destUser.id.toString, challenge.destUser.username)
|
||||
challenge.color match
|
||||
case ChallengeColor.White => (challenger, destUser)
|
||||
case ChallengeColor.Black => (destUser, challenger)
|
||||
case ChallengeColor.Random =>
|
||||
if ThreadLocalRandom.current().nextBoolean() then (challenger, destUser) else (destUser, challenger)
|
||||
|
||||
private def buildTimeControl(challenge: Challenge): Option[CoreTimeControl] =
|
||||
challenge.timeControlType match
|
||||
case "unlimited" => None
|
||||
case "correspondence" => Some(CoreTimeControl(None, None, challenge.timeControlLimitOpt))
|
||||
case _ => Some(CoreTimeControl(challenge.timeControlLimitOpt, challenge.timeControlIncrementOpt, None))
|
||||
|
||||
private def parseColor(raw: String): Either[ChallengeError, ChallengeColor] =
|
||||
raw.toLowerCase match
|
||||
case "white" => Right(ChallengeColor.White)
|
||||
case "black" => Right(ChallengeColor.Black)
|
||||
case "random" => Right(ChallengeColor.Random)
|
||||
case _ => Left(ChallengeError.InvalidColor(raw))
|
||||
|
||||
private def parseDeclineReason(raw: Option[String]): Either[ChallengeError, Option[DeclineReason]] =
|
||||
raw match
|
||||
case None => Right(None)
|
||||
case Some(r) =>
|
||||
DeclineReason.values.find(_.toString.equalsIgnoreCase(r)) match
|
||||
case Some(reason) => Right(Some(reason))
|
||||
case None => Left(ChallengeError.InvalidDeclineReason(r))
|
||||
|
||||
def toDto(c: Challenge): ChallengeDto =
|
||||
ChallengeDto(
|
||||
id = c.id.toString,
|
||||
challenger = PlayerInfo(c.challenger.id.toString, c.challenger.username, c.challenger.rating),
|
||||
destUser = PlayerInfo(c.destUser.id.toString, c.destUser.username, c.destUser.rating),
|
||||
variant = "standard",
|
||||
color = c.color.toString.toLowerCase,
|
||||
timeControl = TimeControlDto(c.timeControlType, c.timeControlLimitOpt, c.timeControlIncrementOpt),
|
||||
status = c.status.toString.toLowerCase,
|
||||
declineReason = c.declineReasonOpt.map(_.toString.toLowerCase),
|
||||
gameId = c.gameIdOpt,
|
||||
createdAt = c.createdAt.toString,
|
||||
expiresAt = c.expiresAt.toString,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
package de.nowchess.account.service
|
||||
|
||||
import de.nowchess.account.config.RedisConfig
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class EventPublisher:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
|
||||
val event =
|
||||
s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
|
||||
()
|
||||
|
||||
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
|
||||
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$destUserId:events", event)
|
||||
()
|
||||
|
||||
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
|
||||
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:user:$challengerId:events", event)
|
||||
()
|
||||
@@ -0,0 +1,36 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8083
|
||||
application:
|
||||
name: nowchess-account
|
||||
smallrye-openapi:
|
||||
info-title: NowChess Account Service
|
||||
path: /openapi
|
||||
swagger-ui:
|
||||
always-include: true
|
||||
path: /swagger-ui
|
||||
datasource:
|
||||
db-kind: h2
|
||||
username: sa
|
||||
password: ""
|
||||
jdbc:
|
||||
url: "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
|
||||
hibernate-orm:
|
||||
schema-management:
|
||||
strategy: drop-and-create
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: keys/test-public.pem
|
||||
issuer: nowchess
|
||||
smallrye:
|
||||
jwt:
|
||||
sign:
|
||||
key:
|
||||
location: keys/test-private.pem
|
||||
nowchess:
|
||||
internal:
|
||||
secret: test-secret
|
||||
auth:
|
||||
enabled: false
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC4zBHgRLMez2b6
|
||||
wfdvvTJVR8xxbr/kJUMiq4ot14KhtTaGikFW+77ezjoqabFWH7CNjDvASWCM2n7X
|
||||
PxL4fhUwzvTbhRZ2XNM80lKB+OIjP3hoNLvgeSNHbS4CztOfk2JVtQFLQdYJ/gvB
|
||||
oFPgBtZYO/SZVML28d5U92JrWRIC1e1Ht1oKwKJoOqtTJrs/RuOlKQ/du4kwY8m0
|
||||
jPw05wFA1YRMUC78xKklCVYCufYewIUTdKxATK0ZKWBoPCJnxDg8gwgpnV1wHQrH
|
||||
GcbZvhcVg3GWpDcYdnogV4rlssws57+uAhGRyQBkmmhVb+zT+LT7WXDPB46MnHkK
|
||||
FIZaxEkHAgMBAAECggEAAvu4Zih1w8+RWAb9mZ4yS9Im6MXi7yny1YJzbp4GC9pD
|
||||
ERT2TRMvV6V4puqh5EQKs55J8Ka+mkeEuLDZ+4z9hpYwucKCRFLnThoPHu4HqI4D
|
||||
wZroVY1fFm4aygzQucjFU6DibnaXn/2r7upJsFor56zAHCGULCxnbHO58QW1Frqa
|
||||
UrTndSkrxavBD9LL1ohPEy3saXlRCVAEM5l7jZbg52dPauIYAOv0e+EE3RETw/Xz
|
||||
3EWukIZ7PKyoyuQm8Sv2u7lyISljDGlvrW5IjVRPMPqOKNOa/pV3qU4mbUY6GjbC
|
||||
B4xt8kEKjVSkTeMXA+W0gnZddnQOtcQYSrYWWes+AQKBgQDzjmt1ZJktZG96M8+f
|
||||
Ov9JznfzSLYxN7EboDhqjTVBOkb6flRSYrd9E6gReIIrq5Sjs9Z+toA/u8BmjQ/P
|
||||
GTrrLVh6bLBicUGKcmQFKw/0D9lOlbxaMg8VO9rqSb/AslumJwjucU7DA+WAN52j
|
||||
cyiLiw+EmWjL/DV51fHHI18SgQKBgQDCPRzpeP8Qox83/+tGR/6fSSRi5ec3ZVPy
|
||||
aCCCZM6qqhLv3hJkV0djRruVVfe136PwUi20BW6aF0PXmxDIGRWqDLQGkvDNEhjw
|
||||
ZLBv/dYtW2HBZhq4E0w8DiaNZCOWvpLQ3QCEtzmuhyHhNqYHzvmuerk+w4c/8fY6
|
||||
DFyPyiAHhwKBgDrpO/zNNG/SV1SLq7CsKIvFsSXbdJY7Dk/MVVkQhs0cN4bnf6Xd
|
||||
0twiIQj4ySOfAPkHyt4jbqn70/H6NNS3GZVBBqG2IIPvORcvzBmj7Nvv6XQkq8Z1
|
||||
TUipja4V4JfPjHOIBZUHOzHYg26cBTk/5ZK7NCmyobKVcqnhofW1DI4BAoGAaRu4
|
||||
8X5QSCh9VEhggH+lAX0K+5l9LTTf4GUIcocqbp/p73M0cKfqMYatK3qBuSF0DS/r
|
||||
G2d1Gl1MkPeQdTddyc9l+8i4FcCdTjiuYWvy4kh49bbS7plCv5zIr+pod8JYoD13
|
||||
clnUFOV7J+vynHccFZbDd3tHTQsaOv9Fd2nhOzECgYEA8SWBEmTuaBh+0vr6zS+E
|
||||
wD+cwB3iaGo+7fP7TZ+v1kxoDlcDjPYM4ikiOB+OPGNkAfqc3MGsbhfgcxqD0+5r
|
||||
kpCFyiyieyoT+7hkMpMsJCNwFO+29fc3DDqPX4Keqp26tMxtRzYea3GtVShiRXew
|
||||
5i4ReFwm3/IWDn9kLmHT6Fg=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuMwR4ESzHs9m+sH3b70y
|
||||
VUfMcW6/5CVDIquKLdeCobU2hopBVvu+3s46KmmxVh+wjYw7wElgjNp+1z8S+H4V
|
||||
MM7024UWdlzTPNJSgfjiIz94aDS74HkjR20uAs7Tn5NiVbUBS0HWCf4LwaBT4AbW
|
||||
WDv0mVTC9vHeVPdia1kSAtXtR7daCsCiaDqrUya7P0bjpSkP3buJMGPJtIz8NOcB
|
||||
QNWETFAu/MSpJQlWArn2HsCFE3SsQEytGSlgaDwiZ8Q4PIMIKZ1dcB0KxxnG2b4X
|
||||
FYNxlqQ3GHZ6IFeK5bLMLOe/rgIRkckAZJpoVW/s0/i0+1lwzweOjJx5ChSGWsRJ
|
||||
BwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@QuarkusTest
|
||||
class AccountResourceTest:
|
||||
|
||||
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||
|
||||
private def registerBody(username: String, email: String = "", password: String = "secret") =
|
||||
val resolvedEmail = if email.isEmpty then s"$username@example.com" else email
|
||||
s"""{"username":"$username","email":"$resolvedEmail","password":"$password"}"""
|
||||
|
||||
private def loginBody(username: String, password: String = "secret") =
|
||||
s"""{"username":"$username","password":"$password"}"""
|
||||
|
||||
private def registerAndLogin(username: String): String =
|
||||
givenRequest()
|
||||
.body(registerBody(username))
|
||||
.when()
|
||||
.post("/api/account")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
givenRequest()
|
||||
.body(loginBody(username))
|
||||
.when()
|
||||
.post("/api/account/login")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path[String]("token")
|
||||
|
||||
@Test
|
||||
def registerReturns200(): Unit =
|
||||
givenRequest()
|
||||
.body(registerBody("alice"))
|
||||
.when()
|
||||
.post("/api/account")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("username", is("alice"))
|
||||
.body("rating", is(1500))
|
||||
|
||||
@Test
|
||||
def registerConflictOnDuplicateUsername(): Unit =
|
||||
givenRequest().body(registerBody("bob")).when().post("/api/account")
|
||||
givenRequest()
|
||||
.body(registerBody("bob"))
|
||||
.when()
|
||||
.post("/api/account")
|
||||
.`then`()
|
||||
.statusCode(409)
|
||||
.body("error", containsString("bob"))
|
||||
|
||||
@Test
|
||||
def loginReturns200WithToken(): Unit =
|
||||
givenRequest().body(registerBody("charlie")).when().post("/api/account")
|
||||
givenRequest()
|
||||
.body(loginBody("charlie"))
|
||||
.when()
|
||||
.post("/api/account/login")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("token", notNullValue())
|
||||
|
||||
@Test
|
||||
def loginUnauthorizedOnWrongPassword(): Unit =
|
||||
givenRequest().body(registerBody("dave")).when().post("/api/account")
|
||||
givenRequest()
|
||||
.body(loginBody("dave", "wrongpassword"))
|
||||
.when()
|
||||
.post("/api/account/login")
|
||||
.`then`()
|
||||
.statusCode(401)
|
||||
|
||||
@Test
|
||||
def getMeReturns200(): Unit =
|
||||
val token = registerAndLogin("eve")
|
||||
givenRequest()
|
||||
.header("Authorization", s"Bearer $token")
|
||||
.when()
|
||||
.get("/api/account/me")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("username", is("eve"))
|
||||
|
||||
@Test
|
||||
def getPublicProfileReturns200(): Unit =
|
||||
givenRequest().body(registerBody("frank")).when().post("/api/account")
|
||||
givenRequest()
|
||||
.when()
|
||||
.get("/api/account/frank")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("username", is("frank"))
|
||||
|
||||
@Test
|
||||
def getPublicProfileNotFound(): Unit =
|
||||
givenRequest()
|
||||
.when()
|
||||
.get("/api/account/doesnotexist")
|
||||
.`then`()
|
||||
.statusCode(404)
|
||||
+179
@@ -0,0 +1,179 @@
|
||||
package de.nowchess.account.resource
|
||||
|
||||
import de.nowchess.account.client.{CoreGameClient, CoreGameResponse}
|
||||
import io.quarkus.test.InjectMock
|
||||
import io.quarkus.test.junit.QuarkusTest
|
||||
import io.restassured.RestAssured
|
||||
import io.restassured.http.ContentType
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
import org.hamcrest.Matchers.*
|
||||
import org.junit.jupiter.api.{BeforeEach, Test}
|
||||
import org.mockito.{ArgumentMatchers, Mockito}
|
||||
|
||||
@QuarkusTest
|
||||
class ChallengeResourceTest:
|
||||
|
||||
@InjectMock
|
||||
@RestClient
|
||||
// scalafix:off DisableSyntax.var
|
||||
var coreGameClient: CoreGameClient = scala.compiletime.uninitialized
|
||||
// scalafix:on
|
||||
|
||||
@BeforeEach
|
||||
def setup(): Unit =
|
||||
Mockito.when(coreGameClient.createGame(ArgumentMatchers.any())).thenReturn(CoreGameResponse("test-game-id"))
|
||||
|
||||
private def givenRequest() = RestAssured.`given`().contentType(ContentType.JSON)
|
||||
|
||||
private def registerBody(username: String, suffix: String = "") =
|
||||
val email = s"$username$suffix@test.com"
|
||||
s"""{"username":"$username$suffix","email":"$email","password":"secret"}"""
|
||||
|
||||
private def loginBody(username: String, suffix: String = "") =
|
||||
s"""{"username":"$username$suffix","password":"secret"}"""
|
||||
|
||||
private def registerAndLogin(username: String, suffix: String = ""): String =
|
||||
givenRequest().body(registerBody(username, suffix)).when().post("/api/account")
|
||||
givenRequest()
|
||||
.body(loginBody(username, suffix))
|
||||
.when()
|
||||
.post("/api/account/login")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.extract()
|
||||
.path[String]("token")
|
||||
|
||||
private val clockBody =
|
||||
"""{"color":"random","timeControl":{"type":"clock","limit":300,"increment":5}}"""
|
||||
|
||||
private def authed(token: String) =
|
||||
givenRequest().header("Authorization", s"Bearer $token")
|
||||
|
||||
@Test
|
||||
def createChallengeReturns201(): Unit =
|
||||
val t1 = registerAndLogin("user1c")
|
||||
registerAndLogin("user2c")
|
||||
authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/user2c")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
.body("status", is("created"))
|
||||
.body("color", is("random"))
|
||||
|
||||
@Test
|
||||
def createChallengeConflictOnDuplicate(): Unit =
|
||||
val t1 = registerAndLogin("user1dup")
|
||||
registerAndLogin("user2dup")
|
||||
authed(t1).contentType(ContentType.JSON).body(clockBody).when().post("/api/challenge/user2dup")
|
||||
authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/user2dup")
|
||||
.`then`()
|
||||
.statusCode(409)
|
||||
|
||||
@Test
|
||||
def createChallengeSelfForbidden(): Unit =
|
||||
val token = registerAndLogin("selfuser")
|
||||
authed(token)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/selfuser")
|
||||
.`then`()
|
||||
.statusCode(400)
|
||||
|
||||
@Test
|
||||
def acceptChallengeReturns200(): Unit =
|
||||
val t1 = registerAndLogin("accUser1")
|
||||
val t2 = registerAndLogin("accUser2")
|
||||
val challengeId = authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/accUser2")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.path[String]("id")
|
||||
authed(t2)
|
||||
.when()
|
||||
.post(s"/api/challenge/$challengeId/accept")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("status", is("accepted"))
|
||||
.body("gameId", is("test-game-id"))
|
||||
|
||||
@Test
|
||||
def declineChallengeReturns200(): Unit =
|
||||
val t1 = registerAndLogin("decUser1")
|
||||
val t2 = registerAndLogin("decUser2")
|
||||
val challengeId = authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/decUser2")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.path[String]("id")
|
||||
authed(t2)
|
||||
.contentType(ContentType.JSON)
|
||||
.body("""{"reason":"later"}""")
|
||||
.when()
|
||||
.post(s"/api/challenge/$challengeId/decline")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("status", is("declined"))
|
||||
.body("declineReason", is("later"))
|
||||
|
||||
@Test
|
||||
def cancelChallengeReturns200(): Unit =
|
||||
val t1 = registerAndLogin("canUser1")
|
||||
registerAndLogin("canUser2")
|
||||
val challengeId = authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/canUser2")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
.extract()
|
||||
.path[String]("id")
|
||||
authed(t1)
|
||||
.when()
|
||||
.post(s"/api/challenge/$challengeId/cancel")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("status", is("canceled"))
|
||||
|
||||
@Test
|
||||
def listChallengesReturnsInAndOut(): Unit =
|
||||
val t1 = registerAndLogin("listUser1")
|
||||
registerAndLogin("listUser2")
|
||||
registerAndLogin("listUser3")
|
||||
authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/listUser2")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
authed(t1)
|
||||
.contentType(ContentType.JSON)
|
||||
.body(clockBody)
|
||||
.when()
|
||||
.post("/api/challenge/listUser3")
|
||||
.`then`()
|
||||
.statusCode(201)
|
||||
authed(t1)
|
||||
.when()
|
||||
.get("/api/challenge")
|
||||
.`then`()
|
||||
.statusCode(200)
|
||||
.body("out.size()", is(2))
|
||||
.body("in.size()", is(0))
|
||||
@@ -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]
|
||||
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
/** Snapshot of remaining clock time for both players in milliseconds. -1 indicates the value is not applicable (e.g.
|
||||
* inactive player in correspondence chess).
|
||||
*/
|
||||
final case class ClockDto(
|
||||
whiteRemainingMs: Long,
|
||||
blackRemainingMs: Long,
|
||||
)
|
||||
@@ -1,6 +1,10 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
import de.nowchess.api.game.GameMode
|
||||
|
||||
final case class CreateGameRequestDto(
|
||||
white: Option[PlayerInfoDto],
|
||||
black: Option[PlayerInfoDto],
|
||||
timeControl: Option[TimeControlDto],
|
||||
mode: Option[GameMode] = None,
|
||||
)
|
||||
|
||||
@@ -9,4 +9,6 @@ final case class GameStateDto(
|
||||
moves: List[String],
|
||||
undoAvailable: Boolean,
|
||||
redoAvailable: Boolean,
|
||||
clock: Option[ClockDto],
|
||||
takebackRequestedBy: Option[String] = None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
case class GameWritebackEventDto(
|
||||
gameId: String,
|
||||
fen: String,
|
||||
pgn: String,
|
||||
moveCount: Int,
|
||||
whiteId: String,
|
||||
whiteName: String,
|
||||
blackId: String,
|
||||
blackName: String,
|
||||
mode: String,
|
||||
resigned: Boolean,
|
||||
limitSeconds: Option[Int],
|
||||
incrementSeconds: Option[Int],
|
||||
daysPerMove: Option[Int],
|
||||
whiteRemainingMs: Option[Long],
|
||||
blackRemainingMs: Option[Long],
|
||||
incrementMs: Option[Long],
|
||||
clockLastTickAt: Option[Long],
|
||||
clockMoveDeadline: Option[Long],
|
||||
clockActiveColor: Option[String],
|
||||
pendingDrawOffer: Option[String],
|
||||
result: Option[String] = None,
|
||||
terminationReason: Option[String] = None,
|
||||
redoStack: List[String] = Nil,
|
||||
pendingTakebackRequest: Option[String] = None,
|
||||
)
|
||||
@@ -4,4 +4,5 @@ final case class ImportFenRequestDto(
|
||||
fen: String,
|
||||
white: Option[PlayerInfoDto],
|
||||
black: Option[PlayerInfoDto],
|
||||
timeControl: Option[TimeControlDto],
|
||||
)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
final case class PlayerInfoDto(id: String, displayName: String)
|
||||
import de.nowchess.api.player.PlayerType
|
||||
|
||||
final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.api.dto
|
||||
|
||||
final case class TimeControlDto(
|
||||
limitSeconds: Option[Int],
|
||||
incrementSeconds: Option[Int],
|
||||
daysPerMove: Option[Int],
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.nowchess.api.error
|
||||
|
||||
enum GameError:
|
||||
case ParseError(details: String)
|
||||
case FileReadError(details: String)
|
||||
case FileWriteError(details: String)
|
||||
case IllegalMove
|
||||
|
||||
def message: String = this match
|
||||
case ParseError(d) => d
|
||||
case FileReadError(d) => d
|
||||
case FileWriteError(d) => d
|
||||
case IllegalMove => "Illegal move"
|
||||
@@ -0,0 +1,55 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
sealed trait ClockState:
|
||||
def activeColor: Color
|
||||
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState]
|
||||
def remainingMs(color: Color, now: Instant): Long
|
||||
|
||||
final case class LiveClockState(
|
||||
whiteRemainingMs: Long,
|
||||
blackRemainingMs: Long,
|
||||
incrementMs: Long,
|
||||
lastTickAt: Instant,
|
||||
activeColor: Color,
|
||||
) extends ClockState:
|
||||
def remainingMs(color: Color, now: Instant): Long =
|
||||
val stored = if color == Color.White then whiteRemainingMs else blackRemainingMs
|
||||
if color == activeColor then math.max(0L, stored - (now.toEpochMilli - lastTickAt.toEpochMilli))
|
||||
else stored
|
||||
|
||||
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
|
||||
val elapsed = at.toEpochMilli - lastTickAt.toEpochMilli
|
||||
val newRemaining =
|
||||
(if movedColor == Color.White then whiteRemainingMs else blackRemainingMs) - elapsed + incrementMs
|
||||
if newRemaining <= 0 then Left(movedColor)
|
||||
else
|
||||
val (w, b) =
|
||||
if movedColor == Color.White then (newRemaining, blackRemainingMs)
|
||||
else (whiteRemainingMs, newRemaining)
|
||||
Right(copy(whiteRemainingMs = w, blackRemainingMs = b, lastTickAt = at, activeColor = movedColor.opposite))
|
||||
|
||||
final case class CorrespondenceClockState(
|
||||
moveDeadline: Instant,
|
||||
daysPerMove: Int,
|
||||
activeColor: Color,
|
||||
) extends ClockState:
|
||||
def remainingMs(color: Color, now: Instant): Long =
|
||||
math.max(0L, moveDeadline.toEpochMilli - now.toEpochMilli)
|
||||
|
||||
def afterMove(movedColor: Color, at: Instant): Either[Color, ClockState] =
|
||||
if at.isAfter(moveDeadline) then Left(movedColor)
|
||||
else Right(copy(moveDeadline = at.plus(daysPerMove.toLong, ChronoUnit.DAYS), activeColor = movedColor.opposite))
|
||||
|
||||
object ClockState:
|
||||
def fromTimeControl(tc: TimeControl, activeColor: Color, now: Instant): Option[ClockState] =
|
||||
tc match
|
||||
case TimeControl.Clock(limit, inc) =>
|
||||
val ms = limit * 1000L
|
||||
Some(LiveClockState(ms, ms, inc * 1000L, now, activeColor))
|
||||
case TimeControl.Correspondence(days) =>
|
||||
Some(CorrespondenceClockState(now.plus(days.toLong, ChronoUnit.DAYS), days, activeColor))
|
||||
case TimeControl.Unlimited => None
|
||||
@@ -0,0 +1,4 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
enum GameMode:
|
||||
case Open, Authenticated
|
||||
@@ -4,5 +4,10 @@ import de.nowchess.api.board.Color
|
||||
|
||||
/** Outcome of a finished game. */
|
||||
enum GameResult:
|
||||
case Win(color: Color)
|
||||
case Win(color: Color, winReason: WinReason)
|
||||
case Draw(reason: DrawReason)
|
||||
|
||||
enum WinReason:
|
||||
case Checkmate
|
||||
case Resignation
|
||||
case TimeControl
|
||||
|
||||
@@ -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,6 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
enum TimeControl:
|
||||
case Clock(limitSeconds: Int, incrementSeconds: Int)
|
||||
case Correspondence(daysPerMove: Int)
|
||||
case Unlimited
|
||||
@@ -0,0 +1,107 @@
|
||||
package de.nowchess.api.grpc
|
||||
|
||||
import de.nowchess.api.board.{CastlingRights as DomainCastlingRights, *}
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult, WinReason}
|
||||
import de.nowchess.api.move.{Move as DomainMove, MoveType, PromotionPiece}
|
||||
|
||||
import scala.jdk.CollectionConverters.*
|
||||
|
||||
trait ProtoMapperBase[PC, PPT, PMK, PM, PSP, PBoard, PCR, PRK, PGC]:
|
||||
def toProtoColor(c: Color): PC
|
||||
def fromProtoColor(c: PC): Color
|
||||
def toProtoPieceType(pt: PieceType): PPT
|
||||
def fromProtoPieceType(pt: PPT): PieceType
|
||||
def toProtoMoveKind(mt: MoveType): PMK
|
||||
def fromProtoMoveKind(k: PMK): MoveType
|
||||
|
||||
def toProtoMove(m: DomainMove): PM
|
||||
def fromProtoMove(m: PM): Option[DomainMove]
|
||||
|
||||
def toProtoSquarePiece(sq: Square, piece: Piece): PSP
|
||||
def fromProtoSquarePiece(sp: PSP): Option[(Square, Piece)]
|
||||
|
||||
def toProtoBoard(board: Board): java.util.List[PSP]
|
||||
def fromProtoBoard(pieces: java.util.List[PSP]): Board
|
||||
|
||||
def toProtoResultKind(r: Option[GameResult]): PRK
|
||||
def fromProtoResultKind(k: PRK): Option[GameResult]
|
||||
|
||||
def toProtoCastlingRights(cr: DomainCastlingRights): PCR
|
||||
def fromProtoCastlingRights(pcr: PCR): DomainCastlingRights
|
||||
|
||||
def toProtoGameContext(ctx: GameContext): PGC
|
||||
def fromProtoGameContext(p: PGC): GameContext
|
||||
|
||||
object ProtoMapperBase:
|
||||
def colorConversions[PC](white: PC, black: PC): (Color => PC, PC => Color) =
|
||||
(
|
||||
(c: Color) =>
|
||||
c match
|
||||
case Color.White => white
|
||||
case Color.Black => black,
|
||||
(pc: PC) =>
|
||||
if pc == white then Color.White
|
||||
else Color.Black,
|
||||
)
|
||||
|
||||
def pieceTypeConversions[PPT](
|
||||
pawn: PPT,
|
||||
knight: PPT,
|
||||
bishop: PPT,
|
||||
rook: PPT,
|
||||
queen: PPT,
|
||||
king: PPT,
|
||||
): (PieceType => PPT, PPT => PieceType) =
|
||||
(
|
||||
(pt: PieceType) =>
|
||||
pt match
|
||||
case PieceType.Pawn => pawn
|
||||
case PieceType.Knight => knight
|
||||
case PieceType.Bishop => bishop
|
||||
case PieceType.Rook => rook
|
||||
case PieceType.Queen => queen
|
||||
case PieceType.King => king,
|
||||
(ppt: PPT) =>
|
||||
if ppt == pawn then PieceType.Pawn
|
||||
else if ppt == knight then PieceType.Knight
|
||||
else if ppt == bishop then PieceType.Bishop
|
||||
else if ppt == rook then PieceType.Rook
|
||||
else if ppt == queen then PieceType.Queen
|
||||
else PieceType.King,
|
||||
)
|
||||
|
||||
def moveKindConversions[PMK](
|
||||
quiet: PMK,
|
||||
capture: PMK,
|
||||
castleKingside: PMK,
|
||||
castleQueenside: PMK,
|
||||
enPassant: PMK,
|
||||
promoQueen: PMK,
|
||||
promoRook: PMK,
|
||||
promoBishop: PMK,
|
||||
promoKnight: PMK,
|
||||
): (MoveType => PMK, PMK => MoveType) =
|
||||
(
|
||||
(mt: MoveType) =>
|
||||
mt match
|
||||
case MoveType.Normal(false) => quiet
|
||||
case MoveType.Normal(true) => capture
|
||||
case MoveType.CastleKingside => castleKingside
|
||||
case MoveType.CastleQueenside => castleQueenside
|
||||
case MoveType.EnPassant => enPassant
|
||||
case MoveType.Promotion(PromotionPiece.Queen) => promoQueen
|
||||
case MoveType.Promotion(PromotionPiece.Rook) => promoRook
|
||||
case MoveType.Promotion(PromotionPiece.Bishop) => promoBishop
|
||||
case MoveType.Promotion(PromotionPiece.Knight) => promoKnight,
|
||||
(pmk: PMK) =>
|
||||
if pmk == quiet then MoveType.Normal(false)
|
||||
else if pmk == capture then MoveType.Normal(true)
|
||||
else if pmk == castleKingside then MoveType.CastleKingside
|
||||
else if pmk == castleQueenside then MoveType.CastleQueenside
|
||||
else if pmk == enPassant then MoveType.EnPassant
|
||||
else if pmk == promoQueen then MoveType.Promotion(PromotionPiece.Queen)
|
||||
else if pmk == promoRook then MoveType.Promotion(PromotionPiece.Rook)
|
||||
else if pmk == promoBishop then MoveType.Promotion(PromotionPiece.Bishop)
|
||||
else if pmk == promoKnight then MoveType.Promotion(PromotionPiece.Knight)
|
||||
else MoveType.Normal(false),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.api.io
|
||||
|
||||
import de.nowchess.api.error.GameError
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
trait GameContextImport:
|
||||
def importGameContext(input: String): Either[String, GameContext]
|
||||
def importGameContext(input: String): Either[GameError, GameContext]
|
||||
|
||||
@@ -23,4 +23,10 @@ object PlayerId:
|
||||
final case class PlayerInfo(
|
||||
id: PlayerId,
|
||||
displayName: String,
|
||||
playerType: PlayerType = PlayerType.Human,
|
||||
)
|
||||
|
||||
enum PlayerType:
|
||||
case Human
|
||||
case OfficialBot
|
||||
case Bot
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.nowchess.api.rules
|
||||
|
||||
final case class PostMoveStatus(
|
||||
isCheckmate: Boolean,
|
||||
isStalemate: Boolean,
|
||||
isInsufficientMaterial: Boolean,
|
||||
isCheck: Boolean,
|
||||
isThreefoldRepetition: Boolean,
|
||||
)
|
||||
@@ -39,3 +39,15 @@ trait RuleSet:
|
||||
* promotion. Updates castling rights, en passant square, half-move clock, turn, and move history.
|
||||
*/
|
||||
def applyMove(context: GameContext)(move: Move): GameContext
|
||||
|
||||
/** Batch status check after a move is applied. Replaces individual isCheckmate/isStalemate/isInsufficientMaterial/
|
||||
* isCheck/isThreefoldRepetition calls with a single round-trip. Override for remote implementations.
|
||||
*/
|
||||
def postMoveStatus(context: GameContext): PostMoveStatus =
|
||||
PostMoveStatus(
|
||||
isCheckmate = isCheckmate(context),
|
||||
isStalemate = isStalemate(context),
|
||||
isInsufficientMaterial = isInsufficientMaterial(context),
|
||||
isCheck = isCheck(context),
|
||||
isThreefoldRepetition = isThreefoldRepetition(context),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.Color
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
import org.scalatest.matchers.should.Matchers
|
||||
import java.time.Instant
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
class ClockStateTest extends AnyFunSuite with Matchers:
|
||||
|
||||
private val t0 = Instant.parse("2024-01-01T00:00:00Z")
|
||||
private val t1s = t0.plusSeconds(1)
|
||||
private val t5s = t0.plusSeconds(5)
|
||||
|
||||
// ── LiveClockState ────────────────────────────────────────────────────────
|
||||
|
||||
test("LiveClockState.afterMove deducts elapsed and adds increment on valid move"):
|
||||
val cs = LiveClockState(300_000L, 300_000L, 3_000L, t0, Color.White)
|
||||
cs.afterMove(Color.White, t5s) match
|
||||
case Right(updated: LiveClockState) =>
|
||||
updated.whiteRemainingMs shouldBe (300_000L - 5_000L + 3_000L)
|
||||
updated.blackRemainingMs shouldBe 300_000L
|
||||
updated.activeColor shouldBe Color.Black
|
||||
updated.lastTickAt shouldBe t5s
|
||||
case other => fail(s"Expected Right(LiveClockState), got $other")
|
||||
|
||||
test("LiveClockState.afterMove returns Left when time exhausted"):
|
||||
val cs = LiveClockState(2_000L, 300_000L, 0L, t0, Color.White)
|
||||
cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
|
||||
|
||||
test("LiveClockState.afterMove returns Left when time exactly zero"):
|
||||
val cs = LiveClockState(5_000L, 300_000L, 0L, t0, Color.White)
|
||||
cs.afterMove(Color.White, t5s) shouldBe Left(Color.White)
|
||||
|
||||
test("LiveClockState.remainingMs for active color deducts live elapsed"):
|
||||
val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.White)
|
||||
val now = t5s
|
||||
cs.remainingMs(Color.White, now) shouldBe (300_000L - 5_000L)
|
||||
|
||||
test("LiveClockState.remainingMs for inactive color returns stored value"):
|
||||
val cs = LiveClockState(200_000L, 300_000L, 0L, t0, Color.White)
|
||||
cs.remainingMs(Color.Black, t5s) shouldBe 300_000L
|
||||
|
||||
test("LiveClockState.remainingMs clamps to zero when overdue"):
|
||||
val cs = LiveClockState(1_000L, 300_000L, 0L, t0, Color.White)
|
||||
cs.remainingMs(Color.White, t5s) shouldBe 0L
|
||||
|
||||
test("LiveClockState.afterMove advances activeColor to opponent"):
|
||||
val cs = LiveClockState(300_000L, 300_000L, 0L, t0, Color.Black)
|
||||
cs.afterMove(Color.Black, t1s) match
|
||||
case Right(updated: LiveClockState) => updated.activeColor shouldBe Color.White
|
||||
case other => fail(s"Expected Right, got $other")
|
||||
|
||||
// ── CorrespondenceClockState ──────────────────────────────────────────────
|
||||
|
||||
test("CorrespondenceClockState.afterMove advances deadline on valid move"):
|
||||
val deadline = t0.plus(3L, ChronoUnit.DAYS)
|
||||
val cs = CorrespondenceClockState(deadline, 3, Color.White)
|
||||
cs.afterMove(Color.White, t1s) match
|
||||
case Right(updated: CorrespondenceClockState) =>
|
||||
updated.moveDeadline shouldBe t1s.plus(3L, ChronoUnit.DAYS)
|
||||
updated.activeColor shouldBe Color.Black
|
||||
case other => fail(s"Expected Right(CorrespondenceClockState), got $other")
|
||||
|
||||
test("CorrespondenceClockState.afterMove returns Left when move is past deadline"):
|
||||
val deadline = t0.plus(1L, ChronoUnit.DAYS)
|
||||
val cs = CorrespondenceClockState(deadline, 3, Color.White)
|
||||
val lateMove = t0.plus(2L, ChronoUnit.DAYS)
|
||||
cs.afterMove(Color.White, lateMove) shouldBe Left(Color.White)
|
||||
|
||||
test("CorrespondenceClockState.remainingMs returns time until deadline"):
|
||||
val deadline = t0.plus(3L, ChronoUnit.DAYS)
|
||||
val cs = CorrespondenceClockState(deadline, 3, Color.White)
|
||||
val expected = deadline.toEpochMilli - t1s.toEpochMilli
|
||||
cs.remainingMs(Color.White, t1s) shouldBe expected
|
||||
|
||||
test("CorrespondenceClockState.remainingMs clamps to zero when overdue"):
|
||||
val deadline = t0.plus(1L, ChronoUnit.DAYS)
|
||||
val cs = CorrespondenceClockState(deadline, 3, Color.White)
|
||||
val overdue = t0.plus(2L, ChronoUnit.DAYS)
|
||||
cs.remainingMs(Color.White, overdue) shouldBe 0L
|
||||
|
||||
// ── ClockState.fromTimeControl ────────────────────────────────────────────
|
||||
|
||||
test("fromTimeControl with Clock returns LiveClockState with correct initial values"):
|
||||
ClockState.fromTimeControl(TimeControl.Clock(300, 3), Color.White, t0) match
|
||||
case Some(cs: LiveClockState) =>
|
||||
cs.whiteRemainingMs shouldBe 300_000L
|
||||
cs.blackRemainingMs shouldBe 300_000L
|
||||
cs.incrementMs shouldBe 3_000L
|
||||
cs.activeColor shouldBe Color.White
|
||||
cs.lastTickAt shouldBe t0
|
||||
case other => fail(s"Expected Some(LiveClockState), got $other")
|
||||
|
||||
test("fromTimeControl with Correspondence returns CorrespondenceClockState"):
|
||||
ClockState.fromTimeControl(TimeControl.Correspondence(3), Color.White, t0) match
|
||||
case Some(cs: CorrespondenceClockState) =>
|
||||
cs.moveDeadline shouldBe t0.plus(3L, ChronoUnit.DAYS)
|
||||
cs.daysPerMove shouldBe 3
|
||||
cs.activeColor shouldBe Color.White
|
||||
case other => fail(s"Expected Some(CorrespondenceClockState), got $other")
|
||||
|
||||
test("fromTimeControl with Unlimited returns None"):
|
||||
ClockState.fromTimeControl(TimeControl.Unlimited, Color.White, t0) shouldBe None
|
||||
|
||||
test("fromTimeControl with Black as starting color sets activeColor correctly"):
|
||||
ClockState.fromTimeControl(TimeControl.Clock(300, 0), Color.Black, t0) match
|
||||
case Some(cs: LiveClockState) => cs.activeColor shouldBe Color.Black
|
||||
case other => fail(s"Expected Some(LiveClockState), got $other")
|
||||
@@ -1,6 +1,7 @@
|
||||
package de.nowchess.api.game
|
||||
|
||||
import de.nowchess.api.board.{Board, CastlingRights, Color, File, Rank, Square}
|
||||
import de.nowchess.api.game.WinReason.Checkmate
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.api.game.{DrawReason, GameResult}
|
||||
import org.scalatest.funsuite.AnyFunSuite
|
||||
@@ -61,7 +62,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
GameContext.initial.withMove(move).moves shouldBe List(move)
|
||||
|
||||
test("withResult sets Win result"):
|
||||
val win = Some(GameResult.Win(Color.White))
|
||||
val win = Some(GameResult.Win(Color.White, Checkmate))
|
||||
GameContext.initial.withResult(win).result shouldBe win
|
||||
|
||||
test("withResult sets Draw result"):
|
||||
@@ -69,7 +70,7 @@ class GameContextTest extends AnyFunSuite with Matchers:
|
||||
GameContext.initial.withResult(draw).result shouldBe draw
|
||||
|
||||
test("withResult clears result"):
|
||||
val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black)))
|
||||
val ctx = GameContext.initial.withResult(Some(GameResult.Win(Color.Black, Checkmate)))
|
||||
ctx.withResult(None).result shouldBe None
|
||||
|
||||
test("kingSquare returns white king position"):
|
||||
|
||||
@@ -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("io.quarkus:quarkus-redis-client")
|
||||
|
||||
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,24 @@
|
||||
quarkus:
|
||||
http:
|
||||
port: 8087
|
||||
application:
|
||||
name: nowchess-bot-platform
|
||||
redis:
|
||||
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||
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,12 @@
|
||||
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.prefix", defaultValue = "nowchess")
|
||||
var prefix: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package de.nowchess.botplatform.registry
|
||||
|
||||
import de.nowchess.botplatform.config.RedisConfig
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import io.quarkus.redis.datasource.pubsub.PubSubCommands
|
||||
import io.smallrye.mutiny.subscription.MultiEmitter
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import scala.compiletime.uninitialized
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.function.Consumer
|
||||
|
||||
@ApplicationScoped
|
||||
class BotRegistry:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val connections = ConcurrentHashMap[String, (MultiEmitter[? >: String], PubSubCommands.RedisSubscriber)]()
|
||||
|
||||
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
|
||||
val channel = s"${redisConfig.prefix}:bot:$botId:events"
|
||||
val handler: Consumer[String] = msg => emitter.emit(msg)
|
||||
val subscriber = redis.pubsub(classOf[String]).subscribe(channel, handler)
|
||||
connections.put(botId, (emitter, subscriber))
|
||||
()
|
||||
|
||||
def unregister(botId: String): Unit =
|
||||
Option(connections.remove(botId)).foreach { (_, subscriber) =>
|
||||
subscriber.unsubscribe(s"${redisConfig.prefix}:bot:$botId:events")
|
||||
}
|
||||
|
||||
def dispatch(botId: String, event: String): Unit =
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:bot:$botId:events", event)
|
||||
()
|
||||
|
||||
def registeredBots: List[String] =
|
||||
import scala.jdk.CollectionConverters.*
|
||||
connections.keys().asScala.toList
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package de.nowchess.botplatform.resource
|
||||
|
||||
import de.nowchess.botplatform.config.RedisConfig
|
||||
import de.nowchess.botplatform.registry.BotRegistry
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
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
|
||||
import java.util.function.Consumer
|
||||
|
||||
@Path("/api/bot")
|
||||
@ApplicationScoped
|
||||
@RolesAllowed(Array("**"))
|
||||
class BotEventResource:
|
||||
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject var registry: BotRegistry = uninitialized
|
||||
@Inject var jwt: JsonWebToken = uninitialized
|
||||
@Inject var redis: RedisDataSource = uninitialized
|
||||
@Inject var redisConfig: RedisConfig = 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 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 =>
|
||||
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||
val handler: Consumer[String] = msg => emitter.emit(msg)
|
||||
val subscriber = redis.pubsub(classOf[String]).subscribe(topicName, handler)
|
||||
emitter.onTermination(() => subscriber.unsubscribe(topicName))
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("/game/{gameId}/move/{uci}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def makeMove(
|
||||
@PathParam("gameId") gameId: String,
|
||||
@PathParam("uci") uci: String,
|
||||
): Response =
|
||||
val playerId = Option(jwt.getSubject).getOrElse("")
|
||||
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
|
||||
redis.pubsub(classOf[String]).publish(s"${redisConfig.prefix}:game:$gameId:c2s", moveMsg)
|
||||
Response.ok().build()
|
||||
@@ -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,60 +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.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(
|
||||
difficulty: BotDifficulty,
|
||||
rules: RuleSet = DefaultRules,
|
||||
book: Option[PolyglotBook] = None,
|
||||
) extends Bot:
|
||||
|
||||
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)] =
|
||||
EvaluationNNUE.initAccumulator(context)
|
||||
val rootHash = ZobristHash.hash(context)
|
||||
moves.map { move =>
|
||||
val child = rules.applyMove(context)(move)
|
||||
val childHash = ZobristHash.nextHash(context, rootHash, move, child)
|
||||
EvaluationNNUE.pushAccumulator(1, move, context, child)
|
||||
val score = -EvaluationNNUE.evaluateAccumulator(1, child, childHash)
|
||||
(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
|
||||
else if moveCount < 5 then 500L
|
||||
else
|
||||
val scores = scored.map(_._2)
|
||||
val best = scores.max
|
||||
val second = scores.filter(_ < best).maxOption.getOrElse(best)
|
||||
if best - second > 200 then 600L else 1000L
|
||||
@@ -0,0 +1,114 @@
|
||||
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"]!!)
|
||||
excludedFiles.set(scoverageExcluded)
|
||||
}
|
||||
|
||||
tasks.withType<ScalaCompile> {
|
||||
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
|
||||
}
|
||||
|
||||
tasks.named("compileScoverageJava").configure {
|
||||
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||
}
|
||||
|
||||
tasks.withType(ScalaCompile::class).configureEach {
|
||||
if (name == "compileScoverageScala") {
|
||||
source = source.asFileTree.matching {
|
||||
exclude("**/grpc/*.scala")
|
||||
exclude("**/service/*.scala")
|
||||
exclude("**/resource/*.scala")
|
||||
exclude("**/config/*.scala")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
implementation("io.quarkus:quarkus-rest")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
implementation("io.quarkus:quarkus-grpc")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("io.quarkus:quarkus-config-yaml")
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-smallrye-openapi")
|
||||
implementation("io.quarkus:quarkus-rest-client")
|
||||
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
implementation("io.quarkus:quarkus-redis-client")
|
||||
implementation("io.fabric8:kubernetes-client:6.13.0")
|
||||
|
||||
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-junit5")
|
||||
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine")
|
||||
}
|
||||
|
||||
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("scalatest", "junit-jupiter")
|
||||
testLogging { events("passed", "skipped", "failed") }
|
||||
}
|
||||
finalizedBy(tasks.reportScoverage)
|
||||
}
|
||||
tasks.reportScoverage { dependsOn(tasks.test) }
|
||||
tasks.jar { duplicatesStrategy = DuplicatesStrategy.EXCLUDE }
|
||||
@@ -0,0 +1,60 @@
|
||||
syntax = "proto3";
|
||||
option java_package = "de.nowchess.coordinator.proto";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "CoordinatorServiceProto";
|
||||
|
||||
service CoordinatorService {
|
||||
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
||||
rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
|
||||
rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
|
||||
rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
|
||||
rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
|
||||
}
|
||||
|
||||
message HeartbeatFrame {
|
||||
string instanceId = 1;
|
||||
string hostname = 2;
|
||||
int32 httpPort = 3;
|
||||
int32 grpcPort = 4;
|
||||
int32 subscriptionCount = 5;
|
||||
int32 localCacheSize = 6;
|
||||
int64 timestampMillis = 7;
|
||||
}
|
||||
|
||||
message CoordinatorCommand {
|
||||
string type = 1;
|
||||
string payload = 2;
|
||||
}
|
||||
|
||||
message BatchResubscribeRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message BatchResubscribeResponse {
|
||||
int32 subscribedCount = 1;
|
||||
repeated string failedGameIds = 2;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesResponse {
|
||||
int32 unsubscribedCount = 1;
|
||||
}
|
||||
|
||||
message EvictGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message EvictGamesResponse {
|
||||
int32 evictedCount = 1;
|
||||
}
|
||||
|
||||
message DrainInstanceRequest {
|
||||
string instanceId = 1;
|
||||
}
|
||||
|
||||
message DrainInstanceResponse {
|
||||
int32 gamesMigrated = 1;
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"reflection": [
|
||||
{ "type": "scala.Tuple1[]" },
|
||||
{ "type": "scala.Tuple2[]" },
|
||||
{ "type": "scala.Tuple3[]" },
|
||||
{ "type": "scala.Tuple4[]" },
|
||||
{ "type": "scala.Tuple5[]" },
|
||||
{ "type": "scala.Tuple6[]" },
|
||||
{ "type": "scala.Tuple7[]" },
|
||||
{ "type": "scala.Tuple8[]" },
|
||||
{ "type": "scala.Tuple9[]" },
|
||||
{ "type": "scala.Tuple10[]" },
|
||||
{ "type": "scala.Tuple11[]" },
|
||||
{ "type": "scala.Tuple12[]" },
|
||||
{ "type": "scala.Tuple13[]" },
|
||||
{ "type": "scala.Tuple14[]" },
|
||||
{ "type": "scala.Tuple15[]" },
|
||||
{ "type": "scala.Tuple16[]" },
|
||||
{ "type": "scala.Tuple17[]" },
|
||||
{ "type": "scala.Tuple18[]" },
|
||||
{ "type": "scala.Tuple19[]" },
|
||||
{ "type": "scala.Tuple20[]" },
|
||||
{ "type": "scala.Tuple21[]" },
|
||||
{ "type": "scala.Tuple22[]" },
|
||||
{ "type": "com.fasterxml.jackson.module.scala.introspect.PropertyDescriptor[]" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
quarkus:
|
||||
application:
|
||||
name: nowchess-coordinator
|
||||
http:
|
||||
port: 8086
|
||||
redis:
|
||||
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||
grpc:
|
||||
server:
|
||||
port: 9086
|
||||
rest-client:
|
||||
connection-timeout: 5000
|
||||
read-timeout: 10000
|
||||
smallrye-openapi:
|
||||
info-title: NowChess Coordinator Service
|
||||
info-version: 1.0.0
|
||||
info-description: Coordination endpoints for instance health, balancing, failover, and scaling
|
||||
path: /openapi
|
||||
swagger-ui:
|
||||
always-include: true
|
||||
path: /swagger-ui
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: ${REDIS_HOST:localhost}
|
||||
port: ${REDIS_PORT:6379}
|
||||
prefix: ${REDIS_PREFIX:nowchess}
|
||||
|
||||
coordinator:
|
||||
max-games-per-core: 500
|
||||
max-deviation-percent: 20
|
||||
rebalance-interval: 30s
|
||||
rebalance-min-interval: 60s
|
||||
heartbeat-ttl: 5s
|
||||
stream-heartbeat-interval: PT0.2S
|
||||
cache-eviction-interval: 10m
|
||||
game-idle-threshold: 45m
|
||||
auto-scale-enabled: false
|
||||
scale-up-threshold: 0.8
|
||||
scale-down-threshold: 0.3
|
||||
scale-min-replicas: 2
|
||||
scale-max-replicas: 10
|
||||
k8s-namespace: default
|
||||
k8s-rollout-name: nowchess-core
|
||||
k8s-rollout-label-selector: "app=nowchess-core"
|
||||
|
||||
---
|
||||
# dev profile
|
||||
"%dev":
|
||||
quarkus:
|
||||
log:
|
||||
level: DEBUG
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.nowchess.coordinator
|
||||
|
||||
import jakarta.ws.rs.core.Application
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
|
||||
@ApplicationScoped
|
||||
class CoordinatorApp extends Application
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.nowchess.coordinator.config
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.inject.Produces
|
||||
import io.fabric8.kubernetes.client.KubernetesClientBuilder
|
||||
import io.fabric8.kubernetes.client.KubernetesClient
|
||||
|
||||
@ApplicationScoped
|
||||
class BeansProducer:
|
||||
|
||||
@Produces
|
||||
@ApplicationScoped
|
||||
def kubernetesClient: KubernetesClient =
|
||||
KubernetesClientBuilder().build()
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package de.nowchess.coordinator.config
|
||||
|
||||
import io.smallrye.config.ConfigMapping
|
||||
import io.smallrye.config.WithName
|
||||
import java.time.Duration
|
||||
|
||||
@ConfigMapping(prefix = "nowchess.coordinator")
|
||||
trait CoordinatorConfig:
|
||||
@WithName("max-games-per-core")
|
||||
def maxGamesPerCore: Int
|
||||
|
||||
@WithName("max-deviation-percent")
|
||||
def maxDeviationPercent: Int
|
||||
|
||||
@WithName("rebalance-interval")
|
||||
def rebalanceInterval: Duration
|
||||
|
||||
@WithName("rebalance-min-interval")
|
||||
def rebalanceMinInterval: Duration
|
||||
|
||||
@WithName("heartbeat-ttl")
|
||||
def heartbeatTtl: Duration
|
||||
|
||||
@WithName("stream-heartbeat-interval")
|
||||
def streamHeartbeatInterval: Duration
|
||||
|
||||
@WithName("cache-eviction-interval")
|
||||
def cacheEvictionInterval: Duration
|
||||
|
||||
@WithName("game-idle-threshold")
|
||||
def gameIdleThreshold: Duration
|
||||
|
||||
@WithName("auto-scale-enabled")
|
||||
def autoScaleEnabled: Boolean
|
||||
|
||||
@WithName("scale-up-threshold")
|
||||
def scaleUpThreshold: Double
|
||||
|
||||
@WithName("scale-down-threshold")
|
||||
def scaleDownThreshold: Double
|
||||
|
||||
@WithName("scale-min-replicas")
|
||||
def scaleMinReplicas: Int
|
||||
|
||||
@WithName("scale-max-replicas")
|
||||
def scaleMaxReplicas: Int
|
||||
|
||||
@WithName("k8s-namespace")
|
||||
def k8sNamespace: String
|
||||
|
||||
@WithName("k8s-rollout-name")
|
||||
def k8sRolloutName: String
|
||||
|
||||
@WithName("k8s-rollout-label-selector")
|
||||
def k8sRolloutLabelSelector: String
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.nowchess.coordinator.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
|
||||
})
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package de.nowchess.coordinator.config
|
||||
|
||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||
import de.nowchess.coordinator.resource.MetricsDto
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[InstanceMetadata],
|
||||
classOf[MetricsDto],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.nowchess.coordinator.dto
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import java.time.Instant
|
||||
|
||||
case class InstanceMetadata(
|
||||
@JsonProperty("instanceId")
|
||||
instanceId: String,
|
||||
@JsonProperty("hostname")
|
||||
hostname: String,
|
||||
@JsonProperty("httpPort")
|
||||
httpPort: Int,
|
||||
@JsonProperty("grpcPort")
|
||||
grpcPort: Int,
|
||||
@JsonProperty("subscriptionCount")
|
||||
subscriptionCount: Int,
|
||||
@JsonProperty("localCacheSize")
|
||||
localCacheSize: Int,
|
||||
@JsonProperty("lastHeartbeat")
|
||||
lastHeartbeat: String,
|
||||
@JsonProperty("state")
|
||||
state: String = "HEALTHY",
|
||||
)
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.nowchess.coordinator.grpc
|
||||
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.inject.Singleton
|
||||
import io.quarkus.grpc.GrpcService
|
||||
import scala.compiletime.uninitialized
|
||||
import de.nowchess.coordinator.service.{FailoverService, InstanceRegistry}
|
||||
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||
import io.grpc.stub.StreamObserver
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import org.jboss.logging.Logger
|
||||
|
||||
@GrpcService
|
||||
@Singleton
|
||||
class CoordinatorGrpcServer extends CoordinatorServiceGrpc.CoordinatorServiceImplBase:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var failoverService: FailoverService = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val mapper = ObjectMapper()
|
||||
private val log = Logger.getLogger(classOf[CoordinatorGrpcServer])
|
||||
|
||||
override def heartbeatStream(
|
||||
responseObserver: StreamObserver[CoordinatorCommand],
|
||||
): StreamObserver[HeartbeatFrame] =
|
||||
new StreamObserver[HeartbeatFrame]:
|
||||
// scalafix:off DisableSyntax.var
|
||||
private var lastInstanceId = ""
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
override def onNext(frame: HeartbeatFrame): Unit =
|
||||
lastInstanceId = frame.getInstanceId
|
||||
try
|
||||
instanceRegistry.updateInstanceFromRedis(frame.getInstanceId)
|
||||
log.debugf(
|
||||
"Received heartbeat from %s with %d subscriptions",
|
||||
frame.getInstanceId,
|
||||
frame.getSubscriptionCount,
|
||||
)
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to process heartbeat from %s", frame.getInstanceId)
|
||||
|
||||
override def onError(t: Throwable): Unit =
|
||||
log.warnf(t, "Heartbeat stream error for instance %s", lastInstanceId)
|
||||
if lastInstanceId.nonEmpty then failoverService.onInstanceStreamDropped(lastInstanceId)
|
||||
|
||||
override def onCompleted: Unit =
|
||||
log.infof("Heartbeat stream completed for instance %s", lastInstanceId)
|
||||
|
||||
override def batchResubscribeGames(
|
||||
request: BatchResubscribeRequest,
|
||||
responseObserver: StreamObserver[BatchResubscribeResponse],
|
||||
): Unit =
|
||||
log.infof("Batch resubscribe request for %d games", request.getGameIdsList.size())
|
||||
val response = BatchResubscribeResponse
|
||||
.newBuilder()
|
||||
.setSubscribedCount(request.getGameIdsList.size())
|
||||
.build()
|
||||
responseObserver.onNext(response)
|
||||
responseObserver.onCompleted()
|
||||
|
||||
override def unsubscribeGames(
|
||||
request: UnsubscribeGamesRequest,
|
||||
responseObserver: StreamObserver[UnsubscribeGamesResponse],
|
||||
): Unit =
|
||||
log.infof("Unsubscribe request for %d games", request.getGameIdsList.size())
|
||||
val response = UnsubscribeGamesResponse
|
||||
.newBuilder()
|
||||
.setUnsubscribedCount(request.getGameIdsList.size())
|
||||
.build()
|
||||
responseObserver.onNext(response)
|
||||
responseObserver.onCompleted()
|
||||
|
||||
override def evictGames(
|
||||
request: EvictGamesRequest,
|
||||
responseObserver: StreamObserver[EvictGamesResponse],
|
||||
): Unit =
|
||||
log.infof("Evict request for %d games", request.getGameIdsList.size())
|
||||
val response = EvictGamesResponse
|
||||
.newBuilder()
|
||||
.setEvictedCount(request.getGameIdsList.size())
|
||||
.build()
|
||||
responseObserver.onNext(response)
|
||||
responseObserver.onCompleted()
|
||||
|
||||
override def drainInstance(
|
||||
request: DrainInstanceRequest,
|
||||
responseObserver: StreamObserver[DrainInstanceResponse],
|
||||
): Unit =
|
||||
val instanceId = request.getInstanceId
|
||||
log.infof("Drain request for instance %s", instanceId)
|
||||
val gamesBefore = instanceRegistry.getInstance(instanceId).map(_.subscriptionCount).getOrElse(0)
|
||||
failoverService.onInstanceStreamDropped(instanceId)
|
||||
val response = DrainInstanceResponse.newBuilder().setGamesMigrated(gamesBefore).build()
|
||||
responseObserver.onNext(response)
|
||||
responseObserver.onCompleted()
|
||||
@@ -0,0 +1,63 @@
|
||||
package de.nowchess.coordinator.grpc
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.annotation.PreDestroy
|
||||
import org.jboss.logging.Logger
|
||||
import io.grpc.ManagedChannel
|
||||
import io.grpc.ManagedChannelBuilder
|
||||
import de.nowchess.coordinator.proto.{CoordinatorServiceGrpc, *}
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@ApplicationScoped
|
||||
class CoreGrpcClient:
|
||||
private val log = Logger.getLogger(classOf[CoreGrpcClient])
|
||||
private val channels = ConcurrentHashMap[String, ManagedChannel]()
|
||||
|
||||
private def getChannel(host: String, port: Int): ManagedChannel =
|
||||
channels.computeIfAbsent(s"$host:$port", _ => ManagedChannelBuilder.forAddress(host, port).usePlaintext().build())
|
||||
|
||||
private def evictStaleChannel(host: String, port: Int): Unit =
|
||||
Option(channels.remove(s"$host:$port")).foreach(_.shutdownNow())
|
||||
|
||||
@PreDestroy
|
||||
def shutdown(): Unit =
|
||||
channels.values.asScala.foreach { ch =>
|
||||
ch.shutdown()
|
||||
if !ch.awaitTermination(5, TimeUnit.SECONDS) then ch.shutdownNow()
|
||||
}
|
||||
channels.clear()
|
||||
|
||||
def batchResubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||
try
|
||||
val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
|
||||
val request = BatchResubscribeRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
|
||||
stub.batchResubscribeGames(request).getSubscribedCount
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "batchResubscribeGames RPC failed for %s:%d", host, port)
|
||||
evictStaleChannel(host, port)
|
||||
0
|
||||
|
||||
def unsubscribeGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||
try
|
||||
val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
|
||||
val request = UnsubscribeGamesRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
|
||||
stub.unsubscribeGames(request).getUnsubscribedCount
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "unsubscribeGames RPC failed for %s:%d", host, port)
|
||||
evictStaleChannel(host, port)
|
||||
0
|
||||
|
||||
def evictGames(host: String, port: Int, gameIds: List[String]): Int =
|
||||
try
|
||||
val stub = CoordinatorServiceGrpc.newBlockingStub(getChannel(host, port))
|
||||
val request = EvictGamesRequest.newBuilder().addAllGameIds(gameIds.asJava).build()
|
||||
stub.evictGames(request).getEvictedCount
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "evictGames RPC failed for %s:%d", host, port)
|
||||
evictStaleChannel(host, port)
|
||||
0
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package de.nowchess.coordinator.resource
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import de.nowchess.coordinator.service.{AutoScaler, FailoverService, InstanceRegistry, LoadBalancer}
|
||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||
import org.jboss.logging.Logger
|
||||
|
||||
@Path("/api/coordinator")
|
||||
@ApplicationScoped
|
||||
class CoordinatorResource:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var loadBalancer: LoadBalancer = uninitialized
|
||||
|
||||
@Inject
|
||||
private var autoScaler: AutoScaler = uninitialized
|
||||
|
||||
@Inject
|
||||
private var failoverService: FailoverService = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val log = Logger.getLogger(classOf[CoordinatorResource])
|
||||
|
||||
@GET
|
||||
@Path("/instances")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def listInstances: java.util.List[InstanceMetadata] =
|
||||
instanceRegistry.getAllInstances.asJava
|
||||
|
||||
@GET
|
||||
@Path("/metrics")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def getMetrics: MetricsDto =
|
||||
val instances = instanceRegistry.getAllInstances
|
||||
val loads = instances.map(_.subscriptionCount)
|
||||
val totalGames = loads.sum
|
||||
val avgLoad = if instances.nonEmpty then loads.sum.toDouble / instances.size else 0.0
|
||||
val maxLoad = if loads.nonEmpty then loads.max else 0
|
||||
val minLoad = if loads.nonEmpty then loads.min else 0
|
||||
|
||||
MetricsDto(
|
||||
totalInstances = instances.size,
|
||||
healthyInstances = instances.count(_.state == "HEALTHY"),
|
||||
deadInstances = instances.count(_.state == "DEAD"),
|
||||
totalGames = totalGames,
|
||||
avgGamesPerCore = avgLoad,
|
||||
maxGamesPerCore = maxLoad,
|
||||
minGamesPerCore = minLoad,
|
||||
instances = instances,
|
||||
)
|
||||
|
||||
@POST
|
||||
@Path("/rebalance")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def triggerRebalance: scala.collection.Map[String, String] =
|
||||
log.info("Manual rebalance triggered")
|
||||
loadBalancer.rebalance
|
||||
Map("status" -> "rebalance_started")
|
||||
|
||||
@POST
|
||||
@Path("/failover/{instanceId}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def triggerFailover(@PathParam("instanceId") instanceId: String): scala.collection.Map[String, String] =
|
||||
log.infof("Manual failover triggered for instance %s", instanceId)
|
||||
failoverService.onInstanceStreamDropped(instanceId)
|
||||
Map("status" -> "failover_started", "instanceId" -> instanceId)
|
||||
|
||||
@POST
|
||||
@Path("/scale-up")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def triggerScaleUp: scala.collection.Map[String, String] =
|
||||
log.info("Manual scale up triggered")
|
||||
autoScaler.scaleUp()
|
||||
Map("status" -> "scale_up_started")
|
||||
|
||||
@POST
|
||||
@Path("/scale-down")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def triggerScaleDown: scala.collection.Map[String, String] =
|
||||
log.info("Manual scale down triggered")
|
||||
autoScaler.scaleDown()
|
||||
Map("status" -> "scale_down_started")
|
||||
|
||||
case class MetricsDto(
|
||||
totalInstances: Int,
|
||||
healthyInstances: Int,
|
||||
deadInstances: Int,
|
||||
totalGames: Int,
|
||||
avgGamesPerCore: Double,
|
||||
maxGamesPerCore: Int,
|
||||
minGamesPerCore: Int,
|
||||
instances: List[InstanceMetadata],
|
||||
)
|
||||
@@ -0,0 +1,135 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.inject.Instance
|
||||
import jakarta.inject.Inject
|
||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||
import io.fabric8.kubernetes.api.model.GenericKubernetesResource
|
||||
import io.fabric8.kubernetes.client.KubernetesClient
|
||||
import org.jboss.logging.Logger
|
||||
|
||||
import scala.compiletime.uninitialized
|
||||
|
||||
@ApplicationScoped
|
||||
class AutoScaler:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var kubeClientInstance: Instance[KubernetesClient] = uninitialized
|
||||
|
||||
@Inject
|
||||
private var config: CoordinatorConfig = uninitialized
|
||||
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val log = Logger.getLogger(classOf[AutoScaler])
|
||||
private val lastScaleTime = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
|
||||
private def kubeClientOpt: Option[KubernetesClient] =
|
||||
if kubeClientInstance.isUnsatisfied then None
|
||||
else Some(kubeClientInstance.get())
|
||||
|
||||
// scalafix:off DisableSyntax.asInstanceOf
|
||||
// scalafix:off DisableSyntax.isInstanceOf
|
||||
private def rolloutSpec(rollout: GenericKubernetesResource): Option[java.util.Map[String, AnyRef]] =
|
||||
Option(rollout.get("spec")).collect {
|
||||
case m if m.isInstanceOf[java.util.Map[?, ?]] => m.asInstanceOf[java.util.Map[String, AnyRef]]
|
||||
}
|
||||
// scalafix:on DisableSyntax.asInstanceOf
|
||||
// scalafix:on DisableSyntax.isInstanceOf
|
||||
|
||||
def checkAndScale: Unit =
|
||||
if config.autoScaleEnabled then
|
||||
val now = System.currentTimeMillis()
|
||||
val last = lastScaleTime.get()
|
||||
if now - last >= 120000 && lastScaleTime.compareAndSet(last, now) then
|
||||
val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
|
||||
if instances.nonEmpty then
|
||||
val avgLoad = instances.map(_.subscriptionCount).sum.toDouble / instances.size
|
||||
|
||||
if avgLoad > config.scaleUpThreshold * config.maxGamesPerCore then scaleUp()
|
||||
else if avgLoad < config.scaleDownThreshold * config.maxGamesPerCore && instances.size > config.scaleMinReplicas
|
||||
then scaleDown()
|
||||
|
||||
def scaleUp(): Unit =
|
||||
log.info("Scaling up Argo Rollout")
|
||||
kubeClientOpt match
|
||||
case None =>
|
||||
log.warn("Kubernetes client not available, cannot scale")
|
||||
case Some(kube) =>
|
||||
try
|
||||
Option(
|
||||
kube
|
||||
.resources(classOf[GenericKubernetesResource])
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withName(config.k8sRolloutName)
|
||||
.get(),
|
||||
).foreach { rollout =>
|
||||
rolloutSpec(rollout).foreach { spec =>
|
||||
spec.get("replicas") match
|
||||
case replicas: Integer =>
|
||||
val currentReplicas = replicas.intValue()
|
||||
val maxReplicas = config.scaleMaxReplicas
|
||||
|
||||
if currentReplicas < maxReplicas then
|
||||
spec.put("replicas", String.valueOf(currentReplicas + 1))
|
||||
kube
|
||||
.resources(classOf[GenericKubernetesResource])
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withName(config.k8sRolloutName)
|
||||
.update()
|
||||
log.infof(
|
||||
"Scaled up %s from %d to %d replicas",
|
||||
config.k8sRolloutName,
|
||||
currentReplicas,
|
||||
currentReplicas + 1,
|
||||
)
|
||||
else log.infof("Already at max replicas %d for %s", maxReplicas, config.k8sRolloutName)
|
||||
case _ => ()
|
||||
}
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to scale up %s", config.k8sRolloutName)
|
||||
|
||||
def scaleDown(): Unit =
|
||||
log.info("Scaling down Argo Rollout")
|
||||
kubeClientOpt match
|
||||
case None =>
|
||||
log.warn("Kubernetes client not available, cannot scale")
|
||||
case Some(kube) =>
|
||||
try
|
||||
Option(
|
||||
kube
|
||||
.resources(classOf[GenericKubernetesResource])
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withName(config.k8sRolloutName)
|
||||
.get(),
|
||||
).foreach { rollout =>
|
||||
rolloutSpec(rollout).foreach { spec =>
|
||||
spec.get("replicas") match
|
||||
case replicas: Integer =>
|
||||
val currentReplicas = replicas.intValue()
|
||||
val minReplicas = config.scaleMinReplicas
|
||||
|
||||
if currentReplicas > minReplicas then
|
||||
spec.put("replicas", String.valueOf(currentReplicas - 1))
|
||||
kube
|
||||
.resources(classOf[GenericKubernetesResource])
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withName(config.k8sRolloutName)
|
||||
.update()
|
||||
log.infof(
|
||||
"Scaled down %s from %d to %d replicas",
|
||||
config.k8sRolloutName,
|
||||
currentReplicas,
|
||||
currentReplicas - 1,
|
||||
)
|
||||
else log.infof("Already at min replicas %d for %s", minReplicas, config.k8sRolloutName)
|
||||
case _ => ()
|
||||
}
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to scale down %s", config.k8sRolloutName)
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.util.Try
|
||||
import java.time.Instant
|
||||
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||
|
||||
@ApplicationScoped
|
||||
class CacheEvictionManager:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var redis: RedisDataSource = uninitialized
|
||||
|
||||
@Inject
|
||||
private var config: CoordinatorConfig = uninitialized
|
||||
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||
|
||||
@Inject
|
||||
private var objectMapper: ObjectMapper = uninitialized
|
||||
|
||||
private val log = Logger.getLogger(classOf[CacheEvictionManager])
|
||||
private var redisPrefix = "nowchess"
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
def setRedisPrefix(prefix: String): Unit =
|
||||
redisPrefix = prefix
|
||||
|
||||
def evictStaleGames: Unit =
|
||||
log.info("Starting cache eviction scan")
|
||||
|
||||
val pattern = s"$redisPrefix:game:entry:*"
|
||||
val keys = redis.key(classOf[String]).keys(pattern)
|
||||
val now = System.currentTimeMillis()
|
||||
val idleThresholdMs = config.gameIdleThreshold.toMillis
|
||||
|
||||
val evictedCount = keys.asScala.foldLeft(0) { (count, key) =>
|
||||
try
|
||||
Option(redis.value(classOf[String]).get(key)).fold(count) { value =>
|
||||
val gameId = key.stripPrefix(s"$redisPrefix:game:entry:")
|
||||
val lastUpdated = extractLastUpdatedTimestamp(value)
|
||||
|
||||
if lastUpdated > 0 && (now - lastUpdated) > idleThresholdMs then
|
||||
findInstanceWithGame(gameId).fold(count) { instance =>
|
||||
try
|
||||
coreGrpcClient.evictGames(instance.hostname, instance.grpcPort, List(gameId))
|
||||
redis.key(classOf[String]).del(key)
|
||||
log.infof("Evicted idle game %s from %s", gameId, instance.instanceId)
|
||||
count + 1
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to evict game %s", gameId)
|
||||
count
|
||||
}
|
||||
else count
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Error processing game key %s", key)
|
||||
count
|
||||
}
|
||||
|
||||
log.infof("Cache eviction scan completed, evicted %d games", evictedCount)
|
||||
|
||||
private def extractLastUpdatedTimestamp(json: String): Long =
|
||||
Try {
|
||||
val parsed = objectMapper.readTree(json)
|
||||
Option(parsed.get("lastHeartbeat"))
|
||||
.filter(_.isTextual)
|
||||
.fold(0L)(lh => Instant.parse(lh.asText()).toEpochMilli)
|
||||
}.getOrElse(0L)
|
||||
|
||||
private def findInstanceWithGame(gameId: String): Option[de.nowchess.coordinator.dto.InstanceMetadata] =
|
||||
try
|
||||
instanceRegistry.getAllInstances.find { instance =>
|
||||
val setKey = s"$redisPrefix:instance:${instance.instanceId}:games"
|
||||
redis.set(classOf[String]).sismember(setKey, gameId)
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.debugf(ex, "Failed to find instance for game %s", gameId)
|
||||
None
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.compiletime.uninitialized
|
||||
import org.jboss.logging.Logger
|
||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||
|
||||
@ApplicationScoped
|
||||
class FailoverService:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var redis: RedisDataSource = uninitialized
|
||||
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||
|
||||
private val log = Logger.getLogger(classOf[FailoverService])
|
||||
private var redisPrefix = "nowchess"
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
def setRedisPrefix(prefix: String): Unit =
|
||||
redisPrefix = prefix
|
||||
|
||||
def onInstanceStreamDropped(instanceId: String): Unit =
|
||||
log.infof("Instance %s stream dropped, triggering failover", instanceId)
|
||||
|
||||
val startTime = System.currentTimeMillis()
|
||||
instanceRegistry.markInstanceDead(instanceId)
|
||||
|
||||
val gameIds = getOrphanedGames(instanceId)
|
||||
log.infof("Found %d orphaned games for instance %s", gameIds.size, instanceId)
|
||||
|
||||
if gameIds.nonEmpty then
|
||||
val healthyInstances = instanceRegistry.getAllInstances
|
||||
.filter(_.state == "HEALTHY")
|
||||
.sortBy(_.subscriptionCount)
|
||||
|
||||
if healthyInstances.nonEmpty then
|
||||
distributeGames(gameIds, healthyInstances, instanceId)
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
log.infof("Failover completed in %dms for instance %s", elapsed, instanceId)
|
||||
else log.warnf("No healthy instances available for failover of %s", instanceId)
|
||||
|
||||
cleanupDeadInstance(instanceId)
|
||||
|
||||
private def getOrphanedGames(instanceId: String): List[String] =
|
||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||
redis.set(classOf[String]).smembers(setKey).asScala.toList
|
||||
|
||||
private def distributeGames(
|
||||
gameIds: List[String],
|
||||
healthyInstances: List[InstanceMetadata],
|
||||
deadInstanceId: String,
|
||||
): Unit =
|
||||
if gameIds.nonEmpty && healthyInstances.nonEmpty then
|
||||
val batchSize = math.max(1, gameIds.size / healthyInstances.size)
|
||||
val batches = gameIds.grouped(batchSize).toList
|
||||
|
||||
batches.zipWithIndex.foreach { case (batch, idx) =>
|
||||
if !tryMigrateBatch(batch, idx, healthyInstances, deadInstanceId) then
|
||||
log.errorf(
|
||||
"Failed to migrate batch of %d games from %s to any healthy instance",
|
||||
batch.size,
|
||||
deadInstanceId,
|
||||
)
|
||||
}
|
||||
|
||||
@scala.annotation.tailrec
|
||||
private def tryMigrateBatch(
|
||||
batch: List[String],
|
||||
batchIdx: Int,
|
||||
instances: List[InstanceMetadata],
|
||||
deadId: String,
|
||||
attempt: Int = 0,
|
||||
): Boolean =
|
||||
if attempt >= instances.size then false
|
||||
else
|
||||
val target = instances((batchIdx + attempt) % instances.size)
|
||||
val success =
|
||||
try
|
||||
val subscribed = coreGrpcClient.batchResubscribeGames(target.hostname, target.grpcPort, batch)
|
||||
if subscribed > 0 then
|
||||
log.infof("Migrated %d games from %s to %s", subscribed, deadId, target.instanceId)
|
||||
true
|
||||
else false
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to migrate batch to %s, trying next", target.instanceId)
|
||||
false
|
||||
if success then true else tryMigrateBatch(batch, batchIdx, instances, deadId, attempt + 1)
|
||||
|
||||
private def cleanupDeadInstance(instanceId: String): Unit =
|
||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||
redis.key(classOf[String]).del(setKey)
|
||||
log.infof("Cleaned up games set for instance %s", instanceId)
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.enterprise.inject.Instance
|
||||
import jakarta.inject.Inject
|
||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||
import io.fabric8.kubernetes.client.KubernetesClient
|
||||
import io.fabric8.kubernetes.api.model.Pod
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import java.time.Instant
|
||||
|
||||
@ApplicationScoped
|
||||
class HealthMonitor:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var kubeClientInstance: Instance[KubernetesClient] = uninitialized
|
||||
|
||||
@Inject
|
||||
private var config: CoordinatorConfig = uninitialized
|
||||
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var redis: RedisDataSource = uninitialized
|
||||
|
||||
private val log = Logger.getLogger(classOf[HealthMonitor])
|
||||
private var redisPrefix = "nowchess"
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private def kubeClientOpt: Option[KubernetesClient] =
|
||||
if kubeClientInstance.isUnsatisfied then None
|
||||
else Some(kubeClientInstance.get())
|
||||
|
||||
def setRedisPrefix(prefix: String): Unit =
|
||||
redisPrefix = prefix
|
||||
|
||||
def checkInstanceHealth: Unit =
|
||||
val instances = instanceRegistry.getAllInstances
|
||||
instances.foreach { inst =>
|
||||
val isHealthy = checkHealth(inst.instanceId)
|
||||
if !isHealthy && inst.state == "HEALTHY" then
|
||||
log.warnf("Instance %s marked unhealthy", inst.instanceId)
|
||||
instanceRegistry.markInstanceDead(inst.instanceId)
|
||||
}
|
||||
|
||||
private def checkHealth(instanceId: String): Boolean =
|
||||
val redisHealthy = checkRedisHeartbeat(instanceId)
|
||||
val k8sHealthy = checkK8sPodStatus(instanceId)
|
||||
redisHealthy && k8sHealthy
|
||||
|
||||
private def checkRedisHeartbeat(instanceId: String): Boolean =
|
||||
try
|
||||
val key = s"$redisPrefix:instances:$instanceId"
|
||||
redis.key(classOf[String]).pttl(key) > 0
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.debugf(ex, "Redis heartbeat check failed for %s", instanceId)
|
||||
false
|
||||
|
||||
private def checkK8sPodStatus(instanceId: String): Boolean =
|
||||
kubeClientOpt.fold(true) { kube =>
|
||||
try
|
||||
val pods = kube
|
||||
.pods()
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withLabel(config.k8sRolloutLabelSelector)
|
||||
.list()
|
||||
.getItems
|
||||
.asScala
|
||||
|
||||
pods.exists { pod =>
|
||||
val podName = pod.getMetadata.getName
|
||||
podName.contains(instanceId) && isPodReady(pod)
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.debugf(ex, "K8s pod status check failed for %s", instanceId)
|
||||
true
|
||||
}
|
||||
|
||||
def watchK8sPods: Unit =
|
||||
kubeClientOpt match
|
||||
case None =>
|
||||
log.debug("Kubernetes client not available for pod watch")
|
||||
case Some(kube) =>
|
||||
try
|
||||
val pods = kube
|
||||
.pods()
|
||||
.inNamespace(config.k8sNamespace)
|
||||
.withLabel(config.k8sRolloutLabelSelector)
|
||||
.list()
|
||||
.getItems
|
||||
.asScala
|
||||
|
||||
val instances = instanceRegistry.getAllInstances
|
||||
instances.foreach { inst =>
|
||||
val matchingPod = pods.find { pod =>
|
||||
pod.getMetadata.getName.contains(inst.instanceId)
|
||||
}
|
||||
|
||||
matchingPod match
|
||||
case Some(pod) =>
|
||||
val isReady = isPodReady(pod)
|
||||
if !isReady && inst.state == "HEALTHY" then
|
||||
log.warnf("Pod %s not ready, marking instance %s dead", pod.getMetadata.getName, inst.instanceId)
|
||||
instanceRegistry.markInstanceDead(inst.instanceId)
|
||||
case None =>
|
||||
if inst.state == "HEALTHY" then
|
||||
log.warnf("No pod found for instance %s, marking dead", inst.instanceId)
|
||||
instanceRegistry.markInstanceDead(inst.instanceId)
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to watch k8s pods")
|
||||
|
||||
private def isPodReady(pod: Pod): Boolean =
|
||||
Option(pod.getStatus)
|
||||
.flatMap(s => Option(s.getConditions))
|
||||
.exists(_.asScala.exists(cond => cond.getType == "Ready" && cond.getStatus == "True"))
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import scala.compiletime.uninitialized
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import de.nowchess.coordinator.dto.InstanceMetadata
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@ApplicationScoped
|
||||
class InstanceRegistry:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var redis: RedisDataSource = uninitialized
|
||||
private var redisPrefix = "nowchess"
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
private val mapper = ObjectMapper()
|
||||
private val instances = ConcurrentHashMap[String, InstanceMetadata]()
|
||||
|
||||
def setRedisPrefix(prefix: String): Unit =
|
||||
redisPrefix = prefix
|
||||
|
||||
def getInstance(instanceId: String): Option[InstanceMetadata] =
|
||||
Option(instances.get(instanceId))
|
||||
|
||||
def getAllInstances: List[InstanceMetadata] =
|
||||
instances.values.asScala.toList
|
||||
|
||||
def updateInstanceFromRedis(instanceId: String): Unit =
|
||||
val key = s"$redisPrefix:instances:$instanceId"
|
||||
Option(redis.value(classOf[String]).get(key)).foreach { value =>
|
||||
try
|
||||
val metadata = mapper.readValue(value, classOf[InstanceMetadata])
|
||||
instances.put(instanceId, metadata)
|
||||
catch case _: Exception => ()
|
||||
}
|
||||
|
||||
def markInstanceDead(instanceId: String): Unit =
|
||||
instances.computeIfPresent(instanceId, (_, inst) => inst.copy(state = "DEAD"))
|
||||
()
|
||||
|
||||
def removeInstance(instanceId: String): Unit =
|
||||
instances.remove(instanceId)
|
||||
()
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package de.nowchess.coordinator.service
|
||||
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import de.nowchess.coordinator.config.CoordinatorConfig
|
||||
import io.quarkus.redis.datasource.RedisDataSource
|
||||
import org.jboss.logging.Logger
|
||||
import scala.compiletime.uninitialized
|
||||
import scala.concurrent.duration.*
|
||||
import scala.jdk.CollectionConverters.*
|
||||
import de.nowchess.coordinator.grpc.CoreGrpcClient
|
||||
|
||||
@ApplicationScoped
|
||||
class LoadBalancer:
|
||||
// scalafix:off DisableSyntax.var
|
||||
@Inject
|
||||
private var config: CoordinatorConfig = uninitialized
|
||||
|
||||
@Inject
|
||||
private var instanceRegistry: InstanceRegistry = uninitialized
|
||||
|
||||
@Inject
|
||||
private var redis: RedisDataSource = uninitialized
|
||||
|
||||
@Inject
|
||||
private var coreGrpcClient: CoreGrpcClient = uninitialized
|
||||
|
||||
private val log = Logger.getLogger(classOf[LoadBalancer])
|
||||
private val lastRebalanceTime = new java.util.concurrent.atomic.AtomicLong(0L)
|
||||
private var redisPrefix = "nowchess"
|
||||
// scalafix:on DisableSyntax.var
|
||||
|
||||
def setRedisPrefix(prefix: String): Unit =
|
||||
redisPrefix = prefix
|
||||
|
||||
def shouldRebalance: Boolean =
|
||||
val now = System.currentTimeMillis()
|
||||
val minInterval = config.rebalanceMinInterval.toMillis
|
||||
if now - lastRebalanceTime.get() < minInterval then false
|
||||
else
|
||||
val instances = instanceRegistry.getAllInstances
|
||||
if instances.isEmpty then false
|
||||
else
|
||||
val loads = instances.map(_.subscriptionCount)
|
||||
val maxLoad = loads.max
|
||||
val minLoad = loads.min
|
||||
val avgLoad = loads.sum.toDouble / loads.size
|
||||
|
||||
val exceededMax = maxLoad > config.maxGamesPerCore
|
||||
val deviationPercent = 100.0 * (maxLoad - avgLoad) / avgLoad
|
||||
val exceededDeviation =
|
||||
maxLoad > avgLoad && deviationPercent > config.maxDeviationPercent && (maxLoad - minLoad) > 50
|
||||
|
||||
exceededMax || exceededDeviation
|
||||
|
||||
def rebalance: Unit =
|
||||
log.info("Starting rebalance")
|
||||
val startTime = System.currentTimeMillis()
|
||||
lastRebalanceTime.set(startTime)
|
||||
|
||||
try
|
||||
val instances = instanceRegistry.getAllInstances.filter(_.state == "HEALTHY")
|
||||
|
||||
if instances.size < 2 then log.info("Not enough healthy instances for rebalance")
|
||||
else
|
||||
val loads = instances.map(_.subscriptionCount)
|
||||
val avgLoad = loads.sum.toDouble / loads.size
|
||||
|
||||
val overloaded = instances
|
||||
.filter(_.subscriptionCount > config.maxGamesPerCore)
|
||||
.sortBy[Int](_.subscriptionCount)
|
||||
.reverse
|
||||
val underloaded = instances
|
||||
.filter(_.subscriptionCount < avgLoad * 0.8)
|
||||
.sortBy(_.subscriptionCount)
|
||||
|
||||
if underloaded.isEmpty then log.info("No underloaded instances available for rebalance")
|
||||
else
|
||||
val allBatches = overloaded.flatMap { over =>
|
||||
val excess = math.max(0, over.subscriptionCount - avgLoad.toInt)
|
||||
val gamesToMove = getGamesToMove(over.instanceId, excess)
|
||||
if gamesToMove.isEmpty then List.empty
|
||||
else
|
||||
val batchSize = math.max(1, (gamesToMove.size + underloaded.size - 1) / underloaded.size)
|
||||
gamesToMove.grouped(batchSize).toList.map((over, _))
|
||||
}
|
||||
|
||||
allBatches.zipWithIndex.foreach { case ((over, batch), idx) =>
|
||||
val target = underloaded(idx % underloaded.size)
|
||||
try
|
||||
coreGrpcClient.unsubscribeGames(over.hostname, over.grpcPort, batch)
|
||||
val subscribed = coreGrpcClient.batchResubscribeGames(target.hostname, target.grpcPort, batch)
|
||||
if subscribed > 0 then
|
||||
updateRedisGameSets(over.instanceId, target.instanceId, batch)
|
||||
log.infof("Moved %d games from %s to %s", subscribed, over.instanceId, target.instanceId)
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to move games from %s to %s", over.instanceId, target.instanceId)
|
||||
}
|
||||
|
||||
val elapsed = System.currentTimeMillis() - startTime
|
||||
log.infof("Rebalance completed in %dms", elapsed)
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Rebalance failed")
|
||||
|
||||
private def getGamesToMove(instanceId: String, count: Int): List[String] =
|
||||
try
|
||||
val setKey = s"$redisPrefix:instance:$instanceId:games"
|
||||
redis.set(classOf[String]).smembers(setKey).asScala.toList.take(count)
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.debugf(ex, "Failed to get games for %s", instanceId)
|
||||
List()
|
||||
|
||||
private def updateRedisGameSets(fromInstanceId: String, toInstanceId: String, gameIds: List[String]): Unit =
|
||||
try
|
||||
val fromKey = s"$redisPrefix:instance:$fromInstanceId:games"
|
||||
val toKey = s"$redisPrefix:instance:$toInstanceId:games"
|
||||
|
||||
gameIds.foreach { gameId =>
|
||||
redis.set(classOf[String]).srem(fromKey, gameId)
|
||||
redis.set(classOf[String]).sadd(toKey, gameId)
|
||||
}
|
||||
catch
|
||||
case ex: Exception =>
|
||||
log.warnf(ex, "Failed to update Redis game sets")
|
||||
@@ -48,7 +48,11 @@ dependencies {
|
||||
}
|
||||
|
||||
implementation(project(":modules:api"))
|
||||
implementation(project(":modules:bot"))
|
||||
implementation(project(":modules:json"))
|
||||
implementation(project(":modules:rule"))
|
||||
implementation(project(":modules:io"))
|
||||
implementation(project(":modules:official-bots"))
|
||||
implementation(project(":modules:security"))
|
||||
|
||||
|
||||
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||
@@ -56,6 +60,7 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-hibernate-orm")
|
||||
implementation("io.quarkus:quarkus-rest-client-jackson")
|
||||
implementation("io.quarkus:quarkus-rest-client")
|
||||
implementation("io.quarkus:quarkus-grpc")
|
||||
implementation("io.quarkus:quarkus-rest-jackson")
|
||||
implementation("io.quarkus:quarkus-config-yaml")
|
||||
implementation("io.quarkus:quarkus-smallrye-fault-tolerance")
|
||||
@@ -63,9 +68,10 @@ dependencies {
|
||||
implementation("io.quarkus:quarkus-smallrye-health")
|
||||
implementation("io.quarkus:quarkus-micrometer")
|
||||
implementation("io.quarkus:quarkus-arc")
|
||||
implementation("io.quarkus:quarkus-websockets-next")
|
||||
|
||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||
|
||||
implementation("io.quarkus:quarkus-redis-client")
|
||||
|
||||
testImplementation(project(":modules:io"))
|
||||
testImplementation(project(":modules:rule"))
|
||||
@@ -119,3 +125,25 @@ tasks.jar {
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
}
|
||||
|
||||
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||
if (name == "compileScoverageScala") {
|
||||
source = source.asFileTree.matching {
|
||||
exclude("**/grpc/*.scala")
|
||||
exclude("**/coordinator/*.scala")
|
||||
exclude("**/registry/RedisGameRegistry.scala")
|
||||
exclude("**/service/InstanceHeartbeatService.scala")
|
||||
exclude("**/resource/GameDtoMapper.scala")
|
||||
exclude("**/resource/GameResource.scala")
|
||||
exclude("**/redis/GameRedis*.scala")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("compileScoverageJava").configure {
|
||||
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||
}
|
||||
|
||||
tasks.compileScala {
|
||||
dependsOn(tasks.named("compileJava"))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
syntax = "proto3";
|
||||
option java_package = "de.nowchess.core.proto";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "ChessTypesProto";
|
||||
|
||||
enum ProtoColor {
|
||||
WHITE = 0;
|
||||
BLACK = 1;
|
||||
}
|
||||
|
||||
enum ProtoPieceType {
|
||||
PAWN = 0;
|
||||
KNIGHT = 1;
|
||||
BISHOP = 2;
|
||||
ROOK = 3;
|
||||
QUEEN = 4;
|
||||
KING = 5;
|
||||
}
|
||||
|
||||
enum ProtoMoveKind {
|
||||
QUIET = 0;
|
||||
CAPTURE = 1;
|
||||
CASTLE_KINGSIDE = 2;
|
||||
CASTLE_QUEENSIDE = 3;
|
||||
EN_PASSANT = 4;
|
||||
PROMO_QUEEN = 5;
|
||||
PROMO_ROOK = 6;
|
||||
PROMO_BISHOP = 7;
|
||||
PROMO_KNIGHT = 8;
|
||||
}
|
||||
|
||||
enum ProtoGameResultKind {
|
||||
ONGOING = 0;
|
||||
WIN_CHECKMATE_W = 1;
|
||||
WIN_CHECKMATE_B = 2;
|
||||
WIN_RESIGN_W = 3;
|
||||
WIN_RESIGN_B = 4;
|
||||
WIN_TIME_W = 5;
|
||||
WIN_TIME_B = 6;
|
||||
DRAW_STALEMATE = 7;
|
||||
DRAW_INSUFFICIENT = 8;
|
||||
DRAW_FIFTY_MOVE = 9;
|
||||
DRAW_THREEFOLD = 10;
|
||||
DRAW_AGREEMENT = 11;
|
||||
}
|
||||
|
||||
message ProtoPiece {
|
||||
ProtoColor color = 1;
|
||||
ProtoPieceType piece_type = 2;
|
||||
}
|
||||
|
||||
message ProtoSquarePiece {
|
||||
string square = 1;
|
||||
ProtoPiece piece = 2;
|
||||
}
|
||||
|
||||
message ProtoMove {
|
||||
string from = 1;
|
||||
string to = 2;
|
||||
ProtoMoveKind move_kind = 3;
|
||||
}
|
||||
|
||||
message ProtoCastlingRights {
|
||||
bool white_king_side = 1;
|
||||
bool white_queen_side = 2;
|
||||
bool black_king_side = 3;
|
||||
bool black_queen_side = 4;
|
||||
}
|
||||
|
||||
message ProtoGameContext {
|
||||
repeated ProtoSquarePiece board = 1;
|
||||
ProtoColor turn = 2;
|
||||
ProtoCastlingRights castling_rights = 3;
|
||||
string en_passant_square = 4;
|
||||
int32 half_move_clock = 5;
|
||||
repeated ProtoMove moves = 6;
|
||||
ProtoGameResultKind result = 7;
|
||||
repeated ProtoSquarePiece initial_board = 8;
|
||||
}
|
||||
|
||||
message ProtoPostMoveStatus {
|
||||
bool is_checkmate = 1;
|
||||
bool is_stalemate = 2;
|
||||
bool is_insufficient_material = 3;
|
||||
bool is_check = 4;
|
||||
bool is_threefold_repetition = 5;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
syntax = "proto3";
|
||||
option java_package = "de.nowchess.coordinator.proto";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "CoordinatorServiceProto";
|
||||
|
||||
service CoordinatorService {
|
||||
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
|
||||
rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
|
||||
rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
|
||||
rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
|
||||
rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
|
||||
}
|
||||
|
||||
message HeartbeatFrame {
|
||||
string instanceId = 1;
|
||||
string hostname = 2;
|
||||
int32 httpPort = 3;
|
||||
int32 grpcPort = 4;
|
||||
int32 subscriptionCount = 5;
|
||||
int32 localCacheSize = 6;
|
||||
int64 timestampMillis = 7;
|
||||
}
|
||||
|
||||
message CoordinatorCommand {
|
||||
string type = 1;
|
||||
string payload = 2;
|
||||
}
|
||||
|
||||
message BatchResubscribeRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message BatchResubscribeResponse {
|
||||
int32 subscribedCount = 1;
|
||||
repeated string failedGameIds = 2;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message UnsubscribeGamesResponse {
|
||||
int32 unsubscribedCount = 1;
|
||||
}
|
||||
|
||||
message EvictGamesRequest {
|
||||
repeated string gameIds = 1;
|
||||
}
|
||||
|
||||
message EvictGamesResponse {
|
||||
int32 evictedCount = 1;
|
||||
}
|
||||
|
||||
message DrainInstanceRequest {}
|
||||
|
||||
message DrainInstanceResponse {
|
||||
int32 gamesMigrated = 1;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
syntax = "proto3";
|
||||
option java_package = "de.nowchess.core.proto";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "IoServiceProto";
|
||||
|
||||
import "chess_types.proto";
|
||||
|
||||
message ProtoImportFenRequest {
|
||||
string fen = 1;
|
||||
}
|
||||
|
||||
message ProtoImportPgnRequest {
|
||||
string pgn = 1;
|
||||
}
|
||||
|
||||
message ProtoCombinedExport {
|
||||
string fen = 1;
|
||||
string pgn = 2;
|
||||
}
|
||||
|
||||
message ProtoStringResult {
|
||||
string value = 1;
|
||||
}
|
||||
|
||||
service IoService {
|
||||
rpc ImportFen (ProtoImportFenRequest) returns (ProtoGameContext);
|
||||
rpc ImportPgn (ProtoImportPgnRequest) returns (ProtoGameContext);
|
||||
rpc ExportCombined (ProtoGameContext) returns (ProtoCombinedExport);
|
||||
rpc ExportFen (ProtoGameContext) returns (ProtoStringResult);
|
||||
rpc ExportPgn (ProtoGameContext) returns (ProtoStringResult);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
syntax = "proto3";
|
||||
option java_package = "de.nowchess.core.proto";
|
||||
option java_multiple_files = true;
|
||||
option java_outer_classname = "RuleServiceProto";
|
||||
|
||||
import "chess_types.proto";
|
||||
|
||||
message ProtoSquareRequest {
|
||||
ProtoGameContext context = 1;
|
||||
string square = 2;
|
||||
}
|
||||
|
||||
message ProtoMoveRequest {
|
||||
ProtoGameContext context = 1;
|
||||
ProtoMove move = 2;
|
||||
}
|
||||
|
||||
message ProtoMoveList {
|
||||
repeated ProtoMove moves = 1;
|
||||
}
|
||||
|
||||
message ProtoBoolResult {
|
||||
bool value = 1;
|
||||
}
|
||||
|
||||
service RuleService {
|
||||
rpc CandidateMoves (ProtoSquareRequest) returns (ProtoMoveList);
|
||||
rpc LegalMoves (ProtoSquareRequest) returns (ProtoMoveList);
|
||||
rpc AllLegalMoves (ProtoGameContext) returns (ProtoMoveList);
|
||||
rpc IsCheck (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc IsCheckmate (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc IsStalemate (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc IsInsufficientMaterial (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc IsFiftyMoveRule (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc IsThreefoldRepetition (ProtoGameContext) returns (ProtoBoolResult);
|
||||
rpc ApplyMove (ProtoMoveRequest) returns (ProtoGameContext);
|
||||
rpc PostMoveStatus (ProtoGameContext) returns (ProtoPostMoveStatus);
|
||||
}
|
||||
@@ -3,8 +3,111 @@ quarkus:
|
||||
port: 8080
|
||||
application:
|
||||
name: nowchess-core
|
||||
rest-client:
|
||||
io-service:
|
||||
url: http://localhost:8081
|
||||
rule-service:
|
||||
url: http://localhost:8082
|
||||
redis:
|
||||
hosts: redis://${REDIS_HOST:localhost}:${REDIS_PORT:6379}
|
||||
grpc:
|
||||
clients:
|
||||
rule-grpc:
|
||||
host: localhost
|
||||
port: 8082
|
||||
io-grpc:
|
||||
host: localhost
|
||||
port: 8081
|
||||
coordinator-grpc:
|
||||
host: localhost
|
||||
port: 9086
|
||||
server:
|
||||
use-separate-server: false
|
||||
|
||||
nowchess:
|
||||
redis:
|
||||
host: localhost
|
||||
port: 6379
|
||||
prefix: nowchess
|
||||
|
||||
internal:
|
||||
secret: 123abc
|
||||
|
||||
coordinator:
|
||||
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
|
||||
host: localhost
|
||||
grpc-port: 9086
|
||||
stream-heartbeat-interval: 200ms
|
||||
redis-heartbeat-interval: 2s
|
||||
instance-id: ${HOSTNAME:local}-${quarkus.uuid}
|
||||
|
||||
"%dev":
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: keys/public.pem
|
||||
issuer: nowchess
|
||||
quarkus:
|
||||
http:
|
||||
cors:
|
||||
~: true
|
||||
origins: http://localhost:4200
|
||||
methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
headers: Content-Type,Accept,Authorization
|
||||
grpc:
|
||||
clients:
|
||||
rule-grpc:
|
||||
host: localhost
|
||||
port: 8082
|
||||
io-grpc:
|
||||
host: localhost
|
||||
port: 8081
|
||||
rest-client:
|
||||
io-service:
|
||||
url: http://localhost:8081
|
||||
rule-service:
|
||||
url: http://localhost:8082
|
||||
store-service:
|
||||
url: http://localhost:8085
|
||||
|
||||
"%deployed":
|
||||
mp:
|
||||
jwt:
|
||||
verify:
|
||||
publickey:
|
||||
location: ${JWT_PUBLIC_KEY_PATH:keys/public.pem}
|
||||
issuer: nowchess
|
||||
quarkus:
|
||||
http:
|
||||
cors:
|
||||
~: true
|
||||
origins: ${CORS_ORIGINS}
|
||||
methods: GET,POST,PUT,DELETE,OPTIONS
|
||||
headers: Content-Type,Accept,Authorization
|
||||
grpc:
|
||||
clients:
|
||||
rule-grpc:
|
||||
host: ${RULE_SERVICE_HOST}
|
||||
port: ${RULE_SERVICE_GRPC_PORT:9082}
|
||||
io-grpc:
|
||||
host: ${IO_SERVICE_HOST}
|
||||
port: ${IO_SERVICE_GRPC_PORT:9081}
|
||||
coordinator-grpc:
|
||||
host: ${COORDINATOR_SERVICE_HOST:localhost}
|
||||
port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
|
||||
rest-client:
|
||||
io-service:
|
||||
url: ${IO_SERVICE_URL}
|
||||
rule-service:
|
||||
url: ${RULE_SERVICE_URL}
|
||||
store-service:
|
||||
url: ${STORE_SERVICE_URL}
|
||||
nowchess:
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
prefix: ${REDIS_PREFIX:nowchess}
|
||||
|
||||
coordinator:
|
||||
enabled: ${NOWCHESS_COORDINATOR_ENABLED:true}
|
||||
host: ${COORDINATOR_SERVICE_HOST:localhost}
|
||||
grpc-port: ${COORDINATOR_SERVICE_GRPC_PORT:9086}
|
||||
stream-heartbeat-interval: 200ms
|
||||
redis-heartbeat-interval: 2s
|
||||
instance-id: ${HOSTNAME:local}-${quarkus.uuid}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxDsnsCAl0vQx7Vu9CLDZ
|
||||
g0SG05NgUzu9T+3DTEaHGq60T2uriO8BenwyvsF3BnDqTbKf4voohZ1DNfzdbT1J
|
||||
Fj8B62FrDmxcO+sp1/b5HUCJP6y2uSRCmzOHe5k7Pk1IEi72FgBpKXSRkFibRlVf
|
||||
634g7mgsPZAQ9PJEsv4Qvm05T9L6+Gmq6N3bMVLKRXs4RhDhaFbYH9GtUg1eI0yH
|
||||
YjGyRfqzW/nqVMstOLHt8CuPouq4p7eMzeDH3YHkxPm4GG5foCXMOd2DZrW0SCcr
|
||||
7dhFeNVWzQ2m53eOhBzNQX+v3pgjVStsePhBRt2LyGfwkNzmqDgqWsMzSHRMY+cn
|
||||
WQIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -4,7 +4,7 @@ import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.chess.client.{RuleMoveRequest, RuleServiceClient, RuleSquareRequest}
|
||||
import de.nowchess.api.rules.RuleSet
|
||||
import de.nowchess.api.rules.{PostMoveStatus, RuleSet}
|
||||
import jakarta.enterprise.context.ApplicationScoped
|
||||
import jakarta.inject.Inject
|
||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||
@@ -49,3 +49,6 @@ class RuleSetRestAdapter extends RuleSet:
|
||||
|
||||
def applyMove(ctx: GameContext)(move: Move): GameContext =
|
||||
client.applyMove(RuleMoveRequest(ctx, move))
|
||||
|
||||
override def postMoveStatus(ctx: GameContext): PostMoveStatus =
|
||||
client.postMoveStatus(ctx)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package de.nowchess.chess.client
|
||||
|
||||
case class GameRecordDto(
|
||||
gameId: String,
|
||||
fen: String,
|
||||
pgn: String,
|
||||
moveCount: Int,
|
||||
whiteId: String,
|
||||
whiteName: String,
|
||||
blackId: String,
|
||||
blackName: String,
|
||||
mode: String,
|
||||
resigned: Boolean,
|
||||
limitSeconds: java.lang.Integer,
|
||||
incrementSeconds: java.lang.Integer,
|
||||
daysPerMove: java.lang.Integer,
|
||||
whiteRemainingMs: java.lang.Long,
|
||||
blackRemainingMs: java.lang.Long,
|
||||
incrementMs: java.lang.Long,
|
||||
clockLastTickAt: java.lang.Long,
|
||||
clockMoveDeadline: java.lang.Long,
|
||||
clockActiveColor: String,
|
||||
pendingDrawOffer: String,
|
||||
)
|
||||
@@ -2,12 +2,17 @@ package de.nowchess.chess.client
|
||||
|
||||
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class CombinedExportResponse(fen: String, pgn: String)
|
||||
|
||||
@Path("/io")
|
||||
@RegisterRestClient(configKey = "io-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait IoServiceClient:
|
||||
|
||||
@POST
|
||||
@@ -33,3 +38,9 @@ trait IoServiceClient:
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array("application/x-chess-pgn"))
|
||||
def exportPgn(ctx: GameContext): String
|
||||
|
||||
@POST
|
||||
@Path("/export/combined")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def exportCombined(ctx: GameContext): CombinedExportResponse
|
||||
|
||||
@@ -2,8 +2,11 @@ package de.nowchess.chess.client
|
||||
|
||||
import de.nowchess.api.game.GameContext
|
||||
import de.nowchess.api.move.Move
|
||||
import de.nowchess.api.rules.PostMoveStatus
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
case class RuleSquareRequest(context: GameContext, square: String)
|
||||
@@ -11,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
|
||||
|
||||
@Path("/api/rules")
|
||||
@RegisterRestClient(configKey = "rule-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
trait RuleServiceClient:
|
||||
|
||||
@POST
|
||||
@@ -72,3 +76,9 @@ trait RuleServiceClient:
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def applyMove(req: RuleMoveRequest): GameContext
|
||||
|
||||
@POST
|
||||
@Path("/post-move-status")
|
||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def postMoveStatus(ctx: GameContext): PostMoveStatus
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.nowchess.chess.client
|
||||
|
||||
import de.nowchess.security.InternalSecretClientFilter
|
||||
import jakarta.ws.rs.*
|
||||
import jakarta.ws.rs.core.MediaType
|
||||
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
|
||||
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||
|
||||
@RegisterRestClient(configKey = "store-service")
|
||||
@RegisterProvider(classOf[InternalSecretClientFilter])
|
||||
@Path("/game")
|
||||
trait StoreServiceClient:
|
||||
@GET
|
||||
@Path("/{gameId}")
|
||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||
def getGame(@PathParam("gameId") gameId: String): GameRecordDto
|
||||
@@ -1,60 +0,0 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
import de.nowchess.api.board.{Piece, Square}
|
||||
import de.nowchess.api.game.GameContext
|
||||
|
||||
/** Marker trait for all commands that can be executed and undone. Commands encapsulate user actions and game state
|
||||
* transitions.
|
||||
*/
|
||||
trait Command:
|
||||
/** Execute the command and return true if successful, false otherwise. */
|
||||
def execute(): Boolean
|
||||
|
||||
/** Undo the command and return true if successful, false otherwise. */
|
||||
def undo(): Boolean
|
||||
|
||||
/** A human-readable description of this command. */
|
||||
def description: String
|
||||
|
||||
/** Command to move a piece from one square to another. Stores the move result so undo can restore previous state.
|
||||
*/
|
||||
case class MoveCommand(
|
||||
from: Square,
|
||||
to: Square,
|
||||
moveResult: Option[MoveResult] = None,
|
||||
previousContext: Option[GameContext] = None,
|
||||
notation: String = "",
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean =
|
||||
moveResult.isDefined
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousContext.isDefined
|
||||
|
||||
override def description: String = s"Move from $from to $to"
|
||||
|
||||
// Sealed hierarchy of move outcomes (for tracking state changes)
|
||||
sealed trait MoveResult
|
||||
object MoveResult:
|
||||
case class Successful(newContext: GameContext, captured: Option[Piece]) extends MoveResult
|
||||
case object InvalidFormat extends MoveResult
|
||||
case object InvalidMove extends MoveResult
|
||||
|
||||
/** Command to quit the game. */
|
||||
case class QuitCommand() extends Command:
|
||||
override def execute(): Boolean = true
|
||||
override def undo(): Boolean = false
|
||||
override def description: String = "Quit game"
|
||||
|
||||
/** Command to reset the board to initial position. */
|
||||
case class ResetCommand(
|
||||
previousContext: Option[GameContext] = None,
|
||||
) extends Command:
|
||||
|
||||
override def execute(): Boolean = true
|
||||
|
||||
override def undo(): Boolean =
|
||||
previousContext.isDefined
|
||||
|
||||
override def description: String = "Reset board"
|
||||
@@ -1,67 +0,0 @@
|
||||
package de.nowchess.chess.command
|
||||
|
||||
/** Manages command execution and history for undo/redo support. */
|
||||
class CommandInvoker:
|
||||
private val executedCommands = scala.collection.mutable.ListBuffer[Command]()
|
||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||
private var currentIndex = -1
|
||||
|
||||
/** Execute a command and add it to history. Discards any redo history if not at the end of the stack.
|
||||
*/
|
||||
def execute(command: Command): Boolean = synchronized {
|
||||
if command.execute() then
|
||||
// Remove any commands after current index (redo stack is discarded)
|
||||
while currentIndex < executedCommands.size - 1 do executedCommands.remove(executedCommands.size - 1)
|
||||
executedCommands += command
|
||||
currentIndex += 1
|
||||
true
|
||||
else false
|
||||
}
|
||||
|
||||
/** Undo the last executed command if possible. */
|
||||
def undo(): Boolean = synchronized {
|
||||
if currentIndex >= 0 && currentIndex < executedCommands.size then
|
||||
val command = executedCommands(currentIndex)
|
||||
if command.undo() then
|
||||
currentIndex -= 1
|
||||
true
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Redo the next command in history if available. */
|
||||
def redo(): Boolean = synchronized {
|
||||
if currentIndex + 1 < executedCommands.size then
|
||||
val command = executedCommands(currentIndex + 1)
|
||||
if command.execute() then
|
||||
currentIndex += 1
|
||||
true
|
||||
else false
|
||||
else false
|
||||
}
|
||||
|
||||
/** Get the history of all executed commands. */
|
||||
def history: List[Command] = synchronized {
|
||||
executedCommands.toList
|
||||
}
|
||||
|
||||
/** Get the current position in command history. */
|
||||
def getCurrentIndex: Int = synchronized {
|
||||
currentIndex
|
||||
}
|
||||
|
||||
/** Clear all command history. */
|
||||
def clear(): Unit = synchronized {
|
||||
executedCommands.clear()
|
||||
currentIndex = -1
|
||||
}
|
||||
|
||||
/** Check if undo is available. */
|
||||
def canUndo: Boolean = synchronized {
|
||||
currentIndex >= 0
|
||||
}
|
||||
|
||||
/** Check if redo is available. */
|
||||
def canRedo: Boolean = synchronized {
|
||||
currentIndex + 1 < executedCommands.size
|
||||
}
|
||||
@@ -2,11 +2,8 @@ package de.nowchess.chess.config
|
||||
|
||||
import com.fasterxml.jackson.core.Version
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule
|
||||
import com.fasterxml.jackson.module.scala.DefaultScalaModule
|
||||
import de.nowchess.api.board.Square
|
||||
import de.nowchess.api.move.MoveType
|
||||
import de.nowchess.chess.json.{MoveTypeDeserializer, MoveTypeSerializer, SquareDeserializer, SquareSerializer}
|
||||
import de.nowchess.json.ChessJacksonModule
|
||||
import io.quarkus.jackson.ObjectMapperCustomizer
|
||||
import jakarta.inject.Singleton
|
||||
|
||||
@@ -19,11 +16,4 @@ class JacksonConfig extends ObjectMapperCustomizer:
|
||||
new Version(2, 21, 1, null, "com.fasterxml.jackson.module", "jackson-module-scala")
|
||||
// scalafix:on DisableSyntax.null
|
||||
})
|
||||
val mod = new SimpleModule()
|
||||
mod.addKeySerializer(classOf[Square], new SquareKeySerializer())
|
||||
mod.addKeyDeserializer(classOf[Square], new SquareKeyDeserializer())
|
||||
mod.addSerializer(classOf[Square], new SquareSerializer())
|
||||
mod.addDeserializer(classOf[Square], new SquareDeserializer())
|
||||
mod.addSerializer(classOf[MoveType], new MoveTypeSerializer())
|
||||
mod.addDeserializer(classOf[MoveType], new MoveTypeDeserializer())
|
||||
mapper.registerModule(mod)
|
||||
mapper.registerModule(new ChessJacksonModule())
|
||||
|
||||
@@ -2,13 +2,14 @@ package de.nowchess.chess.config
|
||||
|
||||
import de.nowchess.api.board.{CastlingRights, Color, File, Piece, PieceType, Rank, Square}
|
||||
import de.nowchess.api.dto.*
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameResult}
|
||||
import de.nowchess.api.game.{DrawReason, GameContext, GameMode, GameResult}
|
||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||
import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
|
||||
@RegisterForReflection(
|
||||
targets = Array(
|
||||
classOf[ApiErrorDto],
|
||||
classOf[ClockDto],
|
||||
classOf[CreateGameRequestDto],
|
||||
classOf[ErrorEventDto],
|
||||
classOf[GameFullDto],
|
||||
@@ -21,6 +22,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[LegalMovesResponseDto],
|
||||
classOf[OkResponseDto],
|
||||
classOf[PlayerInfoDto],
|
||||
classOf[TimeControlDto],
|
||||
classOf[GameContext],
|
||||
classOf[Color],
|
||||
classOf[Piece],
|
||||
@@ -34,6 +36,7 @@ import io.quarkus.runtime.annotations.RegisterForReflection
|
||||
classOf[PromotionPiece],
|
||||
classOf[GameResult],
|
||||
classOf[DrawReason],
|
||||
classOf[GameMode],
|
||||
),
|
||||
)
|
||||
class NativeReflectionConfig
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package de.nowchess.chess.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.prefix", defaultValue = "nowchess")
|
||||
var prefix: String = uninitialized
|
||||
// scalafix:on DisableSyntax.var
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user