Compare commits

...

7 Commits
4.6.2 ... 4.7.3

Author SHA1 Message Date
TeamCity
49a1bd40ff ci: bump version to v4.7.3 2025-12-04 01:32:05 +00:00
f847424b9c fix: BAC-25 Race Condition: Websocket Promises (#99)
Reviewed-on: #99
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-12-04 02:29:19 +01:00
TeamCity
d50a576e31 ci: bump version to v4.7.2 2025-12-03 11:17:48 +00:00
TeamCity
eba2ad6232 ci: bump version to v4.7.1 2025-12-03 09:22:04 +00:00
14961cce01 chore: BAC-17 Add knockoutwhistfrontend submodule (#98)
Reviewed-on: #98
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-03 10:19:15 +01:00
TeamCity
dcb5d7373f ci: bump version to v4.7.0 2025-12-03 08:20:51 +00:00
d57e6efa98 feat(ui): FRO-7 Endscreen (#97)
Added a nice look to the endscreen. Implemented a ranking method inside GameLobby to get an order.

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #97
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: lq64 <lq@blackhole.local>
Co-committed-by: lq64 <lq@blackhole.local>
2025-12-03 09:18:11 +01:00
9 changed files with 219 additions and 65 deletions

5
.gitmodules vendored
View File

@@ -2,3 +2,8 @@
path = knockoutwhist path = knockoutwhist
branch = main branch = main
url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist.git url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist.git
[submodule "knockoutwhistfrontend"]
path = knockoutwhistfrontend
branch = main
url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Frontend.git

View File

@@ -192,3 +192,15 @@
### Bug Fixes ### Bug Fixes
* FRO-6 Websocket Close Handle ([#96](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/96)) ([3585566](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/358556612ec74601c8b31125e4e65f750abf8c4c)) * FRO-6 Websocket Close Handle ([#96](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/96)) ([3585566](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/358556612ec74601c8b31125e4e65f750abf8c4c))
## (2025-12-03)
### Features
* **ui:** FRO-7 Endscreen ([#97](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/97)) ([d57e6ef](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/d57e6efa985ca07c32f9f54595fe7393dbdf4d8a))
## (2025-12-03)
## (2025-12-03)
## (2025-12-04)
### Bug Fixes
* BAC-25 Race Condition: Websocket Promises ([#99](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/99)) ([f847424](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/f847424b9cea423ace5661d1efb6e4f01483c655))

1
knockoutwhistfrontend Submodule

Submodule knockoutwhistfrontend added at 575d1b5e8e

View File

@@ -20,6 +20,7 @@ import play.api.libs.json.{JsObject, Json}
import java.util.{Timer, TimerTask, UUID} import java.util.{Timer, TimerTask, UUID}
import scala.collection.mutable import scala.collection.mutable
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
import scala.util.Try
class GameLobby private( class GameLobby private(
val logic: GameLogic, val logic: GameLogic,
@@ -277,6 +278,37 @@ class GameLobby private(
def getUsers: Set[User] = { def getUsers: Set[User] = {
users.values.map(d => d.user).toSet users.values.map(d => d.user).toSet
} }
def getFinalRanking: List[(String, (Int, Int))] = {
Try {
val match1 = getMatch
if (!match1.isOver) {
List.empty
} else {
val winnerName = logic.getWinner.get.name
val allPlayerNames = match1.totalplayers.map(_.name)
val roundlist = match1.roundlist
val playerMetrics: Map[String, (Int, Int)] = allPlayerNames.map { name =>
val roundsWon = roundlist.count { round =>
round.winner.exists(_.name == name)
}
val totalTricksWon = roundlist.flatMap(_.tricklist).count { trick =>
trick.winner.exists(_.name == name)
}
name -> (roundsWon, totalTricksWon)
}.toMap
val winnerMetrics = playerMetrics(winnerName)
val remainingPlayersMetrics = playerMetrics.view.filterKeys(_ != winnerName).toList
val sortedRemainingPlayers = remainingPlayersMetrics.sortBy { case (_, (rounds, tricks)) =>
(-rounds, -tricks)
}
(winnerName, winnerMetrics) :: sortedRemainingPlayers
}
}.getOrElse(List())
}
private def transmitToAll(event: JsObject): Unit = { private def transmitToAll(event: JsObject): Unit = {
users.values.foreach(session => { users.values.foreach(session => {

View File

@@ -26,6 +26,7 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
else canInteract = Some(InteractionType.Card) else canInteract = Some(InteractionType.Card)
case _ => case _ =>
} }
websocketActor.foreach(_.solveRequests())
websocketActor.foreach(_.transmitEventToClient(event)) websocketActor.foreach(_.transmitEventToClient(event))
} }
@@ -38,8 +39,6 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
} }
def handleWebResponse(eventType: String, data: JsObject): Unit = { def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
val result = Try {
eventType match { eventType match {
case "ping" => case "ping" =>
// No action needed for Ping // No action needed for Ping
@@ -77,11 +76,5 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
gameLobby.returnToLobby(this) gameLobby.returnToLobby(this)
} }
} }
lock.unlock()
if (result.isFailure) {
val throwable = result.failed.get
throw throwable
}
}
} }

View File

@@ -5,6 +5,7 @@ import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json} import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper import util.WebsocketEventMapper
import scala.collection.mutable
import scala.util.{Failure, Success, Try} import scala.util.{Failure, Success, Try}
class UserWebsocketActor( class UserWebsocketActor(
@@ -12,6 +13,8 @@ class UserWebsocketActor(
session: UserSession session: UserSession
) extends Actor { ) extends Actor {
private val requests: mutable.Map[String, String] = mutable.Map()
{ {
session.lock.lock() session.lock.lock()
if (session.websocketActor.isDefined) { if (session.websocketActor.isDefined) {
@@ -48,12 +51,14 @@ class UserWebsocketActor(
} }
private def handle(json: JsValue): Unit = { private def handle(json: JsValue): Unit = {
session.lock.lock()
val idOpt = (json \ "id").asOpt[String] val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) { if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj( transmitJsonToClient(Json.obj(
"status" -> "error", "status" -> "error",
"error" -> "Missing 'id' field" "error" -> "Missing 'id' field"
)) ))
session.lock.unlock()
return return
} }
val id = idOpt.get val id = idOpt.get
@@ -65,17 +70,25 @@ class UserWebsocketActor(
"status" -> "error", "status" -> "error",
"error" -> "Missing 'event' field" "error" -> "Missing 'event' field"
)) ))
session.lock.unlock()
return return
} }
val statusOpt = (json \ "status").asOpt[String] val statusOpt = (json \ "status").asOpt[String]
if (statusOpt.isDefined) { if (statusOpt.isDefined) {
session.lock.unlock()
return return
} }
val event = eventOpt.get val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj()) val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
requests += (id -> event)
val result = Try { val result = Try {
session.handleWebResponse(event, data) session.handleWebResponse(event, data)
} }
if (!requests.contains(id)) {
session.lock.unlock()
return
}
requests -= id
if (result.isSuccess) { if (result.isSuccess) {
transmitJsonToClient(Json.obj( transmitJsonToClient(Json.obj(
"id" -> id, "id" -> id,
@@ -90,6 +103,7 @@ class UserWebsocketActor(
"error" -> result.failed.get.getMessage "error" -> result.failed.get.getMessage
)) ))
} }
session.lock.unlock()
} }
def transmitJsonToClient(jsonObj: JsValue): Unit = { def transmitJsonToClient(jsonObj: JsValue): Unit = {
@@ -100,4 +114,20 @@ class UserWebsocketActor(
transmitJsonToClient(WebsocketEventMapper.toJson(event, session)) transmitJsonToClient(WebsocketEventMapper.toJson(event, session))
} }
def solveRequests(): Unit = {
if (!session.lock.isHeldByCurrentThread)
return;
if (requests.isEmpty)
return;
val pendingRequests = requests.toMap
requests.clear()
pendingRequests.foreach { case (id, event) =>
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "success"
))
}
}
} }

View File

@@ -1,27 +1,108 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby) @(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
<main class="lobby-background vh-100" id="lobbybackground"> <div class="lobby-background vh-100 d-flex align-items-start justify-content-center pt-5">
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);"> <div class="container py-5">
<div class="row"> <div class="row justify-content-center">
<div class="col"> <div class="col-lg-8 col-xl-6">
<div class="p-3 text-center fs-4">Winner: @gamelobby.getLogic.getWinner</div> <div class="card shadow-lg mb-5 text-center bg-white border-0 rounded-4">
<div class="card-body p-4 p-md-5">
<h1 class="card-title display-5 fw-bold text-success mb-3">Match Over!</h1>
<p class="fs-4 text-muted">Congratulations to the winner:</p>
<h2 class="display-3 fw-bolder mb-4 text-primary">
@gamelobby.getLogic.getWinner.get.name
</h2>
</div> </div>
</div> </div>
<div class="row justify-content-center align-items-center flex-grow-1">
@if((gamelobby.getUserSession(user.get.id).host)) { <div class="card shadow mb-5 border-0 rounded-4 overflow-hidden">
<div class="col-12 text-center mb-5"> <div class="card-header bg-dark text-white text-center fs-5 fw-semibold">
<div class="btn btn-success" onclick="handleReturnToLobby()">Return to lobby</div> Final Standings
</div>
<div class="d-flex justify-content-between align-items-center p-2 text-uppercase fw-bold border-bottom bg-light">
<div class="d-flex align-items-center">
Player
</div>
<div class="d-flex flex-row gap-3">
<span class="fs-6 text-dark text-center" style="width: 5rem;">Rounds won</span>
<span class="fs-6 text-dark text-center" style="width: 5rem;">Tricks won</span>
</div>
</div>
<div>
@gamelobby.getFinalRanking.zipWithIndex.map { case ((playerName, (wonRounds, tricksWon)), index) =>
@defining(index + 1) { rank =>
<div class="d-flex justify-content-between align-items-center p-3 border-bottom @if(rank == 1){bg-success-subtle fw-bold}">
<div class="d-flex align-items-center">
<span class="badge @if(rank == 1){bg-warning text-dark fs-6} else {bg-secondary} rounded-pill me-3">#@rank</span>
@playerName
</div>
<div class="d-flex flex-row gap-3">
<span class="fs-6 text-muted text-center" style="width: 5rem;">@wonRounds</span>
<span class="fs-6 text-muted text-center" style="width: 5rem;">@tricksWon</span>
</div>
</div>
}
}
</div>
@if(gamelobby.getFinalRanking.isEmpty) {
<div class="p-3 text-center text-muted">No final scores available.</div>
}
</div>
@if(user.isDefined && gamelobby.getUserSession(user.get.id).host) {
<div class="col-12 text-center mt-4">
<div class="btn btn-success btn-lg shadow" onclick="handleReturnToLobby()">
Return to Lobby
</div>
</div> </div>
} else { } else {
<div class="col-12 text-center mt-3"> <div class="col-12 text-center mt-4">
<div class="spinner-border mt-1" role="status"> <div class="text-primary">
<div class="spinner-grow text-primary mt-1" role="status">
<span class="visually-hidden">Loading...</span> <span class="visually-hidden">Loading...</span>
</div> </div>
<p class="mt-3 fs-6 fw-semibold">
Waiting for the Host to continue...
</p>
</div>
</div> </div>
} }
</div> </div>
</div> </div>
</main> </div>
</div>
<script> <script>
connectWebSocket() function fireConfetti() {
let duration = 3 * 1000;
let animationEnd = Date.now() + duration;
let defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 };
function randomInRange(min, max) {
return Math.random() * (max - min) + min;
}
let interval = setInterval(function() {
let timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
return clearInterval(interval);
}
let particleCount = 50 * (timeLeft / duration);
// Left burst
confetti(Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 }
}));
// Right burst
confetti(Object.assign({}, defaults, {
particleCount,
origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 }
}));
}, 250);
}
connectWebSocket();
fireConfetti();
</script> </script>

View File

@@ -17,7 +17,7 @@
<link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")"> <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
<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/canvas-confetti@@1.6.0/dist/confetti.browser.min.js"></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://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="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script> <script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>

View File

@@ -1,3 +1,3 @@
MAJOR=4 MAJOR=4
MINOR=6 MINOR=7
PATCH=2 PATCH=3