feat(base): Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! (#84)

Reviewed-on: #84
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
This commit is contained in:
2025-11-26 11:26:08 +01:00
committed by lq64
parent 52e5033afc
commit b81bb3d0ae
14 changed files with 200 additions and 134 deletions

View File

@@ -1,6 +1,7 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import de.knockoutwhist.control.GameState
import de.knockoutwhist.control.GameState.* import de.knockoutwhist.control.GameState.*
import exceptions.* import exceptions.*
import logic.PodManager import logic.PodManager
@@ -29,7 +30,7 @@ class IngameController @Inject()(
game match { game match {
case Some(g) => case Some(g) =>
val results = Try { val results = Try {
returnInnerHTML(g, request.user) IngameController.returnInnerHTML(g, g.logic.getCurrentState, request.user)
} }
if (results.isSuccess) { if (results.isSuccess) {
Ok(views.html.main("In-Game - Knockout Whist")(results.get)) 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] => 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 {
@@ -83,7 +56,7 @@ class IngameController @Inject()(
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url, "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 { } else {
val throwable = result.failed.get val throwable = result.failed.get
@@ -436,3 +409,35 @@ class IngameController @Inject()(
} }
} }
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}")
}
}
}

View File

@@ -16,8 +16,7 @@ import javax.inject.*
@Singleton @Singleton
class MainMenuController @Inject()( class MainMenuController @Inject()(
val controllerComponents: ControllerComponents, val controllerComponents: ControllerComponents,
val authAction: AuthAction, val authAction: AuthAction
val ingameController: IngameController
) extends BaseController { ) extends BaseController {
// Pass the request-handling function directly to authAction (no nested Action) // Pass the request-handling function directly to authAction (no nested Action)
@@ -46,7 +45,7 @@ class MainMenuController @Inject()(
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameLobby.id).url, "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 { } else {
BadRequest(Json.obj( BadRequest(Json.obj(
@@ -70,7 +69,7 @@ class MainMenuController @Inject()(
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.IngameController.game(g.id).url, "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 => case None =>
NotFound(Json.obj( NotFound(Json.obj(

View File

@@ -12,12 +12,18 @@ class UserWebsocketActor(
session: UserSession session: UserSession
) extends Actor { ) extends Actor {
{
session.lock.lock()
if (session.websocketActor.isDefined) { if (session.websocketActor.isDefined) {
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection.")) val otherWebsocket = session.websocketActor.get
context.stop(self) otherWebsocket.transmitTextToClient("Error: Multiple websocket connections detected. Closing your connection.")
} else { context.stop(otherWebsocket.self)
session.websocketActor = Some(this) transmitTextToClient("Previous websocket connection closed. You are now connected.")
} }
session.websocketActor = Some(this)
session.lock.unlock()
}
override def receive: Receive = { override def receive: Receive = {
case msg: String => case msg: String =>
@@ -86,12 +92,12 @@ class UserWebsocketActor(
} }
} }
def transmitJsonToClient(jsonObj: JsObject): Unit = { def transmitJsonToClient(jsonObj: JsValue): Unit = {
transmitTextToClient(jsonObj.toString()) transmitTextToClient(jsonObj.toString())
} }
def transmitEventToClient(event: SimpleEvent): Unit = { def transmitEventToClient(event: SimpleEvent): Unit = {
transmitJsonToClient(WebsocketEventMapper.toJson(event)) transmitJsonToClient(WebsocketEventMapper.toJson(event, session))
} }
} }

View File

@@ -2,10 +2,11 @@ package util
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsValue, Json} import play.api.libs.json.{JsValue, Json}
import tools.jackson.databind.json.JsonMapper import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule import tools.jackson.module.scala.ScalaModule
import util.mapper.{ReceivedHandEventMapper, SimpleEventMapper} import util.mapper.{GameStateEventMapper, ReceivedHandEventMapper, SimpleEventMapper}
object WebsocketEventMapper { object WebsocketEventMapper {
@@ -24,10 +25,11 @@ object WebsocketEventMapper {
// Register all custom mappers here // Register all custom mappers here
registerCustomMapper(ReceivedHandEventMapper) 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)) { val data: Option[JsValue] = if (customMappers.contains(obj.id)) {
Some(customMappers(obj.id).toJson(obj)) Some(customMappers(obj.id).toJson(obj, session))
}else { }else {
None None
} }

View File

@@ -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
)
}
}

View File

@@ -1,14 +1,14 @@
package util.mapper package util.mapper
import de.knockoutwhist.events.player.ReceivedHandEvent import de.knockoutwhist.events.player.ReceivedHandEvent
import logic.game.GameLobby import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json} import play.api.libs.json.{JsObject, Json}
import util.WebUIUtils import util.WebUIUtils
object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] { object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] {
override def id: String = "ReceivedHandEvent" override def id: String = "ReceivedHandEvent"
override def toJson(event: ReceivedHandEvent, gameLobby: GameLobby): JsObject = { override def toJson(event: ReceivedHandEvent, session: UserSession): JsObject = {
Json.obj( Json.obj(
"dog" -> event.player.isInDogLife, "dog" -> event.player.isInDogLife,
"hand" -> event.player.currentHand().map(hand => WebUIUtils.handToJson(hand)) "hand" -> event.player.currentHand().map(hand => WebUIUtils.handToJson(hand))

View File

@@ -2,11 +2,12 @@ package util.mapper
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.JsObject import play.api.libs.json.JsObject
trait SimpleEventMapper[T <: SimpleEvent] { trait SimpleEventMapper[T <: SimpleEvent] {
def id: String def id: String
def toJson(event: T, gameLobby: GameLobby): JsObject def toJson(event: T, session: UserSession): JsObject
} }

View File

@@ -10,8 +10,12 @@
<div class="row ms-4 me-4"> <div class="row ms-4 me-4">
<div class="col-4 mt-5 text-start"> <div class="col-4 mt-5 text-start">
<h4 class="fw-semibold mb-1">Current Player</h4> <h4 class="fw-semibold mb-1">Current Player</h4>
@if(gamelobby.getLogic.getCurrentPlayer.isDefined) {
<p class="fs-5 text-primary" id="current-player-name">@gamelobby.getLogic.getCurrentPlayer.get.name</p> <p class="fs-5 text-primary" id="current-player-name">@gamelobby.getLogic.getCurrentPlayer.get.name</p>
@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) { }else {
<p class="fs-5 text-primary" id="current-player-name">---</p>
}
@if(gamelobby.getLogic.getPlayerQueue.isDefined && gamelobby.getLogic.getCurrentMatch && !TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {
<h4 class="fw-semibold mb-1">Next Player</h4> <h4 class="fw-semibold mb-1">Next Player</h4>
@for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) { @for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) {
<p class="fs-5 text-primary" id="next-player-name">@nextplayer</p> <p class="fs-5 text-primary" id="next-player-name">@nextplayer</p>
@@ -29,6 +33,7 @@
<div style="width: 50%">TRICKS</div> <div style="width: 50%">TRICKS</div>
</div> </div>
@if(gamelobby.getLogic.getPlayerQueue.isDefined) {
@for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p => @for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p =>
-(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size) -(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size)
}) { }) {
@@ -39,9 +44,18 @@
</div> </div>
</div> </div>
} }
}else{
<div class="d-flex justify-content-between score-row pt-1">
</div>
}
</div> </div>
<div class="d-flex justify-content-center g-3 mb-5" id="trick-cards-container"> <div class="d-flex justify-content-center g-3 mb-5" id="trick-cards-container">
@if(gamelobby.getLogic.getCurrentTrick.isEmpty || gamelobby.getLogic.getCurrentTrick.get.cards.isEmpty) {
<div class="col-auto">
</div>
} else {
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) { @for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) {
<div class="col-auto"> <div class="col-auto">
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem; <div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem;
@@ -55,15 +69,20 @@
</div> </div>
</div> </div>
} }
}
</div> </div>
</div> </div>
<div class="col-4 mt-5 text-end"> <div class="col-4 mt-5 text-end">
<h4 class="fw-semibold mb-1">Trumpsuit</h4> <h4 class="fw-semibold mb-1">Trumpsuit</h4>
@if(gamelobby.getLogic.getCurrentRound.isEmpty) {
<p class="fs-5 text-primary" id="trumpsuit">No Trumpsuit</p>
}else {
<p class="fs-5 text-primary" id="trumpsuit">@gamelobby.getLogic.getCurrentRound.get.trumpSuit</p> <p class="fs-5 text-primary" id="trumpsuit">@gamelobby.getLogic.getCurrentRound.get.trumpSuit</p>
}
<h5 class="fw-semibold mt-4 mb-1">First Card</h5> <h5 class="fw-semibold mt-4 mb-1">First Card</h5>
<div class="d-inline-block border rounded shadow-sm p-1 bg-light" id="first-card-container"> <div class="d-inline-block border rounded shadow-sm p-1 bg-light" id="first-card-container">
@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) @util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get)
width="80px"/> width="80px"/>
} else { } else {
@@ -79,6 +98,9 @@
<div class="row justify-content-center ingame-cards-slide @{ <div class="row justify-content-center ingame-cards-slide @{
!gamelobby.logic.getCurrentPlayer.contains(player) ? "inactive" |: "" !gamelobby.logic.getCurrentPlayer.contains(player) ? "inactive" |: ""
}" id="card-slide"> }" id="card-slide">
@if(player.currentHand().isEmpty || player.currentHand().get.cards.isEmpty) {
} else {
@for(i <- player.currentHand().get.cards.indices) { @for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto handcard" style="border-radius: 6px"> <div class="col-auto handcard" style="border-radius: 6px">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(this, '@gamelobby.id', '@player.isInDogLife')"> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(this, '@gamelobby.id', '@player.isInDogLife')">
@@ -92,6 +114,7 @@
} }
</div> </div>
} }
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -10,6 +10,7 @@
<h3 class="mb-0">Select Trump Suit</h3> <h3 class="mb-0">Select Trump Suit</h3>
</div> </div>
<div class="card-body"> <div class="card-body">
@if(gamelobby.logic.getCurrentMatch.isDefined) {
@if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) { @if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) {
<div class="alert alert-info" role="alert" aria-live="polite"> <div class="alert alert-info" role="alert" aria-live="polite">
You (@player.toString) won the last round. Choose the trump suit for the next round. You (@player.toString) won the last round. Choose the trump suit for the next round.
@@ -54,6 +55,7 @@
is choosing a trumpsuit. The new round will start once a suit is picked. is choosing a trumpsuit. The new round will start once a suit is picked.
</div> </div>
} }
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -18,16 +18,17 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head> </head>
<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="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/websocket.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/events.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/interact.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
<body class="d-flex flex-column min-vh-100" id="main-body"> <body class="d-flex flex-column min-vh-100" id="main-body">
@* And here's where we render the `Html` object containing @* And here's where we render the `Html` object containing
* the page content. *@ * the page content. *@
@content @content
</body> </body>
<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("../../public/javascripts/websocket.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("../../public/javascripts/events.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("../../public/javascripts/interact.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://code.jquery.com/jquery-3.6.0.min.js"></script>
</html> </html>

View File

@@ -39,4 +39,12 @@ function receiveHandEvent(eventData) {
handElement.html(newHtml); handElement.html(newHtml);
} }
function receiveGameStateChange(eventData) {
const content = eventData.content;
const title = eventData.title || 'Knockout Whist';
exchangeBody(content, title);
}
onEvent("ReceivedHandEvent", receiveHandEvent) onEvent("ReceivedHandEvent", receiveHandEvent)
onEvent("GameStateChangeEvent", receiveGameStateChange)

View File

@@ -217,3 +217,6 @@ function navSpa(page, title) {
}); });
return false return false
} }
globalThis.exchangeBody = exchangeBody;

View File

@@ -1,10 +1,7 @@
type EventHandler = (data: any) => any | Promise<any>;
// javascript // javascript
let ws = null; // will be created by connectWebSocket() let ws = null; // will be created by connectWebSocket()
const pending: Map<string, any> = new Map(); // id -> { resolve, reject, timer } const pending = new Map(); // id -> { resolve, reject, timer }
const handlers: Map<string, EventHandler> = new Map(); // eventType -> handler(data) -> (value|Promise) const handlers = new Map(); // eventType -> handler(data) -> (value|Promise)
let timer = null; let timer = null;
@@ -52,6 +49,7 @@ function setupSocketHandlers(socket) {
if (!handler) { if (!handler) {
// no handler: respond with an error object in data so server can fail it // 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}); sendResponse({error: "No handler for event: " + eventType});
return; return;
} }
@@ -182,7 +180,7 @@ function sendEventAndWait(eventType, eventData, timeoutMs = 10000) {
return p; return p;
} }
function onEvent(eventType: string, handler: EventHandler) { function onEvent(eventType, handler) {
handlers.set(eventType, handler); handlers.set(eventType, handler);
} }