Compare commits

..

5 Commits
3.0.0 ... 4.0.1

Author SHA1 Message Date
TeamCity
10a26404b3 ci: bump version to v4.0.1 2025-11-24 11:21:05 +00:00
TeamCity
11478a096d ci: bump version to v4.0.0 2025-11-23 15:15:09 +00:00
8ca909db52 feat(websocket)!: Implement WebSocket connection and event handling (#82)
Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #82
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-23 16:11:46 +01:00
TeamCity
1edb3bfd89 ci: bump version to v3.0.1 2025-11-22 20:38:51 +00:00
9738a04b7a fix(api): Fixed a bug where the game would reload on game start (#81)
Reviewed-on: #81
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-11-22 21:36:23 +01:00
44 changed files with 2586 additions and 2767 deletions

View File

@@ -128,3 +128,18 @@
### Bug Fixes ### Bug Fixes
* **polling:** Improve polling mechanism and delay handling ([#60](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/60)) ([641c892](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/641c892981649eb85640527cc0fe325ff683fa77)) * **polling:** Improve polling mechanism and delay handling ([#60](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/60)) ([641c892](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/641c892981649eb85640527cc0fe325ff683fa77))
## (2025-11-22)
### Bug Fixes
* **api:** Fixed a bug where the game would reload on game start ([#81](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/81)) ([9738a04](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/9738a04b7a3c63c8cd1450e563ec04823fb3c35a))
## (2025-11-23)
### ⚠ BREAKING CHANGES
* **websocket:** Implement WebSocket connection and event handling (#82)
### Features
* **websocket:** Implement WebSocket connection and event handling ([#82](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/82)) ([8ca909d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8ca909db522dd7108a3e40ce84811eaf8695eaa5))
## (2025-11-24)

View File

@@ -1,12 +1,12 @@
ThisBuild / scalaVersion := "3.5.1" ThisBuild / scalaVersion := "3.5.1"
lazy val commonSettings = Seq( lazy val commonSettings = Seq(
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.18", libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "test", libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test",
libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.0.1", libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.1.0",
libraryDependencies += "org.scalafx" %% "scalafx" % "22.0.0-R33", libraryDependencies += "org.scalafx" %% "scalafx" % "24.0.2-R36",
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0", libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.4.0",
libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M1", libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M9",
libraryDependencies ++= { libraryDependencies ++= {
// Determine OS version of JavaFX binaries // Determine OS version of JavaFX binaries
lazy val osName = System.getProperty("os.name") match { lazy val osName = System.getProperty("os.name") match {
@@ -38,8 +38,9 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
commonSettings, commonSettings,
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0",
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2", libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
) )

View File

@@ -17,7 +17,7 @@
width: 100%; width: 100%;
border: none; border: none;
border-radius: 1rem; border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0,0,0,0.1); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative; position: relative;
z-index: 3; /* ensure card sits above the particles */ z-index: 3; /* ensure card sits above the particles */
} }

View File

@@ -14,37 +14,44 @@
--bs-border-color: rgba(0, 0, 0, 0.125) !important; --bs-border-color: rgba(0, 0, 0, 0.125) !important;
--bs-heading-color: var(--color) !important; --bs-heading-color: var(--color) !important;
} }
@background-color: var(--background-color); @background-color: var(--background-color);
@highlightcolor: var(--highlightscolor); @highlightcolor: var(--highlightscolor);
@background-image: var(--background-image); @background-image: var(--background-image);
@color: var(--color); @color: var(--color);
@keyframes slideIn { @keyframes slideIn {
0% { transform: translateX(-100vw); } 0% {
100% { transform: translateX(0); } transform: translateX(-100vw);
}
100% {
transform: translateX(0);
}
} }
.game-field-background { .game-field-background {
background-image: @background-image; background-image: @background-image;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
min-height: 100vh; min-height: 100vh;
}
.lobby-background {
background-color: @background-color;
width: 100%;
height: 100vh;
} }
.navbar-header{ .lobby-background {
text-align:center; background-color: @background-color;
width: 100%;
height: 100vh;
}
.navbar-header {
text-align: center;
} }
.navbar-toggle { .navbar-toggle {
float: none; float: none;
margin-right:0; margin-right: 0;
} }
.handcard :hover { .handcard :hover {
box-shadow: 3px 3px 3px @highlightcolor; box-shadow: 3px 3px 3px @highlightcolor;
} }
@@ -52,7 +59,7 @@
.inactive::after { .inactive::after {
content: ""; content: "";
position: absolute; position: absolute;
inset: 0; /* cover the whole container */ inset: 0; /* cover the whole container */
background: rgba(0, 0, 0, 0.50); background: rgba(0, 0, 0, 0.50);
z-index: 10; z-index: 10;
border-radius: 6px; border-radius: 6px;
@@ -73,26 +80,26 @@
/* Ensure body text color follows theme variable and works with Bootstrap */ /* Ensure body text color follows theme variable and works with Bootstrap */
body { body {
color: @color; color: @color;
} }
.footer { .footer {
width: 100%; width: 100%;
text-align: center; text-align: center;
font-size: 12px; font-size: 12px;
color: @color; color: @color;
padding: 0.5rem 0; padding: 0.5rem 0;
flex-grow: 1; /* fill remaining vertical space as visual footer background */ flex-grow: 1; /* fill remaining vertical space as visual footer background */
} }
.game-field { .game-field {
position: fixed; position: fixed;
inset: 0; inset: 0;
overflow: auto; overflow: auto;
} }
.navbar-drop-shadow { .navbar-drop-shadow {
box-shadow: 0 1px 15px 0 #000000 box-shadow: 0 1px 15px 0 #000000
} }
.ingame-side-shadow { .ingame-side-shadow {
@@ -100,126 +107,164 @@ body {
} }
#sessions { #sessions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
align-items: center; align-items: center;
text-align: center; text-align: center;
h1 {
animation: slideIn 0.5s ease-out forwards; h1 {
animation-fill-mode: backwards; animation: slideIn 0.5s ease-out forwards;
} animation-fill-mode: backwards;
}
} }
#textanimation { #textanimation {
animation: slideIn 0.5s ease-out forwards; animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards; animation-fill-mode: backwards;
animation-delay: 1s; animation-delay: 1s;
} }
#sessions a, #sessions h1, #sessions p { #sessions a, #sessions h1, #sessions p {
color: @color; color: @color;
font-size: 40px; font-size: 40px;
font-family: Arial, serif; font-family: Arial, serif;
} }
#ingame { #ingame {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
height: 100%; height: 100%;
} }
#ingame a, #ingame h1, #ingame p { #ingame a, #ingame h1, #ingame p {
color: @color; color: @color;
font-size: 40px; font-size: 40px;
font-family: Arial, serif; font-family: Arial, serif;
} }
.ingame-cards-slide { .ingame-cards-slide {
div { div {
animation: slideIn 0.5s ease-out forwards; animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards; animation-fill-mode: backwards;
&:nth-child(1) { animation-delay: 0.5s; }
&:nth-child(2) { animation-delay: 1s; } &:nth-child(1) {
&:nth-child(3) { animation-delay: 1.5s; } animation-delay: 0.5s;
&:nth-child(4) { animation-delay: 2s; } }
&:nth-child(5) { animation-delay: 2.5s; }
&:nth-child(6) { animation-delay: 3s; } &:nth-child(2) {
&:nth-child(7) { animation-delay: 3.5s; } animation-delay: 1s;
}
&:nth-child(3) {
animation-delay: 1.5s;
}
&:nth-child(4) {
animation-delay: 2s;
}
&:nth-child(5) {
animation-delay: 2.5s;
}
&:nth-child(6) {
animation-delay: 3s;
}
&:nth-child(7) {
animation-delay: 3.5s;
}
} }
} }
#playedcardplayer { #playedcardplayer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
} }
#playedcardplayer p { #playedcardplayer p {
font-size: 12px; font-size: 12px;
height: 4%; height: 4%;
} }
#playedcardplayer img { #playedcardplayer img {
height: 90%; height: 90%;
} }
#firstCard { #firstCard {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
height: 20%; height: 20%;
width: 100%; width: 100%;
justify-content: space-between; justify-content: space-between;
} }
#firstCardObject { #firstCardObject {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin-right: 4%; margin-right: 4%;
} }
#firstCardObject img{
height: 90%; #firstCardObject img {
height: 90%;
} }
#firstCardObject p{
height: 10%; #firstCardObject p {
font-size: 20px; height: 10%;
font-size: 20px;
} }
#nextPlayers { #nextPlayers {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 0; height: 0;
p {
margin-top: 0; p {
margin-bottom: 0; margin-top: 0;
} margin-bottom: 0;
}
} }
#invisible { #invisible {
visibility: hidden; visibility: hidden;
} }
#selecttrumpsuit { #selecttrumpsuit {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
height: 100%; height: 100%;
} }
#rules { #rules {
color: @color; color: @color;
font-size: 1.5em; font-size: 1.5em;
font-family: Arial, serif; font-family: Arial, serif;
} }
.score-table { .score-table {
background-color: rgba(255, 255, 255, 0.1); background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px; border-radius: 8px;
padding: 10px; padding: 10px;
margin-bottom: 20px; margin-bottom: 20px;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
} }
.score-header { .score-header {
font-weight: bold; font-weight: bold;
color: #000000; color: #000000;
border-bottom: 1px solid rgba(255, 255, 255, 0.3); border-bottom: 1px solid rgba(255, 255, 255, 0.3);
} }
.score-row { .score-row {
color: #000000; color: #000000;
} }
/* In-game centered stage and blurred sides overlay */ /* In-game centered stage and blurred sides overlay */
@@ -243,12 +288,12 @@ body {
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
/* fallback: subtle vignette if backdrop-filter unsupported */ /* fallback: subtle vignette if backdrop-filter unsupported */
background: radial-gradient(ellipse at center, rgba(0,0,0,0) 30%, rgba(0,0,0,0.35) 100%); background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 30%, rgba(0, 0, 0, 0.35) 100%);
} }
@supports ((-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px))) { @supports ((-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px))) {
.blur-sides::before { .blur-sides::before {
background: rgba(0,0,0,0.08); background: rgba(0, 0, 0, 0.08);
-webkit-backdrop-filter: blur(10px) saturate(110%); -webkit-backdrop-filter: blur(10px) saturate(110%);
backdrop-filter: blur(10px) saturate(110%); backdrop-filter: blur(10px) saturate(110%);
} }

View File

@@ -15,13 +15,6 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
override def executionContext: ExecutionContext = ec override def executionContext: ExecutionContext = ec
protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
override def invokeBlock[A]( override def invokeBlock[A](
request: Request[A], request: Request[A],
block: AuthenticatedRequest[A] => Future[Result] block: AuthenticatedRequest[A] => Future[Result]
@@ -33,5 +26,12 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
Future.successful(Results.Redirect(routes.UserController.login())) Future.successful(Results.Redirect(routes.UserController.login()))
} }
} }
protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
} }

