Compare commits

...

4 Commits
4.1.0 ... 4.3.0

Author SHA1 Message Date
TeamCity
14b4473f72 ci: bump version to v4.3.0 2025-11-26 17:44:23 +00:00
1ef5e8a72f 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>
2025-11-26 18:41:25 +01:00
TeamCity
576e5af87e ci: bump version to v4.2.0 2025-11-26 12:37:57 +00:00
3c0828fdbe feat(api): Implemented card played event via websocket (#85)
Reviewed-on: #85
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-26 13:35:05 +01:00
18 changed files with 325 additions and 22 deletions

View File

@@ -149,3 +149,13 @@
* **api:** Implement received hand event handling and UI updates ([#83](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/83)) ([52e5033](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/52e5033afca344ae40a644196555a9655913710a)), closes [#76](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/76)
* **base:** Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! ([#84](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/84)) ([b81bb3d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b81bb3d0aeb8500a9d7417a10e24e7f8a17d71d2))
## (2025-11-26)
### Features
* **api:** Implemented card played event via websocket ([#85](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/85)) ([3c0828f](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3c0828fdbeb507706b86f1662476c46e760533e4))
## (2025-11-26)
### Features
* **api:** Implemented session closed and kick event via websocket ([#87](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/87)) ([1ef5e8a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1ef5e8a72fdf8a3d1ae624c8c3d7c6595017bc6f))

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.{GameStateEventMapper, ReceivedHandEventMapper, SimpleEventMapper}
import util.mapper.{CardPlayedEventMapper, GameStateEventMapper, KickEventMapper, LeftEventMapper, LobbyUpdateEventMapper, ReceivedHandEventMapper, SessionClosedMapper, SimpleEventMapper}
object WebsocketEventMapper {
@@ -26,6 +26,11 @@ object WebsocketEventMapper {
// Register all custom mappers here
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,20 @@
package util.mapper
import de.knockoutwhist.events.global.CardPlayedEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
import util.WebUIUtils
object CardPlayedEventMapper extends SimpleEventMapper[CardPlayedEvent]{
override def id: String = "CardPlayedEvent"
override def toJson(event: CardPlayedEvent, session: UserSession): JsObject = {
Json.obj(
"firstCard" -> (if (event.trick.firstCard.isDefined) WebUIUtils.cardtoString(event.trick.firstCard.get) else "BLANK"),
"playedCards" -> JsArray(event.trick.cards.map { case (card, player) =>
Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name)
}.toList)
)
}
}

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,9 +42,122 @@ 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;
const playedCards = eventData.playedCards;
const trickCardsContainer = $('#trick-cards-container');
const firstCardContainer = $('#first-card-container')
let trickHTML = '';
playedCards.forEach(cardCombo => {
trickHTML += `
<div class="col-auto">
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem; backdrop-filter: blur(4px);">
<div class="p-2">
<img src="/assets/images/cards/${cardCombo.cardId}.png" width="100%" alt="${cardCombo.cardId}"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">${cardCombo.player}</small>
</div>
</div>
</div>
`;
});
trickCardsContainer.html(trickHTML);
let altText;
let imageSrc;
if (firstCard === "BLANK") {
imageSrc = "/assets/images/cards/1B.png";
altText = "Blank Card";
} else {
imageSrc = `/assets/images/cards/${firstCard}.png`;
altText = `Card ${firstCard}`;
}
const newFirstCardHTML = `
<img src="${imageSrc}" alt="${altText}" width="80px" style="border-radius: 6px"/>
`;
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("GameStateChangeEvent", receiveGameStateChange)
onEvent("CardPlayedEvent", receiveCardPlayedEvent)
onEvent("LobbyUpdateEvent", receiveLobbyUpdateEvent)
onEvent("LeftEvent", receiveGameStateChange)
onEvent("KickEvent", receiveKickEvent)
onEvent("SessionClosed", receiveSessionClosedEvent)

View File

@@ -1,7 +1,10 @@
function handlePlayCard(card, dog) {
// TODO needs implementation
// TODO needs implementation
}
function handleSkipDogLife(button) {
// TODO needs implementation
}
// TODO needs implementation
}
function handleKickPlayer(playerId) {
// TODO needs implementation
}

View File

@@ -1,3 +1,3 @@
MAJOR=4
MINOR=1
MINOR=3
PATCH=0