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/stylesheets/login.less b/knockoutwhistweb/app/assets/stylesheets/login.less index 2e3bb1e..67e85b6 100644 --- a/knockoutwhistweb/app/assets/stylesheets/login.less +++ b/knockoutwhistweb/app/assets/stylesheets/login.less @@ -17,7 +17,7 @@ width: 100%; border: none; border-radius: 1rem; - box-shadow: 0 4px 20px rgba(0,0,0,0.1); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); position: relative; z-index: 3; /* ensure card sits above the particles */ } diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index b9713a8..b321c74 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -14,37 +14,44 @@ --bs-border-color: rgba(0, 0, 0, 0.125) !important; --bs-heading-color: var(--color) !important; } + @background-color: var(--background-color); @highlightcolor: var(--highlightscolor); @background-image: var(--background-image); @color: var(--color); @keyframes slideIn { - 0% { transform: translateX(-100vw); } - 100% { transform: translateX(0); } + 0% { + transform: translateX(-100vw); + } + 100% { + transform: translateX(0); + } } .game-field-background { - background-image: @background-image; - background-repeat: no-repeat; - background-size: cover; - max-width: 1400px; - margin: 0 auto; - min-height: 100vh; -} -.lobby-background { - background-color: @background-color; - width: 100%; - height: 100vh; + background-image: @background-image; + background-repeat: no-repeat; + background-size: cover; + max-width: 1400px; + margin: 0 auto; + min-height: 100vh; } -.navbar-header{ - text-align:center; +.lobby-background { + background-color: @background-color; + width: 100%; + height: 100vh; +} + +.navbar-header { + text-align: center; } .navbar-toggle { - float: none; - margin-right:0; + float: none; + margin-right: 0; } + .handcard :hover { box-shadow: 3px 3px 3px @highlightcolor; } @@ -52,7 +59,7 @@ .inactive::after { content: ""; position: absolute; - inset: 0; /* cover the whole container */ + inset: 0; /* cover the whole container */ background: rgba(0, 0, 0, 0.50); z-index: 10; border-radius: 6px; @@ -73,26 +80,26 @@ /* Ensure body text color follows theme variable and works with Bootstrap */ body { - color: @color; + color: @color; } .footer { - width: 100%; - text-align: center; - font-size: 12px; - color: @color; - padding: 0.5rem 0; - flex-grow: 1; /* fill remaining vertical space as visual footer background */ + width: 100%; + text-align: center; + font-size: 12px; + color: @color; + padding: 0.5rem 0; + flex-grow: 1; /* fill remaining vertical space as visual footer background */ } .game-field { - position: fixed; - inset: 0; - overflow: auto; + position: fixed; + inset: 0; + overflow: auto; } .navbar-drop-shadow { - box-shadow: 0 1px 15px 0 #000000 + box-shadow: 0 1px 15px 0 #000000 } .ingame-side-shadow { @@ -100,126 +107,164 @@ body { } #sessions { - display: flex; - flex-direction: column; - justify-content: flex-start; - align-items: center; - text-align: center; - h1 { - animation: slideIn 0.5s ease-out forwards; - animation-fill-mode: backwards; - } + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + text-align: center; + + h1 { + animation: slideIn 0.5s ease-out forwards; + animation-fill-mode: backwards; + } } + #textanimation { - animation: slideIn 0.5s ease-out forwards; - animation-fill-mode: backwards; - animation-delay: 1s; + animation: slideIn 0.5s ease-out forwards; + animation-fill-mode: backwards; + animation-delay: 1s; } #sessions a, #sessions h1, #sessions p { - color: @color; - font-size: 40px; - font-family: Arial, serif; + color: @color; + font-size: 40px; + font-family: Arial, serif; } + #ingame { - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-end; - height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: 100%; } + #ingame a, #ingame h1, #ingame p { - color: @color; - font-size: 40px; - font-family: Arial, serif; + color: @color; + font-size: 40px; + font-family: Arial, serif; } .ingame-cards-slide { div { animation: slideIn 0.5s ease-out forwards; animation-fill-mode: backwards; - &:nth-child(1) { animation-delay: 0.5s; } - &:nth-child(2) { animation-delay: 1s; } - &:nth-child(3) { animation-delay: 1.5s; } - &:nth-child(4) { animation-delay: 2s; } - &:nth-child(5) { animation-delay: 2.5s; } - &:nth-child(6) { animation-delay: 3s; } - &:nth-child(7) { animation-delay: 3.5s; } + + &:nth-child(1) { + animation-delay: 0.5s; + } + + &:nth-child(2) { + animation-delay: 1s; + } + + &:nth-child(3) { + animation-delay: 1.5s; + } + + &:nth-child(4) { + animation-delay: 2s; + } + + &:nth-child(5) { + animation-delay: 2.5s; + } + + &:nth-child(6) { + animation-delay: 3s; + } + + &:nth-child(7) { + animation-delay: 3.5s; + } } } #playedcardplayer { - display: flex; - flex-direction: column; - justify-content: flex-end; + display: flex; + flex-direction: column; + justify-content: flex-end; } + #playedcardplayer p { - font-size: 12px; - height: 4%; + font-size: 12px; + height: 4%; } + #playedcardplayer img { - height: 90%; + height: 90%; } #firstCard { - display: flex; - flex-direction: row; - height: 20%; - width: 100%; - justify-content: space-between; + display: flex; + flex-direction: row; + height: 20%; + width: 100%; + justify-content: space-between; } + #firstCardObject { - display: flex; - flex-direction: column; - margin-right: 4%; + display: flex; + flex-direction: column; + margin-right: 4%; } -#firstCardObject img{ - height: 90%; + +#firstCardObject img { + height: 90%; } -#firstCardObject p{ - height: 10%; - font-size: 20px; + +#firstCardObject p { + height: 10%; + font-size: 20px; } + #nextPlayers { - display: flex; - flex-direction: column; - align-items: center; - height: 0; - p { - margin-top: 0; - margin-bottom: 0; - } + display: flex; + flex-direction: column; + align-items: center; + height: 0; + + p { + margin-top: 0; + margin-bottom: 0; + } } + #invisible { - visibility: hidden; + visibility: hidden; } + #selecttrumpsuit { - display: flex; - flex-direction: column; - align-items: center; - height: 100%; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; } + #rules { - color: @color; - font-size: 1.5em; - font-family: Arial, serif; + color: @color; + font-size: 1.5em; + font-family: Arial, serif; } + .score-table { - background-color: rgba(255, 255, 255, 0.1); - border-radius: 8px; - padding: 10px; - margin-bottom: 20px; - backdrop-filter: blur(8px); - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + background-color: rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px; + margin-bottom: 20px; + backdrop-filter: blur(8px); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); } + .score-header { - font-weight: bold; - color: #000000; - border-bottom: 1px solid rgba(255, 255, 255, 0.3); + font-weight: bold; + color: #000000; + border-bottom: 1px solid rgba(255, 255, 255, 0.3); } + .score-row { - color: #000000; + color: #000000; } /* In-game centered stage and blurred sides overlay */ @@ -243,12 +288,12 @@ body { inset: 0; pointer-events: none; /* fallback: subtle vignette if backdrop-filter unsupported */ - background: radial-gradient(ellipse at center, rgba(0,0,0,0) 30%, rgba(0,0,0,0.35) 100%); + background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.35) 100%); } @supports ((-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px))) { .blur-sides::before { - background: rgba(0,0,0,0.08); + background: rgba(0, 0, 0, 0.08); -webkit-backdrop-filter: blur(10px) saturate(110%); backdrop-filter: blur(10px) saturate(110%); } diff --git a/knockoutwhistweb/app/auth/AuthAction.scala b/knockoutwhistweb/app/auth/AuthAction.scala index 30add86..c14b1dc 100644 --- a/knockoutwhistweb/app/auth/AuthAction.scala +++ b/knockoutwhistweb/app/auth/AuthAction.scala @@ -15,13 +15,6 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP override def executionContext: ExecutionContext = ec - protected def getUserFromSession(request: RequestHeader): Option[User] = { - val session = request.cookies.get("sessionId") - if (session.isDefined) - return sessionManager.getUserBySession(session.get.value) - None - } - override def invokeBlock[A]( request: Request[A], block: AuthenticatedRequest[A] => Future[Result] @@ -33,5 +26,12 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP Future.successful(Results.Redirect(routes.UserController.login())) } } + + protected def getUserFromSession(request: RequestHeader): Option[User] = { + val session = request.cookies.get("sessionId") + if (session.isDefined) + return sessionManager.getUserBySession(session.get.value) + None + } } diff --git a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala index e70c069..65944ee 100644 --- a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala +++ b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala @@ -8,6 +8,7 @@ import de.knockoutwhist.utils.events.EventListener class WebApplicationConfiguration extends DefaultConfiguration { override def uis: Set[UI] = Set() + override def listener: Set[EventListener] = Set(DelayHandler) } diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index ec941a4..e8588ba 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -1,7 +1,7 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} -import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, SelectTrump, TieBreak} +import de.knockoutwhist.control.GameState.* import exceptions.* import logic.PodManager import logic.game.GameLobby @@ -18,12 +18,28 @@ import scala.concurrent.ExecutionContext import scala.util.Try @Singleton -class IngameController @Inject() ( - val cc: ControllerComponents, - val podManager: PodManager, - val authAction: AuthAction, - implicit val ec: ExecutionContext - ) extends AbstractController(cc) { +class IngameController @Inject()( + val cc: ControllerComponents, + val authAction: AuthAction, + implicit val ec: ExecutionContext + ) extends AbstractController(cc) { + + def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = PodManager.getGame(gameId) + game match { + case Some(g) => + val results = Try { + returnInnerHTML(g, request.user) + } + if (results.isSuccess) { + Ok(views.html.main("In-Game - Knockout Whist")(results.get)) + } else { + InternalServerError(results.failed.get.getMessage) + } + case None => + Redirect(routes.MainMenuController.mainMenu()) + } + } def returnInnerHTML(gameLobby: GameLobby, user: User): Html = { gameLobby.logic.getCurrentState match { @@ -34,10 +50,10 @@ class IngameController @Inject() ( gameLobby ) case SelectTrump => - views.html.ingame.selecttrump( - gameLobby.getPlayerByUser(user), - gameLobby - ) + views.html.ingame.selecttrump( + gameLobby.getPlayerByUser(user), + gameLobby + ) case TieBreak => views.html.ingame.tie( gameLobby.getPlayerByUser(user), @@ -53,24 +69,8 @@ class IngameController @Inject() ( } } - def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => - val game = podManager.getGame(gameId) - game match { - case Some(g) => - val results = Try { - returnInnerHTML(g, request.user) - } - if (results.isSuccess) { - Ok(views.html.main("In-Game - Knockout Whist")(results.get)) - } else { - InternalServerError(results.failed.get.getMessage) - } - case None => - Redirect(routes.MainMenuController.mainMenu()) - } - } 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) => @@ -110,13 +110,14 @@ 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) } - if(result.isSuccess) { + if (result.isSuccess) { Ok(Json.obj( "status" -> "success", "redirectUrl" -> routes.IngameController.game(gameId).url @@ -128,15 +129,17 @@ 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) } if (result.isSuccess) { Ok(Json.obj( "status" -> "success", - "redirectUrl" -> routes.MainMenuController.mainMenu().url + "redirectUrl" -> routes.MainMenuController.mainMenu().url, + "content" -> views.html.mainmenu.creategame(Some(request.user)).toString )) } else { InternalServerError(Json.obj( @@ -145,9 +148,9 @@ 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 @@ -217,8 +220,9 @@ 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 @@ -281,8 +285,9 @@ 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 @@ -333,8 +338,9 @@ class IngameController @Inject() ( NotFound("Game not found") } } + 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 @@ -385,10 +391,10 @@ class IngameController @Inject() ( NotFound("Game not found") } } - - + + 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..5b03508 100644 --- a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala +++ b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala @@ -1,35 +1,24 @@ 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 import javax.inject.Inject -class JavaScriptRoutingController @Inject()( - val controllerComponents: ControllerComponents, - val authAction: AuthAction, - val podManager: PodManager - ) extends BaseController { +class JavaScriptRoutingController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + ) extends BaseController { def javascriptRoutes(): Action[AnyContent] = Action { implicit request => - Ok( - JavaScriptReverseRouter("jsRoutes")( + Ok( + JavaScriptReverseRouter("jsRoutes")( routes.javascript.MainMenuController.createGame, routes.javascript.MainMenuController.joinGame, routes.javascript.MainMenuController.navSPA, - routes.javascript.IngameController.startGame, - routes.javascript.IngameController.kickPlayer, - routes.javascript.IngameController.leaveGame, - routes.javascript.IngameController.playCard, - routes.javascript.IngameController.playDogCard, - routes.javascript.IngameController.playTrump, - routes.javascript.IngameController.playTie, - routes.javascript.IngameController.returnToLobby, - routes.javascript.PollingController.polling, routes.javascript.UserController.login_Post - ) - ).as("text/javascript") - } + ) + ).as("text/javascript") + } } diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala index 63d03ab..4a3eeb7 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 @@ -55,16 +54,16 @@ class MainMenuController @Inject()( "errorMessage" -> "Invalid form submission" )) } - + } - + def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val jsonBody = request.body.asJson val gameId: Option[String] = jsonBody.flatMap { jsValue => (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) @@ -91,7 +90,7 @@ class MainMenuController @Inject()( Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user)))) } - def navSPA(location: String) : Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => location match { case "0" => // Main Menu Ok(Json.obj( diff --git a/knockoutwhistweb/app/controllers/PollingController.scala b/knockoutwhistweb/app/controllers/PollingController.scala deleted file mode 100644 index 9c69192..0000000 --- a/knockoutwhistweb/app/controllers/PollingController.scala +++ /dev/null @@ -1,153 +0,0 @@ -package controllers - -import auth.{AuthAction, AuthenticatedRequest} -import controllers.PollingController.{scheduler, timeoutDuration} -import de.knockoutwhist.cards.Hand -import de.knockoutwhist.player.AbstractPlayer -import logic.PodManager -import logic.game.{GameLobby, PollingEvents} -import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent} -import model.sessions.UserSession -import model.users.User -import play.api.libs.json.{JsArray, JsValue, Json} -import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents, Result} -import util.WebUIUtils - -import javax.inject.{Inject, Singleton} -import scala.concurrent.{ExecutionContext, Future} -import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit} -import scala.concurrent.duration.* -object PollingController { - private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() - private val timeoutDuration = 25.seconds -} -@Singleton -class PollingController @Inject() ( - val cc: ControllerComponents, - val podManager: PodManager, - val authAction: AuthAction, - val ingameController: IngameController, - implicit val ec: ExecutionContext - ) extends AbstractController(cc) { - - private def buildCardPlayResponse(game: GameLobby, hand: Option[Hand], player: AbstractPlayer, newRound: Boolean): JsValue = { - val currentRound = game.logic.getCurrentRound.get - val currentTrick = game.logic.getCurrentTrick.get - - val trickCardsJson = Json.toJson( - currentTrick.cards.map { case (card, player) => - Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name) - } - ) - val scoreTableJson = Json.toJson( - game.getLogic.getPlayerQueue.get.toList.map { player => - Json.obj( - "name" -> player.name, - "tricks" -> currentRound.tricklist.count(_.winner.contains(player)) - ) - } - ) - - val stringHand = hand.map { h => - val cardStrings = h.cards.map(WebUIUtils.cardtoString) - Json.toJson(cardStrings).as[JsArray] - }.getOrElse(Json.arr()) - - val firstCardId = currentTrick.firstCard.map(WebUIUtils.cardtoString).getOrElse("BLANK") - val nextPlayer = game.getLogic.getPlayerQueue.get.duplicate().nextPlayer().name - Json.obj( - "status" -> "cardPlayed", - "animation" -> newRound, - "handData" -> stringHand, - "dog" -> player.isInDogLife, - "currentPlayerName" -> game.logic.getCurrentPlayer.get.name, - "trumpSuit" -> currentRound.trumpSuit.toString, - "trickCards" -> trickCardsJson, - "scoreTable" -> scoreTableJson, - "firstCardId" -> firstCardId, - "nextPlayer" -> nextPlayer, - "yourTurn" -> (game.logic.getCurrentPlayer.get == player) - ) - } - - private def buildLobbyUsersResponse(game: GameLobby, userSession: UserSession): JsValue = { - Json.obj( - "status" -> "lobbyUpdate", - "host" -> userSession.host, - "users" -> game.getUsers.map(u => Json.obj( - "name" -> u.name, - "id" -> u.id, - "self" -> (u.id == userSession.id) - )), - "maxPlayers" -> game.maxPlayers - ) - } - - - def handleEvent(event: PollingEvents, game: GameLobby, userSession: UserSession): Result = { - event match { - case NewRound => - val player = game.getPlayerByUser(userSession.user) - val hand = player.currentHand() - val jsonResponse = buildCardPlayResponse(game, hand, player, true) - Ok(jsonResponse) - case NewTrick => - val player = game.getPlayerByUser(userSession.user) - val hand = player.currentHand() - val jsonResponse = buildCardPlayResponse(game, hand, player, false) - Ok(jsonResponse) - case CardPlayed => - val player = game.getPlayerByUser(userSession.user) - val hand = player.currentHand() - val jsonResponse = buildCardPlayResponse(game, hand, player, false) - Ok(jsonResponse) - case LobbyUpdate => - Ok(buildLobbyUsersResponse(game, userSession)) - case ReloadEvent => - val jsonResponse = Json.obj( - "status" -> "reloadEvent", - "redirectUrl" -> routes.IngameController.game(game.id).url, - "content" -> ingameController.returnInnerHTML(game, userSession.user).toString - ) - Ok(jsonResponse) - } - } - - def polling(gameId: String): Action[AnyContent] = authAction.async { implicit request: AuthenticatedRequest[AnyContent] => - - val playerId = request.user.id - - podManager.getGame(gameId) match { - case Some(game) => - val playerEventQueue = game.getEventsOfPlayer(playerId) - if (playerEventQueue.nonEmpty) { - val event = playerEventQueue.dequeue() - Future.successful(handleEvent(event, game, game.getUserSession(playerId))) - } else { - val eventPromise = game.registerWaiter(playerId) - val scheduledFuture = scheduler.schedule( - new Runnable { - override def run(): Unit = - eventPromise.tryFailure(new java.util.concurrent.TimeoutException("Polling Timeout")) - }, - timeoutDuration.toMillis, - TimeUnit.MILLISECONDS - ) - eventPromise.future.map { event => - scheduledFuture.cancel(false) - game.removeWaiter(playerId) - handleEvent(event, game, game.getUserSession(playerId)) - }.recover { - case _: Throwable => - game.removeWaiter(playerId) - NoContent - } - } - - case None => - Future.successful(NotFound("Game not found.")) - } - } - - -} diff --git a/knockoutwhistweb/app/controllers/WebsocketController.scala b/knockoutwhistweb/app/controllers/WebsocketController.scala new file mode 100644 index 0000000..0a9825e --- /dev/null +++ b/knockoutwhistweb/app/controllers/WebsocketController.scala @@ -0,0 +1,45 @@ +package controllers + + +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.* +import play.api.libs.streams.ActorFlow +import play.api.mvc.* + +import javax.inject.* + + +@Singleton +class WebsocketController @Inject()( + cc: ControllerComponents, + val sessionManger: SessionManager, + )(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) { + + 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, userSession) + } + } + + object KnockOutWebSocketActorFactory { + def create(out: ActorRef, userSession: UserSession): Props = { + Props(new UserWebsocketActor(out, userSession)) + } + } + + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala index ad3e9c8..e00979c 100644 --- a/knockoutwhistweb/app/logic/PodManager.scala +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -11,20 +11,20 @@ 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( - host: User, - name: String, - maxPlayers: Int + host: User, + name: String, + maxPlayers: Int ): GameLobby = { val gameLobby = GameLobby( logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), @@ -35,15 +35,39 @@ class PodManager { host = host ) sessions += (gameLobby.id -> gameLobby) + userSession += (host -> gameLobby.id) gameLobby } def getGame(gameId: String): Option[GameLobby] = { sessions.get(gameId) } - - private[logic] def removeGame(gameId: String): Unit = { - sessions.remove(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 + } + } + + private[logic] def removeGame(gameId: String): Unit = { + sessions.remove(gameId) + // Also remove all user sessions associated with this game + userSession.filterInPlace((_, v) => v != gameId) + } + + } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 0e79ad5..2f96d1f 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -2,66 +2,36 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{InGame, Lobby, MainMenu} +import de.knockoutwhist.control.GameState.{Lobby, MainMenu} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} -import de.knockoutwhist.events.global.tie.TieTurnEvent -import de.knockoutwhist.events.global.{CardPlayedEvent, GameStateChangeEvent, NewTrickEvent, SessionClosed} -import de.knockoutwhist.events.player.{PlayerEvent, ReceivedHandEvent} +import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} +import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} import exceptions.* -import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent} +import logic.PodManager import model.sessions.{InteractionType, UserSession} import model.users.User +import play.api.libs.json.{JsObject, Json} import java.util.UUID import scala.collection.mutable import scala.collection.mutable.ListBuffer -import scala.concurrent.Promise as ScalaPromise class GameLobby private( - val logic: GameLogic, - val id: String, - val internalId: UUID, - val name: String, - val maxPlayers: Int - ) extends EventListener { - + val logic: GameLogic, + val id: String, + val internalId: UUID, + val name: String, + val maxPlayers: Int + ) extends EventListener { private val users: mutable.Map[UUID, UserSession] = mutable.Map() - private val eventsPerPlayer: mutable.Map[UUID, mutable.Queue[PollingEvents]] = mutable.Map() - private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map() - private val lock = new Object - lock.synchronized { - logic.addListener(this) - logic.createSession() - } - - - def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = { - val promise = ScalaPromise[PollingEvents]() - lock.synchronized { - val queue = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) - - if (queue.nonEmpty) { - val evt = queue.dequeue() - promise.success(evt) - promise - } else { - waitingPromises.put(playerId, promise) - promise - } - } - } - - def removeWaiter(playerId: UUID): Unit = { - lock.synchronized { - waitingPromises.remove(playerId) - } - } + logic.addListener(this) + logic.createSession() def addUser(user: User): UserSession = { if (users.size >= maxPlayers) throw new GameFullException("The game is full!") @@ -69,60 +39,32 @@ 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) - addToQueue(LobbyUpdate) + PodManager.registerUserToGame(user, id) + //TODO : transmit Lobby Update transmitToAll() userSession } override def listen(event: SimpleEvent): Unit = { event match { - case event: ReceivedHandEvent => - addToQueue(NewRound) - users.get(event.playerId).foreach(session => session.updatePlayer(event)) - case event: CardPlayedEvent => - addToQueue(CardPlayed) - case event: TieTurnEvent => - addToQueue(ReloadEvent) - users.get(event.player.id).foreach(session => session.updatePlayer(event)) case event: PlayerEvent => users.get(event.playerId).foreach(session => session.updatePlayer(event)) - case event: NewTrickEvent => - addToQueue(NewTrick) case event: GameStateChangeEvent => if (event.oldState == MainMenu && event.newState == Lobby) { return } - addToQueue(ReloadEvent) - users.values.foreach(session => session.updatePlayer(event)) - case event: SessionClosed => users.values.foreach(session => session.updatePlayer(event)) case event: SimpleEvent => users.values.foreach(session => session.updatePlayer(event)) } } - private def addToQueue(event: PollingEvents): Unit = { - lock.synchronized { - users.keys.foreach { playerId => - val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) - q.enqueue(event) - } - val waiterIds = waitingPromises.keys.toList - waiterIds.foreach { playerId => - val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) - if (q.nonEmpty) { - val evt = q.dequeue() - val p = waitingPromises.remove(playerId) - p.foreach(_.success(evt)) - } - } - } - } - /** * Start the game if the user is the host. + * * @param user the user who wants to start the game. */ def startGame(user: User): Unit = { @@ -149,6 +91,7 @@ class GameLobby private( /** * Remove the user from the game lobby. + * * @param user the user who wants to leave the game. */ def leaveGame(userId: UUID): Unit = { @@ -156,14 +99,29 @@ 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 + } + sessionOpt.get.websocketActor.foreach(act => act.transmitJsonToClient(Json.obj( + "id" -> "-1", + "event" -> "SessionClosed", + "data" -> Json.obj( + "reason" -> "You left the game (or got kicked)." + ) + ))) users.remove(userId) - addToQueue(LobbyUpdate) + PodManager.unregisterUserFromGame(sessionOpt.get.user) + //TODO: transmit Lobby Update transmitToAll() } /** * Play a card from the player's hand. + * * @param userSession the user session of the player. - * @param cardIndex the index of the card in the player's hand. + * @param cardIndex the index of the card in the player's hand. */ def playCard(userSession: UserSession, cardIndex: Int): Unit = { val player = getPlayerInteractable(userSession, InteractionType.Card) @@ -179,10 +137,35 @@ class GameLobby private( logic.playerInputLogic.receivedCard(card) } + private def getHand(player: AbstractPlayer): Hand = { + val handOption = player.currentHand() + if (handOption.isEmpty) { + throw new IllegalStateException("You have no cards!") + } + handOption.get + } + + private def getRound: Round = { + val roundOpt = logic.getCurrentRound + if (roundOpt.isEmpty) { + throw new IllegalStateException("No round is currently running!") + } + roundOpt.get + } + + private def getTrick: Trick = { + val trickOpt = logic.getCurrentTrick + if (trickOpt.isEmpty) { + throw new IllegalStateException("No trick is currently running!") + } + trickOpt.get + } + /** * Play a card from the player's hand while in dog life or skip the round. + * * @param userSession the user session of the player. - * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. + * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. */ def playDogCard(userSession: UserSession, cardIndex: Int): Unit = { val player = getPlayerInteractable(userSession, InteractionType.DogCard) @@ -204,8 +187,9 @@ class GameLobby private( /** * Select the trump suit for the round. + * * @param userSession the user session of the player. - * @param trumpIndex the index of the trump suit. + * @param trumpIndex the index of the trump suit. */ def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) @@ -215,8 +199,21 @@ class GameLobby private( logic.playerInputLogic.receivedTrumpSuit(selectedTrump) } + + //------------------- + + private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = { + if (!userSession.lock.isHeldByCurrentThread) { + throw new IllegalStateException("The user session is not locked!") + } + if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) { + throw new NotInteractableException("You can't play a card!") + } + getPlayerBySession(userSession) + } + /** - * + * * @param userSession * @param tieNumber */ @@ -239,9 +236,10 @@ class GameLobby private( logic.createSession() } - - //------------------- - + def getPlayerByUser(user: User): AbstractPlayer = { + getPlayerBySession(getUserSession(user.id)) + } + def getUserSession(userId: UUID): UserSession = { val sessionOpt = users.get(userId) if (sessionOpt.isEmpty) { @@ -250,8 +248,20 @@ class GameLobby private( sessionOpt.get } - def getPlayerByUser(user: User): AbstractPlayer = { - getPlayerBySession(getUserSession(user.id)) + private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { + val playerOption = getMatch.totalplayers.find(_.id == userSession.id) + if (playerOption.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + playerOption.get + } + + private def getMatch: Match = { + val matchOpt = logic.getCurrentMatch + if (matchOpt.isEmpty) { + throw new IllegalStateException("No match is currently running!") + } + matchOpt.get } def getPlayers: mutable.Map[UUID, UserSession] = { @@ -261,63 +271,17 @@ class GameLobby private( def getLogic: GameLogic = { logic } - def getEventsOfPlayer(playerId: UUID): mutable.Queue[PollingEvents] = { - eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) - } - private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { - val playerOption = getMatch.totalplayers.find(_.id == userSession.id) - if (playerOption.isEmpty) { - throw new NotInThisGameException("You are not in this game!") - } - playerOption.get - } - - private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = { - if (!userSession.lock.isHeldByCurrentThread) { - throw new IllegalStateException("The user session is not locked!") - } - if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) { - throw new NotInteractableException("You can't play a card!") - } - getPlayerBySession(userSession) - } - - private def getHand(player: AbstractPlayer): Hand = { - val handOption = player.currentHand() - if (handOption.isEmpty) { - throw new IllegalStateException("You have no cards!") - } - handOption.get - } - - private def getMatch: Match = { - val matchOpt = logic.getCurrentMatch - if (matchOpt.isEmpty) { - throw new IllegalStateException("No match is currently running!") - } - matchOpt.get - } - - private def getRound: Round = { - val roundOpt = logic.getCurrentRound - if (roundOpt.isEmpty) { - throw new IllegalStateException("No round is currently running!") - } - roundOpt.get - } - - private def getTrick: Trick = { - val trickOpt = logic.getCurrentTrick - if (trickOpt.isEmpty) { - throw new IllegalStateException("No trick is currently running!") - } - trickOpt.get - } def getUsers: Set[User] = { users.values.map(d => d.user).toSet } + private def transmitToAll(event: JsObject): Unit = { + users.values.foreach(session => { + session.websocketActor.foreach(act => act.transmitJsonToClient(event)) + }) + } + } object GameLobby { @@ -338,7 +302,8 @@ object GameLobby { ) lobby.users += (host.id -> new UserSession( user = host, - host = true + host = true, + gameLobby = lobby )) lobby } diff --git a/knockoutwhistweb/app/logic/game/PollingEvents.scala b/knockoutwhistweb/app/logic/game/PollingEvents.scala deleted file mode 100644 index cc1117e..0000000 --- a/knockoutwhistweb/app/logic/game/PollingEvents.scala +++ /dev/null @@ -1,10 +0,0 @@ -package logic.game - -enum PollingEvents { - case CardPlayed - case NewRound - case NewTrick - case ReloadEvent - case LobbyUpdate - case LobbyCreation -} \ No newline at end of file diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala index aad7472..4096ffb 100644 --- a/knockoutwhistweb/app/logic/user/SessionManager.scala +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -6,9 +6,11 @@ import model.users.User @ImplementedBy(classOf[BaseSessionManager]) trait SessionManager { - + def createSession(user: User): String + def getUserBySession(sessionId: String): Option[User] + def invalidateSession(sessionId: String): Unit } diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index ecf3a8d..98e1912 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -8,9 +8,13 @@ import model.users.User trait UserManager { def addUser(name: String, password: String): Boolean + def authenticate(name: String, password: String): Option[User] + def userExists(name: String): Option[User] + def userExistsById(id: Long): Option[User] + def removeUser(name: String): Boolean } diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala index 398a908..a50bd58 100644 --- a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -9,7 +9,7 @@ import javax.inject.{Inject, Singleton} @Singleton class StubUserManager @Inject()(val config: Config) extends UserManager { - + private val user: Map[String, User] = Map( "Janis" -> User( internalId = 1L, @@ -53,5 +53,5 @@ class StubUserManager @Inject()(val config: Config) extends UserManager { override def removeUser(name: String): Boolean = { throw new NotImplementedError("StubUserManager.removeUser is not implemented") } - + } diff --git a/knockoutwhistweb/app/model/sessions/InteractionType.scala b/knockoutwhistweb/app/model/sessions/InteractionType.scala index e265edb..db6fedc 100644 --- a/knockoutwhistweb/app/model/sessions/InteractionType.scala +++ b/knockoutwhistweb/app/model/sessions/InteractionType.scala @@ -1,7 +1,7 @@ package model.sessions enum InteractionType { - + case TrumpSuit case Card case DogCard diff --git a/knockoutwhistweb/app/model/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala index 95c39f5..e7ba0b1 100644 --- a/knockoutwhistweb/app/model/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -5,9 +5,11 @@ import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID trait PlayerSession { - + def id: UUID + def name: String + def updatePlayer(event: SimpleEvent): Unit - + } diff --git a/knockoutwhistweb/app/model/sessions/SimpleSession.scala b/knockoutwhistweb/app/model/sessions/SimpleSession.scala index a4c7007..6f31820 100644 --- a/knockoutwhistweb/app/model/sessions/SimpleSession.scala +++ b/knockoutwhistweb/app/model/sessions/SimpleSession.scala @@ -6,9 +6,9 @@ import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { - + def name: String = player.name - + override def updatePlayer(event: SimpleEvent): Unit = { } } diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala index 8bdef2c..ed249c9 100644 --- a/knockoutwhistweb/app/model/sessions/UserSession.scala +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -2,14 +2,18 @@ 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 play.api.libs.json.JsObject import java.util.UUID -import java.util.concurrent.locks.{Lock, ReentrantLock} +import java.util.concurrent.locks.ReentrantLock +import scala.util.Try -class UserSession(val user: User, val host: Boolean) extends PlayerSession { - var canInteract: Option[InteractionType] = None +class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession { val lock: ReentrantLock = ReentrantLock() + var canInteract: Option[InteractionType] = None + var websocketActor: Option[UserWebsocketActor] = None override def updatePlayer(event: SimpleEvent): Unit = { event match { @@ -27,9 +31,21 @@ class UserSession(val user: User, val host: Boolean) extends PlayerSession { override def id: UUID = user.id override def name: String = user.name - + 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..fba8100 --- /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 => + } + + 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 + )) + } + } + + def transmitJsonToClient(jsonObj: JsObject): Unit = { + out ! jsonObj.toString() + } + + def transmitEventToClient(event: SimpleEvent): Unit = { + val jsonString = WebsocketEventMapper.toJsonString(event) + out ! jsonString + } + +} diff --git a/knockoutwhistweb/app/model/users/User.scala b/knockoutwhistweb/app/model/users/User.scala index e56c048..f418618 100644 --- a/knockoutwhistweb/app/model/users/User.scala +++ b/knockoutwhistweb/app/model/users/User.scala @@ -3,10 +3,10 @@ package model.users import java.util.UUID case class User( - internalId: Long, - id: UUID, - name: String, - passwordHash: String + internalId: Long, + id: UUID, + name: String, + passwordHash: String ) { def withName(newName: String): User = { diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala index f1fb46f..bb05df9 100644 --- a/knockoutwhistweb/app/services/JwtKeyProvider.scala +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -12,10 +12,28 @@ import javax.inject.* @Singleton class JwtKeyProvider @Inject()(config: Configuration) { - private def cleanPem(pem: String): String = - pem.replaceAll("-----BEGIN (.*)-----", "") - .replaceAll("-----END (.*)-----", "") - .replaceAll("\\s", "") + val publicKey: RSAPublicKey = { + val pemOpt = config.getOptional[String]("auth.publicKeyPem") + val fileOpt = config.getOptional[String]("auth.publicKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPublicKeyFromPem(pem) + case None => throw new RuntimeException("No RSA public key configured.") + } + } + val privateKey: RSAPrivateKey = { + val pemOpt = config.getOptional[String]("auth.privateKeyPem") + val fileOpt = config.getOptional[String]("auth.privateKeyFile") + + pemOpt.orElse(fileOpt.map { path => + new String(Files.readAllBytes(Paths.get(path))) + }) match { + case Some(pem) => loadPrivateKeyFromPem(pem) + case None => throw new RuntimeException("No RSA private key configured.") + } + } private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { val decoded = Base64.getDecoder.decode(cleanPem(pem)) @@ -29,28 +47,9 @@ class JwtKeyProvider @Inject()(config: Configuration) { KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] } - val publicKey: RSAPublicKey = { - val pemOpt = config.getOptional[String]("auth.publicKeyPem") - val fileOpt = config.getOptional[String]("auth.publicKeyFile") + private def cleanPem(pem: String): String = + pem.replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", "") - pemOpt.orElse(fileOpt.map { path => - new String(Files.readAllBytes(Paths.get(path))) - }) match { - case Some(pem) => loadPublicKeyFromPem(pem) - case None => throw new RuntimeException("No RSA public key configured.") - } - } - - val privateKey: RSAPrivateKey = { - val pemOpt = config.getOptional[String]("auth.privateKeyPem") - val fileOpt = config.getOptional[String]("auth.privateKeyFile") - - pemOpt.orElse(fileOpt.map { path => - new String(Files.readAllBytes(Paths.get(path))) - }) match { - case Some(pem) => loadPrivateKeyFromPem(pem) - case None => throw new RuntimeException("No RSA private key configured.") - } - } - } diff --git a/knockoutwhistweb/app/util/GameUtil.scala b/knockoutwhistweb/app/util/GameUtil.scala index b2caf50..a250603 100644 --- a/knockoutwhistweb/app/util/GameUtil.scala +++ b/knockoutwhistweb/app/util/GameUtil.scala @@ -25,5 +25,5 @@ object GameUtil { code.toString() } - + } 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..85fc7cf 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..9770a8d 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -12,10 +12,10 @@
@gamelobby.getLogic.getCurrentPlayer.get.name
@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) { -@nextplayer
- } +@nextplayer
+ } } @@ -30,21 +30,22 @@ @for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p => - -(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size) + -(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size) }) { -The last round was tied between: - @for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) { - @players - } + @for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) { + @players + }
@@ -23,7 +23,10 @@ @if(player.equals(gamelobby.logic.playerTieLogic.currentTiePlayer())) { @defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum =>@player
-@player
@@ -92,6 +67,38 @@@player
+