From e2a5cb9614a5211fb9528b3697372e3b2e8b7fd8 Mon Sep 17 00:00:00 2001 From: LQ63 Date: Wed, 19 Nov 2025 15:14:47 +0100 Subject: [PATCH] feat(ui): Fixed polling, added JQuery Ajax Fixed Race Condition problems with polling, added JQuery --- .../app/controllers/IngameController.scala | 8 +- .../app/controllers/PollingController.scala | 32 +- .../app/logic/game/GameLobby.scala | 67 ++- .../app/logic/game/PollingEvents.scala | 1 + knockoutwhistweb/app/views/main.scala.html | 1 + knockoutwhistweb/public/javascripts/main.js | 515 ++++++++---------- 6 files changed, 312 insertions(+), 312 deletions(-) diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index 346bb83..6f93a83 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -172,8 +172,7 @@ class IngameController @Inject() ( optSession.foreach(_.lock.unlock()) if (result.isSuccess) { Ok(Json.obj( - "status" -> "success", - "redirectUrl" -> routes.IngameController.game(gameId).url + "status" -> "success" )) } else { val throwable = result.failed.get @@ -198,6 +197,11 @@ class IngameController @Inject() ( "status" -> "failure", "errorMessage" -> throwable.getMessage )) + case _: NotInteractableException => + BadRequest(Json.obj( + "status" -> "failure", + "errorMessage" -> throwable.getMessage + )) case _ => InternalServerError(Json.obj( "status" -> "failure", diff --git a/knockoutwhistweb/app/controllers/PollingController.scala b/knockoutwhistweb/app/controllers/PollingController.scala index 73d4b11..4ed62d1 100644 --- a/knockoutwhistweb/app/controllers/PollingController.scala +++ b/knockoutwhistweb/app/controllers/PollingController.scala @@ -1,10 +1,11 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} +import controllers.PollingController.{scheduler, timeoutDuration} import de.knockoutwhist.cards.Hand import logic.PodManager import logic.game.{GameLobby, PollingEvents} -import logic.game.PollingEvents.{CardPlayed, LobbyUpdate, NewRound, ReloadEvent} +import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, ReloadEvent} import model.sessions.UserSession import model.users.User import play.api.libs.json.{JsArray, JsValue, Json} @@ -13,7 +14,12 @@ import util.WebUIUtils import javax.inject.{Inject, Singleton} import scala.concurrent.{ExecutionContext, Future} - +import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit} +import scala.concurrent.duration.* +object PollingController { + private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor() + private val timeoutDuration = 25.seconds +} @Singleton class PollingController @Inject() ( val cc: ControllerComponents, @@ -96,25 +102,28 @@ class PollingController @Inject() ( } } - // --- 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() - + val playerEventQueue = game.getEventsOfPlayer(playerId) + if (playerEventQueue.nonEmpty) { + val event = playerEventQueue.dequeue() Future.successful(handleEvent(event, game, game.getUserSession(playerId))) } else { - val eventPromise = game.registerWaiter(playerId) - + val scheduledFuture = scheduler.schedule( + new Runnable { + override def run(): Unit = + eventPromise.tryFailure(new java.util.concurrent.TimeoutException("Polling Timeout")) + }, + timeoutDuration.toMillis, + TimeUnit.MILLISECONDS + ) eventPromise.future.map { event => + scheduledFuture.cancel(false) game.removeWaiter(playerId) handleEvent(event, game, game.getUserSession(playerId)) }.recover { @@ -125,7 +134,6 @@ class PollingController @Inject() ( } case None => - // Game not found Future.successful(NotFound("Game not found.")) } } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 1cf7dfa..3f638a8 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -11,7 +11,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.game.PollingEvents.{CardPlayed, LobbyUpdate, NewRound, ReloadEvent} +import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, ReloadEvent} import model.sessions.{InteractionType, UserSession} import model.users.User @@ -27,21 +27,39 @@ class GameLobby private( val name: String, val maxPlayers: Int ) extends EventListener { - logic.addListener(this) - logic.createSession() + + private val users: mutable.Map[UUID, UserSession] = mutable.Map() - private val pollingState: mutable.Queue[PollingEvents] = mutable.Queue() + private val eventsPerPlayer: mutable.Map[UUID, mutable.Queue[PollingEvents]] = mutable.Map() private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map() + private val lock = new Object + lock.synchronized { + logic.addListener(this) + logic.createSession() + } + def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = { val promise = ScalaPromise[PollingEvents]() - waitingPromises.put(playerId, promise) - promise + lock.synchronized { + val queue = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) + + if (queue.nonEmpty) { + val evt = queue.dequeue() + promise.success(evt) + promise + } else { + waitingPromises.put(playerId, promise) + promise + } + } } def removeWaiter(playerId: UUID): Unit = { - waitingPromises.remove(playerId) + lock.synchronized { + waitingPromises.remove(playerId) + } } def addUser(user: User): UserSession = { @@ -72,7 +90,8 @@ class GameLobby private( } if (event.oldState == Lobby && event.newState == InGame) { addToQueue(ReloadEvent) - }else { + return + } else { addToQueue(ReloadEvent) } users.values.foreach(session => session.updatePlayer(event)) @@ -84,11 +103,29 @@ class GameLobby private( } private def addToQueue(event: PollingEvents): Unit = { - if (waitingPromises.nonEmpty) { - waitingPromises.values.foreach(_.success(event)) - waitingPromises.clear() - } else { - pollingState.enqueue(event) + lock.synchronized { + users.keys.foreach { playerId => + val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) + q.enqueue(event) + } + val waiterIds = waitingPromises.keys.toList + waiterIds.foreach { playerId => + val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) + if (q.nonEmpty) { + val evt = q.dequeue() + val p = waitingPromises.remove(playerId) + p.foreach(_.success(evt)) + } + } + } + + waitingPromises.keys.foreach { playerId => + val queue = eventsPerPlayer(playerId) + if (queue.nonEmpty) { + val promise = waitingPromises(playerId) + promise.success(queue.dequeue()) + waitingPromises.remove(playerId) + } } } @@ -218,8 +255,8 @@ class GameLobby private( def getLogic: GameLogic = { logic } - def getPollingState: mutable.Queue[PollingEvents] = { - pollingState + def getEventsOfPlayer(playerId: UUID): mutable.Queue[PollingEvents] = { + eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue()) } private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { val playerOption = getMatch.totalplayers.find(_.id == userSession.id) diff --git a/knockoutwhistweb/app/logic/game/PollingEvents.scala b/knockoutwhistweb/app/logic/game/PollingEvents.scala index 5e5bc8a..3fa5525 100644 --- a/knockoutwhistweb/app/logic/game/PollingEvents.scala +++ b/knockoutwhistweb/app/logic/game/PollingEvents.scala @@ -5,4 +5,5 @@ enum PollingEvents { case NewRound case ReloadEvent case LobbyUpdate + case LobbyCreation } \ No newline at end of file diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html index e1cc5d1..4d8c0b2 100644 --- a/knockoutwhistweb/app/views/main.scala.html +++ b/knockoutwhistweb/app/views/main.scala.html @@ -25,5 +25,6 @@ + diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index 38c15d1..434149a 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -80,210 +80,192 @@ })() function pollForUpdates(gameId) { + console.log(`[DEBUG] Starting poll cycle for Game ID: ${gameId} at ${new Date().toISOString()}`); if (!gameId) { - console.error("Game ID is missing. Stopping poll."); + console.error("[DEBUG] 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. + const $handElement = $('#card-slide'); + const $lobbyElement = $('#lobbybackground'); + const $mainmenuElement = $('#main-menu-screen') + if (!$handElement.length && !$lobbyElement.length && !$mainmenuElement.length) { setTimeout(() => pollForUpdates(gameId), 5000); return; } const route = jsRoutes.controllers.PollingController.polling(gameId); + $.ajax({ + url: route.url, + type: 'GET', + dataType: 'json', - // Call your specific controller endpoint - fetch(route.url) - .then(response => { - if (response.status === 204) { - console.log("Polling: Timeout reached. Restarting poll."); + success: (data => { + if (!data) { + console.log("[DEBUG] Received 204 No Content (Timeout). Restarting poll."); + return; + } + if (data.status === "cardPlayed" && data.handData) { + console.log("Event received: Card played. Redrawing hand."); + const newHand = data.handData; + let newHandHTML = ''; + $handElement.empty(); - // CRITICAL: Pass gameId in the recursive call - setTimeout(() => pollForUpdates(gameId), 5000); - } else if (response.ok && response.status === 200) { - response.json().then(data => { + if(data.animation) { + $handElement.addClass('ingame-cards-slide'); + } else { + $handElement.removeClass('ingame-cards-slide'); + } - if (data.status === "cardPlayed" && data.handData && element) { - console.log("Event received: Card played. Redrawing hand."); + newHand.forEach((cardId, index) => { + const cardHtml = ` +
+
+ + +
+
+ `; + newHandHTML += cardHtml; + }); - const newHand = data.handData; - let newHandHTML = ''; - element.innerHTML = ''; + $handElement.html(newHandHTML); + $('#current-player-name').text(data.currentPlayerName) + if (data.nextPlayer) { + $('#next-player-name').text(data.nextPlayer); + } else if (nextPlayerElement) { + $('#next-player-name').text(""); + } else { + console.warn("[DEBUG] 'current-player-name' element missing in DOM"); + } + $('#trump-suit').text(data.trumpSuit); + if ($('#trick-cards-container').length) { + let trickHTML = ''; - if(data.animation) { - if (!element.classList.contains('ingame-cards-slide')) { - element.classList.add('ingame-cards-slide'); - } - } else { - element.classList.remove('ingame-cards-slide'); - } - - newHand.forEach((cardId, index) => { - const cardHtml = ` -
-
- - + data.trickCards.forEach(trickCard => { + trickHTML += ` +
+
+
+ +
+
+ ${trickCard.player}
- `; - newHandHTML += cardHtml; - }); +
+ `; + }); + $('#trick-cards-container').html(trickHTML); + } + if ($('#score-table-body').length && data.scoreTable) { + let scoreHTML = ''; + scoreHTML += `