View File

@@ -8,6 +8,7 @@ import de.knockoutwhist.utils.events.EventListener
class WebApplicationConfiguration extends DefaultConfiguration { class WebApplicationConfiguration extends DefaultConfiguration {
override def uis: Set[UI] = Set() override def uis: Set[UI] = Set()
override def listener: Set[EventListener] = Set(DelayHandler) override def listener: Set[EventListener] = Set(DelayHandler)
} }

View File

@@ -1,7 +1,7 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.GameState.*
import exceptions.* import exceptions.*
import logic.PodManager import logic.PodManager
import logic.game.GameLobby import logic.game.GameLobby
@@ -18,12 +18,28 @@ import scala.concurrent.ExecutionContext
import scala.util.Try import scala.util.Try
@Singleton @Singleton
class IngameController @Inject() ( class IngameController @Inject()(
val cc: ControllerComponents, val cc: ControllerComponents,
val podManager: PodManager, val authAction: AuthAction,
val authAction: AuthAction, implicit val ec: ExecutionContext
implicit val ec: ExecutionContext ) extends AbstractController(cc) {
) extends AbstractController(cc) {
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val results = Try {
returnInnerHTML(g, request.user)
}
if (results.isSuccess) {
Ok(views.html.main("In-Game - Knockout Whist")(results.get))
} else {
InternalServerError(results.failed.get.getMessage)
}
case None =>
Redirect(routes.MainMenuController.mainMenu())
}
}
def returnInnerHTML(gameLobby: GameLobby, user: User): Html = { def returnInnerHTML(gameLobby: GameLobby, user: User): Html = {
gameLobby.logic.getCurrentState match { gameLobby.logic.getCurrentState match {
@@ -34,10 +50,10 @@ class IngameController @Inject() (
gameLobby gameLobby
) )
case SelectTrump => case SelectTrump =>
views.html.ingame.selecttrump( views.html.ingame.selecttrump(
gameLobby.getPlayerByUser(user), gameLobby.getPlayerByUser(user),
gameLobby gameLobby
) )
case TieBreak => case TieBreak =>
views.html.ingame.tie( views.html.ingame.tie(
gameLobby.getPlayerByUser(user), gameLobby.getPlayerByUser(user),
@@ -53,24 +69,8 @@ class IngameController @Inject() (
} }
} }
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId)
game match {
case Some(g) =>
val results = Try {
returnInnerHTML(g, request.user)
}
if (results.isSuccess) {
Ok(views.html.main("In-Game - Knockout Whist")(results.get))
} else {
InternalServerError(results.failed.get.getMessage)
}
case None =>
Redirect(routes.MainMenuController.mainMenu())
}
}
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 {
game match { game match {
case Some(g) => case Some(g) =>
@@ -82,7 +82,8 @@ class IngameController @Inject() (
if (result.isSuccess) { if (result.isSuccess) {
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()
)) ))
} else { } else {
val throwable = result.failed.get val throwable = result.failed.get
@@ -110,13 +111,14 @@ class IngameController @Inject() (
} }
} }
} }
def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
val playerToKickUUID = UUID.fromString(playerToKick) val playerToKickUUID = UUID.fromString(playerToKick)
val result = Try { val result = Try {
game.get.leaveGame(playerToKickUUID) game.get.leaveGame(playerToKickUUID)
} }
if(result.isSuccess) { if (result.isSuccess) {
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url "redirectUrl" -> routes.IngameController.game(gameId).url
@@ -128,15 +130,17 @@ class IngameController @Inject() (
)) ))
} }
} }
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def leaveGame(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 {
game.get.leaveGame(request.user.id) game.get.leaveGame(request.user.id)
} }
if (result.isSuccess) { if (result.isSuccess) {
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url "redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
)) ))
} else { } else {
InternalServerError(Json.obj( InternalServerError(Json.obj(
@@ -145,9 +149,9 @@ class IngameController @Inject() (
)) ))
} }
} }
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -217,8 +221,9 @@ class IngameController @Inject() (
} }
} }
} }
def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => { case Some(g) => {
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -281,8 +286,9 @@ class IngameController @Inject() (
} }
} }
} }
def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -333,8 +339,9 @@ class IngameController @Inject() (
NotFound("Game not found") NotFound("Game not found")
} }
} }
def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -385,10 +392,10 @@ class IngameController @Inject() (
NotFound("Game not found") NotFound("Game not found")
} }
} }
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = podManager.getGame(gameId) val game = PodManager.getGame(gameId)
game match { game match {
case Some(g) => case Some(g) =>
val result = Try { val result = Try {

View File

@@ -1,35 +1,24 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.AuthAction
import logic.PodManager
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents} import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
import play.api.routing.JavaScriptReverseRouter import play.api.routing.JavaScriptReverseRouter
import javax.inject.Inject import javax.inject.Inject
class JavaScriptRoutingController @Inject()( class JavaScriptRoutingController @Inject()(
val controllerComponents: ControllerComponents, val controllerComponents: ControllerComponents,
val authAction: AuthAction, val authAction: AuthAction,
val podManager: PodManager ) extends BaseController {
) extends BaseController {
def javascriptRoutes(): Action[AnyContent] = def javascriptRoutes(): Action[AnyContent] =
Action { implicit request => Action { implicit request =>
Ok( Ok(
JavaScriptReverseRouter("jsRoutes")( JavaScriptReverseRouter("jsRoutes")(
routes.javascript.MainMenuController.createGame, routes.javascript.MainMenuController.createGame,
routes.javascript.MainMenuController.joinGame, routes.javascript.MainMenuController.joinGame,
routes.javascript.MainMenuController.navSPA, routes.javascript.MainMenuController.navSPA,
routes.javascript.IngameController.startGame,
routes.javascript.IngameController.kickPlayer,
routes.javascript.IngameController.leaveGame,
routes.javascript.IngameController.playCard,
routes.javascript.IngameController.playDogCard,
routes.javascript.IngameController.playTrump,
routes.javascript.IngameController.playTie,
routes.javascript.IngameController.returnToLobby,
routes.javascript.PollingController.polling,
routes.javascript.UserController.login_Post routes.javascript.UserController.login_Post
) )
).as("text/javascript") ).as("text/javascript")
} }
} }

View File

@@ -17,7 +17,6 @@ import javax.inject.*
class MainMenuController @Inject()( class MainMenuController @Inject()(
val controllerComponents: ControllerComponents, val controllerComponents: ControllerComponents,
val authAction: AuthAction, val authAction: AuthAction,
val podManager: PodManager,
val ingameController: IngameController val ingameController: IngameController
) extends BaseController { ) extends BaseController {
@@ -39,7 +38,7 @@ class MainMenuController @Inject()(
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String] val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
.getOrElse(throw new IllegalArgumentException("Player amount is required.")) .getOrElse(throw new IllegalArgumentException("Player amount is required."))
val gameLobby = podManager.createGame( val gameLobby = PodManager.createGame(
host = request.user, host = request.user,
name = gamename, name = gamename,
maxPlayers = playeramount.toInt maxPlayers = playeramount.toInt
@@ -55,16 +54,16 @@ class MainMenuController @Inject()(
"errorMessage" -> "Invalid form submission" "errorMessage" -> "Invalid form submission"
)) ))
} }
} }
def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
val gameId: Option[String] = jsonBody.flatMap { jsValue => val gameId: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "gameId").asOpt[String] (jsValue \ "gameId").asOpt[String]
} }
if (gameId.isDefined) { if (gameId.isDefined) {
val game = podManager.getGame(gameId.get) val game = PodManager.getGame(gameId.get)
game match { game match {
case Some(g) => case Some(g) =>
g.addUser(request.user) g.addUser(request.user)
@@ -91,7 +90,7 @@ class MainMenuController @Inject()(
Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user)))) Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
} }
def navSPA(location: String) : Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
location match { location match {
case "0" => // Main Menu case "0" => // Main Menu
Ok(Json.obj( Ok(Json.obj(

View File

@@ -1,153 +0,0 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import controllers.PollingController.{scheduler, timeoutDuration}
import de.knockoutwhist.cards.Hand
import de.knockoutwhist.player.AbstractPlayer
import logic.PodManager
import logic.game.{GameLobby, PollingEvents}
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
import model.sessions.UserSession
import model.users.User
import play.api.libs.json.{JsArray, JsValue, Json}
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents, Result}
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,
val podManager: PodManager,
val authAction: AuthAction,
val ingameController: IngameController,
implicit val ec: ExecutionContext
) extends AbstractController(cc) {
private def buildCardPlayResponse(game: GameLobby, hand: Option[Hand], player: AbstractPlayer, newRound: Boolean): JsValue = {
val currentRound = game.logic.getCurrentRound.get
val currentTrick = game.logic.getCurrentTrick.get
val trickCardsJson = Json.toJson(
currentTrick.cards.map { case (card, player) =>
Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name)
}
)
val scoreTableJson = Json.toJson(
game.getLogic.getPlayerQueue.get.toList.map { player =>
Json.obj(
"name" -> player.name,
"tricks" -> currentRound.tricklist.count(_.winner.contains(player))
)
}
)
val stringHand = hand.map { h =>
val cardStrings = h.cards.map(WebUIUtils.cardtoString)
Json.toJson(cardStrings).as[JsArray]
}.getOrElse(Json.arr())
val firstCardId = currentTrick.firstCard.map(WebUIUtils.cardtoString).getOrElse("BLANK")
val nextPlayer = game.getLogic.getPlayerQueue.get.duplicate().nextPlayer().name
Json.obj(
"status" -> "cardPlayed",
"animation" -> newRound,
"handData" -> stringHand,
"dog" -> player.isInDogLife,
"currentPlayerName" -> game.logic.getCurrentPlayer.get.name,
"trumpSuit" -> currentRound.trumpSuit.toString,
"trickCards" -> trickCardsJson,
"scoreTable" -> scoreTableJson,
"firstCardId" -> firstCardId,
"nextPlayer" -> nextPlayer,
"yourTurn" -> (game.logic.getCurrentPlayer.get == player)
)
}
private def buildLobbyUsersResponse(game: GameLobby, userSession: UserSession): JsValue = {
Json.obj(
"status" -> "lobbyUpdate",
"host" -> userSession.host,
"users" -> game.getUsers.map(u => Json.obj(
"name" -> u.name,
"id" -> u.id,
"self" -> (u.id == userSession.id)
)),
"maxPlayers" -> game.maxPlayers
)
}
def handleEvent(event: PollingEvents, game: GameLobby, userSession: UserSession): Result = {
event match {
case NewRound =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, true)
Ok(jsonResponse)
case NewTrick =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, false)
Ok(jsonResponse)
case CardPlayed =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, false)
Ok(jsonResponse)
case LobbyUpdate =>
Ok(buildLobbyUsersResponse(game, userSession))
case ReloadEvent =>
val jsonResponse = Json.obj(
"status" -> "reloadEvent",
"redirectUrl" -> routes.IngameController.game(game.id).url,
"content" -> ingameController.returnInnerHTML(game, userSession.user).toString
)
Ok(jsonResponse)
}
}
def polling(gameId: String): Action[AnyContent] = authAction.async { implicit request: AuthenticatedRequest[AnyContent] =>
val playerId = request.user.id
podManager.getGame(gameId) match {
case Some(game) =>
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 {
case _: Throwable =>
game.removeWaiter(playerId)
NoContent
}
}
case None =>
Future.successful(NotFound("Game not found."))
}
}
}

