feat(api): Implemented session closed and kick event via websocket (#87)

Reviewed-on: #87
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 18:41:25 +01:00
committed by lq64
parent 576e5af87e
commit 1ef5e8a72f
14 changed files with 253 additions and 20 deletions

View File

@@ -89,7 +89,7 @@ class IngameController @Inject()(
val game = PodManager.getGame(gameId)
val playerToKickUUID = UUID.fromString(playerToKick)
val result = Try {
game.get.leaveGame(playerToKickUUID)
game.get.leaveGame(playerToKickUUID, true)
}
if (result.isSuccess) {
Ok(Json.obj(
@@ -107,7 +107,7 @@ class IngameController @Inject()(
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
val result = Try {
game.get.leaveGame(request.user.id)
game.get.leaveGame(request.user.id, false)
}
if (result.isSuccess) {
Ok(Json.obj(

View File

@@ -0,0 +1,9 @@
package events
import model.users.User
case class KickEvent(user: User) extends UserEvent(user) {
override def id: String = "KickEvent"
}

View File

@@ -0,0 +1,9 @@
package events
import model.users.User
case class LeftEvent(user: User) extends UserEvent(user) {
override def id: String = "LeftEvent"
}

View File

@@ -0,0 +1,9 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
case class LobbyUpdateEvent() extends SimpleEvent {
override def id: String = "LobbyUpdateEvent"
}

View File

@@ -0,0 +1,12 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
import model.users.User
import java.util.UUID
abstract class UserEvent(user: User) extends SimpleEvent {
def userId: UUID = user.id
}

View File

@@ -10,13 +10,14 @@ 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 events.{KickEvent, LeftEvent, LobbyUpdateEvent, UserEvent}
import exceptions.*
import logic.PodManager
import model.sessions.{InteractionType, UserSession}
import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.UUID
import java.util.{Timer, TimerTask, UUID}
import scala.collection.mutable
import scala.collection.mutable.ListBuffer
@@ -44,7 +45,7 @@ class GameLobby private(
)
users += (user.id -> userSession)
PodManager.registerUserToGame(user, id)
//TODO : transmit Lobby Update transmitToAll()
logic.invoke(LobbyUpdateEvent())
userSession
}
@@ -52,6 +53,8 @@ class GameLobby private(
event match {
case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: UserEvent =>
users.get(event.userId).foreach(session => session.updatePlayer(event))
case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) {
return
@@ -93,8 +96,9 @@ class GameLobby private(
* Remove the user from the game lobby.
*
* @param user the user who wants to leave the game.
* @param kicked whether the user was kicked or left voluntarily.
*/
def leaveGame(userId: UUID): Unit = {
def leaveGame(userId: UUID, kicked: Boolean): Unit = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
@@ -105,16 +109,14 @@ class GameLobby private(
PodManager.removeGame(id)
return
}
sessionOpt.get.websocketActor.foreach(act => act.transmitJsonToClient(Json.obj(
"id" -> "-1",
"event" -> "SessionClosed",
"data" -> Json.obj(
"reason" -> "You left the game (or got kicked)."
)
)))
if (kicked) {
logic.invoke(KickEvent(sessionOpt.get.user))
} else {
logic.invoke(LeftEvent(sessionOpt.get.user))
}
users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user)
//TODO: transmit Lobby Update transmitToAll()
logic.invoke(LobbyUpdateEvent())
}
/**

View File

@@ -6,7 +6,7 @@ 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.{CardPlayedEventMapper, GameStateEventMapper, ReceivedHandEventMapper, SimpleEventMapper}
import util.mapper.{CardPlayedEventMapper, GameStateEventMapper, KickEventMapper, LeftEventMapper, LobbyUpdateEventMapper, ReceivedHandEventMapper, SessionClosedMapper, SimpleEventMapper}
object WebsocketEventMapper {
@@ -27,6 +27,10 @@ object WebsocketEventMapper {
registerCustomMapper(ReceivedHandEventMapper)
registerCustomMapper(GameStateEventMapper)
registerCustomMapper(CardPlayedEventMapper)
registerCustomMapper(LobbyUpdateEventMapper)
registerCustomMapper(LeftEventMapper)
registerCustomMapper(KickEventMapper)
registerCustomMapper(SessionClosedMapper)
def toJson(obj: SimpleEvent, session: UserSession): JsValue = {
val data: Option[JsValue] = if (customMappers.contains(obj.id)) {

View File

@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import events.KickEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object KickEventMapper extends SimpleEventMapper[KickEvent] {
override def id: String = "KickEvent"
override def toJson(event: KickEvent, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
)
}
}

View File

@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import events.{KickEvent, LeftEvent}
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object LeftEventMapper extends SimpleEventMapper[LeftEvent] {
override def id: String = "LeftEvent"
override def toJson(event: LeftEvent, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString
)
}
}

View File

@@ -0,0 +1,25 @@
package util.mapper
import events.LobbyUpdateEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
object LobbyUpdateEventMapper extends SimpleEventMapper[LobbyUpdateEvent] {
override def id: String = "LobbyUpdateEvent"
override def toJson(event: LobbyUpdateEvent, session: UserSession): JsObject = {
Json.obj(
"host" -> session.host,
"maxPlayers" -> session.gameLobby.maxPlayers,
"players" -> JsArray(session.gameLobby.getPlayers.values.map(player => {
Json.obj(
"id" -> player.id,
"name" -> player.name,
"self" -> (player.id == session.user.id)
)
}).toList)
)
}
}

View File

@@ -0,0 +1,19 @@
package util.mapper
import controllers.routes
import de.knockoutwhist.events.global.SessionClosed
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object SessionClosedMapper extends SimpleEventMapper[SessionClosed] {
override def id: String = "SessionClosed"
override def toJson(event: SessionClosed, session: UserSession): JsObject = {
Json.obj(
"url" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(session.user)).toString,
)
}
}

View File

@@ -1,6 +1,35 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
<main class="lobby-background vh-100" id="lobbybackground">
<!-- Kick Modal -->
<div class="modal fade" data-backdrop="static" data-keyboard="false" data-focus="true" id="kickedModal" tabindex="-1" role="dialog" aria-labelledby="kickedModalTitle">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="kickedModalTitle">Kicked</h5>
</div>
<div class="modal-body">
<p>You've been kicked from the lobby.</p>
</div>
</div>
</div>
</div>
<!-- Session Closed Modal -->
<div class="modal fade" data-backdrop="static" data-keyboard="false" data-focus="true" id="sessionClosed" tabindex="-1" role="dialog" aria-labelledby="sessionClosedModalTitle">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="sessionClosedModalTitle">Session Closed</h5>
</div>
<div class="modal-body">
<p>The session was closed.</p>
</div>
</div>
</div>
</div>
<!-- Lobby -->
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);">
<div class="row">
<div class="col">
@@ -15,7 +44,7 @@
<div class="row">
<div class="col">
<div class="p-3 text-center fs-4" id="playerAmount">
Playeramount: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div>
Players: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div>
</div>
</div>
<div class="row justify-content-center align-items-center flex-grow-1">

View File

@@ -42,8 +42,9 @@ function receiveHandEvent(eventData) {
function receiveGameStateChange(eventData) {
const content = eventData.content;
const title = eventData.title || 'Knockout Whist';
const url = eventData.url || null;
exchangeBody(content, title);
exchangeBody(content, title, url);
}
function receiveCardPlayedEvent(eventData) {
const firstCard = eventData.firstCard;
@@ -84,6 +85,79 @@ function receiveCardPlayedEvent(eventData) {
`;
firstCardContainer.html(newFirstCardHTML);
}
function receiveLobbyUpdateEvent(eventData) {
const host = eventData.host;
const maxPlayers = eventData.maxPlayers;
const players = eventData.players;
const lobbyPlayersContainer = $('#players');
const playerAmountBox = $('#playerAmount');
let newHtml = ''
if (host) {
players.forEach(user => {
const inner = user.self ? `<h5 class="card-title">${user.name} (You)</h5>
<a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a>`
: ` <h5 class="card-title">${user.name}</h5>
<div class="btn btn-danger" onclick="removePlayer('${user.id}')">Remove</div>`
newHtml += `<div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;">
<img src="/assets/images/profile.png" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
${inner}
</div>
</div>
</div>`
})
} else {
players.forEach(user => {
const inner = user.self ? `<h5 class="card-title">${user.name} (You)</h5>` : ` <h5 class="card-title">${user.name}</h5>`
newHtml += `<div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;">
<img src="/assets/images/profile.png" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
${inner}
</div>
</div>
</div>`
})
}
lobbyPlayersContainer.html(newHtml);
playerAmountBox.text(`Players: ${players.length} / ${maxPlayers}`);
}
function receiveKickEvent(eventData) {
$('#kickedModal').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
setTimeout(() => {
receiveGameStateChange(eventData)
}, 5000);
}
function receiveSessionClosedEvent(eventData) {
$('#sessionClosed').modal({
backdrop: 'static',
keyboard: false
}).modal('show');
setTimeout(() => {
receiveGameStateChange(eventData)
}, 5000);
}
onEvent("ReceivedHandEvent", receiveHandEvent)
onEvent("GameStateChangeEvent", receiveGameStateChange)
onEvent("CardPlayedEvent", receiveCardPlayedEvent)
onEvent("LobbyUpdateEvent", receiveLobbyUpdateEvent)
onEvent("LeftEvent", receiveGameStateChange)
onEvent("KickEvent", receiveKickEvent)
onEvent("SessionClosed", receiveSessionClosedEvent)

View File

@@ -5,3 +5,6 @@ function handlePlayCard(card, dog) {
function handleSkipDogLife(button) {
// TODO needs implementation
}
function handleKickPlayer(playerId) {
// TODO needs implementation
}