diff --git a/build.sbt b/build.sbt index f842c2f..53846e6 100644 --- a/build.sbt +++ b/build.sbt @@ -1,12 +1,12 @@ ThisBuild / scalaVersion := "3.5.1" lazy val commonSettings = Seq( - libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.18", - libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "test", - libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.0.1", - libraryDependencies += "org.scalafx" %% "scalafx" % "22.0.0-R33", - libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0", - libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M1", + libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19", + libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test", + libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.1.0", + libraryDependencies += "org.scalafx" %% "scalafx" % "24.0.2-R36", + libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.4.0", + libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M9", libraryDependencies ++= { // Determine OS version of JavaFX binaries lazy val osName = System.getProperty("os.name") match { @@ -38,8 +38,9 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) commonSettings, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", - libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", - libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2", + libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0", + libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3", + libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2", JsEngineKeys.engineType := JsEngineKeys.EngineType.Node ) diff --git a/knockoutwhist b/knockoutwhist index e432283..ec94ecd 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit e4322839d1610ec2f641e2022426408faa2c0aa1 +Subproject commit ec94ecd46c4ae89191f99638fab1466e7093ed1e diff --git a/knockoutwhistweb/app/assets/actor/KnockOutWebSocketActor.scala b/knockoutwhistweb/app/assets/actor/KnockOutWebSocketActor.scala deleted file mode 100644 index ce496de..0000000 --- a/knockoutwhistweb/app/assets/actor/KnockOutWebSocketActor.scala +++ /dev/null @@ -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 - } -} diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index ec941a4..5638ae1 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -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 { diff --git a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala index b625e13..9d2937e 100644 --- a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala +++ b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala @@ -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 => diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala index 63d03ab..912b4a8 100644 --- a/knockoutwhistweb/app/controllers/MainMenuController.scala +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -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) diff --git a/knockoutwhistweb/app/controllers/PollingController.scala b/knockoutwhistweb/app/controllers/PollingController.scala index 9c69192..7308766 100644 --- a/knockoutwhistweb/app/controllers/PollingController.scala +++ b/knockoutwhistweb/app/controllers/PollingController.scala @@ -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) { diff --git a/knockoutwhistweb/app/controllers/WebsocketController.scala b/knockoutwhistweb/app/controllers/WebsocketController.scala index f35c49b..2cf702a 100644 --- a/knockoutwhistweb/app/controllers/WebsocketController.scala +++ b/knockoutwhistweb/app/controllers/WebsocketController.scala @@ -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) } } diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala index ad3e9c8..123e018 100644 --- a/knockoutwhistweb/app/logic/PodManager.scala +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -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 + } + } + + } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 0e79ad5..afc954e 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -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 } diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala index 8bdef2c..d26abcc 100644 --- a/knockoutwhistweb/app/model/sessions/UserSession.scala +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -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() + } } diff --git a/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala b/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala new file mode 100644 index 0000000..0262ce1 --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala @@ -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 + )) + } + } + +} diff --git a/knockoutwhistweb/app/util/WebsocketEventMapper.scala b/knockoutwhistweb/app/util/WebsocketEventMapper.scala new file mode 100644 index 0000000..7e01a7f --- /dev/null +++ b/knockoutwhistweb/app/util/WebsocketEventMapper.scala @@ -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) + } + +} diff --git a/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html b/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html index 155eac6..13de1f4 100644 --- a/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html +++ b/knockoutwhistweb/app/views/ingame/finishedMatch.scala.html @@ -34,4 +34,5 @@ }); } waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); + connectWebSocket() \ No newline at end of file diff --git a/knockoutwhistweb/app/views/ingame/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html index b357fc4..1310bf2 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -104,4 +104,5 @@ }); } waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); + connectWebSocket() diff --git a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html index d6041fa..2b9c267 100644 --- a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html +++ b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html @@ -68,4 +68,5 @@ }); } waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); + connectWebSocket() \ No newline at end of file diff --git a/knockoutwhistweb/app/views/ingame/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html index 1d8c801..20bd17e 100644 --- a/knockoutwhistweb/app/views/ingame/tie.scala.html +++ b/knockoutwhistweb/app/views/ingame/tie.scala.html @@ -114,4 +114,5 @@ }); } waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); + connectWebSocket() \ No newline at end of file diff --git a/knockoutwhistweb/app/views/lobby/lobby.scala.html b/knockoutwhistweb/app/views/lobby/lobby.scala.html index fe124c9..85bda17 100644 --- a/knockoutwhistweb/app/views/lobby/lobby.scala.html +++ b/knockoutwhistweb/app/views/lobby/lobby.scala.html @@ -78,4 +78,5 @@ }); } waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); + connectWebSocket() \ No newline at end of file diff --git a/knockoutwhistweb/app/views/login/login.scala.html b/knockoutwhistweb/app/views/login/login.scala.html index cb36915..8be5352 100644 --- a/knockoutwhistweb/app/views/login/login.scala.html +++ b/knockoutwhistweb/app/views/login/login.scala.html @@ -35,6 +35,7 @@ particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() { console.log('callback - particles.js config loaded'); }); + disconnectWebSocket();