From b81bb3d0aeb8500a9d7417a10e24e7f8a17d71d2 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 26 Nov 2025 11:26:08 +0100 Subject: [PATCH] feat(base): Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! (#84) Reviewed-on: https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/pulls/84 Reviewed-by: lq64 Co-authored-by: Janis Co-committed-by: Janis --- knockoutwhist | 2 +- .../app/controllers/IngameController.scala | 67 +++++++------- .../app/controllers/MainMenuController.scala | 7 +- .../model/sessions/UserWebsocketActor.scala | 18 ++-- .../app/util/WebsocketEventMapper.scala | 8 +- .../util/mapper/GameStateEventMapper.scala | 18 ++++ .../util/mapper/ReceivedHandEventMapper.scala | 6 +- .../app/util/mapper/SimpleEventMapper.scala | 3 +- .../app/views/ingame/ingame.scala.html | 91 ++++++++++++------- .../app/views/ingame/selecttrump.scala.html | 74 +++++++-------- knockoutwhistweb/app/views/main.scala.html | 15 +-- knockoutwhistweb/public/javascripts/events.js | 10 +- knockoutwhistweb/public/javascripts/main.js | 5 +- .../public/javascripts/websocket.js | 10 +- 14 files changed, 200 insertions(+), 134 deletions(-) create mode 100644 knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala diff --git a/knockoutwhist b/knockoutwhist index ec94ecd..afa6bc1 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit ec94ecd46c4ae89191f99638fab1466e7093ed1e +Subproject commit afa6bc1406e2a0a09b510f3882fe7bf12d2909f5 diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index 598b470..3929169 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -1,6 +1,7 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} +import de.knockoutwhist.control.GameState import de.knockoutwhist.control.GameState.* import exceptions.* import logic.PodManager @@ -29,7 +30,7 @@ class IngameController @Inject()( game match { case Some(g) => val results = Try { - returnInnerHTML(g, request.user) + IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user) } if (results.isSuccess) { Ok(views.html.main("In-Game - Knockout Whist")(results.get)) @@ -41,34 +42,6 @@ class IngameController @Inject()( } } - def returnInnerHTML(gameLobby: GameLobby, user: User): Html = { - gameLobby.logic.getCurrentState match { - case Lobby => views.html.lobby.lobby(Some(user), gameLobby) - case InGame => - views.html.ingame.ingame( - gameLobby.getPlayerByUser(user), - gameLobby - ) - case SelectTrump => - views.html.ingame.selecttrump( - gameLobby.getPlayerByUser(user), - gameLobby - ) - case TieBreak => - views.html.ingame.tie( - gameLobby.getPlayerByUser(user), - gameLobby - ) - case FinishedMatch => - views.html.ingame.finishedMatch( - Some(user), - gameLobby - ) - case _ => - throw new IllegalStateException(s"Invalid game state for in-game view. GameId: ${gameLobby.id}" + s" State: ${gameLobby.logic.getCurrentState}") - } - } - def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val game = PodManager.getGame(gameId) val result = Try { @@ -83,7 +56,7 @@ class IngameController @Inject()( Ok(Json.obj( "status" -> "success", "redirectUrl" -> routes.IngameController.game(gameId).url, - "content" -> returnInnerHTML(game.get, request.user).toString() + "content" -> IngameController.returnInnerHTML(game.get, game.get.logic.getCurrentState, request.user).toString() )) } else { val throwable = result.failed.get @@ -434,5 +407,37 @@ class IngameController @Inject()( )) } } + +} -} \ No newline at end of file +object IngameController { + + def returnInnerHTML(gameLobby: GameLobby, gameState: GameState, user: User): Html = { + gameState match { + case Lobby => views.html.lobby.lobby(Some(user), gameLobby) + case InGame => + views.html.ingame.ingame( + gameLobby.getPlayerByUser(user), + gameLobby + ) + case SelectTrump => + views.html.ingame.selecttrump( + gameLobby.getPlayerByUser(user), + gameLobby + ) + case TieBreak => + views.html.ingame.tie( + gameLobby.getPlayerByUser(user), + gameLobby + ) + case FinishedMatch => + views.html.ingame.finishedMatch( + Some(user), + gameLobby + ) + case _ => + throw new IllegalStateException(s"Invalid game state for in-game view. GameId: ${gameLobby.id}" + s" State: ${gameLobby.logic.getCurrentState}") + } + } + +} diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala index 4a3eeb7..a8891ed 100644 --- a/knockoutwhistweb/app/controllers/MainMenuController.scala +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -16,8 +16,7 @@ import javax.inject.* @Singleton class MainMenuController @Inject()( val controllerComponents: ControllerComponents, - val authAction: AuthAction, - val ingameController: IngameController + val authAction: AuthAction ) extends BaseController { // Pass the request-handling function directly to authAction (no nested Action) @@ -46,7 +45,7 @@ class MainMenuController @Inject()( Ok(Json.obj( "status" -> "success", "redirectUrl" -> routes.IngameController.game(gameLobby.id).url, - "content" -> ingameController.returnInnerHTML(gameLobby, request.user).toString + "content" -> IngameController.returnInnerHTML(gameLobby, gameLobby.logic.getCurrentState, request.user).toString )) } else { BadRequest(Json.obj( @@ -70,7 +69,7 @@ class MainMenuController @Inject()( Ok(Json.obj( "status" -> "success", "redirectUrl" -> routes.IngameController.game(g.id).url, - "content" -> ingameController.returnInnerHTML(g, request.user).toString + "content" -> IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user).toString )) case None => NotFound(Json.obj( diff --git a/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala b/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala index d7f80f6..b2b59c7 100644 --- a/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala +++ b/knockoutwhistweb/app/model/sessions/UserWebsocketActor.scala @@ -12,13 +12,19 @@ class UserWebsocketActor( 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.lock.lock() + if (session.websocketActor.isDefined) { + val otherWebsocket = session.websocketActor.get + otherWebsocket.transmitTextToClient("Error: Multiple websocket connections detected. Closing your connection.") + context.stop(otherWebsocket.self) + transmitTextToClient("Previous websocket connection closed. You are now connected.") + } session.websocketActor = Some(this) + session.lock.unlock() } + override def receive: Receive = { case msg: String => val jsonObject = Try { @@ -86,12 +92,12 @@ class UserWebsocketActor( } } - def transmitJsonToClient(jsonObj: JsObject): Unit = { + def transmitJsonToClient(jsonObj: JsValue): Unit = { transmitTextToClient(jsonObj.toString()) } def transmitEventToClient(event: SimpleEvent): Unit = { - transmitJsonToClient(WebsocketEventMapper.toJson(event)) + transmitJsonToClient(WebsocketEventMapper.toJson(event, session)) } } diff --git a/knockoutwhistweb/app/util/WebsocketEventMapper.scala b/knockoutwhistweb/app/util/WebsocketEventMapper.scala index c12ecef..f668d29 100644 --- a/knockoutwhistweb/app/util/WebsocketEventMapper.scala +++ b/knockoutwhistweb/app/util/WebsocketEventMapper.scala @@ -2,10 +2,11 @@ package util import de.knockoutwhist.utils.events.SimpleEvent import logic.game.GameLobby +import model.sessions.UserSession import play.api.libs.json.{JsValue, Json} import tools.jackson.databind.json.JsonMapper import tools.jackson.module.scala.ScalaModule -import util.mapper.{ReceivedHandEventMapper, SimpleEventMapper} +import util.mapper.{GameStateEventMapper, ReceivedHandEventMapper, SimpleEventMapper} object WebsocketEventMapper { @@ -24,10 +25,11 @@ object WebsocketEventMapper { // Register all custom mappers here registerCustomMapper(ReceivedHandEventMapper) + registerCustomMapper(GameStateEventMapper) - def toJson(obj: SimpleEvent, gameLobby: GameLobby): JsValue = { + def toJson(obj: SimpleEvent, session: UserSession): JsValue = { val data: Option[JsValue] = if (customMappers.contains(obj.id)) { - Some(customMappers(obj.id).toJson(obj)) + Some(customMappers(obj.id).toJson(obj, session)) }else { None } diff --git a/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala b/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala new file mode 100644 index 0000000..3c4c0dc --- /dev/null +++ b/knockoutwhistweb/app/util/mapper/GameStateEventMapper.scala @@ -0,0 +1,18 @@ +package util.mapper + +import controllers.IngameController +import de.knockoutwhist.events.global.GameStateChangeEvent +import model.sessions.UserSession +import play.api.libs.json.{JsObject, Json} + +object GameStateEventMapper extends SimpleEventMapper[GameStateChangeEvent] { + + override def id: String = "GameStateChangeEvent" + + override def toJson(event: GameStateChangeEvent, session: UserSession): JsObject = { + Json.obj( + //Title + "content" -> IngameController.returnInnerHTML(session.gameLobby, event.newState, session.user).toString + ) + } +} diff --git a/knockoutwhistweb/app/util/mapper/ReceivedHandEventMapper.scala b/knockoutwhistweb/app/util/mapper/ReceivedHandEventMapper.scala index 830a761..0d16aac 100644 --- a/knockoutwhistweb/app/util/mapper/ReceivedHandEventMapper.scala +++ b/knockoutwhistweb/app/util/mapper/ReceivedHandEventMapper.scala @@ -1,14 +1,14 @@ package util.mapper import de.knockoutwhist.events.player.ReceivedHandEvent -import logic.game.GameLobby -import play.api.libs.json.{JsArray, JsObject, Json} +import model.sessions.UserSession +import play.api.libs.json.{JsObject, Json} import util.WebUIUtils object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] { override def id: String = "ReceivedHandEvent" - override def toJson(event: ReceivedHandEvent, gameLobby: GameLobby): JsObject = { + override def toJson(event: ReceivedHandEvent, session: UserSession): JsObject = { Json.obj( "dog" -> event.player.isInDogLife, "hand" -> event.player.currentHand().map(hand => WebUIUtils.handToJson(hand)) diff --git a/knockoutwhistweb/app/util/mapper/SimpleEventMapper.scala b/knockoutwhistweb/app/util/mapper/SimpleEventMapper.scala index 660fcea..5fc30dd 100644 --- a/knockoutwhistweb/app/util/mapper/SimpleEventMapper.scala +++ b/knockoutwhistweb/app/util/mapper/SimpleEventMapper.scala @@ -2,11 +2,12 @@ package util.mapper import de.knockoutwhist.utils.events.SimpleEvent import logic.game.GameLobby +import model.sessions.UserSession import play.api.libs.json.JsObject trait SimpleEventMapper[T <: SimpleEvent] { def id: String - def toJson(event: T, gameLobby: GameLobby): JsObject + def toJson(event: T, session: UserSession): JsObject } diff --git a/knockoutwhistweb/app/views/ingame/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html index 9770a8d..c22a21b 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -10,8 +10,12 @@

Current Player

-

@gamelobby.getLogic.getCurrentPlayer.get.name

- @if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) { + @if(gamelobby.getLogic.getCurrentPlayer.isDefined) { +

@gamelobby.getLogic.getCurrentPlayer.get.name

+ }else { +

---

+ } + @if(gamelobby.getLogic.getPlayerQueue.isDefined && gamelobby.getLogic.getCurrentMatch && !TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {

Next Player

@for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) {

@nextplayer

@@ -29,41 +33,56 @@
TRICKS
- @for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p => - -(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size) - }) { -
-
@player.name
-
- @(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size) + @if(gamelobby.getLogic.getPlayerQueue.isDefined) { + @for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p => + -(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size) + }) { +
+
@player.name
+
+ @(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size) +
+ } + }else{ +
} +
- @for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) { -
-
-
- @util.WebUIUtils.cardtoImage(cardplayed) width="100%"/> -
-
- @player -
+ @if(gamelobby.getLogic.getCurrentTrick.isEmpty || gamelobby.getLogic.getCurrentTrick.get.cards.isEmpty) { +
-
- } + } else { + @for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) { +
+
+
+ @util.WebUIUtils.cardtoImage(cardplayed) width="100%"/> +
+
+ @player +
+
+
+ } + }

