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();
+ diff --git a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html b/knockoutwhistweb/app/views/mainmenu/creategame.scala.html index e3a3b28..958b835 100644 --- a/knockoutwhistweb/app/views/mainmenu/creategame.scala.html +++ b/knockoutwhistweb/app/views/mainmenu/creategame.scala.html @@ -27,4 +27,7 @@
Create Game
- \ No newline at end of file + + \ No newline at end of file diff --git a/knockoutwhistweb/app/views/mainmenu/rules.scala.html b/knockoutwhistweb/app/views/mainmenu/rules.scala.html index 111189e..989611b 100644 --- a/knockoutwhistweb/app/views/mainmenu/rules.scala.html +++ b/knockoutwhistweb/app/views/mainmenu/rules.scala.html @@ -175,3 +175,6 @@ + diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 0584792..73a8569 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -41,4 +41,4 @@ POST /game/:id/returnToLobby controllers.IngameController.returnT GET /polling/:gameId controllers.PollingController.polling(gameId: String) # Websocket -GET /websocket controllers.WebsocketController.socket() \ No newline at end of file +GET /websocket controllers.WebsocketController.socket() \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/ingame.js b/knockoutwhistweb/public/javascripts/ingame.js new file mode 100644 index 0000000..d44e04d --- /dev/null +++ b/knockoutwhistweb/public/javascripts/ingame.js @@ -0,0 +1,187 @@ +// javascript +let ws = null; // will be created by connectWebSocket() +const pending = new Map(); // id -> { resolve, reject, timer } +const handlers = new Map(); // eventType -> handler(data) -> (value|Promise) + + +let timer = null; +// helper to attach message/error/close handlers to a socket +function setupSocketHandlers(socket) { + socket.onmessage = (event) => { + console.debug("SERVER RESPONSE:", event.data); + let msg; + try { + msg = JSON.parse(event.data); + } catch (e) { + console.debug("Non-JSON message from server:", event.data, e); + return; + } + + const id = msg.id; + const eventType = msg.event; + const status = msg.status; + const data = msg.data; + + if (id && typeof status === "string") { + const entry = pending.get(id); + if (!entry) return; + clearTimeout(entry.timer); + pending.delete(id); + + if (status === "success") { + entry.resolve(data === undefined ? {} : data); + } else { + entry.reject(new Error(msg.error || "Server returned error")); + } + return; + } + + if (id && eventType) { + const handler = handlers.get(eventType); + const sendResponse = (respData) => { + const response = { id: id, event: eventType, data: respData === undefined ? {} : respData }; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(JSON.stringify(response)); + } else { + console.warn("Cannot send response, websocket not open"); + } + }; + + if (!handler) { + // no handler: respond with an error object in data so server can fail it + sendResponse({ error: "No handler for event: " + eventType }); + return; + } + + try { + Promise.resolve(handler(data === undefined ? {} : data)) + .then(result => sendResponse(result)) + .catch(err => sendResponse({ error: err?.message ? err.message : String(err) })); + } catch (err) { + sendResponse({ error: err?.message ? err.message : String(err) }); + } + } + }; + + socket.onerror = (error) => { + console.error("WebSocket Error:", error); + if (timer) clearInterval(timer); + for (const [id, entry] of pending.entries()) { + clearTimeout(entry.timer); + entry.reject(new Error("WebSocket error/closed")); + pending.delete(id); + } + if (socket.readyState === WebSocket.OPEN) socket.close(1000, "Unexpected error."); + }; + + socket.onclose = (event) => { + if (timer) clearInterval(timer); + for (const [id, entry] of pending.entries()) { + clearTimeout(entry.timer); + entry.reject(new Error("WebSocket closed")); + pending.delete(id); + } + if (event.wasClean) { + console.log(`Connection closed cleanly, code=${event.code} reason=${event.reason}`); + } else { + console.warn('Connection died unexpectedly.'); + } + }; +} + +// connect/disconnect helpers +function connectWebSocket(url = "ws://localhost:9000/websocket") { + if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(); + if (ws && ws.readyState === WebSocket.CONNECTING) { + // already connecting - return a promise that resolves on open + return new Promise((resolve, reject) => { + const prevOnOpen = ws.onopen; + const prevOnError = ws.onerror; + ws.onopen = (ev) => { + if (prevOnOpen) prevOnOpen(ev); + resolve(); + }; + ws.onerror = (err) => { + if (prevOnError) prevOnError(err); + reject(err); + }; + }); + } + + ws = new WebSocket(url); + setupSocketHandlers(ws); + + return new Promise((resolve, reject) => { + ws.onopen = () => { + console.log("WebSocket connection established!"); + // start heartbeat + timer = setInterval(() => { + if (ws && ws.readyState === WebSocket.OPEN) { + sendEventAndWait("ping", {}).then( + () => console.debug("PING RESPONSE RECEIVED"), + ).catch( + (err) => console.warn("PING ERROR:", err.message), + ); + console.debug("PING SENT"); + } + }, 5000); + resolve(); + }; + + ws.onerror = (err) => { + reject(err); + }; + }); +} + +function disconnectWebSocket(code = 1000, reason = "Client disconnect") { + if (timer) { + clearInterval(timer); + timer = null; + } + if (ws) { + try { ws.close(code, reason); } catch (e) {} + ws = null; + } +} + +function sendEvent(eventType, eventData) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.warn("WebSocket is not open. Unable to send message."); + return; + } + const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9); + const message = { id: id, event: eventType, data: eventData }; + ws.send(JSON.stringify(message)); + console.debug("SENT:", message); +} + +function sendEventAndWait(eventType, eventData, timeoutMs = 10000) { + if (!ws || ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("WebSocket is not open")); + } + const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9); + const message = { id: id, event: eventType, data: eventData }; + const p = new Promise((resolve, reject) => { + const timerId = setTimeout(() => { + if (pending.has(id)) { + pending.delete(id); + reject(new Error(`No response within ${timeoutMs}ms for id=${id}`)); + } + }, timeoutMs); + pending.set(id, { resolve, reject, timer: timerId }); + }); + ws.send(JSON.stringify(message)); + console.debug("SENT (await):", message); + return p; +} +function onEvent(eventType, handler) { + handlers.set(eventType, handler); +} + +globalThis.sendEvent = sendEvent; +globalThis.sendEventAndWait = sendEventAndWait; +globalThis.onEvent = onEvent; +globalThis.connectWebSocket = connectWebSocket; +globalThis.disconnectWebSocket = disconnectWebSocket; +globalThis.isWebSocketConnected = () => !!ws && ws.readyState === WebSocket.OPEN; diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index 5a3c436..1f8f830 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -664,23 +664,4 @@ function sendPlayCardRequest(jsonObj, gameId, cardobject, dog) { }) }) } -const ws = new WebSocket("ws://localhost:9000/websocket"); -ws.onopen = (event) => { - console.log("WebSocket connection established!"); - - ws.send("Client is now connected and ready."); -}; -ws.onmessage = (event) => { - console.log("SERVER RESPONSE:", event.data); -}; -ws.onerror = (error) => { - console.error("WebSocket Error:", error); -}; -ws.onclose = (event) => { - if (event.wasClean) { - console.log(`Connection closed cleanly, code=${event.code} reason=${event.reason}`); - } else { - console.warn('Connection died unexpectedly.'); - } -};