View File

@@ -0,0 +1,45 @@
package controllers
import auth.AuthAction
import logic.PodManager
import logic.user.SessionManager
import model.sessions.{UserSession, UserWebsocketActor}
import org.apache.pekko.actor.{ActorRef, ActorSystem, Props}
import org.apache.pekko.stream.Materializer
import play.api.*
import play.api.libs.streams.ActorFlow
import play.api.mvc.*
import javax.inject.*
@Singleton
class WebsocketController @Inject()(
cc: ControllerComponents,
val sessionManger: SessionManager,
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
def socket(): WebSocket = WebSocket.accept[String, String] { request =>
val session = request.cookies.get("sessionId")
if (session.isEmpty) throw new Exception("No session cookie found")
val userOpt = sessionManger.getUserBySession(session.get.value)
if (userOpt.isEmpty) throw new Exception("Invalid session")
val user = userOpt.get
val game = PodManager.identifyGameOfUser(user)
if (game.isEmpty) throw new Exception("User is not in a game")
val userSession = game.get.getUserSession(user.id)
ActorFlow.actorRef { out =>
println("Connect received")
KnockOutWebSocketActorFactory.create(out, userSession)
}
}
object KnockOutWebSocketActorFactory {
def create(out: ActorRef, userSession: UserSession): Props = {
Props(new UserWebsocketActor(out, userSession))
}
}
}

View File

@@ -11,20 +11,20 @@ import util.GameUtil
import javax.inject.Singleton import javax.inject.Singleton
import scala.collection.mutable import scala.collection.mutable
@Singleton object PodManager {
class PodManager {
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
val podIp: String = System.getenv("POD_IP") val podIp: String = System.getenv("POD_IP")
val podName: String = System.getenv("POD_NAME") val podName: String = System.getenv("POD_NAME")
private val sessions: mutable.Map[String, GameLobby] = mutable.Map() private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
def createGame( def createGame(
host: User, host: User,
name: String, name: String,
maxPlayers: Int maxPlayers: Int
): GameLobby = { ): GameLobby = {
val gameLobby = GameLobby( val gameLobby = GameLobby(
logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), logic = BaseGameLogic(injector.getInstance(classOf[Configuration])),
@@ -35,15 +35,39 @@ class PodManager {
host = host host = host
) )
sessions += (gameLobby.id -> gameLobby) sessions += (gameLobby.id -> gameLobby)
userSession += (host -> gameLobby.id)
gameLobby gameLobby
} }
def getGame(gameId: String): Option[GameLobby] = { def getGame(gameId: String): Option[GameLobby] = {
sessions.get(gameId) sessions.get(gameId)
} }
private[logic] def removeGame(gameId: String): Unit = { def registerUserToGame(user: User, gameId: String): Boolean = {
sessions.remove(gameId) if (sessions.contains(gameId)) {
userSession += (user -> gameId)
true
} else {
false
}
} }
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
}
def identifyGameOfUser(user: User): Option[GameLobby] = {
userSession.get(user) match {
case Some(gameId) => sessions.get(gameId)
case None => None
}
}
private[logic] def removeGame(gameId: String): Unit = {
sessions.remove(gameId)
// Also remove all user sessions associated with this game
userSession.filterInPlace((_, v) => v != gameId)
}
} }

View File

@@ -2,66 +2,36 @@ package logic.game
import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.cards.{Hand, Suit}
import de.knockoutwhist.control.GameLogic import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.control.GameState.{InGame, Lobby, MainMenu} import de.knockoutwhist.control.GameState.{Lobby, MainMenu}
import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil}
import de.knockoutwhist.events.global.tie.TieTurnEvent import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed}
import de.knockoutwhist.events.global.{CardPlayedEvent, GameStateChangeEvent, NewTrickEvent, SessionClosed} import de.knockoutwhist.events.player.PlayerEvent
import de.knockoutwhist.events.player.{PlayerEvent, ReceivedHandEvent}
import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.Playertype.HUMAN
import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import exceptions.* import exceptions.*
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent} import logic.PodManager
import model.sessions.{InteractionType, UserSession} import model.sessions.{InteractionType, UserSession}
import model.users.User import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.UUID import java.util.UUID
import scala.collection.mutable import scala.collection.mutable
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
import scala.concurrent.Promise as ScalaPromise
class GameLobby private( class GameLobby private(
val logic: GameLogic, val logic: GameLogic,
val id: String, val id: String,
val internalId: UUID, val internalId: UUID,
val name: String, val name: String,
val maxPlayers: Int val maxPlayers: Int
) extends EventListener { ) extends EventListener {
private val users: mutable.Map[UUID, UserSession] = mutable.Map() private val users: mutable.Map[UUID, UserSession] = mutable.Map()
private val eventsPerPlayer: mutable.Map[UUID, mutable.Queue[PollingEvents]] = mutable.Map() logic.addListener(this)
private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map() logic.createSession()
private val lock = new Object
lock.synchronized {
logic.addListener(this)
logic.createSession()
}
def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = {
val promise = ScalaPromise[PollingEvents]()
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 = {
lock.synchronized {
waitingPromises.remove(playerId)
}
}
def addUser(user: User): UserSession = { def addUser(user: User): UserSession = {
if (users.size >= maxPlayers) throw new GameFullException("The game is full!") if (users.size >= maxPlayers) throw new GameFullException("The game is full!")
@@ -69,60 +39,32 @@ class GameLobby private(
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
val userSession = new UserSession( val userSession = new UserSession(
user = user, user = user,
host = false host = false,
gameLobby = this
) )
users += (user.id -> userSession) users += (user.id -> userSession)
addToQueue(LobbyUpdate) PodManager.registerUserToGame(user, id)
//TODO : transmit Lobby Update transmitToAll()
userSession userSession
} }
override def listen(event: SimpleEvent): Unit = { override def listen(event: SimpleEvent): Unit = {
event match { event match {
case event: ReceivedHandEvent =>
addToQueue(NewRound)
users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: CardPlayedEvent =>
addToQueue(CardPlayed)
case event: TieTurnEvent =>
addToQueue(ReloadEvent)
users.get(event.player.id).foreach(session => session.updatePlayer(event))
case event: PlayerEvent => case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event)) users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: NewTrickEvent =>
addToQueue(NewTrick)
case event: GameStateChangeEvent => case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) { if (event.oldState == MainMenu && event.newState == Lobby) {
return return
} }
addToQueue(ReloadEvent)
users.values.foreach(session => session.updatePlayer(event))
case event: SessionClosed =>
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
case event: SimpleEvent => case event: SimpleEvent =>
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
} }
} }
private def addToQueue(event: PollingEvents): Unit = {
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))
}
}
}
}
/** /**
* Start the game if the user is the host. * Start the game if the user is the host.
*
* @param user the user who wants to start the game. * @param user the user who wants to start the game.
*/ */
def startGame(user: User): Unit = { def startGame(user: User): Unit = {
@@ -149,6 +91,7 @@ class GameLobby private(
/** /**
* Remove the user from the game lobby. * Remove the user from the game lobby.
*
* @param user the user who wants to leave the game. * @param user the user who wants to leave the game.
*/ */
def leaveGame(userId: UUID): Unit = { def leaveGame(userId: UUID): Unit = {
@@ -156,14 +99,29 @@ class GameLobby private(
if (sessionOpt.isEmpty) { if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!") throw new NotInThisGameException("You are not in this game!")
} }
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
users.clear()
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)."
)
)))
users.remove(userId) users.remove(userId)
addToQueue(LobbyUpdate) PodManager.unregisterUserFromGame(sessionOpt.get.user)
//TODO: transmit Lobby Update transmitToAll()
} }
/** /**
* Play a card from the player's hand. * Play a card from the player's hand.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand. * @param cardIndex the index of the card in the player's hand.
*/ */
def playCard(userSession: UserSession, cardIndex: Int): Unit = { def playCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.Card) val player = getPlayerInteractable(userSession, InteractionType.Card)
@@ -179,10 +137,35 @@ class GameLobby private(
logic.playerInputLogic.receivedCard(card) logic.playerInputLogic.receivedCard(card)
} }
private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand()
if (handOption.isEmpty) {
throw new IllegalStateException("You have no cards!")
}
handOption.get
}
private def getRound: Round = {
val roundOpt = logic.getCurrentRound
if (roundOpt.isEmpty) {
throw new IllegalStateException("No round is currently running!")
}
roundOpt.get
}
private def getTrick: Trick = {
val trickOpt = logic.getCurrentTrick
if (trickOpt.isEmpty) {
throw new IllegalStateException("No trick is currently running!")
}
trickOpt.get
}
/** /**
* Play a card from the player's hand while in dog life or skip the round. * Play a card from the player's hand while in dog life or skip the round.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round.
*/ */
def playDogCard(userSession: UserSession, cardIndex: Int): Unit = { def playDogCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.DogCard) val player = getPlayerInteractable(userSession, InteractionType.DogCard)
@@ -204,8 +187,9 @@ class GameLobby private(
/** /**
* Select the trump suit for the round. * Select the trump suit for the round.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param trumpIndex the index of the trump suit. * @param trumpIndex the index of the trump suit.
*/ */
def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit)
@@ -215,8 +199,21 @@ class GameLobby private(
logic.playerInputLogic.receivedTrumpSuit(selectedTrump) logic.playerInputLogic.receivedTrumpSuit(selectedTrump)
} }
//-------------------
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
/** /**
* *
* @param userSession * @param userSession
* @param tieNumber * @param tieNumber
*/ */
@@ -239,9 +236,10 @@ class GameLobby private(
logic.createSession() logic.createSession()
} }
def getPlayerByUser(user: User): AbstractPlayer = {
//------------------- getPlayerBySession(getUserSession(user.id))
}
def getUserSession(userId: UUID): UserSession = { def getUserSession(userId: UUID): UserSession = {
val sessionOpt = users.get(userId) val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) { if (sessionOpt.isEmpty) {
@@ -250,8 +248,20 @@ class GameLobby private(
sessionOpt.get sessionOpt.get
} }
def getPlayerByUser(user: User): AbstractPlayer = { private def getPlayerBySession(userSession: UserSession): AbstractPlayer = {
getPlayerBySession(getUserSession(user.id)) val playerOption = getMatch.totalplayers.find(_.id == userSession.id)
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
} }
def getPlayers: mutable.Map[UUID, UserSession] = { def getPlayers: mutable.Map[UUID, UserSession] = {
@@ -261,63 +271,17 @@ class GameLobby private(
def getLogic: GameLogic = { def getLogic: GameLogic = {
logic logic
} }
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)
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand()
if (handOption.isEmpty) {
throw new IllegalStateException("You have no cards!")
}
handOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
}
private def getRound: Round = {
val roundOpt = logic.getCurrentRound
if (roundOpt.isEmpty) {
throw new IllegalStateException("No round is currently running!")
}
roundOpt.get
}
private def getTrick: Trick = {
val trickOpt = logic.getCurrentTrick
if (trickOpt.isEmpty) {
throw new IllegalStateException("No trick is currently running!")
}
trickOpt.get
}
def getUsers: Set[User] = { def getUsers: Set[User] = {
users.values.map(d => d.user).toSet users.values.map(d => d.user).toSet
} }
private def transmitToAll(event: JsObject): Unit = {
users.values.foreach(session => {
session.websocketActor.foreach(act => act.transmitJsonToClient(event))
})
}
} }
object GameLobby { object GameLobby {
@@ -338,7 +302,8 @@ object GameLobby {
) )
lobby.users += (host.id -> new UserSession( lobby.users += (host.id -> new UserSession(
user = host, user = host,
host = true host = true,
gameLobby = lobby
)) ))
lobby lobby
} }

