feat(websocket)!: Implement WebSocket connection and event handling

This commit is contained in:
2025-11-22 18:32:28 +01:00
parent 09cc96141d
commit ba373f91e9
25 changed files with 420 additions and 80 deletions

View File

@@ -1,21 +0,0 @@
package actor
import org.apache.pekko.actor.{Actor, ActorRef}
import org.apache.pekko.http.scaladsl.model.ContentRange.Other
class KnockOutWebSocketActor(
out: ActorRef,
) extends Actor {
def receive: Receive = {
case msg: String =>
out ! s"Received your message: ${msg}"
case other: Other =>
println(s"Received unknown message: $other")
}
def sendJsonToClient(json: String): Unit = {
println("Received event from Controller")
out ! json
}
}

View File

@@ -20,7 +20,6 @@ import scala.util.Try
@Singleton
class IngameController @Inject() (
val cc: ControllerComponents,
val podManager: PodManager,
val authAction: AuthAction,
implicit val ec: ExecutionContext
) extends AbstractController(cc) {
@@ -54,7 +53,7 @@ class IngameController @Inject() (
}
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val results = Try {
@@ -70,7 +69,7 @@ class IngameController @Inject() (
}
}
def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
val result = Try {
game match {
case Some(g) =>
@@ -111,7 +110,7 @@ class IngameController @Inject() (
}
}
def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
val playerToKickUUID = UUID.fromString(playerToKick)
val result = Try {
game.get.leaveGame(playerToKickUUID)
@@ -129,7 +128,7 @@ class IngameController @Inject() (
}
}
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
val result = Try {
game.get.leaveGame(request.user.id)
}
@@ -147,7 +146,7 @@ class IngameController @Inject() (
}
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val jsonBody = request.body.asJson
@@ -218,7 +217,7 @@ class IngameController @Inject() (
}
}
def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) => {
val jsonBody = request.body.asJson
@@ -282,7 +281,7 @@ class IngameController @Inject() (
}
}
def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val jsonBody = request.body.asJson
@@ -334,7 +333,7 @@ class IngameController @Inject() (
}
}
def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val jsonBody = request.body.asJson
@@ -388,7 +387,7 @@ class IngameController @Inject() (
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val result = Try {

View File

@@ -1,7 +1,6 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import logic.PodManager
import auth.AuthAction
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
import play.api.routing.JavaScriptReverseRouter
@@ -10,7 +9,6 @@ import javax.inject.Inject
class JavaScriptRoutingController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction,
val podManager: PodManager
) extends BaseController {
def javascriptRoutes(): Action[AnyContent] =
Action { implicit request =>

View File

@@ -17,7 +17,6 @@ import javax.inject.*
class MainMenuController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction,
val podManager: PodManager,
val ingameController: IngameController
) extends BaseController {
@@ -39,7 +38,7 @@ class MainMenuController @Inject()(
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
.getOrElse(throw new IllegalArgumentException("Player amount is required."))
val gameLobby = podManager.createGame(
val gameLobby = PodManager.createGame(
host = request.user,
name = gamename,
maxPlayers = playeramount.toInt
@@ -64,7 +63,7 @@ class MainMenuController @Inject()(
(jsValue \ "gameId").asOpt[String]
}
if (gameId.isDefined) {
val game = podManager.getGame(gameId.get)
val game = PodManager.getGame(gameId.get)
game match {
case Some(g) =>
g.addUser(request.user)

View File

@@ -24,7 +24,6 @@ object PollingController {
@Singleton
class PollingController @Inject() (
val cc: ControllerComponents,
val podManager: PodManager,
val authAction: AuthAction,
val ingameController: IngameController,
implicit val ec: ExecutionContext
@@ -117,7 +116,7 @@ class PollingController @Inject() (
val playerId = request.user.id
podManager.getGame(gameId) match {
PodManager.getGame(gameId) match {
case Some(game) =>
val playerEventQueue = game.getEventsOfPlayer(playerId)
if (playerEventQueue.nonEmpty) {

View File

@@ -1,5 +1,10 @@
package controllers
import actor.KnockOutWebSocketActor
import auth.AuthAction
import logic.PodManager
import logic.user.SessionManager
import model.sessions.{UserSession, UserWebsocketActor}
import org.apache.pekko.actor.{ActorRef, ActorSystem, Props}
import org.apache.pekko.stream.Materializer
import play.api.*
@@ -12,17 +17,28 @@ import javax.inject.*
@Singleton
class WebsocketController @Inject()(
cc: ControllerComponents,
val sessionManger: SessionManager,
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
object KnockOutWebSocketActorFactory {
def create(out: ActorRef) = {
Props(new KnockOutWebSocketActor(out))
def create(out: ActorRef, userSession: UserSession): Props = {
Props(new UserWebsocketActor(out, userSession))
}
}
def socket() = WebSocket.accept[String, String] { request =>
def socket(): WebSocket = WebSocket.accept[String, String] { request =>
val session = request.cookies.get("sessionId")
if (session.isEmpty) throw new Exception("No session cookie found")
val userOpt = sessionManger.getUserBySession(session.get.value)
if (userOpt.isEmpty) throw new Exception("Invalid session")
val user = userOpt.get
val game = PodManager.identifyGameOfUser(user)
if (game.isEmpty) throw new Exception("User is not in a game")
val userSession = game.get.getUserSession(user.id)
ActorFlow.actorRef { out =>
println("Connect received")
KnockOutWebSocketActorFactory.create(out)
KnockOutWebSocketActorFactory.create(out, userSession)
}
}

View File

@@ -11,14 +11,14 @@ import util.GameUtil
import javax.inject.Singleton
import scala.collection.mutable
@Singleton
class PodManager {
object PodManager {
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
val podIp: String = System.getenv("POD_IP")
val podName: String = System.getenv("POD_NAME")
private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
def createGame(
@@ -35,6 +35,7 @@ class PodManager {
host = host
)
sessions += (gameLobby.id -> gameLobby)
userSession += (host -> gameLobby.id)
gameLobby
}
@@ -44,6 +45,30 @@ class PodManager {
private[logic] def removeGame(gameId: String): Unit = {
sessions.remove(gameId)
// Also remove all user sessions associated with this game
userSession.filterInPlace((_, v) => v != gameId)
}
def registerUserToGame(user: User, gameId: String): Boolean = {
if (sessions.contains(gameId)) {
userSession += (user -> gameId)
true
} else {
false
}
}
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
}
def identifyGameOfUser(user: User): Option[GameLobby] = {
userSession.get(user) match {
case Some(gameId) => sessions.get(gameId)
case None => None
}
}
}

View File

@@ -12,6 +12,7 @@ import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import exceptions.*
import logic.PodManager
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
import model.sessions.{InteractionType, UserSession}
import model.users.User
@@ -69,9 +70,11 @@ class GameLobby private(
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
val userSession = new UserSession(
user = user,
host = false
host = false,
gameLobby = this
)
users += (user.id -> userSession)
PodManager.registerUserToGame(user, id)
addToQueue(LobbyUpdate)
userSession
}
@@ -156,7 +159,14 @@ class GameLobby private(
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
users.clear()
PodManager.removeGame(id)
return
}
users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user)
addToQueue(LobbyUpdate)
}
@@ -338,7 +348,8 @@ object GameLobby {
)
lobby.users += (host.id -> new UserSession(
user = host,
host = true
host = true,
gameLobby = lobby
))
lobby
}

View File

@@ -2,13 +2,19 @@ package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import java.util.UUID
import java.util.concurrent.locks.{Lock, ReentrantLock}
import java.util.concurrent.locks.ReentrantLock
import scala.util.{Failure, Success, Try}
class UserSession(val user: User, val host: Boolean) extends PlayerSession {
class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession {
var canInteract: Option[InteractionType] = None
var websocketActor: Option[UserWebsocketActor] = None
val lock: ReentrantLock = ReentrantLock()
override def updatePlayer(event: SimpleEvent): Unit = {
@@ -31,5 +37,17 @@ class UserSession(val user: User, val host: Boolean) extends PlayerSession {
def resetCanInteract(): Unit = {
canInteract = None
}
def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
Try {
eventType match {
case "Ping" =>
// No action needed for Ping
()
}
}
lock.unlock()
}
}

View File

@@ -0,0 +1,94 @@
package model.sessions
import de.knockoutwhist.utils.events.SimpleEvent
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import scala.util.{Failure, Success, Try}
class UserWebsocketActor(
out: ActorRef,
session: UserSession
) extends Actor {
if (session.websocketActor.isDefined) {
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection."))
context.stop(self)
} else {
session.websocketActor = Some(this)
}
override def receive: Receive = {
case msg: String =>
val jsonObject = Try {
Json.parse(msg)
}
Try {
jsonObject match {
case Success(value) =>
handle(value)
case Failure(exception) =>
transmitTextToClient(s"Error parsing JSON: ${exception.getMessage}")
}
}.failed.foreach(
ex => transmitTextToClient(s"Error handling message: ${ex.getMessage}")
)
case other =>
}
def transmitEventToClient(event: SimpleEvent): Unit = {
val jsonString = WebsocketEventMapper.toJsonString(event)
out ! jsonString
}
private def transmitJsonToClient(jsonObj: JsObject): Unit = {
out ! jsonObj.toString()
}
private def transmitTextToClient(text: String): Unit = {
out ! text
}
private def handle(json: JsValue): Unit = {
val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"status" -> "error",
"error" -> "Missing 'id' field"
))
return
}
val id = idOpt.get
val eventOpt = (json \ "event").asOpt[String]
if (eventOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> null,
"status" -> "error",
"error" -> "Missing 'event' field"
))
return
}
val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
val result = Try {
session.handleWebResponse(event, data)
}
if (result.isSuccess) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "success"
))
} else {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "error",
"error" -> result.failed.get.getMessage
))
}
}
}

View File

@@ -0,0 +1,20 @@
package util
import de.knockoutwhist.utils.events.SimpleEvent
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
object WebsocketEventMapper {
private val scalaModule = ScalaModule.builder()
.addAllBuiltinModules()
.supportScala3Classes(true)
.build()
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
def toJsonString(obj: SimpleEvent): String = {
mapper.writeValueAsString(obj)
}
}

View File

@@ -34,4 +34,5 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -104,4 +104,5 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -68,4 +68,5 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -114,4 +114,5 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -78,4 +78,5 @@
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -35,6 +35,7 @@
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
console.log('callback - particles.js config loaded');
});
disconnectWebSocket();
</script>
<div id="particles-js" style="background-color: rgb(11, 8, 8);
background-size: cover;

View File

@@ -25,6 +25,7 @@
</body>
<script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/ingame.js")" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</html>

View File

@@ -27,4 +27,7 @@
<div class="btn btn-success" onclick="createGameJS()">Create Game</div>
</div>
</div>
</main>
</main>
<script>
disconnectWebSocket();
</script>

View File

@@ -175,3 +175,6 @@
</div>
</div>
</main>
<script>
disconnectWebSocket();
</script>