Tricks Won

- element.innerHTML = newHandHTML; +
+
PLAYER
+
TRICKS
+
` + data.scoreTable.forEach(score => { + scoreHTML += ` +
+
${score.name}
+
${score.tricks}
+
+ `; + }); + $('#score-table-body').html(scoreHTML); + } + const cardId = data.firstCardId; + if ($('#first-card-container').length) { + let imageSrc = ''; + let altText = 'First Card'; - 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 === "reloadEvent") { - window.location.href = data.redirectUrl; - } else if (data.status === "lobbyUpdate") { - const players = document.getElementById("players"); - let newHtml = '' - - if (data.host) { - data.users.forEach(user => { - - const inner = user.self ? `
${user.name} (You)
- Remove` - : `
${user.name}
-
Remove
` - - newHtml += `
-
- Profile -
- ${inner} -
-
-
` - }) - } else { - data.users.forEach(user => { - - const inner = user.self ? `
${user.name} (You)
` : `
${user.name}
` - - newHtml += `
-
- Profile -
- ${inner} -
-
-
` - }) - } - players.innerHTML = newHtml; + if (cardId === "BLANK") { + imageSrc = "/assets/images/cards/1B.png"; + altText = "Blank Card"; + } else { + imageSrc = `/assets/images/cards/${cardId}.png`; } - 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); + + const newImageHTML = ` + ${altText} + `; + + $('#first-card-container').html(newImageHTML); + } + } else if (data.status === "reloadEvent") { + console.log("[DEBUG] Reload event received. Redirecting..."); + window.location.href = data.redirectUrl; } + else if (data.status === "lobbyUpdate") { + console.log("[DEBUG] Entering 'lobbyUpdate' logic."); + let newHtml = '' + + if (data.host) { + data.users.forEach(user => { + + const inner = user.self ? `
${user.name} (You)
+ Remove` + : `
${user.name}
+
Remove
` + + newHtml += `
+
+ Profile +
+ ${inner} +
+
+
` + }) + } else { + data.users.forEach(user => { + + const inner = user.self ? `
${user.name} (You)
` : `
${user.name}
` + + newHtml += `
+
+ Profile +
+ ${inner} +
+
+
` + }) + } + $("#players").html(newHtml); + } else { + console.warn(`[DEBUG] Received unknown status: ${data.status}`); + } + }), + error: ((jqXHR, textStatus, errorThrown) => { + if (jqXHR.status >= 400) { + console.error(`Server error: ${jqXHR.status}, ${errorThrown}`); + } + else { + console.error(`Something unexpected happened while polling. ${jqXHR.status}, ${errorThrown}`) + } + }), + complete: ((jqXHR, textStatus) => { + if (!window.location.href.includes("game")) { + console.log("[DEBUG] Page URL changed. Stopping poll restart."); + return; + } + setTimeout(() => pollForUpdates(gameId), 500); }) - .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 === "") { + let lobbyName = $('#lobbyname').val(); + if ($.trim(lobbyName) === "") { lobbyName = "DefaultLobby" } - const playerAmount = document.getElementById("playeramount").value; const jsonObj = { lobbyname: lobbyName, - playeramount: playerAmount + playeramount: $("#playeramount").val() } sendGameCreationRequest(jsonObj); } @@ -291,33 +273,26 @@ function createGameJS() { function sendGameCreationRequest(dataObject) { const route = jsRoutes.controllers.MainMenuController.createGame(); - fetch(route.url, { - method: route.type, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(dataObject) - }) - .then(response => { - return response.json().then(data => { - if (!response.ok) { - return Promise.reject(data); - } - return data; - }); - }) - .then(data => { + $.ajax({ + url: route.url, + type: route.type, + contentType: 'application/json', + data: JSON.stringify(dataObject), + dataType: 'json', + success: (data => { if (data.status === 'success') { window.location.href = data.redirectUrl; } - }) - .catch(error => { - if (error && error.errorMessage) { - alert(`${error.errorMessage}`); + }), + error: ((jqXHR) => { + const errorData = JSON.parse(jqXHR.responseText); + if (errorData && errorData.errorMessage) { + alert(`${errorData.errorMessage}`); } else { - alert('An unexpected error occurred. Please try again.'); + alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); } - }); + }) + }) } function startGame(gameId) { sendGameStartRequest(gameId) @@ -325,30 +300,24 @@ function startGame(gameId) { function sendGameStartRequest(gameId) { const route = jsRoutes.controllers.IngameController.startGame(gameId); - fetch(route.url, { - method: route.type, - }) - .then(response => { - return response.json().then(data => { - if (!response.ok) { - return Promise.reject(data); - } - return data; - }); - }) - .then(data => { - // SUCCESS BLOCK: data is the { status: 'success', ... } object + $.ajax({ + url: route.url, + type: route.type, + dataType: 'json', + success: (data => { if (data.status === 'success') { window.location.href = data.redirectUrl; } - }) - .catch(error => { - if (error && error.errorMessage) { - alert(`${error.errorMessage}`); + }), + error: ((jqXHR) => { + const errorData = JSON.parse(jqXHR.responseText); + if (errorData && errorData.errorMessage) { + alert(`${errorData.errorMessage}`); } else { - alert('An unexpected error occurred. Please try again.'); + alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); } - }); + }) + }) } function removePlayer(gameid, playersessionId) { sendRemovePlayerRequest(gameid, playersessionId) @@ -357,33 +326,25 @@ function removePlayer(gameid, playersessionId) { function sendRemovePlayerRequest(gameId, playersessionId) { const route = jsRoutes.controllers.IngameController.kickPlayer(gameId, playersessionId); - fetch(route.url, { - method: route.type, - headers: { - 'Content-Type': 'application/json', - } - }) - .then(response => { - return response.json().then(data => { - if (!response.ok) { - return Promise.reject(data); - } - return data; - }); - }) - .then(data => { - // SUCCESS BLOCK: data is the { status: 'success', ... } object + $.ajax({ + url: route.url, + type: route.type, + contentType: 'application/json', + dataType: 'json', + success: (data => { if (data.status === 'success') { window.location.href = data.redirectUrl; } - }) - .catch(error => { - if (error && error.errorMessage) { - alert(`${error.errorMessage}`); + }), + error: ((jqXHR) => { + const errorData = JSON.parse(jqXHR.responseText); + if (errorData && errorData.errorMessage) { + alert(`${errorData.errorMessage}`); } else { - alert('An unexpected error occurred. Please try again.'); + alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); } - }); + }) + }) } function leaveGame(gameId) { @@ -393,30 +354,24 @@ function leaveGame(gameId) { function sendLeavePlayerRequest(gameId) { const route = jsRoutes.controllers.IngameController.leaveGame(gameId); - fetch(route.url, { - method: route.type, - }) - .then(response => { - return response.json().then(data => { - if (!response.ok) { - return Promise.reject(data); - } - return data; - }); - }) - .then(data => { - // SUCCESS BLOCK: data is the { status: 'success', ... } object + $.ajax({ + url: route.url, + type: route.type, + dataType: 'json', + success: (data => { if (data.status === 'success') { window.location.href = data.redirectUrl; } - }) - .catch(error => { - if (error && error.errorMessage) { - alert(`${error.errorMessage}`); + }), + error: ((jqXHR) => { + const errorData = JSON.parse(jqXHR.responseText); + if (errorData && errorData.errorMessage) { + alert(`${errorData.errorMessage}`); } else { - alert('An unexpected error occurred. Please try again.'); + alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); } - }); + }) + }) } function handlePlayCard(cardobject, gameId) { @@ -436,37 +391,30 @@ function sendPlayCardRequest(jsonObj, gameId, cardobject) { { transform: 'translateX(0)' } ]; - // Define the timing options const wiggleTiming = { - duration: 400, // 0.4 seconds + duration: 400, 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, { - method: route.type, - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(jsonObj) - }) - .then(response => { - return response.json().then(data => { - if (!response.ok) { - return Promise.reject(data); - } - return data; - }); - }) - .then(data => { + $.ajax({ + url: route.url, + type: route.type, + contentType: 'application/json', + dataType: 'json', + data: JSON.stringify(jsonObj), + success: (data => { if (data.status === 'success') { - //window.location.href = data.redirectUrl; } - }) - .catch(error => { + }), + error: (jqXHR => { + try { + error = JSON.parse(jqXHR.responseText); + } catch (e) { + console.error("Failed to parse error response:", e); + } if (error && error.errorMessage.includes("You can't play this card!")) { cardobject.parentElement.animate(wiggleKeyframes, wiggleTiming); } else if (error && error.errorMessage) { @@ -474,6 +422,7 @@ function sendPlayCardRequest(jsonObj, gameId, cardobject) { } else { alert('An unexpected error occurred. Please try again.'); } - }); + }) + }) }