View File

@@ -1,10 +0,0 @@
package logic.game
enum PollingEvents {
case CardPlayed
case NewRound
case NewTrick
case ReloadEvent
case LobbyUpdate
case LobbyCreation
}

View File

@@ -6,9 +6,11 @@ import model.users.User
@ImplementedBy(classOf[BaseSessionManager]) @ImplementedBy(classOf[BaseSessionManager])
trait SessionManager { trait SessionManager {
def createSession(user: User): String def createSession(user: User): String
def getUserBySession(sessionId: String): Option[User] def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit def invalidateSession(sessionId: String): Unit
} }

View File

@@ -8,9 +8,13 @@ import model.users.User
trait UserManager { trait UserManager {
def addUser(name: String, password: String): Boolean def addUser(name: String, password: String): Boolean
def authenticate(name: String, password: String): Option[User] def authenticate(name: String, password: String): Option[User]
def userExists(name: String): Option[User] def userExists(name: String): Option[User]
def userExistsById(id: Long): Option[User] def userExistsById(id: Long): Option[User]
def removeUser(name: String): Boolean def removeUser(name: String): Boolean
} }

View File

@@ -9,7 +9,7 @@ import javax.inject.{Inject, Singleton}
@Singleton @Singleton
class StubUserManager @Inject()(val config: Config) extends UserManager { class StubUserManager @Inject()(val config: Config) extends UserManager {
private val user: Map[String, User] = Map( private val user: Map[String, User] = Map(
"Janis" -> User( "Janis" -> User(
internalId = 1L, internalId = 1L,
@@ -53,5 +53,5 @@ class StubUserManager @Inject()(val config: Config) extends UserManager {
override def removeUser(name: String): Boolean = { override def removeUser(name: String): Boolean = {
throw new NotImplementedError("StubUserManager.removeUser is not implemented") throw new NotImplementedError("StubUserManager.removeUser is not implemented")
} }
} }

View File

@@ -1,7 +1,7 @@
package model.sessions package model.sessions
enum InteractionType { enum InteractionType {
case TrumpSuit case TrumpSuit
case Card case Card
case DogCard case DogCard

View File

@@ -5,9 +5,11 @@ import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID import java.util.UUID
trait PlayerSession { trait PlayerSession {
def id: UUID def id: UUID
def name: String def name: String
def updatePlayer(event: SimpleEvent): Unit def updatePlayer(event: SimpleEvent): Unit
} }

View File

@@ -6,9 +6,9 @@ import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID import java.util.UUID
case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession {
def name: String = player.name def name: String = player.name
override def updatePlayer(event: SimpleEvent): Unit = { override def updatePlayer(event: SimpleEvent): Unit = {
} }
} }

View File

@@ -2,14 +2,18 @@ package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User import model.users.User
import play.api.libs.json.JsObject
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.{Lock, ReentrantLock} import java.util.concurrent.locks.ReentrantLock
import scala.util.Try
class UserSession(val user: User, val host: Boolean) extends PlayerSession { class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession {
var canInteract: Option[InteractionType] = None
val lock: ReentrantLock = ReentrantLock() val lock: ReentrantLock = ReentrantLock()
var canInteract: Option[InteractionType] = None
var websocketActor: Option[UserWebsocketActor] = None
override def updatePlayer(event: SimpleEvent): Unit = { override def updatePlayer(event: SimpleEvent): Unit = {
event match { event match {
@@ -27,9 +31,21 @@ class UserSession(val user: User, val host: Boolean) extends PlayerSession {
override def id: UUID = user.id override def id: UUID = user.id
override def name: String = user.name override def name: String = user.name
def resetCanInteract(): Unit = { def resetCanInteract(): Unit = {
canInteract = None canInteract = None
} }
def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
Try {
eventType match {
case "Ping" =>
// No action needed for Ping
()
}
}
lock.unlock()
}
} }

View File

@@ -0,0 +1,94 @@
package model.sessions
import de.knockoutwhist.utils.events.SimpleEvent
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import scala.util.{Failure, Success, Try}
class UserWebsocketActor(
out: ActorRef,
session: UserSession
) extends Actor {
if (session.websocketActor.isDefined) {
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection."))
context.stop(self)
} else {
session.websocketActor = Some(this)
}
override def receive: Receive = {
case msg: String =>
val jsonObject = Try {
Json.parse(msg)
}
Try {
jsonObject match {
case Success(value) =>
handle(value)
case Failure(exception) =>
transmitTextToClient(s"Error parsing JSON: ${exception.getMessage}")
}
}.failed.foreach(
ex => transmitTextToClient(s"Error handling message: ${ex.getMessage}")
)
case other =>
}
private def transmitTextToClient(text: String): Unit = {
out ! text
}
private def handle(json: JsValue): Unit = {
val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"status" -> "error",
"error" -> "Missing 'id' field"
))
return
}
val id = idOpt.get
val eventOpt = (json \ "event").asOpt[String]
if (eventOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> null,
"status" -> "error",
"error" -> "Missing 'event' field"
))
return
}
val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
val result = Try {
session.handleWebResponse(event, data)
}
if (result.isSuccess) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "success"
))
} else {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "error",
"error" -> result.failed.get.getMessage
))
}
}
def transmitJsonToClient(jsonObj: JsObject): Unit = {
out ! jsonObj.toString()
}
def transmitEventToClient(event: SimpleEvent): Unit = {
val jsonString = WebsocketEventMapper.toJsonString(event)
out ! jsonString
}
}

View File

@@ -3,10 +3,10 @@ package model.users
import java.util.UUID import java.util.UUID
case class User( case class User(
internalId: Long, internalId: Long,
id: UUID, id: UUID,
name: String, name: String,
passwordHash: String passwordHash: String
) { ) {
def withName(newName: String): User = { def withName(newName: String): User = {

View File

@@ -12,10 +12,28 @@ import javax.inject.*
@Singleton @Singleton
class JwtKeyProvider @Inject()(config: Configuration) { class JwtKeyProvider @Inject()(config: Configuration) {
private def cleanPem(pem: String): String = val publicKey: RSAPublicKey = {
pem.replaceAll("-----BEGIN (.*)-----", "") val pemOpt = config.getOptional[String]("auth.publicKeyPem")
.replaceAll("-----END (.*)-----", "") val fileOpt = config.getOptional[String]("auth.publicKeyFile")
.replaceAll("\\s", "")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPublicKeyFromPem(pem)
case None => throw new RuntimeException("No RSA public key configured.")
}
}
val privateKey: RSAPrivateKey = {
val pemOpt = config.getOptional[String]("auth.privateKeyPem")
val fileOpt = config.getOptional[String]("auth.privateKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPrivateKeyFromPem(pem)
case None => throw new RuntimeException("No RSA private key configured.")
}
}
private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { private def loadPublicKeyFromPem(pem: String): RSAPublicKey = {
val decoded = Base64.getDecoder.decode(cleanPem(pem)) val decoded = Base64.getDecoder.decode(cleanPem(pem))
@@ -29,28 +47,9 @@ class JwtKeyProvider @Inject()(config: Configuration) {
KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey]
} }
val publicKey: RSAPublicKey = { private def cleanPem(pem: String): String =
val pemOpt = config.getOptional[String]("auth.publicKeyPem") pem.replaceAll("-----BEGIN (.*)-----", "")
val fileOpt = config.getOptional[String]("auth.publicKeyFile") .replaceAll("-----END (.*)-----", "")
.replaceAll("\\s", "")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPublicKeyFromPem(pem)
case None => throw new RuntimeException("No RSA public key configured.")
}
}
val privateKey: RSAPrivateKey = {
val pemOpt = config.getOptional[String]("auth.privateKeyPem")
val fileOpt = config.getOptional[String]("auth.privateKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPrivateKeyFromPem(pem)
case None => throw new RuntimeException("No RSA private key configured.")
}
}
} }

View File

@@ -25,5 +25,5 @@ object GameUtil {
code.toString() code.toString()
} }
} }

View File

@@ -0,0 +1,20 @@
package util
import de.knockoutwhist.utils.events.SimpleEvent
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
object WebsocketEventMapper {
private val scalaModule = ScalaModule.builder()
.addAllBuiltinModules()
.supportScala3Classes(true)
.build()
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
def toJsonString(obj: SimpleEvent): String = {
mapper.writeValueAsString(obj)
}
}

View File

@@ -34,4 +34,5 @@
}); });
} }
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script> </script>

View File