Trumpsuit

-

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

+ @if(gamelobby.getLogic.getCurrentRound.isEmpty) { +

No Trumpsuit

+ }else { +

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

+ }
First Card
- @if(gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) { + @if(gamelobby.getLogic.getCurrentTrick.isDefined && gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get) width="80px"/> } else { @@ -79,19 +98,23 @@
- @for(i <- player.currentHand().get.cards.indices) { -
-
- @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/> -
- @if(player.isInDogLife) { -
- + @if(player.currentHand().isEmpty || player.currentHand().get.cards.isEmpty) { + + } else { + @for(i <- player.currentHand().get.cards.indices) { +
+
+ @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/> +
+ @if(player.isInDogLife) { +
+ +
+ }
} -
- } + }
diff --git a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html index c42c2be..8700adc 100644 --- a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html +++ b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html @@ -10,49 +10,51 @@

Select Trump Suit

- @if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) { - + @if(gamelobby.logic.getCurrentMatch.isDefined) { + @if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) { + -
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades)) - width="120px" style="border-radius: 6px"/> +
+
+
+ @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades)) + width="120px" style="border-radius: 6px"/> +
+
+
+
+ @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts)) + width="120px" style="border-radius: 6px"/> +
+
+
+
+ @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds)) + width="120px" style="border-radius: 6px"/> +
+
+
+
+ @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs)) + width="120px" style="border-radius: 6px"/> +
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts)) - width="120px" style="border-radius: 6px"/> +
+ @for(i <- player.currentHand().get.cards.indices) { +
+ @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
+ }
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds)) - width="120px" style="border-radius: 6px"/> -
-
-
-
- @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs)) - width="120px" style="border-radius: 6px"/> -
-
-
-
- @for(i <- player.currentHand().get.cards.indices) { -
- @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/> + } else { + } -
- } else { - }
diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html index aea7e72..d53c6b6 100644 --- a/knockoutwhistweb/app/views/main.scala.html +++ b/knockoutwhistweb/app/views/main.scala.html @@ -18,16 +18,17 @@ + + + + + + + + @* And here's where we render the `Html` object containing * the page content. *@ @content - - - - - - - diff --git a/knockoutwhistweb/public/javascripts/events.js b/knockoutwhistweb/public/javascripts/events.js index 24d7860..8937a91 100644 --- a/knockoutwhistweb/public/javascripts/events.js +++ b/knockoutwhistweb/public/javascripts/events.js @@ -39,4 +39,12 @@ function receiveHandEvent(eventData) { handElement.html(newHtml); } -onEvent("ReceivedHandEvent", receiveHandEvent) \ No newline at end of file +function receiveGameStateChange(eventData) { + const content = eventData.content; + const title = eventData.title || 'Knockout Whist'; + + exchangeBody(content, title); +} + +onEvent("ReceivedHandEvent", receiveHandEvent) +onEvent("GameStateChangeEvent", receiveGameStateChange) \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index d843bd3..73b1e83 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -216,4 +216,7 @@ function navSpa(page, title) { }) }); return false -} \ No newline at end of file +} + + +globalThis.exchangeBody = exchangeBody; \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/websocket.js b/knockoutwhistweb/public/javascripts/websocket.js index ccbf44f..7a32d71 100644 --- a/knockoutwhistweb/public/javascripts/websocket.js +++ b/knockoutwhistweb/public/javascripts/websocket.js @@ -1,10 +1,7 @@ -type EventHandler = (data: any) => any | Promise; - // javascript let ws = null; // will be created by connectWebSocket() -const pending: Map = new Map(); // id -> { resolve, reject, timer } -const handlers: Map = new Map(); // eventType -> handler(data) -> (value|Promise) - +const pending = new Map(); // id -> { resolve, reject, timer } +const handlers = new Map(); // eventType -> handler(data) -> (value|Promise) let timer = null; @@ -52,6 +49,7 @@ function setupSocketHandlers(socket) { if (!handler) { // no handler: respond with an error object in data so server can fail it + console.warn("No handler for event:", eventType); sendResponse({error: "No handler for event: " + eventType}); return; } @@ -182,7 +180,7 @@ function sendEventAndWait(eventType, eventData, timeoutMs = 10000) { return p; } -function onEvent(eventType: string, handler: EventHandler) { +function onEvent(eventType, handler) { handlers.set(eventType, handler); }