feat(redis): implement Redis integration for game state management and websocket communication

This commit is contained in:
2026-04-26 00:13:35 +02:00
parent ec09a1bdb9
commit 83f84371be
48 changed files with 1475 additions and 427 deletions
@@ -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],
)
@@ -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 _ => ()