From 5c8fd8510ee10a09bc4b67117a5460a5e760d200 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 12 Nov 2025 11:46:21 +0100 Subject: [PATCH] feat(ci): Polling Added polling for when the game starts and a card gets played --- .../app/assets/stylesheets/main.less | 6 +- .../app/controllers/IngameController.scala | 115 ++++++++++-- .../JavaScriptRoutingController.scala | 3 +- .../app/logic/game/GameLobby.scala | 41 +++- .../app/logic/game/PollingEvents.scala | 6 + .../app/views/ingame/ingame.scala.html | 17 +- .../app/views/lobby/lobby.scala.html | 7 +- knockoutwhistweb/conf/routes | 4 +- knockoutwhistweb/public/javascripts/main.js | 177 +++++++++++++++++- 9 files changed, 342 insertions(+), 34 deletions(-) create mode 100644 knockoutwhistweb/app/logic/game/PollingEvents.scala diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index 145789b..343b498 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -22,6 +22,7 @@ 0% { transform: translateX(-100vw); } 100% { transform: translateX(0); } } + .game-field-background { background-image: @background-image; max-width: 1400px; @@ -184,11 +185,6 @@ body { font-size: 20px; } -#trumpsuit { - display: flex; - flex-direction: row; - margin-left: 4%; -} #nextPlayers { display: flex; flex-direction: column; diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index d808f05..fcfa2f6 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -1,30 +1,121 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} +import de.knockoutwhist.cards.Hand import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} import logic.PodManager +import logic.game.PollingEvents.CardPlayed +import logic.game.PollingEvents.GameStarted +import logic.game.{GameLobby, PollingEvents} import model.sessions.{PlayerSession, UserSession} +import model.users.User import play.api.* -import play.api.libs.json.Json +import play.api.libs.json.{JsArray, JsValue, Json} import play.api.mvc.* +import util.WebUIUtils import java.util.UUID import javax.inject.* +import scala.concurrent.Future import scala.util.Try - - -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ +import scala.concurrent.ExecutionContext @Singleton -class IngameController @Inject()( - val controllerComponents: ControllerComponents, - val authAction: AuthAction, - val podManager: PodManager - ) extends BaseController { +class IngameController @Inject() ( + val cc: ControllerComponents, + val podManager: PodManager, + val authAction: AuthAction, + implicit val ec: ExecutionContext + ) extends AbstractController(cc) { + // --- Helper function (defined outside match/if for scope) --- + def buildSuccessResponse(game: GameLobby, hand: Option[Hand]): JsValue = { + // NOTE: Replace the unsafe .get calls here if game state is not guaranteed + val currentRound = game.logic.getCurrentRound.get + val currentTrick = game.logic.getCurrentTrick.get + + // JSON Building Logic: + 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", + "handData" -> stringHand, + "currentPlayerName" -> game.logic.getCurrentPlayer.get.name, + "trumpSuit" -> currentRound.trumpSuit.toString, + "trickCards" -> trickCardsJson, + "scoreTable" -> scoreTableJson, + "firstCardId" -> firstCardId, + "nextPlayer" -> nextPlayer + ) + } + + def handleEvent(event: PollingEvents, game: GameLobby, user: User): Result = { + event match { + case CardPlayed => + val player = game.getPlayerByUser(user) + val hand = player.currentHand() + val jsonResponse = buildSuccessResponse(game, hand) + Ok(jsonResponse) + case GameStarted => + val jsonResponse = Json.obj( + "status" -> "gameStart", + "redirectUrl" -> routes.IngameController.game(game.id).url + ) + Ok(jsonResponse) + } + } + // --- Main Polling Action --- + def polling(gameId: String): Action[AnyContent] = authAction.async { implicit request: AuthenticatedRequest[AnyContent] => + + val playerId = request.user.id + + // 1. Safely look up the game + podManager.getGame(gameId) match { + case Some(game) => + + // 2. Short-Poll Check (Check for missed events) + if (game.getPollingState.nonEmpty) { + val event = game.getPollingState.dequeue() + + Future.successful(handleEvent(event, game, request.user)) + } else { + + val eventPromise = game.registerWaiter(playerId) + + eventPromise.future.map { event => + game.removeWaiter(playerId) + handleEvent(event, game, request.user) + }.recover { + case _: Throwable => + game.removeWaiter(playerId) + NoContent + } + } + + case None => + // Game not found + Future.successful(NotFound("Game not found.")) + } + } def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val game = podManager.getGame(gameId) game match { diff --git a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala index 2cd5f06..48c2f9f 100644 --- a/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala +++ b/knockoutwhistweb/app/controllers/JavaScriptRoutingController.scala @@ -19,7 +19,8 @@ class JavaScriptRoutingController @Inject()( routes.javascript.IngameController.startGame, routes.javascript.IngameController.kickPlayer, routes.javascript.IngameController.leaveGame, - routes.javascript.IngameController.playCard + routes.javascript.IngameController.playCard, + routes.javascript.IngameController.polling ) ).as("text/javascript") } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 486bf4f..a897368 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -2,21 +2,23 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{Lobby, MainMenu} +import de.knockoutwhist.control.GameState.{InGame, Lobby, MainMenu} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} -import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} +import de.knockoutwhist.events.global.{CardPlayedEvent, 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, GameStarted} import model.sessions.{InteractionType, UserSession} import model.users.User import java.util.UUID import scala.collection.mutable import scala.collection.mutable.ListBuffer +import scala.concurrent.{Promise => ScalaPromise} class GameLobby private( val logic: GameLogic, @@ -29,7 +31,19 @@ class GameLobby private( logic.createSession() private val users: mutable.Map[UUID, UserSession] = mutable.Map() - + private var pollingState: mutable.Queue[PollingEvents] = mutable.Queue() + private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map() + + def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = { + val promise = ScalaPromise[PollingEvents]() + waitingPromises.put(playerId, promise) + promise + } + + def removeWaiter(playerId: UUID): Unit = { + waitingPromises.remove(playerId) + } + def addUser(user: User): UserSession = { if (users.size >= maxPlayers) throw new GameFullException("The game is full!") if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!") @@ -44,12 +58,29 @@ class GameLobby private( override def listen(event: SimpleEvent): Unit = { event match { + case event: CardPlayedEvent => + val newEvent = PollingEvents.CardPlayed + if (waitingPromises.nonEmpty) { + waitingPromises.values.foreach(_.success(newEvent)) + waitingPromises.clear() + } else { + pollingState.enqueue(newEvent) + } case event: PlayerEvent => users.get(event.playerId).foreach(session => session.updatePlayer(event)) case event: GameStateChangeEvent => if (event.oldState == MainMenu && event.newState == Lobby) { return } + if (event.oldState == Lobby && event.newState == InGame) { + val newEvent = PollingEvents.GameStarted + if (waitingPromises.nonEmpty) { + waitingPromises.values.foreach(_.success(newEvent)) + waitingPromises.clear() + } else { + pollingState.enqueue(newEvent) + } + } users.values.foreach(session => session.updatePlayer(event)) case event: SessionClosed => users.values.foreach(session => session.updatePlayer(event)) @@ -183,7 +214,9 @@ class GameLobby private( def getLogic: GameLogic = { logic } - + def getPollingState: mutable.Queue[PollingEvents] = { + pollingState + } private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { val playerOption = getMatch.totalplayers.find(_.id == userSession.id) if (playerOption.isEmpty) { diff --git a/knockoutwhistweb/app/logic/game/PollingEvents.scala b/knockoutwhistweb/app/logic/game/PollingEvents.scala new file mode 100644 index 0000000..933cf82 --- /dev/null +++ b/knockoutwhistweb/app/logic/game/PollingEvents.scala @@ -0,0 +1,6 @@ +package logic.game + +enum PollingEvents { + case CardPlayed + case GameStarted +} \ 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 271fc28..50f45f6 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -10,18 +10,18 @@

Current Player

-

@gamelobby.getLogic.getCurrentPlayer.get.name

+

@gamelobby.getLogic.getCurrentPlayer.get.name

@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {

Next Player

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

@nextplayer

+

@nextplayer

} }
-
+

