feat(redis): implement Redis integration for game state management and websocket communication
This commit is contained in:
Generated
+1
@@ -17,6 +17,7 @@
|
|||||||
<option value="$PROJECT_DIR$/modules/io" />
|
<option value="$PROJECT_DIR$/modules/io" />
|
||||||
<option value="$PROJECT_DIR$/modules/json" />
|
<option value="$PROJECT_DIR$/modules/json" />
|
||||||
<option value="$PROJECT_DIR$/modules/rule" />
|
<option value="$PROJECT_DIR$/modules/rule" />
|
||||||
|
<option value="$PROJECT_DIR$/modules/ws" />
|
||||||
</set>
|
</set>
|
||||||
</option>
|
</option>
|
||||||
</GradleProjectSettings>
|
</GradleProjectSettings>
|
||||||
|
|||||||
Generated
+1
-1
@@ -5,7 +5,7 @@
|
|||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
</profile>
|
</profile>
|
||||||
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test">
|
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
|
||||||
<option name="deprecationWarnings" value="true" />
|
<option name="deprecationWarnings" value="true" />
|
||||||
<option name="uncheckedWarnings" value="true" />
|
<option name="uncheckedWarnings" value="true" />
|
||||||
<parameters>
|
<parameters>
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ get {
|
|||||||
}
|
}
|
||||||
|
|
||||||
vars:pre-request {
|
vars:pre-request {
|
||||||
gameId: Yg200tOF
|
gameId: j0nPtcjl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,24 @@ ws {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body:ws {
|
body:ws {
|
||||||
name: message 1
|
name: move
|
||||||
content: '''
|
content: '''
|
||||||
{}
|
{
|
||||||
|
"type": "MOVE",
|
||||||
|
"uci": "b1c3"
|
||||||
|
}
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
body:ws {
|
||||||
|
name: ping
|
||||||
|
content: '''
|
||||||
|
{
|
||||||
|
"type": "PING"
|
||||||
|
}
|
||||||
'''
|
'''
|
||||||
}
|
}
|
||||||
|
|
||||||
vars:pre-request {
|
vars:pre-request {
|
||||||
gameId: uWm99efJ
|
gameId: j0nPtcjl
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
vars {
|
vars {
|
||||||
baseUrl: http://localhost:8080
|
baseUrl: http://localhost:8080
|
||||||
wsBaseUrl: ws://localhost:8080
|
wsBaseUrl: ws://localhost:8084
|
||||||
ioBaseUrl: http://localhost:8081
|
ioBaseUrl: http://localhost:8081
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -46,7 +46,11 @@ val coverageExclusions = listOf(
|
|||||||
// AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage
|
// AccountResource / ChallengeResource — REST integration layer; @QuarkusTest not instrumented by Scoverage
|
||||||
"**/account/src/main/scala/de/nowchess/account/resource/**",
|
"**/account/src/main/scala/de/nowchess/account/resource/**",
|
||||||
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
|
// JacksonConfig / NativeReflectionConfig — Quarkus lifecycle hooks, no testable logic
|
||||||
"**/account/src/main/scala/de/nowchess/account/config/**"
|
"**/account/src/main/scala/de/nowchess/account/config/**",
|
||||||
|
// WebSocket service — infrastructure CDI beans (RedisConfig, RedissonProducer)
|
||||||
|
"**/ws/src/main/scala/de/nowchess/ws/config/**",
|
||||||
|
// GameWebSocketResource in core — replaced by ws module
|
||||||
|
"**/core/src/main/scala/de/nowchess/chess/resource/GameWebSocketResource.scala",
|
||||||
)
|
)
|
||||||
|
|
||||||
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
// Converts a Sonar-style glob to a scoverage regex (matched against full source path).
|
||||||
@@ -93,7 +97,8 @@ val versions = mapOf(
|
|||||||
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
"SCALA_PARSER_COMBINATORS" to "2.4.0",
|
||||||
"FASTPARSE" to "3.0.2",
|
"FASTPARSE" to "3.0.2",
|
||||||
"JACKSON" to "2.17.2",
|
"JACKSON" to "2.17.2",
|
||||||
"JACKSON_SCALA" to "2.17.2"
|
"JACKSON_SCALA" to "2.17.2",
|
||||||
|
"REDISSON" to "3.32.0"
|
||||||
)
|
)
|
||||||
extra["VERSIONS"] = versions
|
extra["VERSIONS"] = versions
|
||||||
|
|
||||||
|
|||||||
@@ -30,8 +30,10 @@ class AlreadyLoggedInFilter extends ContainerRequestFilter:
|
|||||||
)
|
)
|
||||||
|
|
||||||
private def isAuthenticated: Boolean =
|
private def isAuthenticated: Boolean =
|
||||||
|
// scalafix:off DisableSyntax.null
|
||||||
try jwt.getName != null
|
try jwt.getName != null
|
||||||
catch case _ => false
|
catch case _ => false
|
||||||
|
// scalafix:on DisableSyntax.null
|
||||||
|
|
||||||
private def isProtectedEndpoint(path: String, method: String): Boolean =
|
private def isProtectedEndpoint(path: String, method: String): Boolean =
|
||||||
(path.contains("/api/account") || path.contains("/account")) &&
|
(path.contains("/api/account") || path.contains("/account")) &&
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ final case class GameStateDto(
|
|||||||
undoAvailable: Boolean,
|
undoAvailable: Boolean,
|
||||||
redoAvailable: Boolean,
|
redoAvailable: Boolean,
|
||||||
clock: Option[ClockDto],
|
clock: Option[ClockDto],
|
||||||
|
takebackRequestedBy: Option[String] = None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ dependencies {
|
|||||||
implementation("io.quarkus:quarkus-websockets-next")
|
implementation("io.quarkus:quarkus-websockets-next")
|
||||||
|
|
||||||
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
|
||||||
|
|
||||||
testImplementation(project(":modules:io"))
|
testImplementation(project(":modules:io"))
|
||||||
testImplementation(project(":modules:rule"))
|
testImplementation(project(":modules:rule"))
|
||||||
@@ -124,3 +124,18 @@ tasks.jar {
|
|||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||||
|
if (name == "compileScoverageScala") {
|
||||||
|
source = source.asFileTree.matching {
|
||||||
|
exclude("**/grpc/*.scala")
|
||||||
|
exclude("**/resource/GameDtoMapper.scala")
|
||||||
|
exclude("**/resource/GameResource.scala")
|
||||||
|
exclude("**/redis/GameRedis*.scala")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("compileScoverageJava").configure {
|
||||||
|
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ quarkus:
|
|||||||
server:
|
server:
|
||||||
use-separate-server: false
|
use-separate-server: false
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: nowchess
|
||||||
|
|
||||||
"%dev":
|
"%dev":
|
||||||
mp:
|
mp:
|
||||||
jwt:
|
jwt:
|
||||||
@@ -41,6 +47,8 @@ quarkus:
|
|||||||
url: http://localhost:8081
|
url: http://localhost:8081
|
||||||
rule-service:
|
rule-service:
|
||||||
url: http://localhost:8082
|
url: http://localhost:8082
|
||||||
|
store-service:
|
||||||
|
url: http://localhost:8085
|
||||||
|
|
||||||
"%deployed":
|
"%deployed":
|
||||||
mp:
|
mp:
|
||||||
@@ -69,3 +77,10 @@ quarkus:
|
|||||||
url: ${IO_SERVICE_URL}
|
url: ${IO_SERVICE_URL}
|
||||||
rule-service:
|
rule-service:
|
||||||
url: ${RULE_SERVICE_URL}
|
url: ${RULE_SERVICE_URL}
|
||||||
|
store-service:
|
||||||
|
url: ${STORE_SERVICE_URL}
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package de.nowchess.chess.client
|
||||||
|
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.MediaType
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
|
||||||
|
|
||||||
|
@RegisterRestClient(configKey = "store-service")
|
||||||
|
@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
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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.host", defaultValue = "localhost")
|
||||||
|
var host: String = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
|
||||||
|
var port: Int = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||||
|
var prefix: String = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.nowchess.chess.config
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.inject.Produces
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.Redisson
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.config.Config
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedissonProducer:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject
|
||||||
|
var redisConfig: RedisConfig = uninitialized
|
||||||
|
|
||||||
|
private var clientOpt: Option[RedissonClient] = None
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
@ApplicationScoped
|
||||||
|
def produceRedissonClient(): RedissonClient =
|
||||||
|
val config = new Config()
|
||||||
|
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
||||||
|
config.useSingleServer().setConnectionMinimumIdleSize(1)
|
||||||
|
config.useSingleServer().setConnectTimeout(500)
|
||||||
|
val client = Redisson.create(config)
|
||||||
|
clientOpt = Some(client)
|
||||||
|
client
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
def shutdown(): Unit =
|
||||||
|
clientOpt.foreach(_.shutdown())
|
||||||
@@ -18,7 +18,6 @@ import de.nowchess.api.game.{
|
|||||||
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
import de.nowchess.chess.controller.Parser
|
import de.nowchess.chess.controller.Parser
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
import de.nowchess.chess.command.{CommandInvoker, MoveCommand, MoveResult}
|
|
||||||
import de.nowchess.api.error.GameError
|
import de.nowchess.api.error.GameError
|
||||||
import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
|
import de.nowchess.api.game.WinReason.{Checkmate, Resignation}
|
||||||
import de.nowchess.api.io.{GameContextExport, GameContextImport}
|
import de.nowchess.api.io.{GameContextExport, GameContextImport}
|
||||||
@@ -39,6 +38,10 @@ class GameEngine(
|
|||||||
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
|
Color.Black -> Human(PlayerInfo(PlayerId("p2"), "Player 2")),
|
||||||
),
|
),
|
||||||
val timeControl: TimeControl = TimeControl.Unlimited,
|
val timeControl: TimeControl = TimeControl.Unlimited,
|
||||||
|
initialClockState: Option[ClockState] = None,
|
||||||
|
initialDrawOffer: Option[Color] = None,
|
||||||
|
initialRedoStack: List[Move] = Nil,
|
||||||
|
initialTakebackRequest: Option[Color] = None,
|
||||||
) extends Observable:
|
) extends Observable:
|
||||||
// Ensure that initialBoard is set correctly for threefold repetition detection
|
// Ensure that initialBoard is set correctly for threefold repetition detection
|
||||||
private val contextWithInitialBoard =
|
private val contextWithInitialBoard =
|
||||||
@@ -48,15 +51,20 @@ class GameEngine(
|
|||||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var currentContext: GameContext = contextWithInitialBoard
|
private var currentContext: GameContext = contextWithInitialBoard
|
||||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var pendingDrawOffer: Option[Color] = None
|
private var pendingDrawOffer: Option[Color] = initialDrawOffer
|
||||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var clockState: Option[ClockState] =
|
private var clockState: Option[ClockState] =
|
||||||
ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now())
|
initialClockState.orElse(ClockState.fromTimeControl(timeControl, contextWithInitialBoard.turn, Instant.now()))
|
||||||
@SuppressWarnings(Array("DisableSyntax.var"))
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
private var scheduledCheck: Option[ScheduledFuture[?]] = None
|
private var scheduledCheck: Option[ScheduledFuture[?]] = None
|
||||||
// One shared scheduler per engine; shut down with the game.
|
// One shared scheduler per engine; shut down with the game.
|
||||||
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
|
||||||
private val invoker = new CommandInvoker()
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
|
private var redoStack: List[Move] = initialRedoStack
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
|
private var isRedoing: Boolean = false
|
||||||
|
@SuppressWarnings(Array("DisableSyntax.var"))
|
||||||
|
private var pendingTakebackRequest: Option[Color] = initialTakebackRequest
|
||||||
|
|
||||||
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
|
// Start scheduler immediately for live clocks so passive expiry fires without waiting for a move.
|
||||||
clockState.foreach(scheduleExpiryCheck)
|
clockState.foreach(scheduleExpiryCheck)
|
||||||
@@ -71,13 +79,16 @@ class GameEngine(
|
|||||||
def currentClockState: Option[ClockState] = synchronized(clockState)
|
def currentClockState: Option[ClockState] = synchronized(clockState)
|
||||||
|
|
||||||
/** Check if undo is available. */
|
/** Check if undo is available. */
|
||||||
def canUndo: Boolean = synchronized(invoker.canUndo)
|
def canUndo: Boolean = synchronized(currentContext.moves.nonEmpty)
|
||||||
|
|
||||||
/** Check if redo is available. */
|
/** Check if redo is available. */
|
||||||
def canRedo: Boolean = synchronized(invoker.canRedo)
|
def canRedo: Boolean = synchronized(redoStack.nonEmpty)
|
||||||
|
|
||||||
/** Get the command history for inspection (testing/debugging). */
|
/** Get redo stack moves for inspection. */
|
||||||
def commandHistory: List[de.nowchess.chess.command.Command] = synchronized(invoker.history)
|
def redoStackMoves: List[Move] = synchronized(redoStack)
|
||||||
|
|
||||||
|
/** Get pending takeback request (if any). */
|
||||||
|
def pendingTakebackRequestBy: Option[Color] = synchronized(pendingTakebackRequest)
|
||||||
|
|
||||||
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
/** Process a raw move input string and update game state if valid. Notifies all observers of the outcome via
|
||||||
* GameEvent.
|
* GameEvent.
|
||||||
@@ -162,8 +173,9 @@ class GameEngine(
|
|||||||
else
|
else
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
|
currentContext = currentContext.withResult(Some(GameResult.Win(color.opposite, Resignation)))
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
|
pendingTakebackRequest = None
|
||||||
stopClock()
|
stopClock()
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
notifyObservers(ResignEvent(currentContext, color))
|
notifyObservers(ResignEvent(currentContext, color))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,8 +205,9 @@ class GameEngine(
|
|||||||
case Some(_) =>
|
case Some(_) =>
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Agreement)))
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
|
pendingTakebackRequest = None
|
||||||
stopClock()
|
stopClock()
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
|
notifyObservers(DrawEvent(currentContext, DrawReason.Agreement))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,12 +233,12 @@ class GameEngine(
|
|||||||
else if currentContext.halfMoveClock >= 100 then
|
else if currentContext.halfMoveClock >= 100 then
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.FiftyMoveRule)))
|
||||||
stopClock()
|
stopClock()
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
notifyObservers(DrawEvent(currentContext, DrawReason.FiftyMoveRule))
|
||||||
else if ruleSet.isThreefoldRepetition(currentContext) then
|
else if ruleSet.isThreefoldRepetition(currentContext) then
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.ThreefoldRepetition)))
|
||||||
stopClock()
|
stopClock()
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
notifyObservers(DrawEvent(currentContext, DrawReason.ThreefoldRepetition))
|
||||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.DrawCannotBeClaimed))
|
||||||
}
|
}
|
||||||
@@ -239,6 +252,8 @@ class GameEngine(
|
|||||||
case Right(ctx) =>
|
case Right(ctx) =>
|
||||||
replayGame(ctx).map { _ =>
|
replayGame(ctx).map { _ =>
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
redoStack = Nil
|
||||||
stopClock()
|
stopClock()
|
||||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||||
notifyObservers(PgnLoadedEvent(currentContext))
|
notifyObservers(PgnLoadedEvent(currentContext))
|
||||||
@@ -248,7 +263,7 @@ class GameEngine(
|
|||||||
private def replayGame(ctx: GameContext): Either[GameError, Unit] =
|
private def replayGame(ctx: GameContext): Either[GameError, Unit] =
|
||||||
val savedContext = currentContext
|
val savedContext = currentContext
|
||||||
currentContext = GameContext.initial
|
currentContext = GameContext.initial
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
|
|
||||||
if ctx.moves.isEmpty then
|
if ctx.moves.isEmpty then
|
||||||
currentContext = ctx.copy(initialBoard = ctx.board)
|
currentContext = ctx.copy(initialBoard = ctx.board)
|
||||||
@@ -283,9 +298,10 @@ class GameEngine(
|
|||||||
else newContext
|
else newContext
|
||||||
currentContext = contextWithInitialBoard
|
currentContext = contextWithInitialBoard
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
redoStack = Nil
|
||||||
stopClock()
|
stopClock()
|
||||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||||
invoker.clear()
|
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,9 +309,10 @@ class GameEngine(
|
|||||||
def reset(): Unit = synchronized {
|
def reset(): Unit = synchronized {
|
||||||
currentContext = GameContext.initial
|
currentContext = GameContext.initial
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
redoStack = Nil
|
||||||
stopClock()
|
stopClock()
|
||||||
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
clockState = ClockState.fromTimeControl(timeControl, currentContext.turn, Instant.now())
|
||||||
invoker.clear()
|
|
||||||
notifyObservers(BoardResetEvent(currentContext))
|
notifyObservers(BoardResetEvent(currentContext))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,7 +321,7 @@ class GameEngine(
|
|||||||
if currentContext.result.isEmpty then
|
if currentContext.result.isEmpty then
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(reason)))
|
||||||
stopClock()
|
stopClock()
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
notifyObservers(DrawEvent(currentContext, reason))
|
notifyObservers(DrawEvent(currentContext, reason))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +346,8 @@ class GameEngine(
|
|||||||
else GameResult.Win(flagged.opposite, WinReason.TimeControl)
|
else GameResult.Win(flagged.opposite, WinReason.TimeControl)
|
||||||
currentContext = currentContext.withResult(Some(result))
|
currentContext = currentContext.withResult(Some(result))
|
||||||
pendingDrawOffer = None
|
pendingDrawOffer = None
|
||||||
invoker.clear()
|
pendingTakebackRequest = None
|
||||||
|
redoStack = Nil
|
||||||
notifyObservers(TimeFlagEvent(currentContext, flagged))
|
notifyObservers(TimeFlagEvent(currentContext, flagged))
|
||||||
|
|
||||||
private def scheduleExpiryCheck(cs: ClockState): Unit =
|
private def scheduleExpiryCheck(cs: ClockState): Unit =
|
||||||
@@ -365,18 +383,14 @@ class GameEngine(
|
|||||||
// ──── Private helpers ────
|
// ──── Private helpers ────
|
||||||
|
|
||||||
private def executeMove(move: Move): Unit =
|
private def executeMove(move: Move): Unit =
|
||||||
|
if !isRedoing then
|
||||||
|
redoStack = Nil
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
|
||||||
val contextBefore = currentContext
|
val contextBefore = currentContext
|
||||||
val nextContext = ruleSet.applyMove(currentContext)(move)
|
val nextContext = ruleSet.applyMove(currentContext)(move)
|
||||||
val captured = computeCaptured(currentContext, move)
|
val captured = computeCaptured(currentContext, move)
|
||||||
|
val notation = translateMoveToNotation(move, contextBefore.board)
|
||||||
val cmd = MoveCommand(
|
|
||||||
from = move.from,
|
|
||||||
to = move.to,
|
|
||||||
moveResult = Some(MoveResult.Successful(nextContext, captured)),
|
|
||||||
previousContext = Some(contextBefore),
|
|
||||||
notation = translateMoveToNotation(move, contextBefore.board),
|
|
||||||
)
|
|
||||||
invoker.execute(cmd)
|
|
||||||
currentContext = nextContext
|
currentContext = nextContext
|
||||||
|
|
||||||
advanceClock(contextBefore.turn)
|
advanceClock(contextBefore.turn)
|
||||||
@@ -397,17 +411,17 @@ class GameEngine(
|
|||||||
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
|
currentContext = currentContext.withResult(Some(GameResult.Win(winner, Checkmate)))
|
||||||
cancelScheduled()
|
cancelScheduled()
|
||||||
notifyObservers(CheckmateEvent(currentContext, winner))
|
notifyObservers(CheckmateEvent(currentContext, winner))
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
else if status.isStalemate then
|
else if status.isStalemate then
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.Stalemate)))
|
||||||
cancelScheduled()
|
cancelScheduled()
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
else if status.isInsufficientMaterial then
|
else if status.isInsufficientMaterial then
|
||||||
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
|
currentContext = currentContext.withResult(Some(GameResult.Draw(DrawReason.InsufficientMaterial)))
|
||||||
cancelScheduled()
|
cancelScheduled()
|
||||||
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
|
notifyObservers(DrawEvent(currentContext, DrawReason.InsufficientMaterial))
|
||||||
invoker.clear()
|
redoStack = Nil
|
||||||
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
|
else if status.isCheck then notifyObservers(CheckDetectedEvent(currentContext))
|
||||||
|
|
||||||
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
if currentContext.halfMoveClock >= 100 then notifyObservers(FiftyMoveRuleAvailableEvent(currentContext))
|
||||||
@@ -504,32 +518,68 @@ class GameEngine(
|
|||||||
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
else if ruleSet.isStalemate(currentContext) then notifyObservers(DrawEvent(currentContext, DrawReason.Stalemate))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private def replayContextFromMoves(moves: List[Move]): GameContext =
|
||||||
|
moves.foldLeft(contextWithInitialBoard)((ctx, move) => ruleSet.applyMove(ctx)(move))
|
||||||
|
|
||||||
private def performUndo(): Unit =
|
private def performUndo(): Unit =
|
||||||
if invoker.canUndo then
|
if currentContext.moves.isEmpty then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex)
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
|
||||||
(cmd: @unchecked) match
|
else
|
||||||
case moveCmd: MoveCommand =>
|
val lastMove = currentContext.moves.last
|
||||||
moveCmd.previousContext.foreach(currentContext = _)
|
val prevCtx = replayContextFromMoves(currentContext.moves.dropRight(1))
|
||||||
invoker.undo()
|
val notation = translateMoveToNotation(lastMove, prevCtx.board)
|
||||||
notifyObservers(MoveUndoneEvent(currentContext, moveCmd.notation))
|
redoStack = lastMove :: redoStack
|
||||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
|
currentContext = prevCtx
|
||||||
|
notifyObservers(MoveUndoneEvent(currentContext, notation))
|
||||||
|
|
||||||
private def performRedo(): Unit =
|
private def performRedo(): Unit =
|
||||||
if invoker.canRedo then
|
if redoStack.isEmpty then
|
||||||
val cmd = invoker.history(invoker.getCurrentIndex + 1)
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
|
||||||
(cmd: @unchecked) match
|
else
|
||||||
case moveCmd: MoveCommand =>
|
val move = redoStack.head
|
||||||
for case MoveResult.Successful(nextCtx, cap) <- moveCmd.moveResult do
|
redoStack = redoStack.tail
|
||||||
currentContext = nextCtx
|
isRedoing = true
|
||||||
invoker.redo()
|
executeMove(move)
|
||||||
val capturedDesc = cap.map(c => s"${c.color.label} ${c.pieceType.label}")
|
isRedoing = false
|
||||||
notifyObservers(
|
|
||||||
MoveRedoneEvent(
|
def requestTakeback(color: Color): Unit = synchronized {
|
||||||
currentContext,
|
if currentContext.result.isDefined then
|
||||||
moveCmd.notation,
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||||
moveCmd.from.toString,
|
else if currentContext.moves.isEmpty then
|
||||||
moveCmd.to.toString,
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToUndo))
|
||||||
capturedDesc,
|
else
|
||||||
),
|
pendingTakebackRequest match
|
||||||
)
|
case Some(_) =>
|
||||||
else notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NothingToRedo))
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.TakebackRequestPending))
|
||||||
|
case None =>
|
||||||
|
pendingTakebackRequest = Some(color)
|
||||||
|
notifyObservers(TakebackRequestedEvent(currentContext, color))
|
||||||
|
}
|
||||||
|
|
||||||
|
def acceptTakeback(color: Color): Unit = synchronized {
|
||||||
|
if currentContext.result.isDefined then
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||||
|
else
|
||||||
|
pendingTakebackRequest match
|
||||||
|
case None =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToAccept))
|
||||||
|
case Some(requester) if requester == color =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotAcceptOwnTakebackRequest))
|
||||||
|
case Some(_) =>
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
performUndo()
|
||||||
|
}
|
||||||
|
|
||||||
|
def declineTakeback(color: Color): Unit = synchronized {
|
||||||
|
if currentContext.result.isDefined then
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.GameAlreadyOver))
|
||||||
|
else
|
||||||
|
pendingTakebackRequest match
|
||||||
|
case None =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.NoTakebackRequestToDecline))
|
||||||
|
case Some(requester) if requester == color =>
|
||||||
|
notifyObservers(InvalidMoveEvent(currentContext, InvalidMoveReason.CannotDeclineOwnTakebackRequest))
|
||||||
|
case Some(_) =>
|
||||||
|
pendingTakebackRequest = None
|
||||||
|
notifyObservers(TakebackDeclinedEvent(currentContext, color))
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,3 +19,8 @@ enum InvalidMoveReason:
|
|||||||
case CannotAcceptOwnDrawOffer
|
case CannotAcceptOwnDrawOffer
|
||||||
case NoDrawOfferToDecline
|
case NoDrawOfferToDecline
|
||||||
case CannotDeclineOwnDrawOffer
|
case CannotDeclineOwnDrawOffer
|
||||||
|
case TakebackRequestPending
|
||||||
|
case NoTakebackRequestToAccept
|
||||||
|
case CannotAcceptOwnTakebackRequest
|
||||||
|
case NoTakebackRequestToDecline
|
||||||
|
case CannotDeclineOwnTakebackRequest
|
||||||
|
|||||||
@@ -60,15 +60,6 @@ case class MoveUndoneEvent(
|
|||||||
pgnNotation: String,
|
pgnNotation: String,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
/** Fired when a previously undone move is redone, carrying PGN notation of the replayed move. */
|
|
||||||
case class MoveRedoneEvent(
|
|
||||||
context: GameContext,
|
|
||||||
pgnNotation: String,
|
|
||||||
fromSquare: String,
|
|
||||||
toSquare: String,
|
|
||||||
capturedPiece: Option[String],
|
|
||||||
) extends GameEvent
|
|
||||||
|
|
||||||
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
/** Fired after a PGN string is successfully loaded and all moves are replayed into history. */
|
||||||
case class PgnLoadedEvent(
|
case class PgnLoadedEvent(
|
||||||
context: GameContext,
|
context: GameContext,
|
||||||
@@ -98,6 +89,18 @@ case class TimeFlagEvent(
|
|||||||
flaggedColor: Color,
|
flaggedColor: Color,
|
||||||
) extends GameEvent
|
) extends GameEvent
|
||||||
|
|
||||||
|
/** Fired when a player requests a takeback of the last move. */
|
||||||
|
case class TakebackRequestedEvent(
|
||||||
|
context: GameContext,
|
||||||
|
requestedBy: Color,
|
||||||
|
) extends GameEvent
|
||||||
|
|
||||||
|
/** Fired when a player declines a takeback request. */
|
||||||
|
case class TakebackDeclinedEvent(
|
||||||
|
context: GameContext,
|
||||||
|
declinedBy: Color,
|
||||||
|
) extends GameEvent
|
||||||
|
|
||||||
/** Observer trait: implement to receive game state updates. */
|
/** Observer trait: implement to receive game state updates. */
|
||||||
trait Observer:
|
trait Observer:
|
||||||
def onGameEvent(event: GameEvent): Unit
|
def onGameEvent(event: GameEvent): Unit
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
sealed trait C2sMessage
|
||||||
|
|
||||||
|
object C2sMessage:
|
||||||
|
case object Connected extends C2sMessage
|
||||||
|
case class Move(uci: String) extends C2sMessage
|
||||||
|
case object Ping extends C2sMessage
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.dto.GameStateEventDto
|
||||||
|
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.observer.{GameEvent, Observer}
|
||||||
|
import de.nowchess.chess.registry.GameRegistry
|
||||||
|
import de.nowchess.chess.resource.GameDtoMapper
|
||||||
|
import org.redisson.api.RTopic
|
||||||
|
|
||||||
|
class GameRedisPublisher(
|
||||||
|
gameId: String,
|
||||||
|
registry: GameRegistry,
|
||||||
|
redisson: org.redisson.api.RedissonClient,
|
||||||
|
objectMapper: ObjectMapper,
|
||||||
|
s2cTopicName: String,
|
||||||
|
writebackEmit: String => Unit,
|
||||||
|
ioClient: IoGrpcClientWrapper,
|
||||||
|
onGameOver: String => Unit,
|
||||||
|
) extends Observer:
|
||||||
|
|
||||||
|
def onGameEvent(event: GameEvent): Unit =
|
||||||
|
registry.get(gameId).foreach { entry =>
|
||||||
|
val dto = GameDtoMapper.toGameStateDto(entry, ioClient)
|
||||||
|
val json = objectMapper.writeValueAsString(GameStateEventDto(dto))
|
||||||
|
redisson.getTopic(s2cTopicName).publish(json)
|
||||||
|
|
||||||
|
val clock = entry.engine.currentClockState
|
||||||
|
val wb = GameWritebackEventDto(
|
||||||
|
gameId = gameId,
|
||||||
|
fen = dto.fen,
|
||||||
|
pgn = dto.pgn,
|
||||||
|
moveCount = entry.engine.context.moves.size,
|
||||||
|
whiteId = entry.white.id.value,
|
||||||
|
whiteName = entry.white.displayName,
|
||||||
|
blackId = entry.black.id.value,
|
||||||
|
blackName = entry.black.displayName,
|
||||||
|
mode = entry.mode.toString,
|
||||||
|
resigned = entry.resigned,
|
||||||
|
limitSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(l, _) => Some(l); case _ => None },
|
||||||
|
incrementSeconds = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Clock(_, i) => Some(i); case _ => None },
|
||||||
|
daysPerMove = entry.engine.timeControl match { case de.nowchess.api.game.TimeControl.Correspondence(d) => Some(d); case _ => None },
|
||||||
|
whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
|
||||||
|
blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
|
||||||
|
incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
|
||||||
|
clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
|
||||||
|
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
|
||||||
|
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
|
||||||
|
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
|
||||||
|
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
|
||||||
|
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
||||||
|
)
|
||||||
|
writebackEmit(objectMapper.writeValueAsString(wb))
|
||||||
|
if entry.engine.context.result.isDefined then onGameOver(gameId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.dto.GameFullEventDto
|
||||||
|
import de.nowchess.chess.config.RedisConfig
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.observer.Observer
|
||||||
|
import de.nowchess.chess.registry.GameRegistry
|
||||||
|
import de.nowchess.chess.resource.GameDtoMapper
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.api.listener.MessageListener
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.util.Try
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameRedisSubscriberManager:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject var redisson: RedissonClient = uninitialized
|
||||||
|
@Inject var registry: GameRegistry = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val c2sListeners = new ConcurrentHashMap[String, Int]()
|
||||||
|
private val s2cObservers = new ConcurrentHashMap[String, Observer]()
|
||||||
|
|
||||||
|
private def c2sTopic(gameId: String): String =
|
||||||
|
s"${redisConfig.prefix}:game:$gameId:c2s"
|
||||||
|
|
||||||
|
private def s2cTopicName(gameId: String): String =
|
||||||
|
s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||||
|
|
||||||
|
def subscribeGame(gameId: String): Unit =
|
||||||
|
try
|
||||||
|
val topic = redisson.getTopic(c2sTopic(gameId))
|
||||||
|
val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
|
||||||
|
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||||
|
handleC2sMessage(gameId, msg)
|
||||||
|
)
|
||||||
|
c2sListeners.put(gameId, listenerId)
|
||||||
|
|
||||||
|
val writebackTopic = redisson.getTopic("game-writeback")
|
||||||
|
val writebackFn: String => Unit = json => writebackTopic.publish(json)
|
||||||
|
val obs = new GameRedisPublisher(gameId, registry, redisson, objectMapper, s2cTopicName(gameId), writebackFn, ioClient, unsubscribeGame)
|
||||||
|
s2cObservers.put(gameId, obs)
|
||||||
|
registry.get(gameId).foreach(_.engine.subscribe(obs))
|
||||||
|
catch
|
||||||
|
case e: Exception =>
|
||||||
|
System.err.println(s"Warning: Redis subscription failed for game $gameId: ${e.getMessage}")
|
||||||
|
()
|
||||||
|
|
||||||
|
def unsubscribeGame(gameId: String): Unit =
|
||||||
|
Option(c2sListeners.remove(gameId)).foreach { listenerId =>
|
||||||
|
redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
|
||||||
|
}
|
||||||
|
Option(s2cObservers.remove(gameId)).foreach { obs =>
|
||||||
|
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||||
|
}
|
||||||
|
|
||||||
|
private def handleC2sMessage(gameId: String, msg: String): Unit =
|
||||||
|
parseC2sMessage(msg) match
|
||||||
|
case Some(C2sMessage.Connected) => handleConnected(gameId)
|
||||||
|
case Some(C2sMessage.Move(uci)) => handleMove(gameId, uci)
|
||||||
|
case Some(C2sMessage.Ping) => ()
|
||||||
|
case None => ()
|
||||||
|
|
||||||
|
private def handleConnected(gameId: String): Unit =
|
||||||
|
registry.get(gameId).foreach { entry =>
|
||||||
|
val dto = GameDtoMapper.toGameFullDto(entry, ioClient)
|
||||||
|
val json = objectMapper.writeValueAsString(GameFullEventDto(dto))
|
||||||
|
redisson.getTopic(s2cTopicName(gameId)).publish(json)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def handleMove(gameId: String, uci: String): Unit =
|
||||||
|
registry.get(gameId).foreach { entry =>
|
||||||
|
entry.engine.processUserInput(uci)
|
||||||
|
}
|
||||||
|
|
||||||
|
private def parseC2sMessage(msg: String): Option[C2sMessage] =
|
||||||
|
Try(objectMapper.readTree(msg)).toOption.flatMap { node =>
|
||||||
|
Option(node.get("type")).map(_.asText()).flatMap {
|
||||||
|
case "CONNECTED" => Some(C2sMessage.Connected)
|
||||||
|
case "MOVE" => Option(node.get("uci")).map(u => C2sMessage.Move(u.asText()))
|
||||||
|
case "PING" => Some(C2sMessage.Ping)
|
||||||
|
case _ => None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
def cleanup(): Unit =
|
||||||
|
c2sListeners.forEach((gameId, listenerId) =>
|
||||||
|
redisson.getTopic(c2sTopic(gameId)).removeListener(listenerId)
|
||||||
|
)
|
||||||
|
s2cObservers.forEach((gameId, obs) =>
|
||||||
|
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
||||||
|
)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package de.nowchess.chess.redis
|
||||||
|
|
||||||
|
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],
|
||||||
|
redoStack: List[String] = Nil,
|
||||||
|
pendingTakebackRequest: Option[String] = None,
|
||||||
|
)
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package de.nowchess.chess.registry
|
||||||
|
|
||||||
|
case class GameCacheDto(
|
||||||
|
gameId: String,
|
||||||
|
whiteId: String,
|
||||||
|
whiteName: String,
|
||||||
|
blackId: String,
|
||||||
|
blackName: String,
|
||||||
|
mode: String,
|
||||||
|
pgn: String,
|
||||||
|
fen: 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],
|
||||||
|
redoStack: List[String] = Nil,
|
||||||
|
pendingTakebackRequest: Option[String] = None,
|
||||||
|
)
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package de.nowchess.chess.registry
|
|
||||||
|
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
|
||||||
import java.security.SecureRandom
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
|
|
||||||
@ApplicationScoped
|
|
||||||
class GameRegistryImpl extends GameRegistry:
|
|
||||||
private val games = ConcurrentHashMap[String, GameEntry]()
|
|
||||||
private val rng = new SecureRandom()
|
|
||||||
|
|
||||||
def store(entry: GameEntry): Unit =
|
|
||||||
games.put(entry.gameId, entry)
|
|
||||||
|
|
||||||
def get(gameId: String): Option[GameEntry] =
|
|
||||||
Option(games.get(gameId))
|
|
||||||
|
|
||||||
def update(entry: GameEntry): Unit =
|
|
||||||
games.put(entry.gameId, entry)
|
|
||||||
|
|
||||||
def generateId(): String =
|
|
||||||
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
|
||||||
Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString // NOSONAR
|
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package de.nowchess.chess.registry
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.game.{ClockState, CorrespondenceClockState, GameContext, GameMode, LiveClockState, TimeControl}
|
||||||
|
import de.nowchess.api.move.Move
|
||||||
|
import de.nowchess.api.player.{PlayerId, PlayerInfo}
|
||||||
|
import de.nowchess.chess.client.{GameRecordDto, StoreServiceClient}
|
||||||
|
import de.nowchess.chess.controller.Parser
|
||||||
|
import de.nowchess.chess.engine.GameEngine
|
||||||
|
import de.nowchess.chess.grpc.RuleSetGrpcAdapter
|
||||||
|
import de.nowchess.chess.config.RedisConfig
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.resource.GameDtoMapper
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.eclipse.microprofile.rest.client.inject.RestClient
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.util.Try
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.{MessageDigest, SecureRandom}
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.concurrent.{ConcurrentHashMap, TimeUnit}
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedisGameRegistry extends GameRegistry:
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var redisson: RedissonClient = uninitialized
|
||||||
|
@Inject var redisConfig: RedisConfig = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var ioClient: IoGrpcClientWrapper = uninitialized
|
||||||
|
@Inject var ruleSetAdapter: RuleSetGrpcAdapter = uninitialized
|
||||||
|
@Inject @RestClient var storeClient: StoreServiceClient = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
private val localEngines = ConcurrentHashMap[String, GameEntry]()
|
||||||
|
private val rng = new SecureRandom()
|
||||||
|
|
||||||
|
private def cacheKey(gameId: String) = s"${redisConfig.prefix}:game:entry:$gameId"
|
||||||
|
private def bucket(gameId: String) = redisson.getBucket[String](cacheKey(gameId))
|
||||||
|
|
||||||
|
def generateId(): String =
|
||||||
|
val chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
|
||||||
|
Iterator.continually(rng.nextInt(chars.length)).map(chars).take(8).mkString
|
||||||
|
|
||||||
|
def store(entry: GameEntry): Unit =
|
||||||
|
localEngines.put(entry.gameId, entry)
|
||||||
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
|
bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
|
||||||
|
|
||||||
|
def get(gameId: String): Option[GameEntry] =
|
||||||
|
Option(localEngines.get(gameId)) match
|
||||||
|
case Some(localEntry) =>
|
||||||
|
readRedisDto(gameId).flatMap(dto => Try(reconstruct(dto)).toOption) match
|
||||||
|
case Some(redisEntry) if !sameSnapshot(localEntry, redisEntry) =>
|
||||||
|
localEngines.put(gameId, redisEntry)
|
||||||
|
Some(redisEntry)
|
||||||
|
case _ => Some(localEntry)
|
||||||
|
case None => fromRedis(gameId).orElse(fromDb(gameId))
|
||||||
|
|
||||||
|
def update(entry: GameEntry): Unit =
|
||||||
|
localEngines.put(entry.gameId, entry)
|
||||||
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
|
bucket(entry.gameId).set(toJson(entry, combined.fen, combined.pgn), 30, TimeUnit.MINUTES)
|
||||||
|
|
||||||
|
private def readRedisDto(gameId: String): Option[GameCacheDto] =
|
||||||
|
Try(Option(bucket(gameId).get())).toOption.flatten.flatMap { json =>
|
||||||
|
Try(objectMapper.readValue(json, classOf[GameCacheDto])).toOption
|
||||||
|
}
|
||||||
|
|
||||||
|
private def fromRedis(gameId: String): Option[GameEntry] =
|
||||||
|
readRedisDto(gameId)
|
||||||
|
.flatMap(dto => Try(reconstruct(dto)).toOption)
|
||||||
|
.map { entry =>
|
||||||
|
localEngines.put(gameId, entry)
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
private def fromDb(gameId: String): Option[GameEntry] =
|
||||||
|
Try {
|
||||||
|
val record = storeClient.getGame(gameId)
|
||||||
|
val dto = GameCacheDto(
|
||||||
|
gameId = record.gameId,
|
||||||
|
fen = record.fen,
|
||||||
|
pgn = record.pgn,
|
||||||
|
whiteId = record.whiteId,
|
||||||
|
whiteName = record.whiteName,
|
||||||
|
blackId = record.blackId,
|
||||||
|
blackName = record.blackName,
|
||||||
|
mode = record.mode,
|
||||||
|
resigned = record.resigned,
|
||||||
|
limitSeconds = Option(record.limitSeconds).map(_.intValue),
|
||||||
|
incrementSeconds = Option(record.incrementSeconds).map(_.intValue),
|
||||||
|
daysPerMove = Option(record.daysPerMove).map(_.intValue),
|
||||||
|
whiteRemainingMs = Option(record.whiteRemainingMs).map(_.longValue),
|
||||||
|
blackRemainingMs = Option(record.blackRemainingMs).map(_.longValue),
|
||||||
|
incrementMs = Option(record.incrementMs).map(_.longValue),
|
||||||
|
clockLastTickAt = Option(record.clockLastTickAt).map(_.longValue),
|
||||||
|
clockMoveDeadline = Option(record.clockMoveDeadline).map(_.longValue),
|
||||||
|
clockActiveColor = Option(record.clockActiveColor),
|
||||||
|
pendingDrawOffer = Option(record.pendingDrawOffer),
|
||||||
|
)
|
||||||
|
(dto, reconstruct(dto))
|
||||||
|
}.toOption
|
||||||
|
.map { case (dto, entry) =>
|
||||||
|
localEngines.put(gameId, entry)
|
||||||
|
bucket(gameId).set(objectMapper.writeValueAsString(dto), 30, TimeUnit.MINUTES)
|
||||||
|
entry
|
||||||
|
}
|
||||||
|
|
||||||
|
private def reconstruct(dto: GameCacheDto): GameEntry =
|
||||||
|
val ctx = if dto.pgn.nonEmpty then ioClient.importPgn(dto.pgn) else GameContext.initial
|
||||||
|
val tc = (dto.limitSeconds, dto.daysPerMove) match
|
||||||
|
case (Some(l), _) => TimeControl.Clock(l, dto.incrementSeconds.getOrElse(0))
|
||||||
|
case (None, Some(d)) => TimeControl.Correspondence(d)
|
||||||
|
case _ => TimeControl.Unlimited
|
||||||
|
val toColor: String => Color = s => if s == "white" then Color.White else Color.Black
|
||||||
|
val restoredClock: Option[ClockState] =
|
||||||
|
dto.clockLastTickAt.map { tick =>
|
||||||
|
LiveClockState(
|
||||||
|
whiteRemainingMs = dto.whiteRemainingMs.get,
|
||||||
|
blackRemainingMs = dto.blackRemainingMs.get,
|
||||||
|
incrementMs = dto.incrementMs.get,
|
||||||
|
lastTickAt = Instant.ofEpochMilli(tick),
|
||||||
|
activeColor = toColor(dto.clockActiveColor.get),
|
||||||
|
)
|
||||||
|
}.orElse {
|
||||||
|
dto.clockMoveDeadline.map { deadline =>
|
||||||
|
CorrespondenceClockState(
|
||||||
|
moveDeadline = Instant.ofEpochMilli(deadline),
|
||||||
|
daysPerMove = dto.daysPerMove.get,
|
||||||
|
activeColor = toColor(dto.clockActiveColor.get),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val restoredDrawOffer = dto.pendingDrawOffer.map(toColor)
|
||||||
|
val restoredTakebackRequest = dto.pendingTakebackRequest.map(toColor)
|
||||||
|
val redoMoves = dto.redoStack.flatMap { uci =>
|
||||||
|
Parser.parseMove(uci).flatMap { case (from, to, pp) =>
|
||||||
|
ruleSetAdapter.legalMoves(ctx)(from)
|
||||||
|
.find(m => m.to == to && (pp.isEmpty || m.moveType == de.nowchess.api.move.MoveType.Promotion(pp.get)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val engine = GameEngine(
|
||||||
|
initialContext = ctx,
|
||||||
|
ruleSet = ruleSetAdapter,
|
||||||
|
timeControl = tc,
|
||||||
|
initialClockState = restoredClock,
|
||||||
|
initialDrawOffer = restoredDrawOffer,
|
||||||
|
initialRedoStack = redoMoves,
|
||||||
|
initialTakebackRequest = restoredTakebackRequest,
|
||||||
|
)
|
||||||
|
GameEntry(
|
||||||
|
gameId = dto.gameId,
|
||||||
|
engine = engine,
|
||||||
|
white = PlayerInfo(PlayerId(dto.whiteId), dto.whiteName),
|
||||||
|
black = PlayerInfo(PlayerId(dto.blackId), dto.blackName),
|
||||||
|
resigned = dto.resigned,
|
||||||
|
mode = if dto.mode == "Authenticated" then GameMode.Authenticated else GameMode.Open,
|
||||||
|
)
|
||||||
|
|
||||||
|
private def toJson(entry: GameEntry, fen: String, pgn: String): String =
|
||||||
|
objectMapper.writeValueAsString(toDto(entry, fen, pgn))
|
||||||
|
|
||||||
|
private def toDto(entry: GameEntry, fen: String, pgn: String): GameCacheDto =
|
||||||
|
val clock = entry.engine.currentClockState
|
||||||
|
GameCacheDto(
|
||||||
|
gameId = entry.gameId,
|
||||||
|
whiteId = entry.white.id.value,
|
||||||
|
whiteName = entry.white.displayName,
|
||||||
|
blackId = entry.black.id.value,
|
||||||
|
blackName = entry.black.displayName,
|
||||||
|
mode = entry.mode.toString,
|
||||||
|
pgn = pgn,
|
||||||
|
fen = fen,
|
||||||
|
resigned = entry.resigned,
|
||||||
|
limitSeconds = entry.engine.timeControl match { case TimeControl.Clock(l, _) => Some(l); case _ => None },
|
||||||
|
incrementSeconds = entry.engine.timeControl match { case TimeControl.Clock(_, i) => Some(i); case _ => None },
|
||||||
|
daysPerMove = entry.engine.timeControl match { case TimeControl.Correspondence(d) => Some(d); case _ => None },
|
||||||
|
whiteRemainingMs = clock.collect { case c: LiveClockState => c.whiteRemainingMs },
|
||||||
|
blackRemainingMs = clock.collect { case c: LiveClockState => c.blackRemainingMs },
|
||||||
|
incrementMs = clock.collect { case c: LiveClockState => c.incrementMs },
|
||||||
|
clockLastTickAt = clock.collect { case c: LiveClockState => c.lastTickAt.toEpochMilli },
|
||||||
|
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
|
||||||
|
clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
|
||||||
|
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
|
||||||
|
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
|
||||||
|
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
||||||
|
)
|
||||||
|
|
||||||
|
private def sameSnapshot(localEntry: GameEntry, redisEntry: GameEntry): Boolean =
|
||||||
|
entryHash(localEntry).exists(localHash => entryHash(redisEntry).contains(localHash))
|
||||||
|
|
||||||
|
private def entryHash(entry: GameEntry): Option[String] =
|
||||||
|
Try {
|
||||||
|
val combined = ioClient.exportCombined(entry.engine.context)
|
||||||
|
val canonicalJson = objectMapper.writeValueAsString(toDto(entry, combined.fen, combined.pgn))
|
||||||
|
val digest = MessageDigest.getInstance("SHA-256").digest(canonicalJson.getBytes(StandardCharsets.UTF_8))
|
||||||
|
digest.map("%02x".format(_)).mkString
|
||||||
|
}.toOption
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package de.nowchess.chess.resource
|
||||||
|
|
||||||
|
import de.nowchess.api.board.Color
|
||||||
|
import de.nowchess.api.dto.*
|
||||||
|
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState, WinReason}
|
||||||
|
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
||||||
|
import de.nowchess.api.player.PlayerInfo
|
||||||
|
import de.nowchess.chess.grpc.IoGrpcClientWrapper
|
||||||
|
import de.nowchess.chess.registry.GameEntry
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
object GameDtoMapper:
|
||||||
|
|
||||||
|
def statusOf(entry: GameEntry): String =
|
||||||
|
if entry.engine.pendingTakebackRequestBy.isDefined then "takebackRequested"
|
||||||
|
else if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
|
||||||
|
else
|
||||||
|
val ctx = entry.engine.context
|
||||||
|
ctx.result match
|
||||||
|
case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
|
||||||
|
case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
|
||||||
|
case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
|
||||||
|
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
||||||
|
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
||||||
|
case Some(GameResult.Draw(_)) => "draw"
|
||||||
|
case None =>
|
||||||
|
if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
|
||||||
|
else if entry.engine.ruleSet.isCheck(ctx) then "check"
|
||||||
|
else "started"
|
||||||
|
|
||||||
|
def moveToUci(move: Move): String =
|
||||||
|
val base = s"${move.from}${move.to}"
|
||||||
|
move.moveType match
|
||||||
|
case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
|
||||||
|
case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
|
||||||
|
case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
|
||||||
|
case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
|
||||||
|
case _ => base
|
||||||
|
|
||||||
|
def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
||||||
|
PlayerInfoDto(info.id.value, info.displayName)
|
||||||
|
|
||||||
|
def toClockDto(entry: GameEntry): Option[ClockDto] =
|
||||||
|
val now = Instant.now()
|
||||||
|
entry.engine.currentClockState.map {
|
||||||
|
case cs: LiveClockState =>
|
||||||
|
ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
|
||||||
|
case cs: CorrespondenceClockState =>
|
||||||
|
val remaining = cs.remainingMs(cs.activeColor, now)
|
||||||
|
ClockDto(
|
||||||
|
whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
|
||||||
|
blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def toGameStateDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameStateDto =
|
||||||
|
val ctx = entry.engine.context
|
||||||
|
val exported = ioClient.exportCombined(ctx)
|
||||||
|
GameStateDto(
|
||||||
|
fen = exported.fen,
|
||||||
|
pgn = exported.pgn,
|
||||||
|
turn = ctx.turn.label.toLowerCase,
|
||||||
|
status = statusOf(entry),
|
||||||
|
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
|
||||||
|
moves = ctx.moves.map(moveToUci),
|
||||||
|
undoAvailable = entry.engine.canUndo,
|
||||||
|
redoAvailable = entry.engine.canRedo,
|
||||||
|
clock = toClockDto(entry),
|
||||||
|
takebackRequestedBy = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
|
||||||
|
)
|
||||||
|
|
||||||
|
def toGameFullDto(entry: GameEntry, ioClient: IoGrpcClientWrapper): GameFullDto =
|
||||||
|
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry, ioClient))
|
||||||
@@ -22,6 +22,7 @@ import de.nowchess.chess.engine.GameEngine
|
|||||||
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
|
import de.nowchess.chess.exception.{BadRequestException, GameNotFoundException}
|
||||||
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
|
||||||
import de.nowchess.chess.observer.*
|
import de.nowchess.chess.observer.*
|
||||||
|
import de.nowchess.chess.redis.GameRedisSubscriberManager
|
||||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
||||||
import jakarta.enterprise.context.ApplicationScoped
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
import jakarta.inject.Inject
|
import jakarta.inject.Inject
|
||||||
@@ -51,6 +52,9 @@ class GameResource:
|
|||||||
|
|
||||||
@Inject
|
@Inject
|
||||||
var jwt: JsonWebToken = uninitialized
|
var jwt: JsonWebToken = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var subscriberManager: GameRedisSubscriberManager = uninitialized
|
||||||
// scalafix:on DisableSyntax.var
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
|
private val DefaultWhite = PlayerInfo(PlayerId("p1"), "Player 1")
|
||||||
@@ -79,31 +83,6 @@ class GameResource:
|
|||||||
|
|
||||||
// ── mapping ──────────────────────────────────────────────────────────────
|
// ── mapping ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private def statusOf(entry: GameEntry): String =
|
|
||||||
if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
|
|
||||||
else
|
|
||||||
val ctx = entry.engine.context
|
|
||||||
ctx.result match
|
|
||||||
case Some(GameResult.Win(_, WinReason.Checkmate)) => "checkmate"
|
|
||||||
case Some(GameResult.Win(_, WinReason.Resignation)) => "resign"
|
|
||||||
case Some(GameResult.Win(_, WinReason.TimeControl)) => "timeout"
|
|
||||||
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
|
||||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
|
||||||
case Some(GameResult.Draw(_)) => "draw"
|
|
||||||
case None =>
|
|
||||||
if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
|
|
||||||
else if entry.engine.ruleSet.isCheck(ctx) then "check"
|
|
||||||
else "started"
|
|
||||||
|
|
||||||
private def moveToUci(move: Move): String =
|
|
||||||
val base = s"${move.from}${move.to}"
|
|
||||||
move.moveType match
|
|
||||||
case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
|
|
||||||
case _ => base
|
|
||||||
|
|
||||||
private def toLegalMoveDto(move: Move): LegalMoveDto =
|
private def toLegalMoveDto(move: Move): LegalMoveDto =
|
||||||
val (moveTypeStr, promotionStr) = move.moveType match
|
val (moveTypeStr, promotionStr) = move.moveType match
|
||||||
case MoveType.Normal(false) => ("normal", None)
|
case MoveType.Normal(false) => ("normal", None)
|
||||||
@@ -115,41 +94,7 @@ class GameResource:
|
|||||||
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
|
case MoveType.Promotion(PromotionPiece.Rook) => ("promotion", Some("rook"))
|
||||||
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
|
case MoveType.Promotion(PromotionPiece.Bishop) => ("promotion", Some("bishop"))
|
||||||
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
|
case MoveType.Promotion(PromotionPiece.Knight) => ("promotion", Some("knight"))
|
||||||
LegalMoveDto(move.from.toString, move.to.toString, moveToUci(move), moveTypeStr, promotionStr)
|
LegalMoveDto(move.from.toString, move.to.toString, GameDtoMapper.moveToUci(move), moveTypeStr, promotionStr)
|
||||||
|
|
||||||
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
|
||||||
PlayerInfoDto(info.id.value, info.displayName)
|
|
||||||
|
|
||||||
private def toClockDto(entry: GameEntry): Option[ClockDto] =
|
|
||||||
val now = Instant.now()
|
|
||||||
entry.engine.currentClockState.map {
|
|
||||||
case cs: LiveClockState =>
|
|
||||||
ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
|
|
||||||
case cs: CorrespondenceClockState =>
|
|
||||||
val remaining = cs.remainingMs(cs.activeColor, now)
|
|
||||||
ClockDto(
|
|
||||||
whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
|
|
||||||
blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
|
||||||
val ctx = entry.engine.context
|
|
||||||
val exported = ioClient.exportCombined(ctx)
|
|
||||||
GameStateDto(
|
|
||||||
fen = exported.fen,
|
|
||||||
pgn = exported.pgn,
|
|
||||||
turn = ctx.turn.label.toLowerCase,
|
|
||||||
status = statusOf(entry),
|
|
||||||
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
|
|
||||||
moves = ctx.moves.map(moveToUci),
|
|
||||||
undoAvailable = entry.engine.canUndo,
|
|
||||||
redoAvailable = entry.engine.canRedo,
|
|
||||||
clock = toClockDto(entry),
|
|
||||||
)
|
|
||||||
|
|
||||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
|
||||||
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
|
|
||||||
|
|
||||||
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
|
private def playerInfoFrom(dto: Option[PlayerInfoDto], default: PlayerInfo): PlayerInfo =
|
||||||
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
dto.fold(default)(d => PlayerInfo(PlayerId(d.id), d.displayName))
|
||||||
@@ -213,15 +158,16 @@ class GameResource:
|
|||||||
val mode = req.mode.getOrElse(GameMode.Open)
|
val mode = req.mode.getOrElse(GameMode.Open)
|
||||||
val entry = newEntry(GameContext.initial, white, black, tc, mode)
|
val entry = newEntry(GameContext.initial, white, black, tc, mode)
|
||||||
registry.store(entry)
|
registry.store(entry)
|
||||||
|
subscriberManager.subscribeGame(entry.gameId)
|
||||||
println(s"Created game ${entry.gameId}")
|
println(s"Created game ${entry.gameId}")
|
||||||
created(toGameFullDto(entry))
|
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{gameId}")
|
@Path("/{gameId}")
|
||||||
@Produces(Array(MediaType.APPLICATION_JSON))
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
def getGame(@PathParam("gameId") gameId: String): Response =
|
def getGame(@PathParam("gameId") gameId: String): Response =
|
||||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
ok(toGameFullDto(entry))
|
ok(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{gameId}/resign")
|
@Path("/{gameId}/resign")
|
||||||
@@ -244,7 +190,8 @@ class GameResource:
|
|||||||
if Parser.parseMove(uci).isEmpty then
|
if Parser.parseMove(uci).isEmpty then
|
||||||
throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
|
throw BadRequestException("INVALID_UCI", s"Invalid UCI notation: $uci", Some("uci"))
|
||||||
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
|
applyMoveInput(entry.engine, uci).foreach(err => throw BadRequestException("INVALID_MOVE", err, Some("uci")))
|
||||||
ok(toGameStateDto(entry))
|
registry.update(entry)
|
||||||
|
ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{gameId}/moves")
|
@Path("/{gameId}/moves")
|
||||||
@@ -271,7 +218,8 @@ class GameResource:
|
|||||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
|
if !entry.engine.canUndo then throw BadRequestException("NO_UNDO", "No moves to undo")
|
||||||
entry.engine.undo()
|
entry.engine.undo()
|
||||||
ok(toGameStateDto(entry))
|
registry.update(entry)
|
||||||
|
ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{gameId}/redo")
|
@Path("/{gameId}/redo")
|
||||||
@@ -280,7 +228,8 @@ class GameResource:
|
|||||||
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
|
if !entry.engine.canRedo then throw BadRequestException("NO_REDO", "No moves to redo")
|
||||||
entry.engine.redo()
|
entry.engine.redo()
|
||||||
ok(toGameStateDto(entry))
|
registry.update(entry)
|
||||||
|
ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/{gameId}/draw/{action}")
|
@Path("/{gameId}/draw/{action}")
|
||||||
@@ -293,12 +242,28 @@ class GameResource:
|
|||||||
assertGameNotOver(entry)
|
assertGameNotOver(entry)
|
||||||
val color = colorOf(entry)
|
val color = colorOf(entry)
|
||||||
action match
|
action match
|
||||||
case "offer" => entry.engine.offerDraw(color); ok(OkResponseDto())
|
case "offer" => entry.engine.offerDraw(color); registry.update(entry); ok(OkResponseDto())
|
||||||
case "accept" => entry.engine.acceptDraw(color); ok(OkResponseDto())
|
case "accept" => entry.engine.acceptDraw(color); registry.update(entry); ok(OkResponseDto())
|
||||||
case "decline" => entry.engine.declineDraw(color); ok(OkResponseDto())
|
case "decline" => entry.engine.declineDraw(color); registry.update(entry); ok(OkResponseDto())
|
||||||
case "claim" => entry.engine.claimDraw(); ok(OkResponseDto())
|
case "claim" => entry.engine.claimDraw(); registry.update(entry); ok(OkResponseDto())
|
||||||
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
|
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown draw action: $action", Some("action"))
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("/{gameId}/takeback/{action}")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
def takebackAction(
|
||||||
|
@PathParam("gameId") gameId: String,
|
||||||
|
@PathParam("action") action: String,
|
||||||
|
): Response =
|
||||||
|
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
|
||||||
|
assertGameNotOver(entry)
|
||||||
|
val color = colorOf(entry)
|
||||||
|
action match
|
||||||
|
case "request" => entry.engine.requestTakeback(color); registry.update(entry); ok(OkResponseDto())
|
||||||
|
case "accept" => entry.engine.acceptTakeback(color); registry.update(entry); ok(GameDtoMapper.toGameStateDto(entry, ioClient))
|
||||||
|
case "decline" => entry.engine.declineTakeback(color); registry.update(entry); ok(OkResponseDto())
|
||||||
|
case _ => throw BadRequestException("INVALID_ACTION", s"Unknown takeback action: $action", Some("action"))
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/import/fen")
|
@Path("/import/fen")
|
||||||
@Consumes(Array(MediaType.APPLICATION_JSON))
|
@Consumes(Array(MediaType.APPLICATION_JSON))
|
||||||
@@ -310,7 +275,8 @@ class GameResource:
|
|||||||
val tc = toTimeControl(body.timeControl)
|
val tc = toTimeControl(body.timeControl)
|
||||||
val entry = newEntry(ctx, white, black, tc)
|
val entry = newEntry(ctx, white, black, tc)
|
||||||
registry.store(entry)
|
registry.store(entry)
|
||||||
created(toGameFullDto(entry))
|
subscriberManager.subscribeGame(entry.gameId)
|
||||||
|
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
@POST
|
@POST
|
||||||
@Path("/import/pgn")
|
@Path("/import/pgn")
|
||||||
@@ -320,7 +286,8 @@ class GameResource:
|
|||||||
val ctx = ioClient.importPgn(body.pgn)
|
val ctx = ioClient.importPgn(body.pgn)
|
||||||
val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
|
val entry = newEntry(ctx, DefaultWhite, DefaultBlack)
|
||||||
registry.store(entry)
|
registry.store(entry)
|
||||||
created(toGameFullDto(entry))
|
subscriberManager.subscribeGame(entry.gameId)
|
||||||
|
created(GameDtoMapper.toGameFullDto(entry, ioClient))
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("/{gameId}/export/fen")
|
@Path("/{gameId}/export/fen")
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
package de.nowchess.chess.resource
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
|
||||||
import de.nowchess.api.board.Color
|
|
||||||
import de.nowchess.api.dto.*
|
|
||||||
import de.nowchess.api.game.{CorrespondenceClockState, DrawReason, GameResult, LiveClockState}
|
|
||||||
import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
|
|
||||||
import de.nowchess.api.player.PlayerInfo
|
|
||||||
import de.nowchess.chess.client.IoServiceClient
|
|
||||||
import de.nowchess.chess.observer.*
|
|
||||||
import de.nowchess.chess.registry.{GameEntry, GameRegistry}
|
|
||||||
import io.quarkus.websockets.next.*
|
|
||||||
import jakarta.inject.Inject
|
|
||||||
import org.eclipse.microprofile.rest.client.inject.RestClient
|
|
||||||
|
|
||||||
import java.time.Instant
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
|
||||||
import scala.compiletime.uninitialized
|
|
||||||
|
|
||||||
@WebSocket(path = "/api/board/game/{gameId}/ws")
|
|
||||||
class GameWebSocketResource:
|
|
||||||
|
|
||||||
// scalafix:off DisableSyntax.var
|
|
||||||
@Inject
|
|
||||||
var registry: GameRegistry = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
var objectMapper: ObjectMapper = uninitialized
|
|
||||||
|
|
||||||
@Inject
|
|
||||||
@RestClient
|
|
||||||
var ioClient: IoServiceClient = uninitialized
|
|
||||||
// scalafix:on DisableSyntax.var
|
|
||||||
|
|
||||||
private val connectionObservers = new ConcurrentHashMap[String, (String, Observer)]()
|
|
||||||
|
|
||||||
@OnOpen
|
|
||||||
def onOpen(connection: WebSocketConnection): Unit =
|
|
||||||
val gameId = connection.pathParam("gameId")
|
|
||||||
registry.get(gameId) match
|
|
||||||
case None =>
|
|
||||||
val err = ErrorEventDto(ApiErrorDto("GAME_NOT_FOUND", s"Game $gameId not found", None))
|
|
||||||
connection
|
|
||||||
.sendText(objectMapper.writeValueAsString(err))
|
|
||||||
.flatMap(_ => connection.close())
|
|
||||||
.subscribe()
|
|
||||||
.`with`(_ => (), _ => ())
|
|
||||||
case Some(entry) =>
|
|
||||||
val initial = objectMapper.writeValueAsString(GameFullEventDto(toGameFullDto(entry)))
|
|
||||||
val obs = new Observer:
|
|
||||||
def onGameEvent(event: GameEvent): Unit =
|
|
||||||
registry.get(gameId).foreach { updated =>
|
|
||||||
connection
|
|
||||||
.sendText(objectMapper.writeValueAsString(GameStateEventDto(toGameStateDto(updated))))
|
|
||||||
.subscribe()
|
|
||||||
.`with`(_ => (), _ => ())
|
|
||||||
}
|
|
||||||
connection
|
|
||||||
.sendText(initial)
|
|
||||||
.subscribe()
|
|
||||||
.`with`(
|
|
||||||
_ => {
|
|
||||||
connectionObservers.put(connection.id(), (gameId, obs))
|
|
||||||
entry.engine.subscribe(obs)
|
|
||||||
},
|
|
||||||
_ => (),
|
|
||||||
)
|
|
||||||
|
|
||||||
@OnClose
|
|
||||||
def onClose(connection: WebSocketConnection): Unit =
|
|
||||||
Option(connectionObservers.remove(connection.id())).foreach { case (gameId, obs) =>
|
|
||||||
registry.get(gameId).foreach(_.engine.unsubscribe(obs))
|
|
||||||
}
|
|
||||||
|
|
||||||
private def statusOf(entry: GameEntry): String =
|
|
||||||
if entry.engine.pendingDrawOfferBy.isDefined then "drawOffered"
|
|
||||||
else
|
|
||||||
val ctx = entry.engine.context
|
|
||||||
ctx.result match
|
|
||||||
case Some(GameResult.Win(_, _)) =>
|
|
||||||
if entry.resigned then "resign"
|
|
||||||
else if entry.engine.ruleSet.isCheckmate(ctx) then "checkmate"
|
|
||||||
else "timeout"
|
|
||||||
case Some(GameResult.Draw(DrawReason.Stalemate)) => "stalemate"
|
|
||||||
case Some(GameResult.Draw(DrawReason.InsufficientMaterial)) => "insufficientMaterial"
|
|
||||||
case Some(GameResult.Draw(_)) => "draw"
|
|
||||||
case None =>
|
|
||||||
if ctx.halfMoveClock >= 100 then "fiftyMoveAvailable"
|
|
||||||
else if entry.engine.ruleSet.isCheck(ctx) then "check"
|
|
||||||
else "started"
|
|
||||||
|
|
||||||
private def moveToUci(move: Move): String =
|
|
||||||
val base = s"${move.from}${move.to}"
|
|
||||||
move.moveType match
|
|
||||||
case MoveType.Promotion(PromotionPiece.Queen) => s"${base}q"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Rook) => s"${base}r"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Bishop) => s"${base}b"
|
|
||||||
case MoveType.Promotion(PromotionPiece.Knight) => s"${base}n"
|
|
||||||
case _ => base
|
|
||||||
|
|
||||||
private def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
|
|
||||||
PlayerInfoDto(info.id.value, info.displayName)
|
|
||||||
|
|
||||||
private def toClockDto(entry: GameEntry): Option[ClockDto] =
|
|
||||||
val now = Instant.now()
|
|
||||||
entry.engine.currentClockState.map {
|
|
||||||
case cs: LiveClockState =>
|
|
||||||
ClockDto(cs.remainingMs(Color.White, now), cs.remainingMs(Color.Black, now))
|
|
||||||
case cs: CorrespondenceClockState =>
|
|
||||||
val remaining = cs.remainingMs(cs.activeColor, now)
|
|
||||||
ClockDto(
|
|
||||||
whiteRemainingMs = if cs.activeColor == Color.White then remaining else -1L,
|
|
||||||
blackRemainingMs = if cs.activeColor == Color.Black then remaining else -1L,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private def toGameStateDto(entry: GameEntry): GameStateDto =
|
|
||||||
val ctx = entry.engine.context
|
|
||||||
GameStateDto(
|
|
||||||
fen = ioClient.exportFen(ctx),
|
|
||||||
pgn = ioClient.exportPgn(ctx),
|
|
||||||
turn = ctx.turn.label.toLowerCase,
|
|
||||||
status = statusOf(entry),
|
|
||||||
winner = ctx.result.collect { case GameResult.Win(c, _) => c.label.toLowerCase },
|
|
||||||
moves = ctx.moves.map(moveToUci),
|
|
||||||
undoAvailable = entry.engine.canUndo,
|
|
||||||
redoAvailable = entry.engine.canRedo,
|
|
||||||
clock = toClockDto(entry),
|
|
||||||
)
|
|
||||||
|
|
||||||
private def toGameFullDto(entry: GameEntry): GameFullDto =
|
|
||||||
GameFullDto(entry.gameId, toPlayerDto(entry.white), toPlayerDto(entry.black), toGameStateDto(entry))
|
|
||||||
@@ -7,3 +7,9 @@ quarkus:
|
|||||||
io-grpc:
|
io-grpc:
|
||||||
host: localhost
|
host: localhost
|
||||||
port: 9081
|
port: 9081
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: test-core
|
||||||
|
|||||||
@@ -115,3 +115,15 @@ tasks.reportScoverage {
|
|||||||
tasks.jar {
|
tasks.jar {
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||||
|
if (name == "compileScoverageScala") {
|
||||||
|
source = source.asFileTree.matching {
|
||||||
|
exclude("**/grpc/*.scala")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("compileScoverageJava").configure {
|
||||||
|
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,3 +115,15 @@ tasks.reportScoverage {
|
|||||||
tasks.jar {
|
tasks.jar {
|
||||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.withType(org.gradle.api.tasks.scala.ScalaCompile::class).configureEach {
|
||||||
|
if (name == "compileScoverageScala") {
|
||||||
|
source = source.asFileTree.matching {
|
||||||
|
exclude("**/grpc/*.scala")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.named("compileScoverageJava").configure {
|
||||||
|
dependsOn(tasks.named("quarkusGenerateCode"))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
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(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
|
||||||
|
implementation("io.quarkus:quarkus-rest")
|
||||||
|
implementation("io.quarkus:quarkus-rest-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-health")
|
||||||
|
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
|
||||||
|
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
|
||||||
|
|
||||||
|
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-junit5")
|
||||||
|
testImplementation("io.quarkus:quarkus-junit5-mockito")
|
||||||
|
testImplementation("io.rest-assured:rest-assured")
|
||||||
|
testImplementation("io.quarkus:quarkus-jdbc-h2")
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package de.nowchess.store.config
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedisConfig:
|
||||||
|
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
|
||||||
|
var host: String = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
|
||||||
|
var port: Int = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||||
|
var prefix: String = uninitialized
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package de.nowchess.store.config
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.inject.Produces
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.Redisson
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.config.Config
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedissonProducer:
|
||||||
|
@Inject
|
||||||
|
var redisConfig: RedisConfig = uninitialized
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
@ApplicationScoped
|
||||||
|
def redissonClient(): RedissonClient =
|
||||||
|
val config = new Config()
|
||||||
|
config.useSingleServer()
|
||||||
|
.setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
||||||
|
.setConnectionMinimumIdleSize(1)
|
||||||
|
.setConnectTimeout(500)
|
||||||
|
Redisson.create(config)
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
def close(client: RedissonClient): Unit =
|
||||||
|
client.shutdown()
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package de.nowchess.store.domain
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase
|
||||||
|
import jakarta.persistence.*
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "game_records")
|
||||||
|
class GameRecord extends PanacheEntityBase:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Id
|
||||||
|
@Column(nullable = false)
|
||||||
|
var gameId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
var fen: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "TEXT")
|
||||||
|
var pgn: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var moveCount: Int = 0
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var createdAt: Instant = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var updatedAt: Instant = uninitialized
|
||||||
|
|
||||||
|
// Player info
|
||||||
|
@Column(nullable = false)
|
||||||
|
var whiteId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var whiteName: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var blackId: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var blackName: String = uninitialized
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
var mode: String = uninitialized
|
||||||
|
|
||||||
|
// Time control
|
||||||
|
@Column
|
||||||
|
var limitSeconds: java.lang.Integer = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var incrementSeconds: java.lang.Integer = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var daysPerMove: java.lang.Integer = uninitialized
|
||||||
|
|
||||||
|
// Clock state
|
||||||
|
@Column
|
||||||
|
var whiteRemainingMs: java.lang.Long = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var blackRemainingMs: java.lang.Long = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var incrementMs: java.lang.Long = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var clockLastTickAt: java.lang.Long = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var clockMoveDeadline: java.lang.Long = uninitialized
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var clockActiveColor: String = uninitialized
|
||||||
|
|
||||||
|
// Game meta
|
||||||
|
@Column(nullable = false)
|
||||||
|
var resigned: Boolean = false
|
||||||
|
|
||||||
|
@Column
|
||||||
|
var pendingDrawOffer: String = uninitialized
|
||||||
|
// scalafix:on
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package de.nowchess.store.redis
|
||||||
|
|
||||||
|
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],
|
||||||
|
)
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package de.nowchess.store.redis
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import de.nowchess.store.service.GameWritebackService
|
||||||
|
import jakarta.annotation.PostConstruct
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.api.listener.MessageListener
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import scala.util.Try
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameWritebackStreamListener:
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var redisson: RedissonClient = uninitialized
|
||||||
|
@Inject var objectMapper: ObjectMapper = uninitialized
|
||||||
|
@Inject var writebackService: GameWritebackService = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
def startListening(): Unit =
|
||||||
|
val topic = redisson.getTopic("game-writeback")
|
||||||
|
topic.addListener(classOf[String], new MessageListener[String]:
|
||||||
|
def onMessage(channel: CharSequence, json: String): Unit =
|
||||||
|
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto]))
|
||||||
|
.toOption
|
||||||
|
.foreach(writebackService.writeBack)
|
||||||
|
)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.nowchess.store.repository
|
||||||
|
|
||||||
|
import de.nowchess.store.domain.GameRecord
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.persistence.EntityManager
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameRecordRepository:
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var em: EntityManager = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
def findByGameId(gameId: String): Option[GameRecord] =
|
||||||
|
Option(em.find(classOf[GameRecord], gameId))
|
||||||
|
|
||||||
|
def persist(record: GameRecord): Unit =
|
||||||
|
em.persist(record)
|
||||||
|
|
||||||
|
def merge(record: GameRecord): Unit =
|
||||||
|
em.merge(record)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package de.nowchess.store.resource
|
||||||
|
|
||||||
|
import de.nowchess.store.repository.GameRecordRepository
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.ws.rs.*
|
||||||
|
import jakarta.ws.rs.core.{MediaType, Response}
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@Path("/game")
|
||||||
|
@ApplicationScoped
|
||||||
|
class StoreGameResource:
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var repository: GameRecordRepository = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("/{gameId}")
|
||||||
|
@Produces(Array(MediaType.APPLICATION_JSON))
|
||||||
|
def getGame(@PathParam("gameId") gameId: String): Response =
|
||||||
|
repository.findByGameId(gameId)
|
||||||
|
.fold(Response.status(404).build())(r => Response.ok(r).build())
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package de.nowchess.store.service
|
||||||
|
|
||||||
|
import de.nowchess.store.domain.GameRecord
|
||||||
|
import de.nowchess.store.redis.GameWritebackEventDto
|
||||||
|
import de.nowchess.store.repository.GameRecordRepository
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import jakarta.transaction.Transactional
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class GameWritebackService:
|
||||||
|
@Inject
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
var repository: GameRecordRepository = uninitialized
|
||||||
|
// scalafix:on
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
def writeBack(event: GameWritebackEventDto): Unit =
|
||||||
|
repository.findByGameId(event.gameId) match
|
||||||
|
case None =>
|
||||||
|
val record = new GameRecord
|
||||||
|
record.gameId = event.gameId
|
||||||
|
record.fen = event.fen
|
||||||
|
record.pgn = event.pgn
|
||||||
|
record.moveCount = event.moveCount
|
||||||
|
record.whiteId = event.whiteId
|
||||||
|
record.whiteName = event.whiteName
|
||||||
|
record.blackId = event.blackId
|
||||||
|
record.blackName = event.blackName
|
||||||
|
record.mode = event.mode
|
||||||
|
record.resigned = event.resigned
|
||||||
|
record.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
|
||||||
|
record.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
|
||||||
|
record.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
|
||||||
|
record.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
record.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
record.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
record.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
|
||||||
|
record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||||
|
record.clockActiveColor = event.clockActiveColor.orNull
|
||||||
|
record.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||||
|
record.createdAt = Instant.now()
|
||||||
|
record.updatedAt = Instant.now()
|
||||||
|
repository.persist(record)
|
||||||
|
case Some(r) if event.moveCount > r.moveCount || event.pgn != r.pgn =>
|
||||||
|
r.fen = event.fen
|
||||||
|
r.pgn = event.pgn
|
||||||
|
r.moveCount = event.moveCount
|
||||||
|
r.whiteId = event.whiteId
|
||||||
|
r.whiteName = event.whiteName
|
||||||
|
r.blackId = event.blackId
|
||||||
|
r.blackName = event.blackName
|
||||||
|
r.mode = event.mode
|
||||||
|
r.resigned = event.resigned
|
||||||
|
r.limitSeconds = event.limitSeconds.map(java.lang.Integer.valueOf).orNull
|
||||||
|
r.incrementSeconds = event.incrementSeconds.map(java.lang.Integer.valueOf).orNull
|
||||||
|
r.daysPerMove = event.daysPerMove.map(java.lang.Integer.valueOf).orNull
|
||||||
|
r.whiteRemainingMs = event.whiteRemainingMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
r.blackRemainingMs = event.blackRemainingMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
r.incrementMs = event.incrementMs.map(java.lang.Long.valueOf).orNull
|
||||||
|
r.clockLastTickAt = event.clockLastTickAt.map(java.lang.Long.valueOf).orNull
|
||||||
|
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
|
||||||
|
r.clockActiveColor = event.clockActiveColor.orNull
|
||||||
|
r.pendingDrawOffer = event.pendingDrawOffer.orNull
|
||||||
|
r.updatedAt = Instant.now()
|
||||||
|
repository.merge(r)
|
||||||
|
case _ => ()
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
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-websockets-next")
|
||||||
|
implementation("io.quarkus:quarkus-arc")
|
||||||
|
implementation("io.quarkus:quarkus-config-yaml")
|
||||||
|
implementation("io.quarkus:quarkus-smallrye-health")
|
||||||
|
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
|
||||||
|
|
||||||
|
testImplementation(platform("org.junit:junit-bom:${versions["JUNIT_BOM"]!!}"))
|
||||||
|
testImplementation("org.junit.jupiter:junit-jupiter")
|
||||||
|
testImplementation("org.scalatest:scalatest_3:${versions["SCALATEST"]!!}")
|
||||||
|
testImplementation("co.helmethair:scalatest-junit-runner:${versions["SCALATEST_JUNIT"]!!}")
|
||||||
|
testImplementation("io.quarkus:quarkus-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,28 @@
|
|||||||
|
quarkus:
|
||||||
|
http:
|
||||||
|
port: 8084
|
||||||
|
application:
|
||||||
|
name: nowchess-ws
|
||||||
|
grpc:
|
||||||
|
server:
|
||||||
|
use-separate-server: false
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: nowchess
|
||||||
|
|
||||||
|
"%dev":
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: nowchess
|
||||||
|
|
||||||
|
"%deployed":
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
prefix: ${REDIS_PREFIX:nowchess}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package de.nowchess.ws.config
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedisConfig:
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
|
||||||
|
var host: String = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
|
||||||
|
var port: Int = uninitialized
|
||||||
|
|
||||||
|
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
|
||||||
|
var prefix: String = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package de.nowchess.ws.config
|
||||||
|
|
||||||
|
import jakarta.annotation.PreDestroy
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped
|
||||||
|
import jakarta.enterprise.inject.Produces
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.Redisson
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import org.redisson.config.Config
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
|
||||||
|
@ApplicationScoped
|
||||||
|
class RedissonProducer:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject
|
||||||
|
var redisConfig: RedisConfig = uninitialized
|
||||||
|
|
||||||
|
private var clientOpt: Option[RedissonClient] = None
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
@Produces
|
||||||
|
@ApplicationScoped
|
||||||
|
def produceRedissonClient(): RedissonClient =
|
||||||
|
val config = new Config()
|
||||||
|
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
|
||||||
|
config.useSingleServer().setConnectionMinimumIdleSize(1)
|
||||||
|
config.useSingleServer().setConnectTimeout(500)
|
||||||
|
val client = Redisson.create(config)
|
||||||
|
clientOpt = Some(client)
|
||||||
|
client
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
def shutdown(): Unit =
|
||||||
|
clientOpt.foreach(_.shutdown())
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package de.nowchess.ws.resource
|
||||||
|
|
||||||
|
import de.nowchess.ws.config.RedisConfig
|
||||||
|
import io.quarkus.websockets.next.*
|
||||||
|
import jakarta.inject.Inject
|
||||||
|
import org.redisson.api.listener.MessageListener
|
||||||
|
import org.redisson.api.RedissonClient
|
||||||
|
import scala.compiletime.uninitialized
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
@WebSocket(path = "/api/board/game/{gameId}/ws")
|
||||||
|
class GameWebSocketResource:
|
||||||
|
|
||||||
|
// scalafix:off DisableSyntax.var
|
||||||
|
@Inject
|
||||||
|
var redisson: RedissonClient = uninitialized
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
var redisConfig: RedisConfig = uninitialized
|
||||||
|
// scalafix:on DisableSyntax.var
|
||||||
|
|
||||||
|
private val listenerIds = new ConcurrentHashMap[String, (String, Int)]()
|
||||||
|
|
||||||
|
private def s2cTopic(gameId: String): String =
|
||||||
|
s"${redisConfig.prefix}:game:$gameId:s2c"
|
||||||
|
|
||||||
|
private def c2sTopic(gameId: String): String =
|
||||||
|
s"${redisConfig.prefix}:game:$gameId:c2s"
|
||||||
|
|
||||||
|
@OnOpen
|
||||||
|
def onOpen(connection: WebSocketConnection): Unit =
|
||||||
|
val gameId = connection.pathParam("gameId")
|
||||||
|
val topic = redisson.getTopic(s2cTopic(gameId))
|
||||||
|
val listenerId = topic.addListener(classOf[String], new MessageListener[String]:
|
||||||
|
def onMessage(channel: CharSequence, msg: String): Unit =
|
||||||
|
connection.sendText(msg).subscribe().`with`(_ => (), _ => ())
|
||||||
|
)
|
||||||
|
listenerIds.put(connection.id(), (gameId, listenerId))
|
||||||
|
val connectedMsg = s"""{"type":"CONNECTED","gameId":"$gameId"}"""
|
||||||
|
redisson.getTopic(c2sTopic(gameId)).publish(connectedMsg)
|
||||||
|
|
||||||
|
@OnTextMessage
|
||||||
|
def onTextMessage(connection: WebSocketConnection, message: String): Unit =
|
||||||
|
Option(listenerIds.get(connection.id())).foreach { case (gameId, _) =>
|
||||||
|
redisson.getTopic(c2sTopic(gameId)).publish(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@OnClose
|
||||||
|
def onClose(connection: WebSocketConnection): Unit =
|
||||||
|
Option(listenerIds.remove(connection.id())).foreach { case (gameId, listenerId) =>
|
||||||
|
redisson.getTopic(s2cTopic(gameId)).removeListener(listenerId)
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
quarkus:
|
||||||
|
http:
|
||||||
|
port: 8084
|
||||||
|
application:
|
||||||
|
name: nowchess-ws
|
||||||
|
|
||||||
|
nowchess:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
prefix: test
|
||||||
@@ -21,4 +21,6 @@ include(
|
|||||||
"modules:rule",
|
"modules:rule",
|
||||||
"modules:bot",
|
"modules:bot",
|
||||||
"modules:account",
|
"modules:account",
|
||||||
|
"modules:ws",
|
||||||
|
"modules:store",
|
||||||
)
|
)
|
||||||
Reference in New Issue
Block a user