@@ -12,10 +12,10 @@
<h4 class="fw-semibold mb-1">Current Player</h4> <h4 class="fw-semibold mb-1">Current Player</h4>
<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)) { @if(!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>
} }
} }
</div> </div>
@@ -30,21 +30,22 @@
</div> </div>
@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)
}) { }) {
<div class="d-flex justify-content-between score-row pt-1"> <div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">@player.name</div> <div style="width: 50%" class="text-truncate">@player.name</div>
<div style="width: 50%"> <div style="width: 50%">
@(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size) @(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size)
</div>
</div> </div>
</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">
@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; backdrop-filter: blur(4px);"> <div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem;
backdrop-filter: blur(4px);">
<div class="p-2"> <div class="p-2">
@util.WebUIUtils.cardtoImage(cardplayed) width="100%"/> @util.WebUIUtils.cardtoImage(cardplayed) width="100%"/>
</div> </div>
@@ -53,7 +54,7 @@
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
</div> </div>
<div class="col-4 mt-5 text-end"> <div class="col-4 mt-5 text-end">
@@ -62,31 +63,35 @@
<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.get.firstCard.isDefined) {
@util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get) width="80px"/> @util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get)
} else { width="80px"/>
} else {
@views.html.render.card.apply("images/cards/1B.png")("Blank Card") width="80px"/> @views.html.render.card.apply("images/cards/1B.png")("Blank Card") width="80px"/>
} }
</div> </div>
</div> </div>
</div> </div>
<div class="row justify-content-center g-2 mt-4 bottom-div" style="backdrop-filter: blur(4px); margin-left: 0; margin-right: 0;"> <div class="row justify-content-center g-2 mt-4 bottom-div" style="backdrop-filter: blur(4px);
margin-left: 0;
margin-right: 0;">
<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">
@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')">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div> </div>
@if(player.isInDogLife) { @if(player.isInDogLife) {
<div class="mt-2"> <div class="mt-2">
<button class="btn btn-danger" onclick="handleSkipDogLife(this, '@gamelobby.id')">Skip Dog Life</button> <button class="btn btn-danger" onclick="handleSkipDogLife(this, '@gamelobby.id')">
Skip Dog Life</button>
</div> </div>
} }
</div> </div>
} }
</div> </div>
</div> </div>
</div> </div>
@@ -104,4 +109,5 @@
}); });
} }
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script> </script>

View File

@@ -18,35 +18,40 @@
<div class="row justify-content-center col-auto mb-5"> <div class="row justify-content-center col-auto mb-5">
<div class="col-auto handcard"> <div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="0" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')"> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="0" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades))
width="120px" style="border-radius: 6px"/>
</div> </div>
</div> </div>
<div class="col-auto handcard"> <div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="1" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')"> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="1" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts))
width="120px" style="border-radius: 6px"/>
</div> </div>
</div> </div>
<div class="col-auto handcard"> <div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="2" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')"> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="2" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds))
width="120px" style="border-radius: 6px"/>
</div> </div>
</div> </div>
<div class="col-auto handcard"> <div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="3" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')"> <div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="3" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs))
width="120px" style="border-radius: 6px"/>
</div> </div>
</div> </div>
</div> </div>
<div class="row justify-content-center ingame-cards-slide" id="card-slide"> <div class="row justify-content-center ingame-cards-slide" id="card-slide">
@for(i <- player.currentHand().get.cards.indices) { @for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto" style="border-radius: 6px"> <div class="col-auto" style="border-radius: 6px">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/> @util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div> </div>
} }
</div> </div>
} else { } else {
<div class="alert alert-warning" role="alert" aria-live="polite"> <div class="alert alert-warning" role="alert" aria-live="polite">
@gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get.name is choosing a trumpsuit. The new round will start once a suit is picked. @gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get.name
is choosing a trumpsuit. The new round will start once a suit is picked.
</div> </div>
} }
</div> </div>
@@ -68,4 +73,5 @@
}); });
} }
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script> </script>

View File

@@ -13,9 +13,9 @@
<p class="card-text"> <p class="card-text">
The last round was tied between: The last round was tied between:
<span class="ms-1"> <span class="ms-1">
@for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) { @for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) {
<span class="badge text-bg-secondary me-1">@players</span> <span class="badge text-bg-secondary me-1">@players</span>
} }
</span> </span>
</p> </p>
</div> </div>
@@ -23,7 +23,10 @@
@if(player.equals(gamelobby.logic.playerTieLogic.currentTiePlayer())) { @if(player.equals(gamelobby.logic.playerTieLogic.currentTiePlayer())) {
@defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum => @defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum =>
<div class="alert alert-info" role="alert" aria-live="polite"> <div class="alert alert-info" role="alert" aria-live="polite">
Pick a number between 1 and @{maxNum + 1}. The resulting card will be your card for the cut. Pick a number between 1 and @{
maxNum + 1
}.
The resulting card will be your card for the cut.
</div> </div>
<div class="row g-2 align-items-center"> <div class="row g-2 align-items-center">
@@ -31,49 +34,21 @@
<label for="tieNumber" class="col-form-label">Your number</label> <label for="tieNumber" class="col-form-label">Your number</label>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<input type="number" id="tieNumber" class="form-control" name="tie" min="1" max="@{maxNum + 1}" placeholder="1" required> <input type="number" id="tieNumber" class="form-control" name="tie" min="1" max="@{
maxNum + 1
}" placeholder="1" required>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button onclick="selectTie('@gamelobby.id')" class="btn btn-primary">Confirm</button> <button onclick="selectTie('@gamelobby.id')" class="btn btn-primary">
Confirm</button>
</div> </div>
</div> </div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6> <h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center"> <div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div>
}
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
<strong>@gamelobby.logic.playerTieLogic.currentTiePlayer()</strong> is currently picking a number for the cut.
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) { @if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) { @for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6 col-sm-4 col-md-3 col-lg-2"> <div class="col-6">
<div class="card shadow-sm border-0 h-100 text-center"> <div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between"> <div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p> <p class="card-text fw-semibold mb-2 text-primary">@player</p>
@@ -92,6 +67,38 @@
</div> </div>
</div> </div>
} }
</div>
}
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
<strong>@gamelobby.logic.playerTieLogic.currentTiePlayer()</strong>
is currently picking a number for the cut.
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div> </div>
} }
@@ -114,4 +121,5 @@
}); });
} }
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script> </script>

View File

@@ -14,7 +14,8 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="p-3 text-center fs-4" id="playerAmount">Playeramount: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div> <div class="p-3 text-center fs-4" id="playerAmount">
Playeramount: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div>
</div> </div>
</div> </div>
<div class="row justify-content-center align-items-center flex-grow-1"> <div class="row justify-content-center align-items-center flex-grow-1">
@@ -30,7 +31,8 @@
<a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a> <a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a>
} else { } else {
<h5 class="card-title">@playersession.name</h5> <h5 class="card-title">@playersession.name</h5>
<div class="btn btn-danger" onclick="removePlayer('@gamelobby.id', '@playersession.id')">Remove</div> <div class="btn btn-danger" onclick="removePlayer('@gamelobby.id', '@playersession.id')">
Remove</div>
} }
</div> </div>
</div> </div>
@@ -78,4 +80,5 @@
}); });
} }
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id')); waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script> </script>

View File

@@ -35,6 +35,7 @@
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() { particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
console.log('callback - particles.js config loaded'); console.log('callback - particles.js config loaded');
}); });
disconnectWebSocket();
</script> </script>
<div id="particles-js" style="background-color: rgb(11, 8, 8); <div id="particles-js" style="background-color: rgb(11, 8, 8);
background-size: cover; background-size: cover;

View File

@@ -1,8 +1,8 @@
@* @*
* This template is called from the `index` template. This template * This template is called from the `index` template. This template
* handles the rendering of the page header and body tags. It takes * handles the rendering of the page header and body tags. It takes
* two arguments, a `String` for the title of the page and an `Html` * two arguments, a `String` for the title of the page and an `Html`
* object to insert into the body of the page. * object to insert into the body of the page.
*@ *@
@(title: String)(content: Html) @(title: String)(content: Html)
@@ -19,12 +19,13 @@
</head> </head>
<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.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" 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="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>
</html> </html>

View File

@@ -11,20 +11,23 @@
<input class="form-check-input" type="checkbox" id="visibilityswitch" disabled> <input class="form-check-input" type="checkbox" id="visibilityswitch" disabled>
<label class="form-check-label" for="visibilityswitch">public/private</label> <label class="form-check-label" for="visibilityswitch">public/private</label>
</div> </div>
<div class="mt-3"> <div class="mt-3">
<label for="playeramount" class="form-label">Playeramount:</label> <label for="playeramount" class="form-label">Playeramount:</label>
<input type="range" class="form-range text-body" min="2" max="7" value="2" id="playeramount" name="playeramount"> <input type="range" class="form-range text-body" min="2" max="7" value="2" id="playeramount" name="playeramount">
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between">
<span>2</span> <span>2</span>
<span>3</span> <span>3</span>
<span>4</span> <span>4</span>
<span>5</span> <span>5</span>
<span>6</span> <span>6</span>
<span>7</span> <span>7</span>
</div>
</div> </div>
</div>
<div class="mt-3 text-center"> <div class="mt-3 text-center">
<div class="btn btn-success" onclick="createGameJS()">Create Game</div> <div class="btn btn-success" onclick="createGameJS()">Create Game</div>
</div> </div>
</div> </div>
</main> </main>
<script>
disconnectWebSocket();
</script>

View File