Tricks Won

@@ -41,7 +41,7 @@ }
-
+
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) {
@@ -58,10 +58,10 @@

Trumpsuit

-

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

+

@gamelobby.getLogic.getCurrentRound.get.trumpSuit

First Card
-
+
@if(gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get) width="80px"/> } else { @@ -85,4 +85,9 @@
+ } diff --git a/knockoutwhistweb/app/views/lobby/lobby.scala.html b/knockoutwhistweb/app/views/lobby/lobby.scala.html index 75ba8d4..1ac678f 100644 --- a/knockoutwhistweb/app/views/lobby/lobby.scala.html +++ b/knockoutwhistweb/app/views/lobby/lobby.scala.html @@ -1,7 +1,7 @@ @(user: Option[model.users.User], gamelobby: logic.game.GameLobby) @main("Lobby") { -
+
@@ -66,4 +66,9 @@
+ } \ No newline at end of file diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 9a1a4a6..8267bdc 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -28,4 +28,6 @@ GET /game/:id/join controllers.IngameController.joinGame(id: St GET /game/:id/start controllers.IngameController.startGame(id: String) POST /game/:id/kickPlayer controllers.IngameController.kickPlayer(id: String, playerId: String) GET /game/:id/leaveGame controllers.IngameController.leaveGame(id: String) -POST /game/:id/playCard controllers.IngameController.playCard(id: String) \ No newline at end of file +POST /game/:id/playCard controllers.IngameController.playCard(id: String) +# Polling +GET /polling controllers.IngameController.polling(gameId: String) \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index aa8ec29..d48ba74 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -79,6 +79,157 @@ }) })() +function pollForUpdates(gameId) { + if (!gameId) { + console.error("Game ID is missing. Stopping poll."); + return; + } + const element = document.getElementById('card-slide'); + const element2 = document.getElementById('lobbybackground'); + // Safety check for the target element + if (!element && !element2) { + console.error("Polling target element not found. Stopping poll."); + // Use a timeout to retry in case the DOM loads late, passing gameId. + setTimeout(() => pollForUpdates(gameId), 5000); + return; + } + const route = jsRoutes.controllers.IngameController.polling(gameId); + + // Call your specific controller endpoint + fetch(route.url) + .then(response => { + if (response.status === 204) { + console.log("Polling: Timeout reached. Restarting poll."); + + // CRITICAL: Pass gameId in the recursive call + setTimeout(() => pollForUpdates(gameId), 5000); + } else if (response.ok && response.status === 200) { + response.json().then(data => { + + if (data.status === "cardPlayed" && data.handData) { + console.log("Event received: Card played. Redrawing hand."); + + const newHand = data.handData; + let newHandHTML = ''; + element.innerHTML = ''; + + newHand.forEach((cardId, index) => { + const cardHtml = ` +
+
+ + +
+
+ `; + newHandHTML += cardHtml; + }); + + element.innerHTML = newHandHTML; + + const currentPlayerElement = document.getElementById('current-player-name'); + if (currentPlayerElement) { + currentPlayerElement.textContent = data.currentPlayerName; + } + const nextPlayerElement = document.getElementById('next-player-name'); + if (nextPlayerElement && data.nextPlayer) { + // Use the correctly named field from the server response + nextPlayerElement.textContent = data.nextPlayer; + } else { + // Case 2: Player name is empty or null (signal to clear display). + nextPlayerElement.textContent = ""; + } + + const trumpElement = document.getElementById('trump-suit'); + if (trumpElement) { + trumpElement.textContent = data.trumpSuit; + } + const trickContainer = document.getElementById('trick-cards-container'); + if (trickContainer) { + let trickHTML = ''; + + // Iterate over the array of played cards received from the server + data.trickCards.forEach(trickCard => { + // Reconstruct the HTML structure from your template + trickHTML += ` +
+
+
+ +
+
+ ${trickCard.player} +
+
+
+ `; + }); + trickContainer.innerHTML = trickHTML; + } + const scoreBody = document.getElementById('score-table-body'); + if (scoreBody && data.scoreTable) { + let scoreHTML = ''; + scoreHTML += `

Tricks Won

+ +
+
PLAYER
+
TRICKS
+
` + data.scoreTable.forEach(score => { + scoreHTML += ` +
+
${score.name}
+
${score.tricks}
+
+ `; + }); + scoreBody.innerHTML = scoreHTML; + } + const firstCardContainer = document.getElementById('first-card-container'); + const cardId = data.firstCardId; // This will be "KH", "S7", or "BLANK" + + if (firstCardContainer) { + let imageSrc = ''; + let altText = 'First Card'; + + // Check if a card was actually played or if it's the start of a trick + if (cardId === "BLANK") { + imageSrc = "/assets/images/cards/1B.png"; + altText = "Blank Card"; + } else { + imageSrc = `/assets/images/cards/${cardId}.png`; + } + + // Reconstruct the image HTML (assuming the inner element needs replacement) + const newImageHTML = ` + ${altText} + `; + + // Clear the container and insert the new image + firstCardContainer.innerHTML = newImageHTML; + } + } else if (data.status === "gameStart") { + window.location.href = data.redirectUrl; + } + pollForUpdates(gameId); + }); + } else { + // Handle network or server errors + console.error(`Polling error: Status ${response.status}`); + // Wait before retrying, passing gameId correctly + setTimeout(() => pollForUpdates(gameId), 5000); + } + }) + .catch(error => { + console.error("Network error during polling:", error); + // Wait before retrying on network failure, passing gameId correctly + setTimeout(() => pollForUpdates(gameId), 5000); + }); +} + function createGameJS() { let lobbyName = document.getElementById("lobbyname").value; if (lobbyName === "") { @@ -226,10 +377,26 @@ function handlePlayCard(cardobject, gameId) { const jsonObj = { cardID: cardId } - sendPlayCardRequest(jsonObj, gameId) + sendPlayCardRequest(jsonObj, gameId, cardobject) } -function sendPlayCardRequest(jsonObj, gameId) { +function sendPlayCardRequest(jsonObj, gameId, cardobject) { + const wiggleKeyframes = [ + { transform: 'translateX(0)' }, + { transform: 'translateX(-5px)' }, + { transform: 'translateX(5px)' }, + { transform: 'translateX(-5px)' }, + { transform: 'translateX(0)' } + ]; + + // Define the timing options + const wiggleTiming = { + duration: 400, // 0.4 seconds + iterations: 1, + easing: 'ease-in-out', + // Fill mode ensures the final state is applied until reset + fill: 'forwards' + }; const route = jsRoutes.controllers.IngameController.playCard(gameId); fetch(route.url, { @@ -249,11 +416,13 @@ function sendPlayCardRequest(jsonObj, gameId) { }) .then(data => { if (data.status === 'success') { - window.location.href = data.redirectUrl; + //window.location.href = data.redirectUrl; } }) .catch(error => { - if (error && error.errorMessage) { + if (error && error.errorMessage.includes("You can't play this card!")) { + cardobject.parentElement.animate(wiggleKeyframes, wiggleTiming); + } else if (error && error.errorMessage) { alert(`${error.errorMessage}`); } else { alert('An unexpected error occurred. Please try again.');