feat(websocket)!: Implement WebSocket connection and event handling
This commit is contained in:
17
build.sbt
17
build.sbt
@@ -1,12 +1,12 @@
|
|||||||
ThisBuild / scalaVersion := "3.5.1"
|
ThisBuild / scalaVersion := "3.5.1"
|
||||||
|
|
||||||
lazy val commonSettings = Seq(
|
lazy val commonSettings = Seq(
|
||||||
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.18",
|
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19",
|
||||||
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "test",
|
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test",
|
||||||
libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.0.1",
|
libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.1.0",
|
||||||
libraryDependencies += "org.scalafx" %% "scalafx" % "22.0.0-R33",
|
libraryDependencies += "org.scalafx" %% "scalafx" % "24.0.2-R36",
|
||||||
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0",
|
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.4.0",
|
||||||
libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M1",
|
libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M9",
|
||||||
libraryDependencies ++= {
|
libraryDependencies ++= {
|
||||||
// Determine OS version of JavaFX binaries
|
// Determine OS version of JavaFX binaries
|
||||||
lazy val osName = System.getProperty("os.name") match {
|
lazy val osName = System.getProperty("os.name") match {
|
||||||
@@ -38,8 +38,9 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
|
|||||||
commonSettings,
|
commonSettings,
|
||||||
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
|
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
|
||||||
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
|
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
|
||||||
libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0",
|
libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0",
|
||||||
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2",
|
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
|
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Submodule knockoutwhist updated: e4322839d1...ec94ecd46c
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,7 +20,6 @@ import scala.util.Try
|
|||||||
@Singleton
|
@Singleton
|
||||||
class IngameController @Inject() (
|
class IngameController @Inject() (
|
||||||
val cc: ControllerComponents,
|
val cc: ControllerComponents,
|
||||||
val podManager: PodManager,
|
|
||||||
val authAction: AuthAction,
|
val authAction: AuthAction,
|
||||||
implicit val ec: ExecutionContext
|
implicit val ec: ExecutionContext
|
||||||
) extends AbstractController(cc) {
|
) extends AbstractController(cc) {
|
||||||
@@ -54,7 +53,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
|
|
||||||
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
val results = Try {
|
val results = Try {
|
||||||
@@ -70,7 +69,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
val result = Try {
|
val result = Try {
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
@@ -111,7 +110,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
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 playerToKickUUID = UUID.fromString(playerToKick)
|
||||||
val result = Try {
|
val result = Try {
|
||||||
game.get.leaveGame(playerToKickUUID)
|
game.get.leaveGame(playerToKickUUID)
|
||||||
@@ -129,7 +128,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
val result = Try {
|
val result = Try {
|
||||||
game.get.leaveGame(request.user.id)
|
game.get.leaveGame(request.user.id)
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
|
|
||||||
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
|
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
val jsonBody = request.body.asJson
|
val jsonBody = request.body.asJson
|
||||||
@@ -218,7 +217,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
|
def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) => {
|
case Some(g) => {
|
||||||
val jsonBody = request.body.asJson
|
val jsonBody = request.body.asJson
|
||||||
@@ -282,7 +281,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
val jsonBody = request.body.asJson
|
val jsonBody = request.body.asJson
|
||||||
@@ -334,7 +333,7 @@ class IngameController @Inject() (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
val jsonBody = request.body.asJson
|
val jsonBody = request.body.asJson
|
||||||
@@ -388,7 +387,7 @@ class IngameController @Inject() (
|
|||||||
|
|
||||||
|
|
||||||
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
|
||||||
val game = podManager.getGame(gameId)
|
val game = PodManager.getGame(gameId)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
val result = Try {
|
val result = Try {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controllers
|
package controllers
|
||||||
|
|
||||||
import auth.{AuthAction, AuthenticatedRequest}
|
import auth.AuthAction
|
||||||
import logic.PodManager
|
|
||||||
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
|
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
|
||||||
import play.api.routing.JavaScriptReverseRouter
|
import play.api.routing.JavaScriptReverseRouter
|
||||||
|
|
||||||
@@ -10,7 +9,6 @@ import javax.inject.Inject
|
|||||||
class JavaScriptRoutingController @Inject()(
|
class JavaScriptRoutingController @Inject()(
|
||||||
val controllerComponents: ControllerComponents,
|
val controllerComponents: ControllerComponents,
|
||||||
val authAction: AuthAction,
|
val authAction: AuthAction,
|
||||||
val podManager: PodManager
|
|
||||||
) extends BaseController {
|
) extends BaseController {
|
||||||
def javascriptRoutes(): Action[AnyContent] =
|
def javascriptRoutes(): Action[AnyContent] =
|
||||||
Action { implicit request =>
|
Action { implicit request =>
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import javax.inject.*
|
|||||||
class MainMenuController @Inject()(
|
class MainMenuController @Inject()(
|
||||||
val controllerComponents: ControllerComponents,
|
val controllerComponents: ControllerComponents,
|
||||||
val authAction: AuthAction,
|
val authAction: AuthAction,
|
||||||
val podManager: PodManager,
|
|
||||||
val ingameController: IngameController
|
val ingameController: IngameController
|
||||||
) extends BaseController {
|
) extends BaseController {
|
||||||
|
|
||||||
@@ -39,7 +38,7 @@ class MainMenuController @Inject()(
|
|||||||
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
|
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
|
||||||
.getOrElse(throw new IllegalArgumentException("Player amount is required."))
|
.getOrElse(throw new IllegalArgumentException("Player amount is required."))
|
||||||
|
|
||||||
val gameLobby = podManager.createGame(
|
val gameLobby = PodManager.createGame(
|
||||||
host = request.user,
|
host = request.user,
|
||||||
name = gamename,
|
name = gamename,
|
||||||
maxPlayers = playeramount.toInt
|
maxPlayers = playeramount.toInt
|
||||||
@@ -64,7 +63,7 @@ class MainMenuController @Inject()(
|
|||||||
(jsValue \ "gameId").asOpt[String]
|
(jsValue \ "gameId").asOpt[String]
|
||||||
}
|
}
|
||||||
if (gameId.isDefined) {
|
if (gameId.isDefined) {
|
||||||
val game = podManager.getGame(gameId.get)
|
val game = PodManager.getGame(gameId.get)
|
||||||
game match {
|
game match {
|
||||||
case Some(g) =>
|
case Some(g) =>
|
||||||
g.addUser(request.user)
|
g.addUser(request.user)
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ object PollingController {
|
|||||||
@Singleton
|
@Singleton
|
||||||
class PollingController @Inject() (
|
class PollingController @Inject() (
|
||||||
val cc: ControllerComponents,
|
val cc: ControllerComponents,
|
||||||
val podManager: PodManager,
|
|
||||||
val authAction: AuthAction,
|
val authAction: AuthAction,
|
||||||
val ingameController: IngameController,
|
val ingameController: IngameController,
|
||||||
implicit val ec: ExecutionContext
|
implicit val ec: ExecutionContext
|
||||||
@@ -117,7 +116,7 @@ class PollingController @Inject() (
|
|||||||
|
|
||||||
val playerId = request.user.id
|
val playerId = request.user.id
|
||||||
|
|
||||||
podManager.getGame(gameId) match {
|
PodManager.getGame(gameId) match {
|
||||||
case Some(game) =>
|
case Some(game) =>
|
||||||
val playerEventQueue = game.getEventsOfPlayer(playerId)
|
val playerEventQueue = game.getEventsOfPlayer(playerId)
|
||||||
if (playerEventQueue.nonEmpty) {
|
if (playerEventQueue.nonEmpty) {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
package controllers
|
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.actor.{ActorRef, ActorSystem, Props}
|
||||||
import org.apache.pekko.stream.Materializer
|
import org.apache.pekko.stream.Materializer
|
||||||
import play.api.*
|
import play.api.*
|
||||||
@@ -12,17 +17,28 @@ import javax.inject.*
|
|||||||
@Singleton
|
@Singleton
|
||||||
class WebsocketController @Inject()(
|
class WebsocketController @Inject()(
|
||||||
cc: ControllerComponents,
|
cc: ControllerComponents,
|
||||||
|
val sessionManger: SessionManager,
|
||||||
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
|
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
|
||||||
|
|
||||||
object KnockOutWebSocketActorFactory {
|
object KnockOutWebSocketActorFactory {
|
||||||
def create(out: ActorRef) = {
|
def create(out: ActorRef, userSession: UserSession): Props = {
|
||||||
Props(new KnockOutWebSocketActor(out))
|
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 =>
|
ActorFlow.actorRef { out =>
|
||||||
println("Connect received")
|
println("Connect received")
|
||||||
KnockOutWebSocketActorFactory.create(out)
|
KnockOutWebSocketActorFactory.create(out, userSession)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import util.GameUtil
|
|||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
import scala.collection.mutable
|
import scala.collection.mutable
|
||||||
|
|
||||||
@Singleton
|
object PodManager {
|
||||||
class PodManager {
|
|
||||||
|
|
||||||
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
|
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
|
||||||
val podIp: String = System.getenv("POD_IP")
|
val podIp: String = System.getenv("POD_IP")
|
||||||
val podName: String = System.getenv("POD_NAME")
|
val podName: String = System.getenv("POD_NAME")
|
||||||
|
|
||||||
private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
|
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())
|
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
|
||||||
|
|
||||||
def createGame(
|
def createGame(
|
||||||
@@ -35,6 +35,7 @@ class PodManager {
|
|||||||
host = host
|
host = host
|
||||||
)
|
)
|
||||||
sessions += (gameLobby.id -> gameLobby)
|
sessions += (gameLobby.id -> gameLobby)
|
||||||
|
userSession += (host -> gameLobby.id)
|
||||||
gameLobby
|
gameLobby
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +45,30 @@ class PodManager {
|
|||||||
|
|
||||||
private[logic] def removeGame(gameId: String): Unit = {
|
private[logic] def removeGame(gameId: String): Unit = {
|
||||||
sessions.remove(gameId)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
|
|||||||
import de.knockoutwhist.rounds.{Match, Round, Trick}
|
import de.knockoutwhist.rounds.{Match, Round, Trick}
|
||||||
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
|
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
|
||||||
import exceptions.*
|
import exceptions.*
|
||||||
|
import logic.PodManager
|
||||||
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
|
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
|
||||||
import model.sessions.{InteractionType, UserSession}
|
import model.sessions.{InteractionType, UserSession}
|
||||||
import model.users.User
|
import model.users.User
|
||||||
@@ -69,9 +70,11 @@ class GameLobby private(
|
|||||||
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
|
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
|
||||||
val userSession = new UserSession(
|
val userSession = new UserSession(
|
||||||
user = user,
|
user = user,
|
||||||
host = false
|
host = false,
|
||||||
|
gameLobby = this
|
||||||
)
|
)
|
||||||
users += (user.id -> userSession)
|
users += (user.id -> userSession)
|
||||||
|
PodManager.registerUserToGame(user, id)
|
||||||
addToQueue(LobbyUpdate)
|
addToQueue(LobbyUpdate)
|
||||||
userSession
|
userSession
|
||||||
}
|
}
|
||||||
@@ -156,7 +159,14 @@ class GameLobby private(
|
|||||||
if (sessionOpt.isEmpty) {
|
if (sessionOpt.isEmpty) {
|
||||||
throw new NotInThisGameException("You are not in this game!")
|
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)
|
users.remove(userId)
|
||||||
|
PodManager.unregisterUserFromGame(sessionOpt.get.user)
|
||||||
addToQueue(LobbyUpdate)
|
addToQueue(LobbyUpdate)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +348,8 @@ object GameLobby {
|
|||||||
)
|
)
|
||||||
lobby.users += (host.id -> new UserSession(
|
lobby.users += (host.id -> new UserSession(
|
||||||
user = host,
|
user = host,
|
||||||
host = true
|
host = true,
|
||||||
|
gameLobby = lobby
|
||||||
))
|
))
|
||||||
lobby
|
lobby
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,19 @@ package model.sessions
|
|||||||
|
|
||||||
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
|
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
|
||||||
import de.knockoutwhist.utils.events.SimpleEvent
|
import de.knockoutwhist.utils.events.SimpleEvent
|
||||||
|
import logic.game.GameLobby
|
||||||
import model.users.User
|
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.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 canInteract: Option[InteractionType] = None
|
||||||
|
var websocketActor: Option[UserWebsocketActor] = None
|
||||||
val lock: ReentrantLock = ReentrantLock()
|
val lock: ReentrantLock = ReentrantLock()
|
||||||
|
|
||||||
override def updatePlayer(event: SimpleEvent): Unit = {
|
override def updatePlayer(event: SimpleEvent): Unit = {
|
||||||
@@ -32,4 +38,16 @@ class UserSession(val user: User, val host: Boolean) extends PlayerSession {
|
|||||||
canInteract = None
|
canInteract = None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def handleWebResponse(eventType: String, data: JsObject): Unit = {
|
||||||
|
lock.lock()
|
||||||
|
Try {
|
||||||
|
eventType match {
|
||||||
|
case "Ping" =>
|
||||||
|
// No action needed for Ping
|
||||||
|
()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lock.unlock()
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
94
knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala
Normal file
94
knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package model.sessions
|
||||||
|
|
||||||
|
import de.knockoutwhist.utils.events.SimpleEvent
|
||||||
|
import org.apache.pekko.actor.{Actor, ActorRef}
|
||||||
|
import play.api.libs.json.{JsObject, JsValue, Json}
|
||||||
|
import util.WebsocketEventMapper
|
||||||
|
|
||||||
|
import scala.util.{Failure, Success, Try}
|
||||||
|
|
||||||
|
class UserWebsocketActor(
|
||||||
|
out: ActorRef,
|
||||||
|
session: UserSession
|
||||||
|
) extends Actor {
|
||||||
|
|
||||||
|
if (session.websocketActor.isDefined) {
|
||||||
|
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection."))
|
||||||
|
context.stop(self)
|
||||||
|
} else {
|
||||||
|
session.websocketActor = Some(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
override def receive: Receive = {
|
||||||
|
case msg: String =>
|
||||||
|
val jsonObject = Try {
|
||||||
|
Json.parse(msg)
|
||||||
|
}
|
||||||
|
Try {
|
||||||
|
jsonObject match {
|
||||||
|
case Success(value) =>
|
||||||
|
handle(value)
|
||||||
|
case Failure(exception) =>
|
||||||
|
transmitTextToClient(s"Error parsing JSON: ${exception.getMessage}")
|
||||||
|
}
|
||||||
|
}.failed.foreach(
|
||||||
|
ex => transmitTextToClient(s"Error handling message: ${ex.getMessage}")
|
||||||
|
)
|
||||||
|
case other =>
|
||||||
|
}
|
||||||
|
|
||||||
|
def transmitEventToClient(event: SimpleEvent): Unit = {
|
||||||
|
val jsonString = WebsocketEventMapper.toJsonString(event)
|
||||||
|
out ! jsonString
|
||||||
|
}
|
||||||
|
|
||||||
|
private def transmitJsonToClient(jsonObj: JsObject): Unit = {
|
||||||
|
out ! jsonObj.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private def transmitTextToClient(text: String): Unit = {
|
||||||
|
out ! text
|
||||||
|
}
|
||||||
|
|
||||||
|
private def handle(json: JsValue): Unit = {
|
||||||
|
val idOpt = (json \ "id").asOpt[String]
|
||||||
|
if (idOpt.isEmpty) {
|
||||||
|
transmitJsonToClient(Json.obj(
|
||||||
|
"status" -> "error",
|
||||||
|
"error" -> "Missing 'id' field"
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val id = idOpt.get
|
||||||
|
val eventOpt = (json \ "event").asOpt[String]
|
||||||
|
if (eventOpt.isEmpty) {
|
||||||
|
transmitJsonToClient(Json.obj(
|
||||||
|
"id" -> id,
|
||||||
|
"event" -> null,
|
||||||
|
"status" -> "error",
|
||||||
|
"error" -> "Missing 'event' field"
|
||||||
|
))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val event = eventOpt.get
|
||||||
|
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
|
||||||
|
val result = Try {
|
||||||
|
session.handleWebResponse(event, data)
|
||||||
|
}
|
||||||
|
if (result.isSuccess) {
|
||||||
|
transmitJsonToClient(Json.obj(
|
||||||
|
"id" -> id,
|
||||||
|
"event" -> event,
|
||||||
|
"status" -> "success"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
transmitJsonToClient(Json.obj(
|
||||||
|
"id" -> id,
|
||||||
|
"event" -> event,
|
||||||
|
"status" -> "error",
|
||||||
|
"error" -> result.failed.get.getMessage
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
20
knockoutwhistweb/app/util/WebsocketEventMapper.scala
Normal file
20
knockoutwhistweb/app/util/WebsocketEventMapper.scala
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import de.knockoutwhist.utils.events.SimpleEvent
|
||||||
|
import tools.jackson.databind.json.JsonMapper
|
||||||
|
import tools.jackson.module.scala.ScalaModule
|
||||||
|
|
||||||
|
object WebsocketEventMapper {
|
||||||
|
|
||||||
|
private val scalaModule = ScalaModule.builder()
|
||||||
|
.addAllBuiltinModules()
|
||||||
|
.supportScala3Classes(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
|
||||||
|
|
||||||
|
def toJsonString(obj: SimpleEvent): String = {
|
||||||
|
mapper.writeValueAsString(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -34,4 +34,5 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
||||||
|
connectWebSocket()
|
||||||
</script>
|
</script>
|
||||||
@@ -104,4 +104,5 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
||||||
|
connectWebSocket()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -68,4 +68,5 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
||||||
|
connectWebSocket()
|
||||||
</script>
|
</script>
|
||||||
@@ -114,4 +114,5 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
||||||
|
connectWebSocket()
|
||||||
</script>
|
</script>
|
||||||
@@ -78,4 +78,5 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
|
||||||
|
connectWebSocket()
|
||||||
</script>
|
</script>
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
|
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
|
||||||
console.log('callback - particles.js config loaded');
|
console.log('callback - particles.js config loaded');
|
||||||
});
|
});
|
||||||
|
disconnectWebSocket();
|
||||||
</script>
|
</script>
|
||||||
<div id="particles-js" style="background-color: rgb(11, 8, 8);
|
<div id="particles-js" style="background-color: rgb(11, 8, 8);
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
</body>
|
</body>
|
||||||
<script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
|
<script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
|
||||||
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
|
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
|
||||||
|
<script src="@routes.Assets.versioned("javascripts/ingame.js")" type="text/javascript"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
|
||||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -28,3 +28,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
disconnectWebSocket();
|
||||||
|
</script>
|
||||||
@@ -175,3 +175,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
disconnectWebSocket();
|
||||||
|
</script>
|
||||||
|
|||||||
187
knockoutwhistweb/public/javascripts/ingame.js
Normal file
187
knockoutwhistweb/public/javascripts/ingame.js
Normal file
@@ -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;
|
||||||
@@ -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.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user