@@ -1,57 +1,61 @@
@(user: Option[model.users.User]) @(user: Option[model.users.User])
<nav class="navbar navbar-expand-lg bg-body-tertiary navbar-drop-shadow"> <nav class="navbar navbar-expand-lg bg-body-tertiary navbar-drop-shadow">
<div class="container d-flex justify-content-start"> <div class="container d-flex justify-content-start">
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navBar" aria-controls="navBar" aria-expanded="false" aria-label="Toggle navigation"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navBar" aria-controls="navBar" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse justify-content-center" id="navBar"> <div class="collapse navbar-collapse justify-content-center" id="navBar">
<a class="navbar-brand ms-auto" href="@routes.MainMenuController.mainMenu()"> <a class="navbar-brand ms-auto" href="@routes.MainMenuController.mainMenu()">
<img src="@routes.Assets.versioned("images/logo.png")" alt="Logo" width="30" height="20" class="d-inline-block align-text-top"> <img src="@routes.Assets.versioned("images/logo.png")" alt="Logo" width="30" height="20" class="d-inline-block align-text-top">
KnockOutWhist KnockOutWhist
</a> </a>
<div class="navbar-nav me-auto mb-2 mb-lg-0"> <div class="navbar-nav me-auto mb-2 mb-lg-0">
<ul class="navbar-nav mb-2 mb-lg-0"> <ul class="navbar-nav mb-2 mb-lg-0">
@if(user.isDefined) { @if(user.isDefined) {
<li class="nav-item">
<a class="nav-link active" aria-current="page" onclick="navSpa('0', 'Knockout Whist - Create Game'); return false;" href="@routes.MainMenuController.mainMenu()">Create Game</a>
</li>
<li class="nav-item">
<a class="nav-link disabled" aria-disabled="true">Lobbies</a>
</li>
}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link active" onclick="navSpa('1', 'Knockout Whist - Rules'); return false;" href="@routes.MainMenuController.rules()">Rules</a> <a class="nav-link active" aria-current="page" onclick="navSpa('0', 'Knockout Whist - Create Game'); return false;" href="@routes.MainMenuController.mainMenu()">
Create Game</a>
</li> </li>
</ul> <li class="nav-item">
<form class="navbar-nav me-auto mb-2 mb-lg-0" onsubmit="joinGame(); return false;"> <a class="nav-link disabled" aria-disabled="true">Lobbies</a>
<input class="form-control me-2" type="text" placeholder="Enter GameCode" id="gameId" aria-label="Join Game"/>
<button class="btn btn-outline-success" type="submit">Join</button>
</form>
</div>
@* Right side: profile dropdown if logged in, otherwise Login / Sign Up buttons *@
@if(user.isDefined) {
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="profileDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="rounded-circle" width="30" height="30" />
<span class="ms-2">@user.get.name</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="profileDropdown">
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">Stats</a></li>
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul>
</li> </li>
</ul> }
} else { <li class="nav-item">
<div class="d-flex ms-auto"> <a class="nav-link active" onclick="navSpa('1', 'Knockout Whist - Rules'); return false;" href="@routes.MainMenuController.rules()">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a> Rules</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a> </li>
</div> </ul>
} <form class="navbar-nav me-auto mb-2 mb-lg-0" onsubmit="joinGame(); return false;">
<input class="form-control me-2" type="text" placeholder="Enter GameCode" id="gameId" aria-label="Join Game"/>
<button class="btn btn-outline-success" type="submit">Join</button>
</form>
</div> </div>
@* Right side: profile dropdown if logged in, otherwise Login / Sign Up buttons *@
@if(user.isDefined) {
<ul class="navbar-nav ms-auto mb-2 mb-lg-0">
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="profileDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="rounded-circle" width="30" height="30" />
<span class="ms-2">@user.get.name</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="profileDropdown">
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Stats</a></li>
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul>
</li>
</ul>
} else {
<div class="d-flex ms-auto">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a>
</div>
}
</div> </div>
</nav> </div>
</nav>

View File

@@ -1,177 +1,180 @@
@(user: Option[model.users.User]) @(user: Option[model.users.User])
@navbar(user) @navbar(user)
<main class="lobby-background flex-grow-1"> <main class="lobby-background flex-grow-1">
<div class="container my-4" style="max-width:980px;"> <div class="container my-4" style="max-width: 980px;">
<div class="card rules-card shadow-sm rounded-3 overflow-hidden"> <div class="card rules-card shadow-sm rounded-3 overflow-hidden">
<div class="card-header text-center py-3 border-0"> <div class="card-header text-center py-3 border-0">
<h3 class="mb-0 rules-title">Game Rules Overview</h3> <h3 class="mb-0 rules-title">Game Rules Overview</h3>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<style> <style>
</style> </style>
<div class="accordion rules-accordion" id="rulesAccordion"> <div class="accordion rules-accordion" id="rulesAccordion">
<div class="accordion-item"> <div class="accordion-item">
<h2 class="accordion-header" id="headingPlayers"> <h2 class="accordion-header" id="headingPlayers">
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlayers" aria-expanded="true" aria-controls="collapsePlayers"> <button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlayers" aria-expanded="true" aria-controls="collapsePlayers">
Players Players
</button> </button>
</h2> </h2>
<div id="collapsePlayers" class="accordion-collapse collapse show" aria-labelledby="headingPlayers" data-bs-parent="#rulesAccordion"> <div id="collapsePlayers" class="accordion-collapse collapse show" aria-labelledby="headingPlayers" data-bs-parent="#rulesAccordion">
<div class="accordion-body"> <div class="accordion-body">
Two to seven players. The aim is to be the last player left in the game. Two to seven players. The aim is to be the last player left in the game.
</div>
</div> </div>
</div> </div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingAim">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseAim" aria-expanded="false" aria-controls="collapseAim">
Aim
</button>
</h2>
<div id="collapseAim" class="accordion-collapse collapse" aria-labelledby="headingAim" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
To be the last player left at the end of the game, with the object in each hand being to win a majority of tricks.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingEquipment">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseEquipment" aria-expanded="false" aria-controls="collapseEquipment">
Equipment
</button>
</h2>
<div id="collapseEquipment" class="accordion-collapse collapse" aria-labelledby="headingEquipment" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
A standard 52-card pack is used.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingRanks">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseRanks" aria-expanded="false" aria-controls="collapseRanks">
Card Ranks
</button>
</h2>
<div id="collapseRanks" class="accordion-collapse collapse" aria-labelledby="headingRanks" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
In each suit, cards rank from highest to lowest: A K Q J 10 9 8 7 6 5 4 3 2.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDealFirst">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealFirst" aria-expanded="false" aria-controls="collapseDealFirst">
Deal (First Hand)
</button>
</h2>
<div id="collapseDealFirst" class="accordion-collapse collapse" aria-labelledby="headingDealFirst" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The dealer deals seven cards to each player. One card is turned up to determine the trump suit for the round.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDealSubsequent">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealSubsequent" aria-expanded="false" aria-controls="collapseDealSubsequent">
Deal (Subsequent Hands)
</button>
</h2>
<div id="collapseDealSubsequent" class="accordion-collapse collapse" aria-labelledby="headingDealSubsequent" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The deal rotates clockwise. The player who took the most tricks in the previous hand selects the trump suit. If there's a tie, players cut cards to decide who calls trumps. One fewer card is dealt in each successive hand until the final hand consists of one card each.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingPlay">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlay" aria-expanded="false" aria-controls="collapsePlay">
Play
</button>
</h2>
<div id="collapsePlay" class="accordion-collapse collapse" aria-labelledby="headingPlay" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The player to the dealer's left (eldest hand) leads the first trick. Any card can be led. Other players must follow suit if possible. A player with no cards of the suit led may play any card.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningTrick">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningTrick" aria-expanded="false" aria-controls="collapseWinningTrick">
Winning a Trick
</button>
</h2>
<div id="collapseWinningTrick" class="accordion-collapse collapse" aria-labelledby="headingWinningTrick" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The highest card of the suit led wins, unless a trump is played, in which case the highest trump wins. The winner of the trick leads the next.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingLeadingTrumps">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLeadingTrumps" aria-expanded="false" aria-controls="collapseLeadingTrumps">
Leading Trumps
</button>
</h2>
<div id="collapseLeadingTrumps" class="accordion-collapse collapse" aria-labelledby="headingLeadingTrumps" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
Some rules disallow leading trumps before the suit has been 'broken' (a trump has been played to the lead of another suit). Leading trumps is always permissible if a player holds only trumps.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingKnockout">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseKnockout" aria-expanded="false" aria-controls="collapseKnockout">
Knockout
</button>
</h2>
<div id="collapseKnockout" class="accordion-collapse collapse" aria-labelledby="headingKnockout" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
At the end of each hand, any player who took no tricks is knocked out and takes no further part in the game.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningGame">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningGame" aria-expanded="false" aria-controls="collapseWinningGame">
Winning the Game
</button>
</h2>
<div id="collapseWinningGame" class="accordion-collapse collapse" aria-labelledby="headingWinningGame" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The game is won when a player takes all the tricks in a round, as all other players are knocked out, leaving only one player remaining.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDogLife">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDogLife" aria-expanded="false" aria-controls="collapseDogLife">
Dog Life
</button>
</h2>
<div id="collapseDogLife" class="accordion-collapse collapse" aria-labelledby="headingDogLife" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The first player who takes no tricks is awarded a \"dog's life\". In the next hand, that player is dealt one card and can decide which trick to play it to. Each time a trick is played the dog may either play the card or knock on the table and wait to play it later. If the dog wins a trick, the player to the left leads the next and the dog re-enters the game properly in the next hand. If the dog fails, they are knocked out.
</div>
</div>
</div>
</div> </div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingAim">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseAim" aria-expanded="false" aria-controls="collapseAim">
Aim
</button>
</h2>
<div id="collapseAim" class="accordion-collapse collapse" aria-labelledby="headingAim" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
To be the last player left at the end of the game, with the object in each hand being to win a majority of tricks.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingEquipment">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseEquipment" aria-expanded="false" aria-controls="collapseEquipment">
Equipment
</button>
</h2>
<div id="collapseEquipment" class="accordion-collapse collapse" aria-labelledby="headingEquipment" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
A standard 52-card pack is used.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingRanks">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseRanks" aria-expanded="false" aria-controls="collapseRanks">
Card Ranks
</button>
</h2>
<div id="collapseRanks" class="accordion-collapse collapse" aria-labelledby="headingRanks" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
In each suit, cards rank from highest to lowest: A K Q J 10 9 8 7 6 5 4 3 2.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDealFirst">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealFirst" aria-expanded="false" aria-controls="collapseDealFirst">
Deal (First Hand)
</button>
</h2>
<div id="collapseDealFirst" class="accordion-collapse collapse" aria-labelledby="headingDealFirst" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The dealer deals seven cards to each player. One card is turned up to determine the trump suit for the round.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDealSubsequent">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDealSubsequent" aria-expanded="false" aria-controls="collapseDealSubsequent">
Deal (Subsequent Hands)
</button>
</h2>
<div id="collapseDealSubsequent" class="accordion-collapse collapse" aria-labelledby="headingDealSubsequent" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The deal rotates clockwise. The player who took the most tricks in the previous hand selects the trump suit. If there's a tie, players cut cards to decide who calls trumps. One fewer card is dealt in each successive hand until the final hand consists of one card each.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingPlay">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsePlay" aria-expanded="false" aria-controls="collapsePlay">
Play
</button>
</h2>
<div id="collapsePlay" class="accordion-collapse collapse" aria-labelledby="headingPlay" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The player to the dealer's left (eldest hand) leads the first trick. Any card can be led. Other players must follow suit if possible. A player with no cards of the suit led may play any card.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningTrick">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningTrick" aria-expanded="false" aria-controls="collapseWinningTrick">
Winning a Trick
</button>
</h2>
<div id="collapseWinningTrick" class="accordion-collapse collapse" aria-labelledby="headingWinningTrick" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The highest card of the suit led wins, unless a trump is played, in which case the highest trump wins. The winner of the trick leads the next.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingLeadingTrumps">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseLeadingTrumps" aria-expanded="false" aria-controls="collapseLeadingTrumps">
Leading Trumps
</button>
</h2>
<div id="collapseLeadingTrumps" class="accordion-collapse collapse" aria-labelledby="headingLeadingTrumps" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
Some rules disallow leading trumps before the suit has been 'broken' (a trump has been played to the lead of another suit). Leading trumps is always permissible if a player holds only trumps.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingKnockout">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseKnockout" aria-expanded="false" aria-controls="collapseKnockout">
Knockout
</button>
</h2>
<div id="collapseKnockout" class="accordion-collapse collapse" aria-labelledby="headingKnockout" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
At the end of each hand, any player who took no tricks is knocked out and takes no further part in the game.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingWinningGame">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseWinningGame" aria-expanded="false" aria-controls="collapseWinningGame">
Winning the Game
</button>
</h2>
<div id="collapseWinningGame" class="accordion-collapse collapse" aria-labelledby="headingWinningGame" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The game is won when a player takes all the tricks in a round, as all other players are knocked out, leaving only one player remaining.
</div>
</div>
</div>
<div class="accordion-item">
<h2 class="accordion-header" id="headingDogLife">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapseDogLife" aria-expanded="false" aria-controls="collapseDogLife">
Dog Life
</button>
</h2>
<div id="collapseDogLife" class="accordion-collapse collapse" aria-labelledby="headingDogLife" data-bs-parent="#rulesAccordion">
<div class="accordion-body">
The first player who takes no tricks is awarded a \"dog's life\". In the next hand, that player is dealt one card and can decide which trick to play it to. Each time a trick is played the dog may either play the card or knock on the table and wait to play it later. If the dog wins a trick, the player to the left leads the next and the dog re-enters the game properly in the next hand. If the dog fails, they are knocked out.
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
</main> </div>
</main>
<script>
disconnectWebSocket();
</script>

View File

@@ -5,46 +5,48 @@
<!DOCTYPE configuration> <!DOCTYPE configuration>
<configuration> <configuration>
<import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder"/> <import class="ch.qos.logback.classic.encoder.PatternLayoutEncoder" />
<import class="ch.qos.logback.classic.AsyncAppender"/> <import class="ch.qos.logback.classic.AsyncAppender" />
<import class="ch.qos.logback.core.FileAppender"/> <import class="ch.qos.logback.core.FileAppender" />
<import class="ch.qos.logback.core.ConsoleAppender"/> <import class="ch.qos.logback.core.ConsoleAppender" />
<appender name="FILE" class="FileAppender"> <appender name="FILE" class="FileAppender">
<file>${application.home:-.}/logs/application.log</file> <file>${application.home:-.}/logs/application.log</file>
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<charset>UTF-8</charset> <charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n
</encoder> </pattern>
</appender> </encoder>
</appender>
<appender name="STDOUT" class="ConsoleAppender"> <appender name="STDOUT" class="ConsoleAppender">
<!-- <!--
On Windows, enabling Jansi is recommended to benefit from color code interpretation on DOS command prompts, On Windows, enabling Jansi is recommended to benefit from color code interpretation on DOS command prompts,
which otherwise risk being sent ANSI escape sequences that they cannot interpret. which otherwise risk being sent ANSI escape sequences that they cannot interpret.
See https://logback.qos.ch/manual/layouts.html#coloring See https://logback.qos.ch/manual/layouts.html#coloring
--> -->
<!-- <withJansi>true</withJansi> --> <!-- <withJansi>true</withJansi> -->
<encoder class="PatternLayoutEncoder"> <encoder class="PatternLayoutEncoder">
<charset>UTF-8</charset> <charset>UTF-8</charset>
<pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n</pattern> <pattern>%d{yyyy-MM-dd HH:mm:ss} %highlight(%-5level) %cyan(%logger{36}) %magenta(%X{pekkoSource}) %msg%n
</encoder> </pattern>
</appender> </encoder>
</appender>
<appender name="ASYNCFILE" class="AsyncAppender"> <appender name="ASYNCFILE" class="AsyncAppender">
<appender-ref ref="FILE"/> <appender-ref ref="FILE" />
</appender> </appender>
<appender name="ASYNCSTDOUT" class="AsyncAppender"> <appender name="ASYNCSTDOUT" class="AsyncAppender">
<appender-ref ref="STDOUT"/> <appender-ref ref="STDOUT" />
</appender> </appender>
<logger name="play" level="INFO"/> <logger name="play" level="INFO" />
<logger name="application" level="DEBUG"/> <logger name="application" level="DEBUG" />
<root level="WARN"> <root level="WARN">
<appender-ref ref="ASYNCFILE"/> <appender-ref ref="ASYNCFILE" />
<appender-ref ref="ASYNCSTDOUT"/> <appender-ref ref="ASYNCSTDOUT" />
</root> </root>
</configuration> </configuration>

View File

@@ -4,38 +4,27 @@
# ~~~~ # ~~~~
# For the javascript routing # For the javascript routing
GET /assets/js/routes controllers.JavaScriptRoutingController.javascriptRoutes() GET /assets/js/routes controllers.JavaScriptRoutingController.javascriptRoutes()
# Primary routes # Primary routes
GET / controllers.MainMenuController.index() GET / controllers.MainMenuController.index()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# Main menu routes # Main menu routes
GET /mainmenu controllers.MainMenuController.mainMenu() GET /mainmenu controllers.MainMenuController.mainMenu()
GET /rules controllers.MainMenuController.rules() GET /rules controllers.MainMenuController.rules()
GET /navSPA/:pType controllers.MainMenuController.navSPA(pType) GET /navSPA/:pType controllers.MainMenuController.navSPA(pType)
POST /createGame controllers.MainMenuController.createGame() POST /createGame controllers.MainMenuController.createGame()
POST /joinGame controllers.MainMenuController.joinGame() POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes # User authentication routes
GET /login controllers.UserController.login() GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post() POST /login controllers.UserController.login_Post()
GET /logout controllers.UserController.logout() GET /logout controllers.UserController.logout()
# In-game routes # In-game routes
GET /game/:id controllers.IngameController.game(id: String) GET /game/:id controllers.IngameController.game(id: String)
POST /game/:id/start controllers.IngameController.startGame(id: String)
POST /game/:id/kickPlayer/:playerToKick controllers.IngameController.kickPlayer(id: String, playerToKick: String)
POST /game/:id/trump controllers.IngameController.playTrump(id: String) # Websocket
POST /game/:id/tie controllers.IngameController.playTie(id: String) GET /websocket controllers.WebsocketController.socket()
GET /game/:id/leaveGame controllers.IngameController.leaveGame(id: String)
POST /game/:id/playCard controllers.IngameController.playCard(id: String)
POST /game/:id/dogPlayCard controllers.IngameController.playDogCard(id: String)
POST /game/:id/returnToLobby controllers.IngameController.returnToLobby(id: String)
# Polling
GET /polling/:gameId controllers.PollingController.polling(gameId: String)

View File

@@ -79,206 +79,6 @@
}) })
})() })()
let polling = false;
function pollForUpdates(gameId) {
if (polling) {
console.log("[DEBUG] Polling already in progress. Skipping this cycle.");
return;
}
polling = true;
console.log(`[DEBUG] Starting poll cycle for Game ID: ${gameId} at ${new Date().toISOString()}`);
if (!gameId) {
console.error("[DEBUG] Game ID is missing. Stopping poll.");
return;
}
const $handElement = $('#card-slide');
const $lobbyElement = $('#lobbybackground');
const $mainmenuElement = $('#main-menu-screen')
const $mainbody = $('#main-body')
if (!$handElement.length && !$lobbyElement.length && !$mainmenuElement.length && !$mainbody.length) {
setTimeout(() => { polling = false; pollForUpdates(gameId) }, 1000);
return;
}
const route = jsRoutes.controllers.PollingController.polling(gameId);
$.ajax({
url: route.url,
type: 'GET',
dataType: 'json',
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();
if(data.animation) {
$handElement.addClass('ingame-cards-slide');
} else {
$handElement.removeClass('ingame-cards-slide');
}
const dog = data.dog;
newHand.forEach((cardId, index) => {
const cardHtml = `
<div class="col-auto handcard" style="border-radius: 6px">
<div class="btn btn-outline-light p-0 border-0 shadow-none"
data-card-id="${index}"
style="border-radius: 6px"
onclick="handlePlayCard(this, '${gameId}', '${dog}')">
<img src="/assets/images/cards/${cardId}.png" width="120px" style="border-radius: 6px"/>
</div>
</div>
`;
newHandHTML += cardHtml;
});
if (dog) {
newHandHTML += `
<div class="mt-2">
<button class="btn btn-danger" onclick="handleSkipDogLife(this, '${gameId}')">Skip Dog Life</button>
</div>
`;
}
$handElement.html(newHandHTML);
if (data.yourTurn) {
$handElement.removeClass('inactive');
} else {
$handElement.addClass('inactive');
}
$('#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 = '';
data.trickCards.forEach(trickCard => {
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/${trickCard.cardId}.png" width="100%"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">${trickCard.player}</small>
</div>
</div>
</div>
`;
});
$('#trick-cards-container').html(trickHTML);
}
if ($('#score-table-body').length && data.scoreTable) {
let scoreHTML = '';
scoreHTML += `<h4 class="fw-bold mb-3 text-black">Tricks Won</h4>
<div class="d-flex justify-content-between score-header pb-1">
<div style="width: 50%">PLAYER</div>
<div style="width: 50%">TRICKS</div>
</div>`
data.scoreTable.forEach(score => {
scoreHTML += `
<div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">${score.name}</div>
<div style="width: 50%">${score.tricks}</div>
</div>
`;
});
$('#score-table-body').html(scoreHTML);
}
const cardId = data.firstCardId;
if ($('#first-card-container').length) {
let imageSrc = '';
let altText = 'First Card';
if (cardId === "BLANK") {
imageSrc = "/assets/images/cards/1B.png";
altText = "Blank Card";
} else {
imageSrc = `/assets/images/cards/${cardId}.png`;
}
const newImageHTML = `
<img src="${imageSrc}" alt="${altText}" width="80px" style="border-radius: 6px"/>
`;
$('#first-card-container').html(newImageHTML);
}
} else if (data.status === "reloadEvent") {
console.log("[DEBUG] Reload event received. Redirecting...");
exchangeBody(data.content, "Knockout Whist - Ingame", 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 ? `<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('${gameId}', '${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 {
data.users.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>`
})
}
$("#players").html(newHtml);
$('#playerAmount').text(`Playeramount: ${data.users.length} / ${data.maxPlayers}`);
} 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: (() => {
setTimeout(() => { polling = false; pollForUpdates(gameId) }, 200);
})
})
}
function createGameJS() { function createGameJS() {
let lobbyName = $('#lobbyname').val(); let lobbyName = $('#lobbyname').val();
if ($.trim(lobbyName) === "") { if ($.trim(lobbyName) === "") {
@@ -291,26 +91,6 @@ function createGameJS() {
sendGameCreationRequest(jsonObj); sendGameCreationRequest(jsonObj);
} }
function backToLobby(gameId) {
const route = jsRoutes.controllers.IngameController.returnToLobby(gameId);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function sendGameCreationRequest(dataObject) { function sendGameCreationRequest(dataObject) {
const route = jsRoutes.controllers.MainMenuController.createGame(); const route = jsRoutes.controllers.MainMenuController.createGame();
@@ -344,60 +124,6 @@ function exchangeBody(content, title = "Knockout Whist", url = null) {
document.title = title; document.title = title;
} }
function startGame(gameId) {
sendGameStartRequest(gameId)
}
function sendGameStartRequest(gameId) {
const route = jsRoutes.controllers.IngameController.startGame(gameId);
$.ajax({
url: route.url,
type: route.type,
dataType: 'json',
success: (data => {
if (data.status === 'success') {
window.location.href = data.redirectUrl;
}
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function removePlayer(gameid, playersessionId) {
sendRemovePlayerRequest(gameid, playersessionId)
}
function sendRemovePlayerRequest(gameId, playersessionId) {
const route = jsRoutes.controllers.IngameController.kickPlayer(gameId, playersessionId);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
success: (data => {
if (data.status === 'success') {
window.location.href = data.redirectUrl;
}
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function login() { function login() {
const username = $('#username').val(); const username = $('#username').val();
const password = $('#password').val(); const password = $('#password').val();
@@ -423,7 +149,7 @@ function login() {
}), }),
error: ((jqXHR) => { error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText); const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) { if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`); alert(`${errorData.errorMessage}`);
} else { } else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
@@ -455,7 +181,7 @@ function joinGame() {
}), }),
error: ((jqXHR) => { error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText); const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) { if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`); alert(`${errorData.errorMessage}`);
} else { } else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
@@ -482,7 +208,7 @@ function navSpa(page, title) {
}), }),
error: ((jqXHR) => { error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText); const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) { if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`); alert(`${errorData.errorMessage}`);
} else { } else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`); alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
@@ -490,178 +216,4 @@ function navSpa(page, title) {
}) })
}); });
return false return false
} }
function selectTie(gameId) {
const route = jsRoutes.controllers.IngameController.playTie(gameId);
const jsonObj = {
tie: $('#tieNumber').val()
};
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
error: (jqXHR => {
let error;
try {
error = JSON.parse(jqXHR.responseText);
} catch (e) {
console.error("Failed to parse error response:", e);
}
if (error?.errorMessage) {
alert(`${error.errorMessage}`);
} else {
alert('An unexpected error occurred. Please try again.');
}
})
})
}
function leaveGame(gameId) {
sendLeavePlayerRequest(gameId)
}
function sendLeavePlayerRequest(gameId) {
const route = jsRoutes.controllers.IngameController.leaveGame(gameId);
$.ajax({
url: route.url,
type: route.type,
dataType: 'json',
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function handleTrumpSelection(cardobject, gameId) {
const trumpId = cardobject.dataset.trump;
const jsonObj = {
trump: trumpId
}
const route = jsRoutes.controllers.IngameController.playTrump(gameId);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
error: (jqXHR => {
let error;
try {
error = JSON.parse(jqXHR.responseText);
} catch (e) {
console.error("Failed to parse error response:", e);
}
if (error?.errorMessage) {
alert(`${error.errorMessage}`);
} else {
alert('An unexpected error occurred. Please try again.');
}
})
})
}
function handlePlayCard(cardobject, gameId, dog = false) {
const cardId = cardobject.dataset.cardId;
const jsonObj = {
cardID: cardId
}
sendPlayCardRequest(jsonObj, gameId, cardobject, dog)
}
function handleSkipDogLife(cardobject, gameId) {
const wiggleKeyframes = [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(5px)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(0)' }
];
const wiggleTiming = {
duration: 400,
iterations: 1,
easing: 'ease-in-out',
fill: 'forwards'
};
const route = jsRoutes.controllers.IngameController.playDogCard(gameId);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify({
cardID: 'skip'
}),
error: (jqXHR => {
let error;
try {
error = JSON.parse(jqXHR.responseText);
} catch (e) {
console.error("Failed to parse error response:", e);
}
if (error?.errorMessage.includes("You can't skip this round!")) {
cardobject.parentElement.animate(wiggleKeyframes, wiggleTiming);
} else if (error?.errorMessage) {
alert(`${error.errorMessage}`);
} else {
alert('An unexpected error occurred. Please try again.');
}
})
})
}
function sendPlayCardRequest(jsonObj, gameId, cardobject, dog) {
const wiggleKeyframes = [
{ transform: 'translateX(0)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(5px)' },
{ transform: 'translateX(-5px)' },
{ transform: 'translateX(0)' }
];
const wiggleTiming = {
duration: 400,
iterations: 1,
easing: 'ease-in-out',
fill: 'forwards'
};
const route = dog === "true" ? jsRoutes.controllers.IngameController.playDogCard(gameId) : jsRoutes.controllers.IngameController.playCard(gameId);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
error: (jqXHR => {
try {
error = JSON.parse(jqXHR.responseText);
} catch (e) {
console.error("Failed to parse error response:", e);
}
if (error?.errorMessage.includes("You can't play this card!")) {
cardobject.parentElement.animate(wiggleKeyframes, wiggleTiming);
} else if (error?.errorMessage) {
alert(`${error.errorMessage}`);
} else {
alert('An unexpected error occurred. Please try again.');
}
})
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
// javascript
let ws = null; // will be created by connectWebSocket()
const pending = new Map(); // id -> { resolve, reject, timer }
const handlers = new Map(); // eventType -> handler(data) -> (value|Promise)
let timer = null;
// helper to attach message/error/close handlers to a socket
function setupSocketHandlers(socket) {
socket.onmessage = (event) => {
console.debug("SERVER MESSAGE:", event.data);
let msg;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.debug("Non-JSON message from server:", event.data, e);
return;
}
const id = msg.id;
const eventType = msg.event;
const status = msg.status;
const data = msg.data;
if (id && typeof status === "string") {
const entry = pending.get(id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(id);
if (status === "success") {
entry.resolve(data === undefined ? {} : data);
} else {
entry.reject(new Error(msg.error || "Server returned error"));
}
return;
}
if (id && eventType) {
const handler = handlers.get(eventType);
const sendResponse = (respData) => {
const response = {id: id, event: eventType, data: respData === undefined ? {} : respData};
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(response));
} else {
console.warn("Cannot send response, websocket not open");
}
};
if (!handler) {
// no handler: respond with an error object in data so server can fail it
sendResponse({error: "No handler for event: " + eventType});
return;
}
try {
Promise.resolve(handler(data === undefined ? {} : data))
.then(result => sendResponse(result))
.catch(err => sendResponse({error: err?.message ? err.message : String(err)}));
} catch (err) {
sendResponse({error: err?.message ? err.message : String(err)});
}
}
};
socket.onerror = (error) => {
console.error("WebSocket Error:", error);
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket error/closed"));
pending.delete(id);
}
if (socket.readyState === WebSocket.OPEN) socket.close(1000, "Unexpected error.");
};
socket.onclose = (event) => {
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket closed"));
pending.delete(id);
}
if (event.wasClean) {
console.log(`Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
console.warn('Connection died unexpectedly.');
}
};
}
// connect/disconnect helpers
function connectWebSocket(url = "ws://localhost:9000/websocket") {
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
if (ws && ws.readyState === WebSocket.CONNECTING) {
// already connecting - return a promise that resolves on open
return new Promise((resolve, reject) => {
const prevOnOpen = ws.onopen;
const prevOnError = ws.onerror;
ws.onopen = (ev) => {
if (prevOnOpen) prevOnOpen(ev);
resolve();
};
ws.onerror = (err) => {
if (prevOnError) prevOnError(err);
reject(err);
};
});
}
ws = new WebSocket(url);
setupSocketHandlers(ws);
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log("WebSocket connection established!");
// start heartbeat
timer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
sendEventAndWait("ping", {}).then(
() => console.debug("PING RESPONSE RECEIVED"),
).catch(
(err) => console.warn("PING ERROR:", err.message),
);
console.debug("PING SENT");
}
}, 5000);
resolve();
};
ws.onerror = (err) => {
reject(err);
};
});
}
function disconnectWebSocket(code = 1000, reason = "Client disconnect") {
if (timer) {
clearInterval(timer);
timer = null;
}
if (ws) {
try {
ws.close(code, reason);
} catch (e) {
}
ws = null;
}
}
function sendEvent(eventType, eventData) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open. Unable to send message.");
return;
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
ws.send(JSON.stringify(message));
console.debug("SENT:", message);
}
function sendEventAndWait(eventType, eventData, timeoutMs = 10000) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not open"));
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
const p = new Promise((resolve, reject) => {
const timerId = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`No response within ${timeoutMs}ms for id=${id}`));
}
}, timeoutMs);
pending.set(id, {resolve, reject, timer: timerId});
});
ws.send(JSON.stringify(message));
console.debug("SENT (await):", message);
return p;
}
function onEvent(eventType, handler) {
handlers.set(eventType, handler);
}
globalThis.sendEvent = sendEvent;
globalThis.sendEventAndWait = sendEventAndWait;
globalThis.onEvent = onEvent;
globalThis.connectWebSocket = connectWebSocket;
globalThis.disconnectWebSocket = disconnectWebSocket;
globalThis.isWebSocketConnected = () => !!ws && ws.readyState === WebSocket.OPEN;

View File

@@ -13,33 +13,33 @@ import play.api.test.Helpers.*
*/ */
class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting { class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting {
// "HomeController GET" should { // "HomeController GET" should {
// //
// "render the index page from a new instance of controller" in { // "render the index page from a new instance of controller" in {
// val controller = new HomeController(stubControllerComponents()) // val controller = new HomeController(stubControllerComponents())
// val home = controller.index().apply(FakeRequest(GET, "/")) // val home = controller.index().apply(FakeRequest(GET, "/"))
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// //
// "render the index page from the application" in { // "render the index page from the application" in {
// val controller = inject[HomeController] // val controller = inject[HomeController]
// val home = controller.index().apply(FakeRequest(GET, "/")) // val home = controller.index().apply(FakeRequest(GET, "/"))
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// //
// "render the index page from the router" in { // "render the index page from the router" in {
// val request = FakeRequest(GET, "/") // val request = FakeRequest(GET, "/")
// val home = route(app, request).get // val home = route(app, request).get
// //
// status(home) mustBe OK // status(home) mustBe OK
// contentType(home) mustBe Some("text/html") // contentType(home) mustBe Some("text/html")
// contentAsString(home) must include ("Welcome to Play") // contentAsString(home) must include ("Welcome to Play")
// } // }
// } // }
} }

View File

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