From d87348f13f68cc6dd8027fef4feee6d2f579d3c7 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 26 Oct 2025 11:24:05 +0100 Subject: [PATCH 01/16] chore(base): resolved merge-conflict #5 [Story] Create User Sessions --- README.md | 9 +++ build.sbt | 3 +- .../app/controllers/HomeController.scala | 10 +--- knockoutwhistweb/app/controllers/WebUI.scala | 3 +- .../PodGameManager.scala | 4 +- .../app/logic/user/SessionManager.scala | 11 ++++ .../app/logic/user/UserManager.scala | 10 ++++ .../sessions/AdvancedSession.scala | 2 +- .../sessions/PlayerSession.scala | 2 +- knockoutwhistweb/app/model/users/User.scala | 20 +++++++ knockoutwhistweb/app/util/UserHash.scala | 23 ++++++++ .../app/views/sessions.scala.html | 3 +- .../test/controllers/HomeControllerSpec.scala | 58 +++++++++---------- 13 files changed, 114 insertions(+), 44 deletions(-) create mode 100644 README.md rename knockoutwhistweb/app/{controllers => logic}/PodGameManager.scala (92%) create mode 100644 knockoutwhistweb/app/logic/user/SessionManager.scala create mode 100644 knockoutwhistweb/app/logic/user/UserManager.scala rename knockoutwhistweb/app/{controllers => model}/sessions/AdvancedSession.scala (91%) rename knockoutwhistweb/app/{controllers => model}/sessions/PlayerSession.scala (86%) create mode 100644 knockoutwhistweb/app/model/users/User.scala create mode 100644 knockoutwhistweb/app/util/UserHash.scala diff --git a/README.md b/README.md new file mode 100644 index 0000000..6cc01af --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ + + + + + + +## User Password Protection + +All the User Passwords are encrypted using Argon2. \ No newline at end of file diff --git a/build.sbt b/build.sbt index 535154e..490def8 100644 --- a/build.sbt +++ b/build.sbt @@ -37,7 +37,8 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) .dependsOn(knockoutwhist % "compile->compile;test->test") .settings( 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" ) lazy val root = (project in file(".")) diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala index 7de648f..4db8838 100644 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ b/knockoutwhistweb/app/controllers/HomeController.scala @@ -7,6 +7,8 @@ import de.knockoutwhist.components.Configuration import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule +import logic.PodGameManager +import model.sessions.AdvancedSession import play.api.mvc.* import play.api.* import play.twirl.api.Html @@ -81,13 +83,5 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e Action { implicit request => InternalServerError("Oops") } - //if (logic.getCurrentState == Lobby) { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} - //} else { - //Action { implicit request => - //Ok(views.html.tui.apply(player, logic)) - //} } } \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/WebUI.scala b/knockoutwhistweb/app/controllers/WebUI.scala index 726a1c4..4015d64 100644 --- a/knockoutwhistweb/app/controllers/WebUI.scala +++ b/knockoutwhistweb/app/controllers/WebUI.scala @@ -1,6 +1,5 @@ package controllers -import controllers.sessions.AdvancedSession import de.knockoutwhist.cards.{Card, CardValue, Hand, Suit} import de.knockoutwhist.control.GameLogic import de.knockoutwhist.control.GameState.{InGame, Lobby} @@ -12,6 +11,8 @@ import de.knockoutwhist.rounds.Match import de.knockoutwhist.ui.UI import de.knockoutwhist.utils.CustomThread import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} +import logic.PodGameManager +import model.sessions.AdvancedSession object WebUI extends CustomThread with EventListener with UI { diff --git a/knockoutwhistweb/app/controllers/PodGameManager.scala b/knockoutwhistweb/app/logic/PodGameManager.scala similarity index 92% rename from knockoutwhistweb/app/controllers/PodGameManager.scala rename to knockoutwhistweb/app/logic/PodGameManager.scala index fc9da31..891e046 100644 --- a/knockoutwhistweb/app/controllers/PodGameManager.scala +++ b/knockoutwhistweb/app/logic/PodGameManager.scala @@ -1,7 +1,7 @@ -package controllers +package logic -import controllers.sessions.PlayerSession import de.knockoutwhist.utils.events.SimpleEvent +import model.sessions.PlayerSession import java.util.UUID import scala.collection.mutable diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala new file mode 100644 index 0000000..c573b23 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -0,0 +1,11 @@ +package logic.user + +import model.users.User + +trait SessionManager { + + def createSession(user: User): String + def getUserBySession(sessionId: String): Option[User] + def invalidateSession(sessionId: String): Unit + +} diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala new file mode 100644 index 0000000..69e4481 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -0,0 +1,10 @@ +package logic.user + +trait UserManager { + + def addUser(name: String, password: String): Boolean + def authenticate(name: String, password: String): Boolean + def userExists(name: String): Boolean + def removeUser(name: String): Boolean + +} diff --git a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala b/knockoutwhistweb/app/model/sessions/AdvancedSession.scala similarity index 91% rename from knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala rename to knockoutwhistweb/app/model/sessions/AdvancedSession.scala index 770e3fb..7a2fe66 100644 --- a/knockoutwhistweb/app/controllers/sessions/AdvancedSession.scala +++ b/knockoutwhistweb/app/model/sessions/AdvancedSession.scala @@ -1,4 +1,4 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.utils.events.SimpleEvent diff --git a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala similarity index 86% rename from knockoutwhistweb/app/controllers/sessions/PlayerSession.scala rename to knockoutwhistweb/app/model/sessions/PlayerSession.scala index e76ddc2..95c39f5 100644 --- a/knockoutwhistweb/app/controllers/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -1,4 +1,4 @@ -package controllers.sessions +package model.sessions import de.knockoutwhist.utils.events.SimpleEvent diff --git a/knockoutwhistweb/app/model/users/User.scala b/knockoutwhistweb/app/model/users/User.scala new file mode 100644 index 0000000..e56c048 --- /dev/null +++ b/knockoutwhistweb/app/model/users/User.scala @@ -0,0 +1,20 @@ +package model.users + +import java.util.UUID + +case class User( + internalId: Long, + id: UUID, + name: String, + passwordHash: String + ) { + + def withName(newName: String): User = { + this.copy(name = newName) + } + + private def withPasswordHash(newPasswordHash: String): User = { + this.copy(passwordHash = newPasswordHash) + } + +} diff --git a/knockoutwhistweb/app/util/UserHash.scala b/knockoutwhistweb/app/util/UserHash.scala new file mode 100644 index 0000000..1c48cab --- /dev/null +++ b/knockoutwhistweb/app/util/UserHash.scala @@ -0,0 +1,23 @@ +package util + +import de.mkammerer.argon2.Argon2Factory +import de.mkammerer.argon2.Argon2Factory.Argon2Types +import model.users.User + +object UserHash { + private val ITERATIONS: Int = 3 + private val MEMORY: Int = 32_768 + private val PARALLELISM: Int = 1 + private val SALT_LENGTH: Int = 32 + private val HASH_LENGTH: Int = 64 + private val ARGON_2 = Argon2Factory.create(Argon2Types.ARGON2id, SALT_LENGTH, HASH_LENGTH) + + def hashPW(password: String): String = { + ARGON_2.hash(ITERATIONS, MEMORY, PARALLELISM, password.toCharArray) + } + + def verifyUser(password: String, user: User): Boolean = { + ARGON_2.verify(user.passwordHash, password.toCharArray) + } + +} diff --git a/knockoutwhistweb/app/views/sessions.scala.html b/knockoutwhistweb/app/views/sessions.scala.html index 8be9656..352ee4f 100644 --- a/knockoutwhistweb/app/views/sessions.scala.html +++ b/knockoutwhistweb/app/views/sessions.scala.html @@ -1,4 +1,5 @@ -@(sessions: List[controllers.sessions.PlayerSession]) +@import model.sessions.PlayerSession +@(sessions: List[PlayerSession]) @main("Sessions") {
diff --git a/knockoutwhistweb/test/controllers/HomeControllerSpec.scala b/knockoutwhistweb/test/controllers/HomeControllerSpec.scala index cc4ca72..db9c4b9 100644 --- a/knockoutwhistweb/test/controllers/HomeControllerSpec.scala +++ b/knockoutwhistweb/test/controllers/HomeControllerSpec.scala @@ -13,33 +13,33 @@ import play.api.test.Helpers.* */ class HomeControllerSpec extends PlaySpec with GuiceOneAppPerTest with Injecting { - "HomeController GET" should { - - "render the index page from a new instance of controller" in { - val controller = new HomeController(stubControllerComponents()) - val home = controller.index().apply(FakeRequest(GET, "/")) - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - - "render the index page from the application" in { - val controller = inject[HomeController] - val home = controller.index().apply(FakeRequest(GET, "/")) - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - - "render the index page from the router" in { - val request = FakeRequest(GET, "/") - val home = route(app, request).get - - status(home) mustBe OK - contentType(home) mustBe Some("text/html") - contentAsString(home) must include ("Welcome to Play") - } - } +// "HomeController GET" should { +// +// "render the index page from a new instance of controller" in { +// val controller = new HomeController(stubControllerComponents()) +// val home = controller.index().apply(FakeRequest(GET, "/")) +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// +// "render the index page from the application" in { +// val controller = inject[HomeController] +// val home = controller.index().apply(FakeRequest(GET, "/")) +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// +// "render the index page from the router" in { +// val request = FakeRequest(GET, "/") +// val home = route(app, request).get +// +// status(home) mustBe OK +// contentType(home) mustBe Some("text/html") +// contentAsString(home) must include ("Welcome to Play") +// } +// } } -- 2.52.0 From 6b5c29c0c5d474321fc1e8091fe14ec806bde67e Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 26 Oct 2025 18:13:26 +0100 Subject: [PATCH 02/16] chore(gitignore): add currentSnapshot.json to .gitignore --- .gitignore | 1 + knockoutwhist | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index aa06509..c0c2529 100644 --- a/.gitignore +++ b/.gitignore @@ -137,3 +137,4 @@ target /knockoutwhist/ /knockoutwhistweb/.g8/ /knockoutwhistweb/.bsp/ +/currentSnapshot.json diff --git a/knockoutwhist b/knockoutwhist index fbc0ea2..f998d5f 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit fbc0ea2277596e2a2d29125b5f9a84213336dc18 +Subproject commit f998d5f6f01ca6d86d23efade8615b52ab23c484 -- 2.52.0 From 306d5b309b3b2268d28d9d261a050f80c0efcdc6 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 28 Oct 2025 18:32:57 +0100 Subject: [PATCH 03/16] feat(user-sessions): implement user login, logout, and session management --- build.sbt | 4 +- .../app/controllers/HomeController.scala | 1 - .../app/controllers/UserController.scala | 75 +++++++++++++++++++ .../app/logic/user/SessionManager.scala | 3 + .../app/logic/user/UserManager.scala | 9 ++- .../logic/user/impl/BaseSessionManager.scala | 28 +++++++ .../app/logic/user/impl/StubUserManager.scala | 47 ++++++++++++ knockoutwhistweb/app/views/login.scala.html | 18 +++++ knockoutwhistweb/conf/routes | 5 ++ 9 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 knockoutwhistweb/app/controllers/UserController.scala create mode 100644 knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala create mode 100644 knockoutwhistweb/app/logic/user/impl/StubUserManager.scala create mode 100644 knockoutwhistweb/app/views/login.scala.html diff --git a/build.sbt b/build.sbt index 490def8..9485c6f 100644 --- a/build.sbt +++ b/build.sbt @@ -38,7 +38,9 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) .settings( commonSettings, 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.5.0", + libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2" ) lazy val root = (project in file(".")) diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala index 4db8838..ff7ae53 100644 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ b/knockoutwhistweb/app/controllers/HomeController.scala @@ -1,7 +1,6 @@ package controllers import com.google.inject.{Guice, Injector} -import controllers.sessions.AdvancedSession import de.knockoutwhist.KnockOutWhist import de.knockoutwhist.components.Configuration import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala new file mode 100644 index 0000000..af4831c --- /dev/null +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -0,0 +1,75 @@ +package controllers + +import com.google.inject.{Guice, Injector} +import de.knockoutwhist.KnockOutWhist +import de.knockoutwhist.components.Configuration +import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} +import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic +import di.KnockOutWebConfigurationModule +import logic.PodGameManager +import logic.user.{SessionManager, UserManager} +import model.sessions.AdvancedSession +import play.api.* +import play.api.mvc.* +import play.twirl.api.Html + +import java.util.UUID +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class UserController @Inject()(val controllerComponents: ControllerComponents, val sessionManager: SessionManager, val userManager: UserManager) extends BaseController { + + def login(): Action[AnyContent] = { + Action { implicit request => + val session = request.cookies.get("sessionId") + if (session.isDefined) { + val possibleUser = sessionManager.getUserBySession(session.get.value) + if (possibleUser.isDefined) { + Redirect("/main-menu") + } else + { + Ok(views.html.login()) + } + } else { + Ok(views.html.login()) + } + } + } + + def login_Post(): Action[AnyContent] = { + Action { implicit request => + val postData = request.body.asFormUrlEncoded + if (postData.isDefined) { + // Extract username and password from form data + val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") + val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") + val possibleUser = userManager.authenticate(username, password) + if (possibleUser.isDefined) { + Redirect("/mainmenu").withCookies( + Cookie("sessionId", sessionManager.createSession(possibleUser.get)) + ) + } else { + Unauthorized("Invalid username or password") + } + } else { + BadRequest("Invalid form submission") + } + } + } + + def logout(): Action[AnyContent] = { + Action { implicit request => + val sessionCookie = request.cookies.get("sessionId") + if (sessionCookie.isDefined) { + sessionManager.invalidateSession(sessionCookie.get.value) + } + NoContent.discardingCookies(DiscardingCookie("sessionId")) + } + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala index c573b23..aad7472 100644 --- a/knockoutwhistweb/app/logic/user/SessionManager.scala +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -1,7 +1,10 @@ package logic.user +import com.google.inject.ImplementedBy +import logic.user.impl.BaseSessionManager import model.users.User +@ImplementedBy(classOf[BaseSessionManager]) trait SessionManager { def createSession(user: User): String diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index 69e4481..b6a4f47 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -1,10 +1,15 @@ package logic.user +import com.google.inject.ImplementedBy +import logic.user.impl.StubUserManager +import model.users.User + +@ImplementedBy(classOf[StubUserManager]) trait UserManager { def addUser(name: String, password: String): Boolean - def authenticate(name: String, password: String): Boolean - def userExists(name: String): Boolean + def authenticate(name: String, password: String): Option[User] + def userExists(name: String): Option[User] def removeUser(name: String): Boolean } diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala new file mode 100644 index 0000000..70d7d9d --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -0,0 +1,28 @@ +package logic.user.impl + +import com.typesafe.config.Config +import logic.user.SessionManager +import model.users.User + +import javax.inject.{Inject, Singleton} + +@Singleton +class BaseSessionManager @Inject()(val config: Config) extends SessionManager { + + override def createSession(user: User): String = { + //TODO create JWT token instead of random string + //Write session identifier to cache and DB + val sessionId = java.util.UUID.randomUUID().toString + sessionId + } + + override def getUserBySession(sessionId: String): Option[User] = { + //TODO verify JWT token instead of looking up in cache + //Read session identifier from cache and DB + None + } + + override def invalidateSession(sessionId: String): Unit = { + + } +} diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala new file mode 100644 index 0000000..a367358 --- /dev/null +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -0,0 +1,47 @@ +package logic.user.impl + +import com.typesafe.config.Config +import logic.user.UserManager +import model.users.User +import util.UserHash + +import javax.inject.{Inject, Singleton} + +@Singleton +class StubUserManager @Inject()(val config: Config) extends UserManager { + + private val user: Map[String, User] = Map( + "Janis" -> User( + internalId = 1L, + id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), + name = "Janis", + passwordHash = UserHash.hashPW("password123") + ), + "Leon" -> User( + internalId = 2L, + id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"), + name = "Leon", + passwordHash = UserHash.hashPW("password123") + ) + ) + + override def addUser(name: String, password: String): Boolean = { + throw new NotImplementedError("StubUserManager.addUser is not implemented") + } + + override def authenticate(name: String, password: String): Option[User] = { + user.get(name) match { + case Some(u) if UserHash.verifyUser(password, u) => Some(u) + case _ => None + } + } + + override def userExists(name: String): Option[User] = { + user.get(name) + } + + override def removeUser(name: String): Boolean = { + throw new NotImplementedError("StubUserManager.removeUser is not implemented") + } + +} diff --git a/knockoutwhistweb/app/views/login.scala.html b/knockoutwhistweb/app/views/login.scala.html new file mode 100644 index 0000000..e254fa4 --- /dev/null +++ b/knockoutwhistweb/app/views/login.scala.html @@ -0,0 +1,18 @@ +@() + +@main("Login") { +
+

Login

+
+
+ + +
+
+ + +
+ +
+
+} \ No newline at end of file diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 3d2cb6d..565ed7d 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -12,3 +12,8 @@ GET /ingame/:id controllers.HomeController.ingame(id: String GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) GET /rules controllers.HomeController.rules() + + +GET /login controllers.UserController.login() +POST /login controllers.UserController.login_Post() +GET /logout controllers.UserController.logout() \ No newline at end of file -- 2.52.0 From b6f0089d64e2e3fc9419630b35a9c7f7afa50ad5 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 29 Oct 2025 10:31:49 +0100 Subject: [PATCH 04/16] feat(user-sessions): add JWT-based session management and main menu route --- bruno/KnockOutWhist/Login.bru | 25 +++++++++ bruno/KnockOutWhist/bruno.json | 9 +++ bruno/KnockOutWhist/environments/Local.bru | 3 + build.sbt | 2 +- .../app/controllers/UserController.scala | 21 ++++++- .../logic/user/impl/BaseSessionManager.scala | 15 ++++- .../app/services/JwtKeyProvider.scala | 56 +++++++++++++++++++ knockoutwhistweb/conf/application.conf | 11 ++++ knockoutwhistweb/conf/routes | 2 +- 9 files changed, 138 insertions(+), 6 deletions(-) create mode 100644 bruno/KnockOutWhist/Login.bru create mode 100644 bruno/KnockOutWhist/bruno.json create mode 100644 bruno/KnockOutWhist/environments/Local.bru create mode 100644 knockoutwhistweb/app/services/JwtKeyProvider.scala diff --git a/bruno/KnockOutWhist/Login.bru b/bruno/KnockOutWhist/Login.bru new file mode 100644 index 0000000..4c266ed --- /dev/null +++ b/bruno/KnockOutWhist/Login.bru @@ -0,0 +1,25 @@ +meta { + name: Login + type: http + seq: 1 +} + +post { + url: {{host}}/login + body: formUrlEncoded + auth: inherit +} + +body:form-urlencoded { + username: Janis + password: password123 +} + +body:multipart-form { + username: Janis + password: password123 +} + +settings { + encodeUrl: true +} diff --git a/bruno/KnockOutWhist/bruno.json b/bruno/KnockOutWhist/bruno.json new file mode 100644 index 0000000..c687c5e --- /dev/null +++ b/bruno/KnockOutWhist/bruno.json @@ -0,0 +1,9 @@ +{ + "version": "1", + "name": "KnockOutWhist", + "type": "collection", + "ignore": [ + "node_modules", + ".git" + ] +} \ No newline at end of file diff --git a/bruno/KnockOutWhist/environments/Local.bru b/bruno/KnockOutWhist/environments/Local.bru new file mode 100644 index 0000000..b22b9b6 --- /dev/null +++ b/bruno/KnockOutWhist/environments/Local.bru @@ -0,0 +1,3 @@ +vars { + host: http://localhost:9000 +} diff --git a/build.sbt b/build.sbt index 9485c6f..50d5526 100644 --- a/build.sbt +++ b/build.sbt @@ -39,7 +39,7 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) commonSettings, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", -// libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0", + libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0", libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2" ) diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index af4831c..db36946 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -24,13 +24,31 @@ import javax.inject.* @Singleton class UserController @Inject()(val controllerComponents: ControllerComponents, val sessionManager: SessionManager, val userManager: UserManager) extends BaseController { + def mainMenu() : Action[AnyContent] = { + Action { implicit request => + val session = request.cookies.get("sessionId") + if (session.isDefined) { + val possibleUser = sessionManager.getUserBySession(session.get.value) + if (possibleUser.isDefined) { + Ok("Main Menu for user: " + possibleUser.get.name) + } else + { + println("Invalid session, redirecting to login") + Redirect("/login") + } + } else { + Redirect("/login") + } + } + } + def login(): Action[AnyContent] = { Action { implicit request => val session = request.cookies.get("sessionId") if (session.isDefined) { val possibleUser = sessionManager.getUserBySession(session.get.value) if (possibleUser.isDefined) { - Redirect("/main-menu") + Redirect("/mainmenu") } else { Ok(views.html.login()) @@ -54,6 +72,7 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v Cookie("sessionId", sessionManager.createSession(possibleUser.get)) ) } else { + println("Failed login attempt for user: " + username) Unauthorized("Invalid username or password") } } else { diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala index 70d7d9d..b7ba80e 100644 --- a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -1,18 +1,27 @@ package logic.user.impl +import com.auth0.jwt.JWT +import com.auth0.jwt.algorithms.Algorithm import com.typesafe.config.Config import logic.user.SessionManager import model.users.User +import services.JwtKeyProvider import javax.inject.{Inject, Singleton} @Singleton -class BaseSessionManager @Inject()(val config: Config) extends SessionManager { +class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val config: Config) extends SessionManager { + + private val algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey) override def createSession(user: User): String = { - //TODO create JWT token instead of random string //Write session identifier to cache and DB - val sessionId = java.util.UUID.randomUUID().toString + val sessionId = JWT.create() + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .withSubject(user.internalId.toString) + .sign(algorithm) + //TODO write to DB sessionId } diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala new file mode 100644 index 0000000..9a4d624 --- /dev/null +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -0,0 +1,56 @@ +package services + +import java.nio.file.{Files, Paths} +import java.security.{KeyFactory, PrivateKey, PublicKey} +import java.security.spec.X509EncodedKeySpec +import java.util.Base64 +import javax.inject.* +import play.api.Configuration + +import java.security.interfaces.{RSAPrivateKey, RSAPublicKey} + +@Singleton +class JwtKeyProvider @Inject()(config: Configuration) { + + private def cleanPem(pem: String): String = + pem.replaceAll("-----BEGIN (.*)-----", "") + .replaceAll("-----END (.*)-----", "") + .replaceAll("\\s", "") + + private def loadPublicKeyFromPem(pem: String): RSAPublicKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new X509EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePublic(spec).asInstanceOf[RSAPublicKey] + } + + private def loadPrivateKeyFromPem(pem: String): RSAPrivateKey = { + val decoded = Base64.getDecoder.decode(cleanPem(pem)) + val spec = new X509EncodedKeySpec(decoded) + KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] + } + + val publicKey: RSAPublicKey = { + val pemOpt = config.getOptional[String]("auth.publicKeyPem") + val fileOpt = config.getOptional[String]("auth.publicKeyFile") + + 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.") + } + } + +} diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index cb94680..bc03440 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -1 +1,12 @@ # https://www.playframework.com/documentation/latest/Configuration +play.filters.disabled += play.filters.csrf.CSRFFilter + + +auth { + issuer = "knockoutwhistweb" + audience = "ui" + privateKeyFile = D:\Workspaces\Gitops\rsa512-private.pem # ${?PUBLIC_KEY_FILE} + privateKeyPem = ${?PUBLIC_KEY_PEM} + publicKeyFile = D:\Workspaces\Gitops\rsa512-public.pem #${?PUBLIC_KEY_FILE} + publicKeyPem = ${?PUBLIC_KEY_PEM} +} diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index 565ed7d..d0b13eb 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -13,7 +13,7 @@ GET /assets/*file controllers.Assets.versioned(path="/public", GET /rules controllers.HomeController.rules() - +GET /mainmenu controllers.UserController.mainMenu() GET /login controllers.UserController.login() POST /login controllers.UserController.login_Post() GET /logout controllers.UserController.logout() \ No newline at end of file -- 2.52.0 From ac1200d3a452bd610598ad46543f2f28f9eed3f5 Mon Sep 17 00:00:00 2001 From: Janis Date: Sun, 26 Oct 2025 11:24:05 +0100 Subject: [PATCH 05/16] chore(base): resolved merge-conflict #5 [Story] Create User Sessions --- knockoutwhistweb/app/logic/user/SessionManager.scala | 3 --- knockoutwhistweb/app/logic/user/UserManager.scala | 9 ++------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala index aad7472..c573b23 100644 --- a/knockoutwhistweb/app/logic/user/SessionManager.scala +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -1,10 +1,7 @@ package logic.user -import com.google.inject.ImplementedBy -import logic.user.impl.BaseSessionManager import model.users.User -@ImplementedBy(classOf[BaseSessionManager]) trait SessionManager { def createSession(user: User): String diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index b6a4f47..69e4481 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -1,15 +1,10 @@ package logic.user -import com.google.inject.ImplementedBy -import logic.user.impl.StubUserManager -import model.users.User - -@ImplementedBy(classOf[StubUserManager]) trait UserManager { def addUser(name: String, password: String): Boolean - def authenticate(name: String, password: String): Option[User] - def userExists(name: String): Option[User] + def authenticate(name: String, password: String): Boolean + def userExists(name: String): Boolean def removeUser(name: String): Boolean } -- 2.52.0 From abf689c8e38faa325917596a310c98243adc6968 Mon Sep 17 00:00:00 2001 From: Janis Date: Tue, 28 Oct 2025 18:32:57 +0100 Subject: [PATCH 06/16] feat(user-sessions): implement user login, logout, and session management --- knockoutwhistweb/app/logic/user/SessionManager.scala | 3 +++ knockoutwhistweb/app/logic/user/UserManager.scala | 9 +++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/knockoutwhistweb/app/logic/user/SessionManager.scala b/knockoutwhistweb/app/logic/user/SessionManager.scala index c573b23..aad7472 100644 --- a/knockoutwhistweb/app/logic/user/SessionManager.scala +++ b/knockoutwhistweb/app/logic/user/SessionManager.scala @@ -1,7 +1,10 @@ package logic.user +import com.google.inject.ImplementedBy +import logic.user.impl.BaseSessionManager import model.users.User +@ImplementedBy(classOf[BaseSessionManager]) trait SessionManager { def createSession(user: User): String diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index 69e4481..b6a4f47 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -1,10 +1,15 @@ package logic.user +import com.google.inject.ImplementedBy +import logic.user.impl.StubUserManager +import model.users.User + +@ImplementedBy(classOf[StubUserManager]) trait UserManager { def addUser(name: String, password: String): Boolean - def authenticate(name: String, password: String): Boolean - def userExists(name: String): Boolean + def authenticate(name: String, password: String): Option[User] + def userExists(name: String): Option[User] def removeUser(name: String): Boolean } -- 2.52.0 From 465004b9cedc8e0e79622839d9916dea93667022 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 29 Oct 2025 15:39:35 +0100 Subject: [PATCH 07/16] chore: rebase --- knockoutwhist | 2 +- .../app/assets/stylesheets/login.less | 35 + .../app/assets/stylesheets/main.less | 21 +- .../app/controllers/UserController.scala | 1 + .../app/logic/user/UserManager.scala | 1 + .../logic/user/impl/BaseSessionManager.scala | 42 +- .../app/logic/user/impl/StubUserManager.scala | 4 + .../app/services/JwtKeyProvider.scala | 6 +- knockoutwhistweb/app/views/ingame.scala.html | 2 +- knockoutwhistweb/app/views/login.scala.html | 47 +- knockoutwhistweb/app/views/main.scala.html | 5 +- .../app/views/selecttrump.scala.html | 2 +- .../app/views/sessions.scala.html | 2 +- knockoutwhistweb/app/views/tie.scala.html | 2 +- knockoutwhistweb/conf/application.conf | 6 +- .../public/conf/particlesjs-config.json | 110 ++ knockoutwhistweb/public/javascripts/main.js | 3 + .../public/javascripts/particles.js | 1541 +++++++++++++++++ 18 files changed, 1795 insertions(+), 37 deletions(-) create mode 100644 knockoutwhistweb/app/assets/stylesheets/login.less create mode 100644 knockoutwhistweb/public/conf/particlesjs-config.json create mode 100644 knockoutwhistweb/public/javascripts/particles.js diff --git a/knockoutwhist b/knockoutwhist index f998d5f..fbc0ea2 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit f998d5f6f01ca6d86d23efade8615b52ab23c484 +Subproject commit fbc0ea2277596e2a2d29125b5f9a84213336dc18 diff --git a/knockoutwhistweb/app/assets/stylesheets/login.less b/knockoutwhistweb/app/assets/stylesheets/login.less new file mode 100644 index 0000000..2e3bb1e --- /dev/null +++ b/knockoutwhistweb/app/assets/stylesheets/login.less @@ -0,0 +1,35 @@ +.login-box { + position: fixed; /* changed from absolute to fixed so it centers relative to the viewport */ + align-items: center; + justify-content: center; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); /* center exactly */ + display: flex; + width: 100%; + max-width: 420px; /* keeps box from stretching too wide */ + padding: 1rem; + z-index: 2; /* above particles */ +} + +.login-card { + max-width: 400px; + width: 100%; + border: none; + border-radius: 1rem; + box-shadow: 0 4px 20px rgba(0,0,0,0.1); + position: relative; + z-index: 3; /* ensure card sits above the particles */ +} + +#particles-js { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; /* behind content */ + pointer-events: none; /* allow clicks through particles */ + background-repeat: no-repeat; + background-size: cover; +} \ No newline at end of file diff --git a/knockoutwhistweb/app/assets/stylesheets/main.less b/knockoutwhistweb/app/assets/stylesheets/main.less index 6c77638..8bc3b69 100644 --- a/knockoutwhistweb/app/assets/stylesheets/main.less +++ b/knockoutwhistweb/app/assets/stylesheets/main.less @@ -1,5 +1,6 @@ @import "light-mode.less"; @import "dark-mode.less"; +@import "login.less"; @background-image: var(--background-image); @color: var(--color); @@ -7,14 +8,16 @@ 0% { transform: translateX(-100vw); } 100% { transform: translateX(0); } } -body { +.game-field-background { background-image: @background-image; background-size: 100vw 100vh; + background-repeat: no-repeat; } -html, body { - height: 100vh; - margin: 0; +.game-field { + position: fixed; + inset: 0; + overflow: auto; } #sessions { display: flex; @@ -31,8 +34,9 @@ html, body { animation: slideIn 0.5s ease-out forwards; animation-fill-mode: backwards; animation-delay: 1s; - } -#sessions a, h1, p { +} + +#sessions a, #sessions h1, #sessions p { color: @color; font-size: 40px; font-family: Arial, serif; @@ -44,6 +48,11 @@ html, body { justify-content: flex-end; height: 100%; } +#ingame a, #ingame h1, #ingame p { + color: @color; + font-size: 40px; + font-family: Arial, serif; +} #playercards { display: flex; flex-direction: row; diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index db36946..5e566ea 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -62,6 +62,7 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v def login_Post(): Action[AnyContent] = { Action { implicit request => val postData = request.body.asFormUrlEncoded + println(request.body.asText) if (postData.isDefined) { // Extract username and password from form data val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") diff --git a/knockoutwhistweb/app/logic/user/UserManager.scala b/knockoutwhistweb/app/logic/user/UserManager.scala index b6a4f47..ecf3a8d 100644 --- a/knockoutwhistweb/app/logic/user/UserManager.scala +++ b/knockoutwhistweb/app/logic/user/UserManager.scala @@ -10,6 +10,7 @@ trait UserManager { def addUser(name: String, password: String): Boolean def authenticate(name: String, password: String): Option[User] def userExists(name: String): Option[User] + def userExistsById(id: Long): Option[User] def removeUser(name: String): Boolean } diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala index b7ba80e..88e046c 100644 --- a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -1,37 +1,63 @@ package logic.user.impl -import com.auth0.jwt.JWT +import com.auth0.jwt.{JWT, JWTVerifier} import com.auth0.jwt.algorithms.Algorithm +import com.github.benmanes.caffeine.cache.{Cache, Caffeine} import com.typesafe.config.Config import logic.user.SessionManager import model.users.User +import scalafx.util.Duration import services.JwtKeyProvider +import java.time.Instant +import java.time.temporal.ChronoUnit +import java.util.concurrent.TimeUnit import javax.inject.{Inject, Singleton} @Singleton -class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val config: Config) extends SessionManager { +class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val userManager: StubUserManager, val config: Config) extends SessionManager { private val algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey) - + private val verifier: JWTVerifier = JWT.require(algorithm) + .withIssuer(config.getString("auth.issuer")) + .withAudience(config.getString("auth.audience")) + .build() + + //TODO reduce cache to a minimum amount, as JWT should be self-contained + private val cache: Cache[String, User] = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(5, TimeUnit.MINUTES).build() + override def createSession(user: User): String = { //Write session identifier to cache and DB val sessionId = JWT.create() .withIssuer(config.getString("auth.issuer")) .withAudience(config.getString("auth.audience")) - .withSubject(user.internalId.toString) + .withSubject(user.id.toString) + .withClaim("id", user.internalId) + .withExpiresAt(Instant.now.plus(7, ChronoUnit.DAYS)) .sign(algorithm) - //TODO write to DB + //TODO write to Redis and DB + cache.put(sessionId, user) + sessionId } override def getUserBySession(sessionId: String): Option[User] = { //TODO verify JWT token instead of looking up in cache - //Read session identifier from cache and DB - None + val cachedUser = cache.getIfPresent(sessionId) + if (cachedUser != null) { + Some(cachedUser) + } else { + val decoded = verifier.verify(sessionId) + val user = userManager.userExistsById(decoded.getClaim("id").asLong()) + user.foreach(u => cache.put(sessionId, u)) + user + } } override def invalidateSession(sessionId: String): Unit = { - + //TODO remove from Redis and DB + cache.invalidate(sessionId) } } diff --git a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala index a367358..44f71ee 100644 --- a/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/StubUserManager.scala @@ -40,6 +40,10 @@ class StubUserManager @Inject()(val config: Config) extends UserManager { user.get(name) } + override def userExistsById(id: Long): Option[User] = { + user.values.find(_.internalId == id) + } + override def removeUser(name: String): Boolean = { throw new NotImplementedError("StubUserManager.removeUser is not implemented") } diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala index 9a4d624..7e87c0d 100644 --- a/knockoutwhistweb/app/services/JwtKeyProvider.scala +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -1,8 +1,8 @@ package services import java.nio.file.{Files, Paths} -import java.security.{KeyFactory, PrivateKey, PublicKey} -import java.security.spec.X509EncodedKeySpec +import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec} import java.util.Base64 import javax.inject.* import play.api.Configuration @@ -25,7 +25,7 @@ class JwtKeyProvider @Inject()(config: Configuration) { private def loadPrivateKeyFromPem(pem: String): RSAPrivateKey = { val decoded = Base64.getDecoder.decode(cleanPem(pem)) - val spec = new X509EncodedKeySpec(decoded) + val spec = new PKCS8EncodedKeySpec(decoded) KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey] } diff --git a/knockoutwhistweb/app/views/ingame.scala.html b/knockoutwhistweb/app/views/ingame.scala.html index 379f219..23eaf99 100644 --- a/knockoutwhistweb/app/views/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame.scala.html @@ -1,7 +1,7 @@ @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @main("Ingame") { -
+

Knockout Whist

Next Player:

diff --git a/knockoutwhistweb/app/views/login.scala.html b/knockoutwhistweb/app/views/login.scala.html index e254fa4..f2a5365 100644 --- a/knockoutwhistweb/app/views/login.scala.html +++ b/knockoutwhistweb/app/views/login.scala.html @@ -1,18 +1,41 @@ @() @main("Login") { -
-

Login

-
-
- - + + +
} \ No newline at end of file diff --git a/knockoutwhistweb/app/views/main.scala.html b/knockoutwhistweb/app/views/main.scala.html index f3d0d99..6a535d9 100644 --- a/knockoutwhistweb/app/views/main.scala.html +++ b/knockoutwhistweb/app/views/main.scala.html @@ -13,6 +13,8 @@ @title + + @@ -20,6 +22,7 @@ * the page content. *@ @content - + + diff --git a/knockoutwhistweb/app/views/selecttrump.scala.html b/knockoutwhistweb/app/views/selecttrump.scala.html index 6cf8851..9e2da55 100644 --- a/knockoutwhistweb/app/views/selecttrump.scala.html +++ b/knockoutwhistweb/app/views/selecttrump.scala.html @@ -1,7 +1,7 @@ @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @main("Selecting Trumpsuit...") { -
+
@if(player.equals(logic.getCurrentMatch.get.roundlist.last.winner.get)) {

Knockout Whist

You (@player.toString) have won the last round! Select a trumpsuit for the next round!

diff --git a/knockoutwhistweb/app/views/sessions.scala.html b/knockoutwhistweb/app/views/sessions.scala.html index 352ee4f..d400832 100644 --- a/knockoutwhistweb/app/views/sessions.scala.html +++ b/knockoutwhistweb/app/views/sessions.scala.html @@ -2,7 +2,7 @@ @(sessions: List[PlayerSession]) @main("Sessions") { -
+

Knockout Whist sessions

Please select your session to jump inside the game!

@for(session <- sessions) { diff --git a/knockoutwhistweb/app/views/tie.scala.html b/knockoutwhistweb/app/views/tie.scala.html index 75b74aa..eede1be 100644 --- a/knockoutwhistweb/app/views/tie.scala.html +++ b/knockoutwhistweb/app/views/tie.scala.html @@ -1,7 +1,7 @@ @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @main("Tie") { -
+

Knockout Whist

The last Round was tied between @for(players <- logic.playerTieLogic.getTiedPlayers) { diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index bc03440..d6d372a 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -5,8 +5,10 @@ play.filters.disabled += play.filters.csrf.CSRFFilter auth { issuer = "knockoutwhistweb" audience = "ui" - privateKeyFile = D:\Workspaces\Gitops\rsa512-private.pem # ${?PUBLIC_KEY_FILE} + # ${?PUBLIC_KEY_FILE} + privateKeyFile = "D:\\Workspaces\\Gitops\\rsa512-private.pem" privateKeyPem = ${?PUBLIC_KEY_PEM} - publicKeyFile = D:\Workspaces\Gitops\rsa512-public.pem #${?PUBLIC_KEY_FILE} + #${?PUBLIC_KEY_FILE} + publicKeyFile = "D:\\Workspaces\\Gitops\\rsa512-public.pem" publicKeyPem = ${?PUBLIC_KEY_PEM} } diff --git a/knockoutwhistweb/public/conf/particlesjs-config.json b/knockoutwhistweb/public/conf/particlesjs-config.json new file mode 100644 index 0000000..e0215db --- /dev/null +++ b/knockoutwhistweb/public/conf/particlesjs-config.json @@ -0,0 +1,110 @@ +{ + "particles": { + "number": { + "value": 80, + "density": { + "enable": true, + "value_area": 800 + } + }, + "color": { + "value": "#ffffff" + }, + "shape": { + "type": "circle", + "stroke": { + "width": 0, + "color": "#000000" + }, + "polygon": { + "nb_sides": 5 + }, + "image": { + "src": "img/github.svg", + "width": 100, + "height": 100 + } + }, + "opacity": { + "value": 0.5, + "random": false, + "anim": { + "enable": false, + "speed": 1, + "opacity_min": 0.1, + "sync": false + } + }, + "size": { + "value": 3, + "random": true, + "anim": { + "enable": false, + "speed": 40, + "size_min": 0.1, + "sync": false + } + }, + "line_linked": { + "enable": true, + "distance": 150, + "color": "#ffffff", + "opacity": 0.4, + "width": 1 + }, + "move": { + "enable": true, + "speed": 1, + "direction": "none", + "random": false, + "straight": false, + "out_mode": "out", + "bounce": false, + "attract": { + "enable": false, + "rotateX": 600, + "rotateY": 1200 + } + } + }, + "interactivity": { + "detect_on": "canvas", + "events": { + "onhover": { + "enable": false, + "mode": "repulse" + }, + "onclick": { + "enable": false, + "mode": "push" + }, + "resize": true + }, + "modes": { + "grab": { + "distance": 400, + "line_linked": { + "opacity": 1 + } + }, + "bubble": { + "distance": 400, + "size": 40, + "duration": 2, + "opacity": 8, + "speed": 3 + }, + "repulse": { + "distance": 200, + "duration": 0.4 + }, + "push": { + "particles_nb": 4 + }, + "remove": { + "particles_nb": 2 + } + } + }, + "retina_detect": true +} \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/main.js b/knockoutwhistweb/public/javascripts/main.js index e69de29..2474284 100644 --- a/knockoutwhistweb/public/javascripts/main.js +++ b/knockoutwhistweb/public/javascripts/main.js @@ -0,0 +1,3 @@ +particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() { + console.log('callback - particles.js config loaded'); +}); \ No newline at end of file diff --git a/knockoutwhistweb/public/javascripts/particles.js b/knockoutwhistweb/public/javascripts/particles.js new file mode 100644 index 0000000..325d834 --- /dev/null +++ b/knockoutwhistweb/public/javascripts/particles.js @@ -0,0 +1,1541 @@ +/* ----------------------------------------------- +/* Author : Vincent Garreau - vincentgarreau.com +/* MIT license: http://opensource.org/licenses/MIT +/* Demo / Generator : vincentgarreau.com/particles.js +/* GitHub : github.com/VincentGarreau/particles.js +/* How to use? : Check the GitHub README +/* v2.0.0 +/* ----------------------------------------------- */ + +var pJS = function(tag_id, params){ + + var canvas_el = document.querySelector('#'+tag_id+' > .particles-js-canvas-el'); + + /* particles.js variables with default values */ + this.pJS = { + canvas: { + el: canvas_el, + w: canvas_el.offsetWidth, + h: canvas_el.offsetHeight + }, + particles: { + number: { + value: 400, + density: { + enable: true, + value_area: 800 + } + }, + color: { + value: '#fff' + }, + shape: { + type: 'circle', + stroke: { + width: 0, + color: '#ff0000' + }, + polygon: { + nb_sides: 5 + }, + image: { + src: '', + width: 100, + height: 100 + } + }, + opacity: { + value: 1, + random: false, + anim: { + enable: false, + speed: 2, + opacity_min: 0, + sync: false + } + }, + size: { + value: 20, + random: false, + anim: { + enable: false, + speed: 20, + size_min: 0, + sync: false + } + }, + line_linked: { + enable: true, + distance: 100, + color: '#fff', + opacity: 1, + width: 1 + }, + move: { + enable: true, + speed: 2, + direction: 'none', + random: false, + straight: false, + out_mode: 'out', + bounce: false, + attract: { + enable: false, + rotateX: 3000, + rotateY: 3000 + } + }, + array: [] + }, + interactivity: { + detect_on: 'canvas', + events: { + onhover: { + enable: true, + mode: 'grab' + }, + onclick: { + enable: true, + mode: 'push' + }, + resize: true + }, + modes: { + grab:{ + distance: 100, + line_linked:{ + opacity: 1 + } + }, + bubble:{ + distance: 200, + size: 80, + duration: 0.4 + }, + repulse:{ + distance: 200, + duration: 0.4 + }, + push:{ + particles_nb: 4 + }, + remove:{ + particles_nb: 2 + } + }, + mouse:{} + }, + retina_detect: false, + fn: { + interact: {}, + modes: {}, + vendors:{} + }, + tmp: {} + }; + + var pJS = this.pJS; + + /* params settings */ + if(params){ + Object.deepExtend(pJS, params); + } + + pJS.tmp.obj = { + size_value: pJS.particles.size.value, + size_anim_speed: pJS.particles.size.anim.speed, + move_speed: pJS.particles.move.speed, + line_linked_distance: pJS.particles.line_linked.distance, + line_linked_width: pJS.particles.line_linked.width, + mode_grab_distance: pJS.interactivity.modes.grab.distance, + mode_bubble_distance: pJS.interactivity.modes.bubble.distance, + mode_bubble_size: pJS.interactivity.modes.bubble.size, + mode_repulse_distance: pJS.interactivity.modes.repulse.distance + }; + + + pJS.fn.retinaInit = function(){ + + if(pJS.retina_detect && window.devicePixelRatio > 1){ + pJS.canvas.pxratio = window.devicePixelRatio; + pJS.tmp.retina = true; + } + else{ + pJS.canvas.pxratio = 1; + pJS.tmp.retina = false; + } + + pJS.canvas.w = pJS.canvas.el.offsetWidth * pJS.canvas.pxratio; + pJS.canvas.h = pJS.canvas.el.offsetHeight * pJS.canvas.pxratio; + + pJS.particles.size.value = pJS.tmp.obj.size_value * pJS.canvas.pxratio; + pJS.particles.size.anim.speed = pJS.tmp.obj.size_anim_speed * pJS.canvas.pxratio; + pJS.particles.move.speed = pJS.tmp.obj.move_speed * pJS.canvas.pxratio; + pJS.particles.line_linked.distance = pJS.tmp.obj.line_linked_distance * pJS.canvas.pxratio; + pJS.interactivity.modes.grab.distance = pJS.tmp.obj.mode_grab_distance * pJS.canvas.pxratio; + pJS.interactivity.modes.bubble.distance = pJS.tmp.obj.mode_bubble_distance * pJS.canvas.pxratio; + pJS.particles.line_linked.width = pJS.tmp.obj.line_linked_width * pJS.canvas.pxratio; + pJS.interactivity.modes.bubble.size = pJS.tmp.obj.mode_bubble_size * pJS.canvas.pxratio; + pJS.interactivity.modes.repulse.distance = pJS.tmp.obj.mode_repulse_distance * pJS.canvas.pxratio; + + }; + + + + /* ---------- pJS functions - canvas ------------ */ + + pJS.fn.canvasInit = function(){ + pJS.canvas.ctx = pJS.canvas.el.getContext('2d'); + }; + + pJS.fn.canvasSize = function(){ + + pJS.canvas.el.width = pJS.canvas.w; + pJS.canvas.el.height = pJS.canvas.h; + + if(pJS && pJS.interactivity.events.resize){ + + window.addEventListener('resize', function(){ + + pJS.canvas.w = pJS.canvas.el.offsetWidth; + pJS.canvas.h = pJS.canvas.el.offsetHeight; + + /* resize canvas */ + if(pJS.tmp.retina){ + pJS.canvas.w *= pJS.canvas.pxratio; + pJS.canvas.h *= pJS.canvas.pxratio; + } + + pJS.canvas.el.width = pJS.canvas.w; + pJS.canvas.el.height = pJS.canvas.h; + + /* repaint canvas on anim disabled */ + if(!pJS.particles.move.enable){ + pJS.fn.particlesEmpty(); + pJS.fn.particlesCreate(); + pJS.fn.particlesDraw(); + pJS.fn.vendors.densityAutoParticles(); + } + + /* density particles enabled */ + pJS.fn.vendors.densityAutoParticles(); + + }); + + } + + }; + + + pJS.fn.canvasPaint = function(){ + pJS.canvas.ctx.fillRect(0, 0, pJS.canvas.w, pJS.canvas.h); + }; + + pJS.fn.canvasClear = function(){ + pJS.canvas.ctx.clearRect(0, 0, pJS.canvas.w, pJS.canvas.h); + }; + + + /* --------- pJS functions - particles ----------- */ + + pJS.fn.particle = function(color, opacity, position){ + + /* size */ + this.radius = (pJS.particles.size.random ? Math.random() : 1) * pJS.particles.size.value; + if(pJS.particles.size.anim.enable){ + this.size_status = false; + this.vs = pJS.particles.size.anim.speed / 100; + if(!pJS.particles.size.anim.sync){ + this.vs = this.vs * Math.random(); + } + } + + /* position */ + this.x = position ? position.x : Math.random() * pJS.canvas.w; + this.y = position ? position.y : Math.random() * pJS.canvas.h; + + /* check position - into the canvas */ + if(this.x > pJS.canvas.w - this.radius*2) this.x = this.x - this.radius; + else if(this.x < this.radius*2) this.x = this.x + this.radius; + if(this.y > pJS.canvas.h - this.radius*2) this.y = this.y - this.radius; + else if(this.y < this.radius*2) this.y = this.y + this.radius; + + /* check position - avoid overlap */ + if(pJS.particles.move.bounce){ + pJS.fn.vendors.checkOverlap(this, position); + } + + /* color */ + this.color = {}; + if(typeof(color.value) == 'object'){ + + if(color.value instanceof Array){ + var color_selected = color.value[Math.floor(Math.random() * pJS.particles.color.value.length)]; + this.color.rgb = hexToRgb(color_selected); + }else{ + if(color.value.r != undefined && color.value.g != undefined && color.value.b != undefined){ + this.color.rgb = { + r: color.value.r, + g: color.value.g, + b: color.value.b + } + } + if(color.value.h != undefined && color.value.s != undefined && color.value.l != undefined){ + this.color.hsl = { + h: color.value.h, + s: color.value.s, + l: color.value.l + } + } + } + + } + else if(color.value == 'random'){ + this.color.rgb = { + r: (Math.floor(Math.random() * (255 - 0 + 1)) + 0), + g: (Math.floor(Math.random() * (255 - 0 + 1)) + 0), + b: (Math.floor(Math.random() * (255 - 0 + 1)) + 0) + } + } + else if(typeof(color.value) == 'string'){ + this.color = color; + this.color.rgb = hexToRgb(this.color.value); + } + + /* opacity */ + this.opacity = (pJS.particles.opacity.random ? Math.random() : 1) * pJS.particles.opacity.value; + if(pJS.particles.opacity.anim.enable){ + this.opacity_status = false; + this.vo = pJS.particles.opacity.anim.speed / 100; + if(!pJS.particles.opacity.anim.sync){ + this.vo = this.vo * Math.random(); + } + } + + /* animation - velocity for speed */ + var velbase = {} + switch(pJS.particles.move.direction){ + case 'top': + velbase = { x:0, y:-1 }; + break; + case 'top-right': + velbase = { x:0.5, y:-0.5 }; + break; + case 'right': + velbase = { x:1, y:-0 }; + break; + case 'bottom-right': + velbase = { x:0.5, y:0.5 }; + break; + case 'bottom': + velbase = { x:0, y:1 }; + break; + case 'bottom-left': + velbase = { x:-0.5, y:1 }; + break; + case 'left': + velbase = { x:-1, y:0 }; + break; + case 'top-left': + velbase = { x:-0.5, y:-0.5 }; + break; + default: + velbase = { x:0, y:0 }; + break; + } + + if(pJS.particles.move.straight){ + this.vx = velbase.x; + this.vy = velbase.y; + if(pJS.particles.move.random){ + this.vx = this.vx * (Math.random()); + this.vy = this.vy * (Math.random()); + } + }else{ + this.vx = velbase.x + Math.random()-0.5; + this.vy = velbase.y + Math.random()-0.5; + } + + // var theta = 2.0 * Math.PI * Math.random(); + // this.vx = Math.cos(theta); + // this.vy = Math.sin(theta); + + this.vx_i = this.vx; + this.vy_i = this.vy; + + + + /* if shape is image */ + + var shape_type = pJS.particles.shape.type; + if(typeof(shape_type) == 'object'){ + if(shape_type instanceof Array){ + var shape_selected = shape_type[Math.floor(Math.random() * shape_type.length)]; + this.shape = shape_selected; + } + }else{ + this.shape = shape_type; + } + + if(this.shape == 'image'){ + var sh = pJS.particles.shape; + this.img = { + src: sh.image.src, + ratio: sh.image.width / sh.image.height + } + if(!this.img.ratio) this.img.ratio = 1; + if(pJS.tmp.img_type == 'svg' && pJS.tmp.source_svg != undefined){ + pJS.fn.vendors.createSvgImg(this); + if(pJS.tmp.pushing){ + this.img.loaded = false; + } + } + } + + + + }; + + + pJS.fn.particle.prototype.draw = function() { + + var p = this; + + if(p.radius_bubble != undefined){ + var radius = p.radius_bubble; + }else{ + var radius = p.radius; + } + + if(p.opacity_bubble != undefined){ + var opacity = p.opacity_bubble; + }else{ + var opacity = p.opacity; + } + + if(p.color.rgb){ + var color_value = 'rgba('+p.color.rgb.r+','+p.color.rgb.g+','+p.color.rgb.b+','+opacity+')'; + }else{ + var color_value = 'hsla('+p.color.hsl.h+','+p.color.hsl.s+'%,'+p.color.hsl.l+'%,'+opacity+')'; + } + + pJS.canvas.ctx.fillStyle = color_value; + pJS.canvas.ctx.beginPath(); + + switch(p.shape){ + + case 'circle': + pJS.canvas.ctx.arc(p.x, p.y, radius, 0, Math.PI * 2, false); + break; + + case 'edge': + pJS.canvas.ctx.rect(p.x-radius, p.y-radius, radius*2, radius*2); + break; + + case 'triangle': + pJS.fn.vendors.drawShape(pJS.canvas.ctx, p.x-radius, p.y+radius / 1.66, radius*2, 3, 2); + break; + + case 'polygon': + pJS.fn.vendors.drawShape( + pJS.canvas.ctx, + p.x - radius / (pJS.particles.shape.polygon.nb_sides/3.5), // startX + p.y - radius / (2.66/3.5), // startY + radius*2.66 / (pJS.particles.shape.polygon.nb_sides/3), // sideLength + pJS.particles.shape.polygon.nb_sides, // sideCountNumerator + 1 // sideCountDenominator + ); + break; + + case 'star': + pJS.fn.vendors.drawShape( + pJS.canvas.ctx, + p.x - radius*2 / (pJS.particles.shape.polygon.nb_sides/4), // startX + p.y - radius / (2*2.66/3.5), // startY + radius*2*2.66 / (pJS.particles.shape.polygon.nb_sides/3), // sideLength + pJS.particles.shape.polygon.nb_sides, // sideCountNumerator + 2 // sideCountDenominator + ); + break; + + case 'image': + + function draw(){ + pJS.canvas.ctx.drawImage( + img_obj, + p.x-radius, + p.y-radius, + radius*2, + radius*2 / p.img.ratio + ); + } + + if(pJS.tmp.img_type == 'svg'){ + var img_obj = p.img.obj; + }else{ + var img_obj = pJS.tmp.img_obj; + } + + if(img_obj){ + draw(); + } + + break; + + } + + pJS.canvas.ctx.closePath(); + + if(pJS.particles.shape.stroke.width > 0){ + pJS.canvas.ctx.strokeStyle = pJS.particles.shape.stroke.color; + pJS.canvas.ctx.lineWidth = pJS.particles.shape.stroke.width; + pJS.canvas.ctx.stroke(); + } + + pJS.canvas.ctx.fill(); + + }; + + + pJS.fn.particlesCreate = function(){ + for(var i = 0; i < pJS.particles.number.value; i++) { + pJS.particles.array.push(new pJS.fn.particle(pJS.particles.color, pJS.particles.opacity.value)); + } + }; + + pJS.fn.particlesUpdate = function(){ + + for(var i = 0; i < pJS.particles.array.length; i++){ + + /* the particle */ + var p = pJS.particles.array[i]; + + // var d = ( dx = pJS.interactivity.mouse.click_pos_x - p.x ) * dx + ( dy = pJS.interactivity.mouse.click_pos_y - p.y ) * dy; + // var f = -BANG_SIZE / d; + // if ( d < BANG_SIZE ) { + // var t = Math.atan2( dy, dx ); + // p.vx = f * Math.cos(t); + // p.vy = f * Math.sin(t); + // } + + /* move the particle */ + if(pJS.particles.move.enable){ + var ms = pJS.particles.move.speed/2; + p.x += p.vx * ms; + p.y += p.vy * ms; + } + + /* change opacity status */ + if(pJS.particles.opacity.anim.enable) { + if(p.opacity_status == true) { + if(p.opacity >= pJS.particles.opacity.value) p.opacity_status = false; + p.opacity += p.vo; + }else { + if(p.opacity <= pJS.particles.opacity.anim.opacity_min) p.opacity_status = true; + p.opacity -= p.vo; + } + if(p.opacity < 0) p.opacity = 0; + } + + /* change size */ + if(pJS.particles.size.anim.enable){ + if(p.size_status == true){ + if(p.radius >= pJS.particles.size.value) p.size_status = false; + p.radius += p.vs; + }else{ + if(p.radius <= pJS.particles.size.anim.size_min) p.size_status = true; + p.radius -= p.vs; + } + if(p.radius < 0) p.radius = 0; + } + + /* change particle position if it is out of canvas */ + if(pJS.particles.move.out_mode == 'bounce'){ + var new_pos = { + x_left: p.radius, + x_right: pJS.canvas.w, + y_top: p.radius, + y_bottom: pJS.canvas.h + } + }else{ + var new_pos = { + x_left: -p.radius, + x_right: pJS.canvas.w + p.radius, + y_top: -p.radius, + y_bottom: pJS.canvas.h + p.radius + } + } + + if(p.x - p.radius > pJS.canvas.w){ + p.x = new_pos.x_left; + p.y = Math.random() * pJS.canvas.h; + } + else if(p.x + p.radius < 0){ + p.x = new_pos.x_right; + p.y = Math.random() * pJS.canvas.h; + } + if(p.y - p.radius > pJS.canvas.h){ + p.y = new_pos.y_top; + p.x = Math.random() * pJS.canvas.w; + } + else if(p.y + p.radius < 0){ + p.y = new_pos.y_bottom; + p.x = Math.random() * pJS.canvas.w; + } + + /* out of canvas modes */ + switch(pJS.particles.move.out_mode){ + case 'bounce': + if (p.x + p.radius > pJS.canvas.w) p.vx = -p.vx; + else if (p.x - p.radius < 0) p.vx = -p.vx; + if (p.y + p.radius > pJS.canvas.h) p.vy = -p.vy; + else if (p.y - p.radius < 0) p.vy = -p.vy; + break; + } + + /* events */ + if(isInArray('grab', pJS.interactivity.events.onhover.mode)){ + pJS.fn.modes.grabParticle(p); + } + + if(isInArray('bubble', pJS.interactivity.events.onhover.mode) || isInArray('bubble', pJS.interactivity.events.onclick.mode)){ + pJS.fn.modes.bubbleParticle(p); + } + + if(isInArray('repulse', pJS.interactivity.events.onhover.mode) || isInArray('repulse', pJS.interactivity.events.onclick.mode)){ + pJS.fn.modes.repulseParticle(p); + } + + /* interaction auto between particles */ + if(pJS.particles.line_linked.enable || pJS.particles.move.attract.enable){ + for(var j = i + 1; j < pJS.particles.array.length; j++){ + var p2 = pJS.particles.array[j]; + + /* link particles */ + if(pJS.particles.line_linked.enable){ + pJS.fn.interact.linkParticles(p,p2); + } + + /* attract particles */ + if(pJS.particles.move.attract.enable){ + pJS.fn.interact.attractParticles(p,p2); + } + + /* bounce particles */ + if(pJS.particles.move.bounce){ + pJS.fn.interact.bounceParticles(p,p2); + } + + } + } + + + } + + }; + + pJS.fn.particlesDraw = function(){ + + /* clear canvas */ + pJS.canvas.ctx.clearRect(0, 0, pJS.canvas.w, pJS.canvas.h); + + /* update each particles param */ + pJS.fn.particlesUpdate(); + + /* draw each particle */ + for(var i = 0; i < pJS.particles.array.length; i++){ + var p = pJS.particles.array[i]; + p.draw(); + } + + }; + + pJS.fn.particlesEmpty = function(){ + pJS.particles.array = []; + }; + + pJS.fn.particlesRefresh = function(){ + + /* init all */ + cancelRequestAnimFrame(pJS.fn.checkAnimFrame); + cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + pJS.tmp.source_svg = undefined; + pJS.tmp.img_obj = undefined; + pJS.tmp.count_svg = 0; + pJS.fn.particlesEmpty(); + pJS.fn.canvasClear(); + + /* restart */ + pJS.fn.vendors.start(); + + }; + + + /* ---------- pJS functions - particles interaction ------------ */ + + pJS.fn.interact.linkParticles = function(p1, p2){ + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + /* draw a line between p1 and p2 if the distance between them is under the config distance */ + if(dist <= pJS.particles.line_linked.distance){ + + var opacity_line = pJS.particles.line_linked.opacity - (dist / (1/pJS.particles.line_linked.opacity)) / pJS.particles.line_linked.distance; + + if(opacity_line > 0){ + + /* style */ + var color_line = pJS.particles.line_linked.color_rgb_line; + pJS.canvas.ctx.strokeStyle = 'rgba('+color_line.r+','+color_line.g+','+color_line.b+','+opacity_line+')'; + pJS.canvas.ctx.lineWidth = pJS.particles.line_linked.width; + //pJS.canvas.ctx.lineCap = 'round'; /* performance issue */ + + /* path */ + pJS.canvas.ctx.beginPath(); + pJS.canvas.ctx.moveTo(p1.x, p1.y); + pJS.canvas.ctx.lineTo(p2.x, p2.y); + pJS.canvas.ctx.stroke(); + pJS.canvas.ctx.closePath(); + + } + + } + + }; + + + pJS.fn.interact.attractParticles = function(p1, p2){ + + /* condensed particles */ + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + if(dist <= pJS.particles.line_linked.distance){ + + var ax = dx/(pJS.particles.move.attract.rotateX*1000), + ay = dy/(pJS.particles.move.attract.rotateY*1000); + + p1.vx -= ax; + p1.vy -= ay; + + p2.vx += ax; + p2.vy += ay; + + } + + + } + + + pJS.fn.interact.bounceParticles = function(p1, p2){ + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy), + dist_p = p1.radius+p2.radius; + + if(dist <= dist_p){ + p1.vx = -p1.vx; + p1.vy = -p1.vy; + + p2.vx = -p2.vx; + p2.vy = -p2.vy; + } + + } + + + /* ---------- pJS functions - modes events ------------ */ + + pJS.fn.modes.pushParticles = function(nb, pos){ + + pJS.tmp.pushing = true; + + for(var i = 0; i < nb; i++){ + pJS.particles.array.push( + new pJS.fn.particle( + pJS.particles.color, + pJS.particles.opacity.value, + { + 'x': pos ? pos.pos_x : Math.random() * pJS.canvas.w, + 'y': pos ? pos.pos_y : Math.random() * pJS.canvas.h + } + ) + ) + if(i == nb-1){ + if(!pJS.particles.move.enable){ + pJS.fn.particlesDraw(); + } + pJS.tmp.pushing = false; + } + } + + }; + + + pJS.fn.modes.removeParticles = function(nb){ + + pJS.particles.array.splice(0, nb); + if(!pJS.particles.move.enable){ + pJS.fn.particlesDraw(); + } + + }; + + + pJS.fn.modes.bubbleParticle = function(p){ + + /* on hover event */ + if(pJS.interactivity.events.onhover.enable && isInArray('bubble', pJS.interactivity.events.onhover.mode)){ + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse), + ratio = 1 - dist_mouse / pJS.interactivity.modes.bubble.distance; + + function init(){ + p.opacity_bubble = p.opacity; + p.radius_bubble = p.radius; + } + + /* mousemove - check ratio */ + if(dist_mouse <= pJS.interactivity.modes.bubble.distance){ + + if(ratio >= 0 && pJS.interactivity.status == 'mousemove'){ + + /* size */ + if(pJS.interactivity.modes.bubble.size != pJS.particles.size.value){ + + if(pJS.interactivity.modes.bubble.size > pJS.particles.size.value){ + var size = p.radius + (pJS.interactivity.modes.bubble.size*ratio); + if(size >= 0){ + p.radius_bubble = size; + } + }else{ + var dif = p.radius - pJS.interactivity.modes.bubble.size, + size = p.radius - (dif*ratio); + if(size > 0){ + p.radius_bubble = size; + }else{ + p.radius_bubble = 0; + } + } + + } + + /* opacity */ + if(pJS.interactivity.modes.bubble.opacity != pJS.particles.opacity.value){ + + if(pJS.interactivity.modes.bubble.opacity > pJS.particles.opacity.value){ + var opacity = pJS.interactivity.modes.bubble.opacity*ratio; + if(opacity > p.opacity && opacity <= pJS.interactivity.modes.bubble.opacity){ + p.opacity_bubble = opacity; + } + }else{ + var opacity = p.opacity - (pJS.particles.opacity.value-pJS.interactivity.modes.bubble.opacity)*ratio; + if(opacity < p.opacity && opacity >= pJS.interactivity.modes.bubble.opacity){ + p.opacity_bubble = opacity; + } + } + + } + + } + + }else{ + init(); + } + + + /* mouseleave */ + if(pJS.interactivity.status == 'mouseleave'){ + init(); + } + + } + + /* on click event */ + else if(pJS.interactivity.events.onclick.enable && isInArray('bubble', pJS.interactivity.events.onclick.mode)){ + + + if(pJS.tmp.bubble_clicking){ + var dx_mouse = p.x - pJS.interactivity.mouse.click_pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.click_pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse), + time_spent = (new Date().getTime() - pJS.interactivity.mouse.click_time)/1000; + + if(time_spent > pJS.interactivity.modes.bubble.duration){ + pJS.tmp.bubble_duration_end = true; + } + + if(time_spent > pJS.interactivity.modes.bubble.duration*2){ + pJS.tmp.bubble_clicking = false; + pJS.tmp.bubble_duration_end = false; + } + } + + + function process(bubble_param, particles_param, p_obj_bubble, p_obj, id){ + + if(bubble_param != particles_param){ + + if(!pJS.tmp.bubble_duration_end){ + if(dist_mouse <= pJS.interactivity.modes.bubble.distance){ + if(p_obj_bubble != undefined) var obj = p_obj_bubble; + else var obj = p_obj; + if(obj != bubble_param){ + var value = p_obj - (time_spent * (p_obj - bubble_param) / pJS.interactivity.modes.bubble.duration); + if(id == 'size') p.radius_bubble = value; + if(id == 'opacity') p.opacity_bubble = value; + } + }else{ + if(id == 'size') p.radius_bubble = undefined; + if(id == 'opacity') p.opacity_bubble = undefined; + } + }else{ + if(p_obj_bubble != undefined){ + var value_tmp = p_obj - (time_spent * (p_obj - bubble_param) / pJS.interactivity.modes.bubble.duration), + dif = bubble_param - value_tmp; + value = bubble_param + dif; + if(id == 'size') p.radius_bubble = value; + if(id == 'opacity') p.opacity_bubble = value; + } + } + + } + + } + + if(pJS.tmp.bubble_clicking){ + /* size */ + process(pJS.interactivity.modes.bubble.size, pJS.particles.size.value, p.radius_bubble, p.radius, 'size'); + /* opacity */ + process(pJS.interactivity.modes.bubble.opacity, pJS.particles.opacity.value, p.opacity_bubble, p.opacity, 'opacity'); + } + + } + + }; + + + pJS.fn.modes.repulseParticle = function(p){ + + if(pJS.interactivity.events.onhover.enable && isInArray('repulse', pJS.interactivity.events.onhover.mode) && pJS.interactivity.status == 'mousemove') { + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse); + + var normVec = {x: dx_mouse/dist_mouse, y: dy_mouse/dist_mouse}, + repulseRadius = pJS.interactivity.modes.repulse.distance, + velocity = 100, + repulseFactor = clamp((1/repulseRadius)*(-1*Math.pow(dist_mouse/repulseRadius,2)+1)*repulseRadius*velocity, 0, 50); + + var pos = { + x: p.x + normVec.x * repulseFactor, + y: p.y + normVec.y * repulseFactor + } + + if(pJS.particles.move.out_mode == 'bounce'){ + if(pos.x - p.radius > 0 && pos.x + p.radius < pJS.canvas.w) p.x = pos.x; + if(pos.y - p.radius > 0 && pos.y + p.radius < pJS.canvas.h) p.y = pos.y; + }else{ + p.x = pos.x; + p.y = pos.y; + } + + } + + + else if(pJS.interactivity.events.onclick.enable && isInArray('repulse', pJS.interactivity.events.onclick.mode)) { + + if(!pJS.tmp.repulse_finish){ + pJS.tmp.repulse_count++; + if(pJS.tmp.repulse_count == pJS.particles.array.length){ + pJS.tmp.repulse_finish = true; + } + } + + if(pJS.tmp.repulse_clicking){ + + var repulseRadius = Math.pow(pJS.interactivity.modes.repulse.distance/6, 3); + + var dx = pJS.interactivity.mouse.click_pos_x - p.x, + dy = pJS.interactivity.mouse.click_pos_y - p.y, + d = dx*dx + dy*dy; + + var force = -repulseRadius / d * 1; + + function process(){ + + var f = Math.atan2(dy,dx); + p.vx = force * Math.cos(f); + p.vy = force * Math.sin(f); + + if(pJS.particles.move.out_mode == 'bounce'){ + var pos = { + x: p.x + p.vx, + y: p.y + p.vy + } + if (pos.x + p.radius > pJS.canvas.w) p.vx = -p.vx; + else if (pos.x - p.radius < 0) p.vx = -p.vx; + if (pos.y + p.radius > pJS.canvas.h) p.vy = -p.vy; + else if (pos.y - p.radius < 0) p.vy = -p.vy; + } + + } + + // default + if(d <= repulseRadius){ + process(); + } + + // bang - slow motion mode + // if(!pJS.tmp.repulse_finish){ + // if(d <= repulseRadius){ + // process(); + // } + // }else{ + // process(); + // } + + + }else{ + + if(pJS.tmp.repulse_clicking == false){ + + p.vx = p.vx_i; + p.vy = p.vy_i; + + } + + } + + } + + } + + + pJS.fn.modes.grabParticle = function(p){ + + if(pJS.interactivity.events.onhover.enable && pJS.interactivity.status == 'mousemove'){ + + var dx_mouse = p.x - pJS.interactivity.mouse.pos_x, + dy_mouse = p.y - pJS.interactivity.mouse.pos_y, + dist_mouse = Math.sqrt(dx_mouse*dx_mouse + dy_mouse*dy_mouse); + + /* draw a line between the cursor and the particle if the distance between them is under the config distance */ + if(dist_mouse <= pJS.interactivity.modes.grab.distance){ + + var opacity_line = pJS.interactivity.modes.grab.line_linked.opacity - (dist_mouse / (1/pJS.interactivity.modes.grab.line_linked.opacity)) / pJS.interactivity.modes.grab.distance; + + if(opacity_line > 0){ + + /* style */ + var color_line = pJS.particles.line_linked.color_rgb_line; + pJS.canvas.ctx.strokeStyle = 'rgba('+color_line.r+','+color_line.g+','+color_line.b+','+opacity_line+')'; + pJS.canvas.ctx.lineWidth = pJS.particles.line_linked.width; + //pJS.canvas.ctx.lineCap = 'round'; /* performance issue */ + + /* path */ + pJS.canvas.ctx.beginPath(); + pJS.canvas.ctx.moveTo(p.x, p.y); + pJS.canvas.ctx.lineTo(pJS.interactivity.mouse.pos_x, pJS.interactivity.mouse.pos_y); + pJS.canvas.ctx.stroke(); + pJS.canvas.ctx.closePath(); + + } + + } + + } + + }; + + + + /* ---------- pJS functions - vendors ------------ */ + + pJS.fn.vendors.eventsListeners = function(){ + + /* events target element */ + if(pJS.interactivity.detect_on == 'window'){ + pJS.interactivity.el = window; + }else{ + pJS.interactivity.el = pJS.canvas.el; + } + + + /* detect mouse pos - on hover / click event */ + if(pJS.interactivity.events.onhover.enable || pJS.interactivity.events.onclick.enable){ + + /* el on mousemove */ + pJS.interactivity.el.addEventListener('mousemove', function(e){ + + if(pJS.interactivity.el == window){ + var pos_x = e.clientX, + pos_y = e.clientY; + } + else{ + var pos_x = e.offsetX || e.clientX, + pos_y = e.offsetY || e.clientY; + } + + pJS.interactivity.mouse.pos_x = pos_x; + pJS.interactivity.mouse.pos_y = pos_y; + + if(pJS.tmp.retina){ + pJS.interactivity.mouse.pos_x *= pJS.canvas.pxratio; + pJS.interactivity.mouse.pos_y *= pJS.canvas.pxratio; + } + + pJS.interactivity.status = 'mousemove'; + + }); + + /* el on onmouseleave */ + pJS.interactivity.el.addEventListener('mouseleave', function(e){ + + pJS.interactivity.mouse.pos_x = null; + pJS.interactivity.mouse.pos_y = null; + pJS.interactivity.status = 'mouseleave'; + + }); + + } + + /* on click event */ + if(pJS.interactivity.events.onclick.enable){ + + pJS.interactivity.el.addEventListener('click', function(){ + + pJS.interactivity.mouse.click_pos_x = pJS.interactivity.mouse.pos_x; + pJS.interactivity.mouse.click_pos_y = pJS.interactivity.mouse.pos_y; + pJS.interactivity.mouse.click_time = new Date().getTime(); + + if(pJS.interactivity.events.onclick.enable){ + + switch(pJS.interactivity.events.onclick.mode){ + + case 'push': + if(pJS.particles.move.enable){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb, pJS.interactivity.mouse); + }else{ + if(pJS.interactivity.modes.push.particles_nb == 1){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb, pJS.interactivity.mouse); + } + else if(pJS.interactivity.modes.push.particles_nb > 1){ + pJS.fn.modes.pushParticles(pJS.interactivity.modes.push.particles_nb); + } + } + break; + + case 'remove': + pJS.fn.modes.removeParticles(pJS.interactivity.modes.remove.particles_nb); + break; + + case 'bubble': + pJS.tmp.bubble_clicking = true; + break; + + case 'repulse': + pJS.tmp.repulse_clicking = true; + pJS.tmp.repulse_count = 0; + pJS.tmp.repulse_finish = false; + setTimeout(function(){ + pJS.tmp.repulse_clicking = false; + }, pJS.interactivity.modes.repulse.duration*1000) + break; + + } + + } + + }); + + } + + + }; + + pJS.fn.vendors.densityAutoParticles = function(){ + + if(pJS.particles.number.density.enable){ + + /* calc area */ + var area = pJS.canvas.el.width * pJS.canvas.el.height / 1000; + if(pJS.tmp.retina){ + area = area/(pJS.canvas.pxratio*2); + } + + /* calc number of particles based on density area */ + var nb_particles = area * pJS.particles.number.value / pJS.particles.number.density.value_area; + + /* add or remove X particles */ + var missing_particles = pJS.particles.array.length - nb_particles; + if(missing_particles < 0) pJS.fn.modes.pushParticles(Math.abs(missing_particles)); + else pJS.fn.modes.removeParticles(missing_particles); + + } + + }; + + + pJS.fn.vendors.checkOverlap = function(p1, position){ + for(var i = 0; i < pJS.particles.array.length; i++){ + var p2 = pJS.particles.array[i]; + + var dx = p1.x - p2.x, + dy = p1.y - p2.y, + dist = Math.sqrt(dx*dx + dy*dy); + + if(dist <= p1.radius + p2.radius){ + p1.x = position ? position.x : Math.random() * pJS.canvas.w; + p1.y = position ? position.y : Math.random() * pJS.canvas.h; + pJS.fn.vendors.checkOverlap(p1); + } + } + }; + + + pJS.fn.vendors.createSvgImg = function(p){ + + /* set color to svg element */ + var svgXml = pJS.tmp.source_svg, + rgbHex = /#([0-9A-F]{3,6})/gi, + coloredSvgXml = svgXml.replace(rgbHex, function (m, r, g, b) { + if(p.color.rgb){ + var color_value = 'rgba('+p.color.rgb.r+','+p.color.rgb.g+','+p.color.rgb.b+','+p.opacity+')'; + }else{ + var color_value = 'hsla('+p.color.hsl.h+','+p.color.hsl.s+'%,'+p.color.hsl.l+'%,'+p.opacity+')'; + } + return color_value; + }); + + /* prepare to create img with colored svg */ + var svg = new Blob([coloredSvgXml], {type: 'image/svg+xml;charset=utf-8'}), + DOMURL = window.URL || window.webkitURL || window, + url = DOMURL.createObjectURL(svg); + + /* create particle img obj */ + var img = new Image(); + img.addEventListener('load', function(){ + p.img.obj = img; + p.img.loaded = true; + DOMURL.revokeObjectURL(url); + pJS.tmp.count_svg++; + }); + img.src = url; + + }; + + + pJS.fn.vendors.destroypJS = function(){ + cancelAnimationFrame(pJS.fn.drawAnimFrame); + canvas_el.remove(); + pJSDom = null; + }; + + + pJS.fn.vendors.drawShape = function(c, startX, startY, sideLength, sideCountNumerator, sideCountDenominator){ + + // By Programming Thomas - https://programmingthomas.wordpress.com/2013/04/03/n-sided-shapes/ + var sideCount = sideCountNumerator * sideCountDenominator; + var decimalSides = sideCountNumerator / sideCountDenominator; + var interiorAngleDegrees = (180 * (decimalSides - 2)) / decimalSides; + var interiorAngle = Math.PI - Math.PI * interiorAngleDegrees / 180; // convert to radians + c.save(); + c.beginPath(); + c.translate(startX, startY); + c.moveTo(0,0); + for (var i = 0; i < sideCount; i++) { + c.lineTo(sideLength,0); + c.translate(sideLength,0); + c.rotate(interiorAngle); + } + //c.stroke(); + c.fill(); + c.restore(); + + }; + + pJS.fn.vendors.exportImg = function(){ + window.open(pJS.canvas.el.toDataURL('image/png'), '_blank'); + }; + + + pJS.fn.vendors.loadImg = function(type){ + + pJS.tmp.img_error = undefined; + + if(pJS.particles.shape.image.src != ''){ + + if(type == 'svg'){ + + var xhr = new XMLHttpRequest(); + xhr.open('GET', pJS.particles.shape.image.src); + xhr.onreadystatechange = function (data) { + if(xhr.readyState == 4){ + if(xhr.status == 200){ + pJS.tmp.source_svg = data.currentTarget.response; + pJS.fn.vendors.checkBeforeDraw(); + }else{ + console.log('Error pJS - Image not found'); + pJS.tmp.img_error = true; + } + } + } + xhr.send(); + + }else{ + + var img = new Image(); + img.addEventListener('load', function(){ + pJS.tmp.img_obj = img; + pJS.fn.vendors.checkBeforeDraw(); + }); + img.src = pJS.particles.shape.image.src; + + } + + }else{ + console.log('Error pJS - No image.src'); + pJS.tmp.img_error = true; + } + + }; + + + pJS.fn.vendors.draw = function(){ + + if(pJS.particles.shape.type == 'image'){ + + if(pJS.tmp.img_type == 'svg'){ + + if(pJS.tmp.count_svg >= pJS.particles.number.value){ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + }else{ + //console.log('still loading...'); + if(!pJS.tmp.img_error) pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + }else{ + + if(pJS.tmp.img_obj != undefined){ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + }else{ + if(!pJS.tmp.img_error) pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + } + + }else{ + pJS.fn.particlesDraw(); + if(!pJS.particles.move.enable) cancelRequestAnimFrame(pJS.fn.drawAnimFrame); + else pJS.fn.drawAnimFrame = requestAnimFrame(pJS.fn.vendors.draw); + } + + }; + + + pJS.fn.vendors.checkBeforeDraw = function(){ + + // if shape is image + if(pJS.particles.shape.type == 'image'){ + + if(pJS.tmp.img_type == 'svg' && pJS.tmp.source_svg == undefined){ + pJS.tmp.checkAnimFrame = requestAnimFrame(check); + }else{ + //console.log('images loaded! cancel check'); + cancelRequestAnimFrame(pJS.tmp.checkAnimFrame); + if(!pJS.tmp.img_error){ + pJS.fn.vendors.init(); + pJS.fn.vendors.draw(); + } + + } + + }else{ + pJS.fn.vendors.init(); + pJS.fn.vendors.draw(); + } + + }; + + + pJS.fn.vendors.init = function(){ + + /* init canvas + particles */ + pJS.fn.retinaInit(); + pJS.fn.canvasInit(); + pJS.fn.canvasSize(); + pJS.fn.canvasPaint(); + pJS.fn.particlesCreate(); + pJS.fn.vendors.densityAutoParticles(); + + /* particles.line_linked - convert hex colors to rgb */ + pJS.particles.line_linked.color_rgb_line = hexToRgb(pJS.particles.line_linked.color); + + }; + + + pJS.fn.vendors.start = function(){ + + if(isInArray('image', pJS.particles.shape.type)){ + pJS.tmp.img_type = pJS.particles.shape.image.src.substr(pJS.particles.shape.image.src.length - 3); + pJS.fn.vendors.loadImg(pJS.tmp.img_type); + }else{ + pJS.fn.vendors.checkBeforeDraw(); + } + + }; + + + + + /* ---------- pJS - start ------------ */ + + + pJS.fn.vendors.eventsListeners(); + + pJS.fn.vendors.start(); + + + +}; + +/* ---------- global functions - vendors ------------ */ + +Object.deepExtend = function(destination, source) { + for (var property in source) { + if (source[property] && source[property].constructor && + source[property].constructor === Object) { + destination[property] = destination[property] || {}; + arguments.callee(destination[property], source[property]); + } else { + destination[property] = source[property]; + } + } + return destination; +}; + +window.requestAnimFrame = (function(){ + return window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function(callback){ + window.setTimeout(callback, 1000 / 60); + }; +})(); + +window.cancelRequestAnimFrame = ( function() { + return window.cancelAnimationFrame || + window.webkitCancelRequestAnimationFrame || + window.mozCancelRequestAnimationFrame || + window.oCancelRequestAnimationFrame || + window.msCancelRequestAnimationFrame || + clearTimeout +} )(); + +function hexToRgb(hex){ + // By Tim Down - http://stackoverflow.com/a/5624139/3493650 + // Expand shorthand form (e.g. "03F") to full form (e.g. "0033FF") + var shorthandRegex = /^#?([a-f\d])([a-f\d])([a-f\d])$/i; + hex = hex.replace(shorthandRegex, function(m, r, g, b) { + return r + r + g + g + b + b; + }); + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : null; +}; + +function clamp(number, min, max) { + return Math.min(Math.max(number, min), max); +}; + +function isInArray(value, array) { + return array.indexOf(value) > -1; +} + + +/* ---------- particles.js functions - start ------------ */ + +window.pJSDom = []; + +window.particlesJS = function(tag_id, params){ + + //console.log(params); + + /* no string id? so it's object params, and set the id with default id */ + if(typeof(tag_id) != 'string'){ + params = tag_id; + tag_id = 'particles-js'; + } + + /* no id? set the id to default id */ + if(!tag_id){ + tag_id = 'particles-js'; + } + + /* pJS elements */ + var pJS_tag = document.getElementById(tag_id), + pJS_canvas_class = 'particles-js-canvas-el', + exist_canvas = pJS_tag.getElementsByClassName(pJS_canvas_class); + + /* remove canvas if exists into the pJS target tag */ + if(exist_canvas.length){ + while(exist_canvas.length > 0){ + pJS_tag.removeChild(exist_canvas[0]); + } + } + + /* create canvas element */ + var canvas_el = document.createElement('canvas'); + canvas_el.className = pJS_canvas_class; + + /* set size canvas */ + canvas_el.style.width = "100%"; + canvas_el.style.height = "100%"; + + /* append canvas */ + var canvas = document.getElementById(tag_id).appendChild(canvas_el); + + /* launch particle.js */ + if(canvas != null){ + pJSDom.push(new pJS(tag_id, params)); + } + +}; + +window.particlesJS.load = function(tag_id, path_config_json, callback){ + + /* load json config */ + var xhr = new XMLHttpRequest(); + xhr.open('GET', path_config_json); + xhr.onreadystatechange = function (data) { + if(xhr.readyState == 4){ + if(xhr.status == 200){ + var params = JSON.parse(data.currentTarget.response); + window.particlesJS(tag_id, params); + if(callback) callback(); + }else{ + console.log('Error pJS - XMLHttpRequest status: '+xhr.status); + console.log('Error pJS - File config not found'); + } + } + }; + xhr.send(); + +}; \ No newline at end of file -- 2.52.0 From f4f886727f4f46cf2a041d25c59f9b51ec261c61 Mon Sep 17 00:00:00 2001 From: Janis Date: Wed, 29 Oct 2025 19:05:55 +0100 Subject: [PATCH 08/16] feat(user-sessions): introduce GameLobby and GameUtil for session management and code generation --- .../WebApplicationConfiguration.scala | 5 +- .../app/controllers/HomeController.scala | 6 +-- .../app/controllers/UserController.scala | 2 +- knockoutwhistweb/app/controllers/WebUI.scala | 50 ------------------- .../app/model/game/GameLobby.scala | 14 ++++++ .../app/model/sessions/PlayerSession.scala | 2 + ...ancedSession.scala => SimpleSession.scala} | 2 +- .../app/model/sessions/UserSession.scala | 8 +++ knockoutwhistweb/app/util/GameUtil.scala | 29 +++++++++++ 9 files changed, 60 insertions(+), 58 deletions(-) delete mode 100644 knockoutwhistweb/app/controllers/WebUI.scala create mode 100644 knockoutwhistweb/app/model/game/GameLobby.scala rename knockoutwhistweb/app/model/sessions/{AdvancedSession.scala => SimpleSession.scala} (74%) create mode 100644 knockoutwhistweb/app/model/sessions/UserSession.scala create mode 100644 knockoutwhistweb/app/util/GameUtil.scala diff --git a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala index 8226cf8..2f13029 100644 --- a/knockoutwhistweb/app/components/WebApplicationConfiguration.scala +++ b/knockoutwhistweb/app/components/WebApplicationConfiguration.scala @@ -1,13 +1,12 @@ package components -import controllers.WebUI import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.ui.UI import de.knockoutwhist.utils.events.EventListener class WebApplicationConfiguration extends DefaultConfiguration { - override def uis: Set[UI] = super.uis + WebUI - override def listener: Set[EventListener] = super.listener + WebUI + override def uis: Set[UI] = Set() + override def listener: Set[EventListener] = Set() } diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala index ff7ae53..e4f5bb6 100644 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ b/knockoutwhistweb/app/controllers/HomeController.scala @@ -7,7 +7,7 @@ import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule import logic.PodGameManager -import model.sessions.AdvancedSession +import model.sessions.SimpleSession import play.api.mvc.* import play.api.* import play.twirl.api.Html @@ -61,8 +61,8 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e } } else { val session = PodGameManager.identify(uuid).get - val player = session.asInstanceOf[AdvancedSession].player - val logic = WebUI.logic.get.asInstanceOf[BaseGameLogic] + val player = session.asInstanceOf[SimpleSession].player + val logic = null if (logic.getCurrentState == Lobby) { } else if (logic.getCurrentState == InGame) { diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index 5e566ea..8b32c2e 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -8,7 +8,7 @@ import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule import logic.PodGameManager import logic.user.{SessionManager, UserManager} -import model.sessions.AdvancedSession +import model.sessions.SimpleSession import play.api.* import play.api.mvc.* import play.twirl.api.Html diff --git a/knockoutwhistweb/app/controllers/WebUI.scala b/knockoutwhistweb/app/controllers/WebUI.scala deleted file mode 100644 index 4015d64..0000000 --- a/knockoutwhistweb/app/controllers/WebUI.scala +++ /dev/null @@ -1,50 +0,0 @@ -package controllers - -import de.knockoutwhist.cards.{Card, CardValue, Hand, Suit} -import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.{InGame, Lobby} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import de.knockoutwhist.events.* -import de.knockoutwhist.events.global.GameStateChangeEvent -import de.knockoutwhist.player.AbstractPlayer -import de.knockoutwhist.rounds.Match -import de.knockoutwhist.ui.UI -import de.knockoutwhist.utils.CustomThread -import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} -import logic.PodGameManager -import model.sessions.AdvancedSession - -object WebUI extends CustomThread with EventListener with UI { - - setName("WebUI") - - var init = false - var logic: Option[GameLogic] = None - - var latestOutput: String = "" - - override def instance: CustomThread = WebUI - - override def listen(event: SimpleEvent): Unit = { - event match { - case event: GameStateChangeEvent => - if (event.oldState == Lobby && event.newState == InGame) { - val match1: Option[Match] = logic.get.asInstanceOf[BaseGameLogic].getCurrentMatch - val players: List[AbstractPlayer] = match1.get.totalplayers - players.map(player => PodGameManager.addSession(AdvancedSession(player.id, player))) - } - case _ => - } - } - - override def initial(gameLogic: GameLogic): Boolean = { - if (init) { - return false - } - init = true - this.logic = Some(gameLogic) - start() - true - } - -} diff --git a/knockoutwhistweb/app/model/game/GameLobby.scala b/knockoutwhistweb/app/model/game/GameLobby.scala new file mode 100644 index 0000000..3e4767b --- /dev/null +++ b/knockoutwhistweb/app/model/game/GameLobby.scala @@ -0,0 +1,14 @@ +package model.game + +import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} + +class GameLobby(val logic: GameLogic) extends EventListener{ + logic.addListener(this) + logic.createSession() + + + override def listen(event: SimpleEvent): Unit = { + + } +} diff --git a/knockoutwhistweb/app/model/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala index 95c39f5..87248f3 100644 --- a/knockoutwhistweb/app/model/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -1,5 +1,6 @@ package model.sessions +import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID @@ -8,6 +9,7 @@ trait PlayerSession { def id: UUID def name: String + def player: AbstractPlayer def updatePlayer(event: SimpleEvent): Unit } diff --git a/knockoutwhistweb/app/model/sessions/AdvancedSession.scala b/knockoutwhistweb/app/model/sessions/SimpleSession.scala similarity index 74% rename from knockoutwhistweb/app/model/sessions/AdvancedSession.scala rename to knockoutwhistweb/app/model/sessions/SimpleSession.scala index 7a2fe66..a4c7007 100644 --- a/knockoutwhistweb/app/model/sessions/AdvancedSession.scala +++ b/knockoutwhistweb/app/model/sessions/SimpleSession.scala @@ -5,7 +5,7 @@ import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID -case class AdvancedSession(id: UUID, player: AbstractPlayer) extends PlayerSession { +case class SimpleSession(id: UUID, player: AbstractPlayer) extends PlayerSession { def name: String = player.name diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala new file mode 100644 index 0000000..1019b59 --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -0,0 +1,8 @@ +package model.sessions + +import de.knockoutwhist.player.AbstractPlayer +import java.util.UUID + +class UserSession(id: UUID, player: AbstractPlayer) extends SimpleSession(id, player) { + +} diff --git a/knockoutwhistweb/app/util/GameUtil.scala b/knockoutwhistweb/app/util/GameUtil.scala new file mode 100644 index 0000000..b2caf50 --- /dev/null +++ b/knockoutwhistweb/app/util/GameUtil.scala @@ -0,0 +1,29 @@ +package util + +import scala.util.Random + +object GameUtil { + + private val CharPool: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" + private val CodeLength: Int = 6 + private val MaxRepetition: Int = 2 + private val random = new Random() + + def generateCode(): String = { + val freq = Array.fill(CharPool.length)(0) + val code = new StringBuilder(CodeLength) + + for (_ <- 0 until CodeLength) { + var index = random.nextInt(CharPool.length) + // Pick a new character if it's already used twice + while (freq(index) >= MaxRepetition) { + index = random.nextInt(CharPool.length) + } + freq(index) += 1 + code.append(CharPool.charAt(index)) + } + + code.toString() + } + +} -- 2.52.0 From 76bde997efbbd5ff6482ae774cc19df6aa8f38ed Mon Sep 17 00:00:00 2001 From: Janis Date: Thu, 30 Oct 2025 09:00:15 +0100 Subject: [PATCH 09/16] feat(user-sessions): implement GameLobby and user session management with exception handling --- .../app/controllers/HomeController.scala | 53 +++++++------- .../app/exceptions/NotHostException.java | 7 ++ .../exceptions/NotInThisGameException.java | 7 ++ .../exceptions/NotInteractableException.java | 7 ++ .../app/logic/game/GameLobby.scala | 69 +++++++++++++++++++ .../app/model/game/GameLobby.scala | 14 ---- .../app/model/sessions/PlayerSession.scala | 2 - .../app/model/sessions/UserSession.scala | 24 ++++++- knockoutwhistweb/conf/application.conf | 4 +- 9 files changed, 142 insertions(+), 45 deletions(-) create mode 100644 knockoutwhistweb/app/exceptions/NotHostException.java create mode 100644 knockoutwhistweb/app/exceptions/NotInThisGameException.java create mode 100644 knockoutwhistweb/app/exceptions/NotInteractableException.java create mode 100644 knockoutwhistweb/app/logic/game/GameLobby.scala delete mode 100644 knockoutwhistweb/app/model/game/GameLobby.scala diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala index e4f5bb6..b8435f6 100644 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ b/knockoutwhistweb/app/controllers/HomeController.scala @@ -55,32 +55,35 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e def ingame(id: String): Action[AnyContent] = { val uuid: UUID = UUID.fromString(id) - if (PodGameManager.identify(uuid).isEmpty) { - return Action { implicit request => - NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) - } - } else { - val session = PodGameManager.identify(uuid).get - val player = session.asInstanceOf[SimpleSession].player - val logic = null - if (logic.getCurrentState == Lobby) { - - } else if (logic.getCurrentState == InGame) { - return Action { implicit request => - Ok(views.html.ingame.apply(player, logic)) - } - } else if (logic.getCurrentState == SelectTrump) { - return Action { implicit request => - Ok(views.html.selecttrump.apply(player, logic)) - } - } else if (logic.getCurrentState == TieBreak) { - return Action { implicit request => - Ok(views.html.tie.apply(player, logic)) - } - } - } Action { implicit request => - InternalServerError("Oops") + NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) } +// if (PodGameManager.identify(uuid).isEmpty) { +// return Action { implicit request => +// NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) +// } +// } else { +// val session = PodGameManager.identify(uuid).get +// val player = session.asInstanceOf[SimpleSession].player +// val logic = BaseGameLogic(null) +// if (logic.getCurrentState == Lobby) { +// +// } else if (logic.getCurrentState == InGame) { +// return Action { implicit request => +// Ok(views.html.ingame.apply(player, logic)) +// } +// } else if (logic.getCurrentState == SelectTrump) { +// return Action { implicit request => +// Ok(views.html.selecttrump.apply(player, logic)) +// } +// } else if (logic.getCurrentState == TieBreak) { +// return Action { implicit request => +// Ok(views.html.tie.apply(player, logic)) +// } +// } +// } +// Action { implicit request => +// InternalServerError("Oops") +// } } } \ No newline at end of file diff --git a/knockoutwhistweb/app/exceptions/NotHostException.java b/knockoutwhistweb/app/exceptions/NotHostException.java new file mode 100644 index 0000000..490d619 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotHostException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotHostException extends RuntimeException { + public NotHostException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInThisGameException.java b/knockoutwhistweb/app/exceptions/NotInThisGameException.java new file mode 100644 index 0000000..e33b706 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInThisGameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInThisGameException extends RuntimeException { + public NotInThisGameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotInteractableException.java b/knockoutwhistweb/app/exceptions/NotInteractableException.java new file mode 100644 index 0000000..e4642c9 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotInteractableException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotInteractableException extends RuntimeException { + public NotInteractableException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala new file mode 100644 index 0000000..6f20ae9 --- /dev/null +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -0,0 +1,69 @@ +package logic.game + +import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.events.player.PlayerEvent +import de.knockoutwhist.player.Playertype.HUMAN +import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} +import de.knockoutwhist.rounds.Match +import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} +import exceptions.{NotHostException, NotInThisGameException, NotInteractableException} +import model.sessions.UserSession +import model.users.User + +import java.util.UUID +import scala.collection.mutable.ListBuffer + +class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends EventListener{ + logic.addListener(this) + logic.createSession() + + val users: Map[UUID, UserSession] = Map() + + override def listen(event: SimpleEvent): Unit = { + event match { + case event: PlayerEvent => + users.get(event.playerId).foreach(session => session.updatePlayer(event)) + case event: SimpleEvent => + users.values.foreach(session => session.updatePlayer(event)) + } + } + + def startGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + if (!sessionOpt.get.host) { + throw new NotHostException("Only the host can start the game!") + } + val playerNamesList = ListBuffer[AbstractPlayer]() + users.values.foreach { player => + playerNamesList += PlayerFactory.createPlayer(player.name, player.id, HUMAN) + } + logic.createMatch(playerNamesList.toList) + logic.controlMatch() + } + + def playCard(user: User, card: Int): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + if (!sessionOpt.get.canInteract) { + throw new NotInteractableException("You can't play a card!") + } + + } + + + //------------------- + + private def getMatch: Match = { + val matchOpt = logic.getCurrentMatch + if (matchOpt.isEmpty) { + throw new IllegalStateException("No match is currently running!") + } + matchOpt.get + } + +} diff --git a/knockoutwhistweb/app/model/game/GameLobby.scala b/knockoutwhistweb/app/model/game/GameLobby.scala deleted file mode 100644 index 3e4767b..0000000 --- a/knockoutwhistweb/app/model/game/GameLobby.scala +++ /dev/null @@ -1,14 +0,0 @@ -package model.game - -import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} - -class GameLobby(val logic: GameLogic) extends EventListener{ - logic.addListener(this) - logic.createSession() - - - override def listen(event: SimpleEvent): Unit = { - - } -} diff --git a/knockoutwhistweb/app/model/sessions/PlayerSession.scala b/knockoutwhistweb/app/model/sessions/PlayerSession.scala index 87248f3..95c39f5 100644 --- a/knockoutwhistweb/app/model/sessions/PlayerSession.scala +++ b/knockoutwhistweb/app/model/sessions/PlayerSession.scala @@ -1,6 +1,5 @@ package model.sessions -import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.utils.events.SimpleEvent import java.util.UUID @@ -9,7 +8,6 @@ trait PlayerSession { def id: UUID def name: String - def player: AbstractPlayer def updatePlayer(event: SimpleEvent): Unit } diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala index 1019b59..ccfd172 100644 --- a/knockoutwhistweb/app/model/sessions/UserSession.scala +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -1,8 +1,28 @@ package model.sessions -import de.knockoutwhist.player.AbstractPlayer +import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} +import de.knockoutwhist.utils.events.SimpleEvent +import model.users.User + import java.util.UUID -class UserSession(id: UUID, player: AbstractPlayer) extends SimpleSession(id, player) { +class UserSession(user: User, val host: Boolean) extends PlayerSession { + var canInteract: Boolean = false + override def updatePlayer(event: SimpleEvent): Unit = { + event match { + case event: RequestTrumpSuitEvent => + canInteract = true + case event: RequestTieChoiceEvent => + canInteract = true + case event: RequestCardEvent => + canInteract = true + case _ => + } + } + + override def id: UUID = user.id + + override def name: String = user.name + } diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index d6d372a..c9d8a3d 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -6,9 +6,9 @@ auth { issuer = "knockoutwhistweb" audience = "ui" # ${?PUBLIC_KEY_FILE} - privateKeyFile = "D:\\Workspaces\\Gitops\\rsa512-private.pem" + privateKeyFile = "/home/janis/Workspaces/IntelliJ/KnockOutWhist/Gitops/rsa512-private.pem" privateKeyPem = ${?PUBLIC_KEY_PEM} #${?PUBLIC_KEY_FILE} - publicKeyFile = "D:\\Workspaces\\Gitops\\rsa512-public.pem" + publicKeyFile = "/home/janis/Workspaces/IntelliJ/KnockOutWhist/Gitops/rsa512-public.pem" publicKeyPem = ${?PUBLIC_KEY_PEM} } -- 2.52.0 From 992dd8f11cd429a169dfb28f0e3c7e8cfc18c704 Mon Sep 17 00:00:00 2001 From: Janis Date: Thu, 30 Oct 2025 10:46:19 +0100 Subject: [PATCH 10/16] feat(user-sessions): remove debug print statement from login_Post method --- knockoutwhistweb/app/controllers/UserController.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index 8b32c2e..ce31739 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -62,7 +62,6 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v def login_Post(): Action[AnyContent] = { Action { implicit request => val postData = request.body.asFormUrlEncoded - println(request.body.asText) if (postData.isDefined) { // Extract username and password from form data val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") -- 2.52.0 From aeeb8c13e980ed9be1e204162cc51f45d2bc6899 Mon Sep 17 00:00:00 2001 From: Janis Date: Fri, 31 Oct 2025 17:37:58 +0100 Subject: [PATCH 11/16] feat(user-sessions): add custom exceptions for game logic and enhance user session interaction --- .../app/exceptions/CantPlayCardException.java | 7 ++ .../app/exceptions/GameException.java | 7 ++ .../app/exceptions/NotHostException.java | 2 +- .../exceptions/NotInThisGameException.java | 2 +- .../exceptions/NotInteractableException.java | 2 +- .../app/logic/game/GameLobby.scala | 110 ++++++++++++++++-- .../app/model/sessions/InteractionType.scala | 10 ++ .../app/model/sessions/UserSession.scala | 11 +- 8 files changed, 134 insertions(+), 17 deletions(-) create mode 100644 knockoutwhistweb/app/exceptions/CantPlayCardException.java create mode 100644 knockoutwhistweb/app/exceptions/GameException.java create mode 100644 knockoutwhistweb/app/model/sessions/InteractionType.scala diff --git a/knockoutwhistweb/app/exceptions/CantPlayCardException.java b/knockoutwhistweb/app/exceptions/CantPlayCardException.java new file mode 100644 index 0000000..a313c07 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/CantPlayCardException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class CantPlayCardException extends GameException { + public CantPlayCardException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/GameException.java b/knockoutwhistweb/app/exceptions/GameException.java new file mode 100644 index 0000000..5e001f2 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameException.java @@ -0,0 +1,7 @@ +package exceptions; + +public abstract class GameException extends RuntimeException { + public GameException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/exceptions/NotHostException.java b/knockoutwhistweb/app/exceptions/NotHostException.java index 490d619..423bbfd 100644 --- a/knockoutwhistweb/app/exceptions/NotHostException.java +++ b/knockoutwhistweb/app/exceptions/NotHostException.java @@ -1,6 +1,6 @@ package exceptions; -public class NotHostException extends RuntimeException { +public class NotHostException extends GameException { public NotHostException(String message) { super(message); } diff --git a/knockoutwhistweb/app/exceptions/NotInThisGameException.java b/knockoutwhistweb/app/exceptions/NotInThisGameException.java index e33b706..fad03ed 100644 --- a/knockoutwhistweb/app/exceptions/NotInThisGameException.java +++ b/knockoutwhistweb/app/exceptions/NotInThisGameException.java @@ -1,6 +1,6 @@ package exceptions; -public class NotInThisGameException extends RuntimeException { +public class NotInThisGameException extends GameException { public NotInThisGameException(String message) { super(message); } diff --git a/knockoutwhistweb/app/exceptions/NotInteractableException.java b/knockoutwhistweb/app/exceptions/NotInteractableException.java index e4642c9..4c71ac7 100644 --- a/knockoutwhistweb/app/exceptions/NotInteractableException.java +++ b/knockoutwhistweb/app/exceptions/NotInteractableException.java @@ -1,6 +1,6 @@ package exceptions; -public class NotInteractableException extends RuntimeException { +public class NotInteractableException extends GameException { public NotInteractableException(String message) { super(message); } diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 6f20ae9..a22392d 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -1,13 +1,15 @@ package logic.game +import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil, RoundUtil} import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} -import de.knockoutwhist.rounds.Match +import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} -import exceptions.{NotHostException, NotInThisGameException, NotInteractableException} -import model.sessions.UserSession +import exceptions.{CantPlayCardException, NotHostException, NotInThisGameException, NotInteractableException} +import model.sessions.{InteractionType, UserSession} import model.users.User import java.util.UUID @@ -44,20 +46,92 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends logic.controlMatch() } - def playCard(user: User, card: Int): Unit = { - val sessionOpt = users.get(user.id) - if (sessionOpt.isEmpty) { - throw new NotInThisGameException("You are not in this game!") + /** + * Play a card from the player's hand. + * @param userSession the user session of the player. + * @param cardIndex the index of the card in the player's hand. + */ + def playCard(userSession: UserSession, cardIndex: Int): Unit = { + val player = getPlayer(userSession, InteractionType.Card) + if (player.isInDogLife) { + throw new CantPlayCardException("You are in dog life!") } - if (!sessionOpt.get.canInteract) { - throw new NotInteractableException("You can't play a card!") + val hand = getHand(player) + val card = hand.cards(cardIndex) + if (!PlayerUtil.canPlayCard(card, getRound, getTrick, player)) { + throw new CantPlayCardException("You can't play this card!") } - + logic.playerInputLogic.receivedCard(card) + } + + /** + * 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 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 = { + val player = getPlayer(userSession, InteractionType.DogCard) + if (!player.isInDogLife) { + throw new CantPlayCardException("You are not in dog life!") + } + if (cardIndex == -1) { + if (!MatchUtil.dogNeedsToPlay(getMatch, getRound)) { + throw new CantPlayCardException("You can't skip this round!") + } + logic.playerInputLogic.receivedDog(None) + } + val hand = getHand(player) + val card = hand.cards(cardIndex) + logic.playerInputLogic.receivedDog(Some(card)) + } + + /** + * Select the trump suit for the round. + * @param userSession the user session of the player. + * @param trumpIndex the index of the trump suit. + */ + def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { + val player = getPlayer(userSession, InteractionType.TrumpSuit) + val trumpSuits = Suit.values.toList + val selectedTrump = trumpSuits(trumpIndex) + logic.playerInputLogic.receivedTrumpSuit(selectedTrump) + } + + /** + * + * @param userSession + * @param tieNumber + */ + def selectTie(userSession: UserSession, tieNumber: Int): Unit = { + val player = getPlayer(userSession, InteractionType.TieChoice) + logic.playerTieLogic.receivedTieBreakerCard(tieNumber) } //------------------- + private def getPlayer(userSession: UserSession, iType: InteractionType): AbstractPlayer = { + if (!Thread.holdsLock(userSession.lock)) { + 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!") + } + val playerOption = getMatch.totalplayers.find(_.id == userSession.id) + if (playerOption.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + playerOption.get + } + + 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) { @@ -65,5 +139,21 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends } 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 + } } diff --git a/knockoutwhistweb/app/model/sessions/InteractionType.scala b/knockoutwhistweb/app/model/sessions/InteractionType.scala new file mode 100644 index 0000000..e265edb --- /dev/null +++ b/knockoutwhistweb/app/model/sessions/InteractionType.scala @@ -0,0 +1,10 @@ +package model.sessions + +enum InteractionType { + + case TrumpSuit + case Card + case DogCard + case TieChoice + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/model/sessions/UserSession.scala b/knockoutwhistweb/app/model/sessions/UserSession.scala index ccfd172..df45464 100644 --- a/knockoutwhistweb/app/model/sessions/UserSession.scala +++ b/knockoutwhistweb/app/model/sessions/UserSession.scala @@ -5,18 +5,21 @@ import de.knockoutwhist.utils.events.SimpleEvent import model.users.User import java.util.UUID +import java.util.concurrent.locks.{Lock, ReentrantLock} class UserSession(user: User, val host: Boolean) extends PlayerSession { - var canInteract: Boolean = false + var canInteract: Option[InteractionType] = None + val lock: Lock = ReentrantLock() override def updatePlayer(event: SimpleEvent): Unit = { event match { case event: RequestTrumpSuitEvent => - canInteract = true + canInteract = Some(InteractionType.TrumpSuit) case event: RequestTieChoiceEvent => - canInteract = true + canInteract = Some(InteractionType.TieChoice) case event: RequestCardEvent => - canInteract = true + if (event.player.isInDogLife) canInteract = Some(InteractionType.DogCard) + else canInteract = Some(InteractionType.Card) case _ => } } -- 2.52.0 From 8df3491757fd241ff0542023828ddbcba71fa29b Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 1 Nov 2025 10:30:37 +0100 Subject: [PATCH 12/16] feat(user-sessions): enhance game lobby management with user session handling and game full exception --- knockoutwhist | 2 +- .../app/controllers/HomeController.scala | 6 +- .../app/controllers/UserController.scala | 2 +- .../app/exceptions/GameFullException.java | 7 ++ .../app/logic/PodGameManager.scala | 37 -------- knockoutwhistweb/app/logic/PodManager.scala | 48 ++++++++++ .../app/logic/game/GameLobby.scala | 88 +++++++++++++++++-- 7 files changed, 140 insertions(+), 50 deletions(-) create mode 100644 knockoutwhistweb/app/exceptions/GameFullException.java delete mode 100644 knockoutwhistweb/app/logic/PodGameManager.scala create mode 100644 knockoutwhistweb/app/logic/PodManager.scala diff --git a/knockoutwhist b/knockoutwhist index fbc0ea2..e0e45c4 160000 --- a/knockoutwhist +++ b/knockoutwhist @@ -1 +1 @@ -Subproject commit fbc0ea2277596e2a2d29125b5f9a84213336dc18 +Subproject commit e0e45c4b431fff6740e38a59906f5e217fcd801f diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala index b8435f6..a84e529 100644 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ b/knockoutwhistweb/app/controllers/HomeController.scala @@ -6,7 +6,7 @@ import de.knockoutwhist.components.Configuration import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule -import logic.PodGameManager +import logic.PodManager import model.sessions.SimpleSession import play.api.mvc.* import play.api.* @@ -44,12 +44,12 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents) e } def rules(): Action[AnyContent] = { Action { implicit request => - Ok(views.html.rules.apply()) + Ok(views.html.rules()) } } def sessions(): Action[AnyContent] = { Action { implicit request => - Ok(views.html.sessions.apply(PodGameManager.listSessions())) + Ok(views.html.rules()) } } diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index ce31739..1c7352c 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -6,7 +6,7 @@ import de.knockoutwhist.components.Configuration import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule -import logic.PodGameManager +import logic.PodManager import logic.user.{SessionManager, UserManager} import model.sessions.SimpleSession import play.api.* diff --git a/knockoutwhistweb/app/exceptions/GameFullException.java b/knockoutwhistweb/app/exceptions/GameFullException.java new file mode 100644 index 0000000..f0db380 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/GameFullException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class GameFullException extends GameException { + public GameFullException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/logic/PodGameManager.scala b/knockoutwhistweb/app/logic/PodGameManager.scala deleted file mode 100644 index 891e046..0000000 --- a/knockoutwhistweb/app/logic/PodGameManager.scala +++ /dev/null @@ -1,37 +0,0 @@ -package logic - -import de.knockoutwhist.utils.events.SimpleEvent -import model.sessions.PlayerSession - -import java.util.UUID -import scala.collection.mutable - -object PodGameManager { - - private val sessions: mutable.Map[UUID, PlayerSession] = mutable.Map() - - def addSession(session: PlayerSession): Unit = { - sessions.put(session.id, session) - } - - def clearSessions(): Unit = { - sessions.clear() - } - - def identify(id: UUID): Option[PlayerSession] = { - sessions.get(id) - } - - def transmit(id: UUID, event: SimpleEvent): Unit = { - identify(id).foreach(_.updatePlayer(event)) - } - - def transmitAll(event: SimpleEvent): Unit = { - sessions.foreach(session => session._2.updatePlayer(event)) - } - - def listSessions(): List[PlayerSession] = { - sessions.values.toList - } - -} diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala new file mode 100644 index 0000000..ebf046d --- /dev/null +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -0,0 +1,48 @@ +package logic + +import com.google.inject.{Guice, Injector} +import de.knockoutwhist.components.Configuration +import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic +import di.KnockOutWebConfigurationModule +import logic.game.GameLobby +import model.users.User + +import javax.inject.Singleton +import scala.collection.mutable + +@Singleton +class PodManager { + + val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds + val podIp: String = System.getenv("POD_IP") + val podName: String = System.getenv("POD_NAME") + + private val sessions: mutable.Map[String, GameLobby] = mutable.Map() + private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) + + def createGame( + host: User, + name: String, + maxPlayers: Int + ): GameLobby = { + val gameLobby = GameLobby( + logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), + id = java.util.UUID.randomUUID().toString, + internalId = java.util.UUID.randomUUID(), + name = name, + maxPlayers = maxPlayers, + host = host + ) + sessions += (gameLobby.id -> gameLobby) + gameLobby + } + + def getGame(gameId: String): Option[GameLobby] = { + sessions.get(gameId) + } + + private[logic] def removeGame(gameId: String): Unit = { + sessions.remove(gameId) + } + +} diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index a22392d..1549f8c 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -2,34 +2,62 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic +import de.knockoutwhist.control.GameState.Lobby import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil, RoundUtil} +import de.knockoutwhist.events.global.SessionClosed import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} -import exceptions.{CantPlayCardException, NotHostException, NotInThisGameException, NotInteractableException} +import exceptions.{CantPlayCardException, GameFullException, NotHostException, NotInThisGameException, NotInteractableException} import model.sessions.{InteractionType, UserSession} import model.users.User import java.util.UUID +import scala.collection.mutable import scala.collection.mutable.ListBuffer -class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends EventListener{ +class GameLobby private( + val logic: GameLogic, + val id: String, + val internalId: UUID, + val name: String, + val maxPlayers: Int + ) extends EventListener { logic.addListener(this) logic.createSession() - val users: Map[UUID, UserSession] = Map() + private val users: mutable.Map[UUID, UserSession] = mutable.Map() + + def addUser(user: User): UserSession = { + if (users.size >= maxPlayers) throw new GameFullException("The game is full!") + if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!") + if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") + val userSession = new UserSession( + user = user, + host = false + ) + users += (user.id -> userSession) + userSession + } override def listen(event: SimpleEvent): Unit = { event match { case event: PlayerEvent => users.get(event.playerId).foreach(session => session.updatePlayer(event)) + case event: SessionClosed => + users.values.foreach(session => session.updatePlayer(event)) + case event: SimpleEvent => users.values.foreach(session => session.updatePlayer(event)) } } + /** + * Start the game if the user is the host. + * @param user the user who wants to start the game. + */ def startGame(user: User): Unit = { val sessionOpt = users.get(user.id) if (sessionOpt.isEmpty) { @@ -46,13 +74,25 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends logic.controlMatch() } + /** + * Remove the user from the game lobby. + * @param user the user who wants to leave the game. + */ + def leaveGame(user: User): Unit = { + val sessionOpt = users.get(user.id) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + users.remove(user.id) + } + /** * Play a card from the player's hand. * @param userSession the user session of the player. * @param cardIndex the index of the card in the player's hand. */ def playCard(userSession: UserSession, cardIndex: Int): Unit = { - val player = getPlayer(userSession, InteractionType.Card) + val player = getPlayerInteractable(userSession, InteractionType.Card) if (player.isInDogLife) { throw new CantPlayCardException("You are in dog life!") } @@ -70,7 +110,7 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends * @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 = { - val player = getPlayer(userSession, InteractionType.DogCard) + val player = getPlayerInteractable(userSession, InteractionType.DogCard) if (!player.isInDogLife) { throw new CantPlayCardException("You are not in dog life!") } @@ -91,7 +131,7 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends * @param trumpIndex the index of the trump suit. */ def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { - val player = getPlayer(userSession, InteractionType.TrumpSuit) + val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) val trumpSuits = Suit.values.toList val selectedTrump = trumpSuits(trumpIndex) logic.playerInputLogic.receivedTrumpSuit(selectedTrump) @@ -103,14 +143,22 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends * @param tieNumber */ def selectTie(userSession: UserSession, tieNumber: Int): Unit = { - val player = getPlayer(userSession, InteractionType.TieChoice) + val player = getPlayerInteractable(userSession, InteractionType.TieChoice) logic.playerTieLogic.receivedTieBreakerCard(tieNumber) } //------------------- - private def getPlayer(userSession: UserSession, iType: InteractionType): AbstractPlayer = { + private def getUserSession(userId: UUID): UserSession = { + val sessionOpt = users.get(userId) + if (sessionOpt.isEmpty) { + throw new NotInThisGameException("You are not in this game!") + } + sessionOpt.get + } + + private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = { if (!Thread.holdsLock(userSession.lock)) { throw new IllegalStateException("The user session is not locked!") } @@ -157,3 +205,27 @@ class GameLobby(val logic: GameLogic, val id: String, internalId: UUID) extends } } + +object GameLobby { + def apply( + logic: GameLogic, + id: String, + internalId: UUID, + name: String, + maxPlayers: Int, + host: User + ): GameLobby = { + val lobby = new GameLobby( + logic = logic, + id = id, + internalId = internalId, + name = name, + maxPlayers = maxPlayers + ) + lobby.users += (host.id -> new UserSession( + user = host, + host = true + )) + lobby + } +} -- 2.52.0 From b604e6935f461a2c4d06b2a5da4f45cfcabfd982 Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 1 Nov 2025 11:44:55 +0100 Subject: [PATCH 13/16] chore: rebase --- knockoutwhistweb/app/auth/Auth.scala | 39 ++++++++ .../app/controllers/HomeController.scala | 89 ------------------- .../app/controllers/IngameController.scala | 75 ++++++++++++++++ .../app/controllers/MainMenuController.scala | 35 ++++++++ .../app/controllers/UserController.scala | 56 ++++-------- .../app/logic/game/GameLobby.scala | 4 +- .../logic/user/impl/BaseSessionManager.scala | 2 +- .../app/services/JwtKeyProvider.scala | 10 +-- .../app/views/sessions.scala.html | 13 --- knockoutwhistweb/conf/application.conf | 4 +- knockoutwhistweb/conf/routes | 18 ++-- 11 files changed, 185 insertions(+), 160 deletions(-) create mode 100644 knockoutwhistweb/app/auth/Auth.scala delete mode 100644 knockoutwhistweb/app/controllers/HomeController.scala create mode 100644 knockoutwhistweb/app/controllers/IngameController.scala create mode 100644 knockoutwhistweb/app/controllers/MainMenuController.scala delete mode 100644 knockoutwhistweb/app/views/sessions.scala.html diff --git a/knockoutwhistweb/app/auth/Auth.scala b/knockoutwhistweb/app/auth/Auth.scala new file mode 100644 index 0000000..8ec161e --- /dev/null +++ b/knockoutwhistweb/app/auth/Auth.scala @@ -0,0 +1,39 @@ +package auth + +import controllers.routes +import logic.user.SessionManager +import model.users.User +import play.api.mvc.* + +import javax.inject.Inject +import scala.concurrent.{ExecutionContext, Future} + +class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request) + +class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyParsers.Default)(implicit ec: ExecutionContext) + extends ActionBuilder[AuthenticatedRequest, AnyContent] { + + override def executionContext: ExecutionContext = ec + + // This simulates checking if a user is logged in (e.g. via session) + private def getUserFromSession(request: RequestHeader): Option[User] = { + val session = request.cookies.get("sessionId") + if (session.isDefined) + return sessionManager.getUserBySession(session.get.value) + None + } + + // Transform a normal request into an AuthenticatedRequest + override def invokeBlock[A]( + request: Request[A], + block: AuthenticatedRequest[A] => Future[Result] + ): Future[Result] = { + getUserFromSession(request) match { + case Some(user) => + block(new AuthenticatedRequest(user, request)) + case None => + Future.successful(Results.Redirect(routes.UserController.login())) + } + } +} + diff --git a/knockoutwhistweb/app/controllers/HomeController.scala b/knockoutwhistweb/app/controllers/HomeController.scala deleted file mode 100644 index a84e529..0000000 --- a/knockoutwhistweb/app/controllers/HomeController.scala +++ /dev/null @@ -1,89 +0,0 @@ -package controllers - -import com.google.inject.{Guice, Injector} -import de.knockoutwhist.KnockOutWhist -import de.knockoutwhist.components.Configuration -import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import di.KnockOutWebConfigurationModule -import logic.PodManager -import model.sessions.SimpleSession -import play.api.mvc.* -import play.api.* -import play.twirl.api.Html - -import java.util.UUID -import javax.inject.* - - -/** - * This controller creates an `Action` to handle HTTP requests to the - * application's home page. - */ -@Singleton -class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController { - - private var initial = false - private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) - - /** - * Create an Action to render an HTML page. - * - * The configuration in the `routes` file means that this method - * will be called when the application receives a `GET` request with - * a path of `/`. - */ - def index(): Action[AnyContent] = { - if (!initial) { - initial = true - KnockOutWhist.entry(injector.getInstance(classOf[Configuration])) - } - Action { implicit request => - Redirect("/sessions") - } - } - def rules(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.rules()) - } - } - def sessions(): Action[AnyContent] = { - Action { implicit request => - Ok(views.html.rules()) - } - } - - def ingame(id: String): Action[AnyContent] = { - val uuid: UUID = UUID.fromString(id) - Action { implicit request => - NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) - } -// if (PodGameManager.identify(uuid).isEmpty) { -// return Action { implicit request => -// NotFound(views.html.tui.apply(List(Html(s"

Session with id $id not found!

")))) -// } -// } else { -// val session = PodGameManager.identify(uuid).get -// val player = session.asInstanceOf[SimpleSession].player -// val logic = BaseGameLogic(null) -// if (logic.getCurrentState == Lobby) { -// -// } else if (logic.getCurrentState == InGame) { -// return Action { implicit request => -// Ok(views.html.ingame.apply(player, logic)) -// } -// } else if (logic.getCurrentState == SelectTrump) { -// return Action { implicit request => -// Ok(views.html.selecttrump.apply(player, logic)) -// } -// } else if (logic.getCurrentState == TieBreak) { -// return Action { implicit request => -// Ok(views.html.tie.apply(player, logic)) -// } -// } -// } -// Action { implicit request => -// InternalServerError("Oops") -// } - } -} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala new file mode 100644 index 0000000..ccf12c2 --- /dev/null +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -0,0 +1,75 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import logic.user.{SessionManager, UserManager} +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class IngameController @Inject()( + val controllerComponents: ControllerComponents, + val sessionManager: SessionManager, + val userManager: UserManager, + val authAction: AuthAction + ) extends BaseController { + + // Pass the request-handling function directly to authAction (no nested Action) + def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Ok("Main Menu for user: " + request.user.name) + } + + def login(): Action[AnyContent] = { + Action { implicit request => + val session = request.cookies.get("sessionId") + if (session.isDefined) { + val possibleUser = sessionManager.getUserBySession(session.get.value) + if (possibleUser.isDefined) { + Redirect("/mainmenu") + } else { + Ok(views.html.login()) + } + } else { + Ok(views.html.login()) + } + } + } + + def login_Post(): Action[AnyContent] = { + Action { implicit request => + val postData = request.body.asFormUrlEncoded + if (postData.isDefined) { + // Extract username and password from form data + val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") + val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") + val possibleUser = userManager.authenticate(username, password) + if (possibleUser.isDefined) { + Redirect("/mainmenu").withCookies( + Cookie("sessionId", sessionManager.createSession(possibleUser.get)) + ) + } else { + println("Failed login attempt for user: " + username) + Unauthorized("Invalid username or password") + } + } else { + BadRequest("Invalid form submission") + } + } + } + + // Pass the request-handling function directly to authAction (no nested Action) + def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val sessionCookie = request.cookies.get("sessionId") + if (sessionCookie.isDefined) { + sessionManager.invalidateSession(sessionCookie.get.value) + } + NoContent.discardingCookies(DiscardingCookie("sessionId")) + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala new file mode 100644 index 0000000..5d85e17 --- /dev/null +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -0,0 +1,35 @@ +package controllers + +import auth.{AuthAction, AuthenticatedRequest} +import play.api.* +import play.api.mvc.* + +import javax.inject.* + + +/** + * This controller creates an `Action` to handle HTTP requests to the + * application's home page. + */ +@Singleton +class MainMenuController @Inject()( + val controllerComponents: ControllerComponents, + val authAction: AuthAction + ) extends BaseController { + + // Pass the request-handling function directly to authAction (no nested Action) + def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Ok("Main Menu for user: " + request.user.name) + } + + def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + Redirect("/mainmenu") + } + + def rules(): Action[AnyContent] = { + Action { implicit request => + Ok(views.html.rules()) + } + } + +} \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index 1c7352c..e10bffd 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -1,19 +1,10 @@ package controllers -import com.google.inject.{Guice, Injector} -import de.knockoutwhist.KnockOutWhist -import de.knockoutwhist.components.Configuration -import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} -import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic -import di.KnockOutWebConfigurationModule -import logic.PodManager +import auth.{AuthAction, AuthenticatedRequest} import logic.user.{SessionManager, UserManager} -import model.sessions.SimpleSession import play.api.* import play.api.mvc.* -import play.twirl.api.Html -import java.util.UUID import javax.inject.* @@ -22,25 +13,12 @@ import javax.inject.* * application's home page. */ @Singleton -class UserController @Inject()(val controllerComponents: ControllerComponents, val sessionManager: SessionManager, val userManager: UserManager) extends BaseController { - - def mainMenu() : Action[AnyContent] = { - Action { implicit request => - val session = request.cookies.get("sessionId") - if (session.isDefined) { - val possibleUser = sessionManager.getUserBySession(session.get.value) - if (possibleUser.isDefined) { - Ok("Main Menu for user: " + possibleUser.get.name) - } else - { - println("Invalid session, redirecting to login") - Redirect("/login") - } - } else { - Redirect("/login") - } - } - } +class UserController @Inject()( + val controllerComponents: ControllerComponents, + val sessionManager: SessionManager, + val userManager: UserManager, + val authAction: AuthAction + ) extends BaseController { def login(): Action[AnyContent] = { Action { implicit request => @@ -48,9 +26,8 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v if (session.isDefined) { val possibleUser = sessionManager.getUserBySession(session.get.value) if (possibleUser.isDefined) { - Redirect("/mainmenu") - } else - { + Redirect(routes.MainMenuController.mainMenu()) + } else { Ok(views.html.login()) } } else { @@ -68,7 +45,7 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") val possibleUser = userManager.authenticate(username, password) if (possibleUser.isDefined) { - Redirect("/mainmenu").withCookies( + Redirect(routes.MainMenuController.mainMenu()).withCookies( Cookie("sessionId", sessionManager.createSession(possibleUser.get)) ) } else { @@ -81,14 +58,13 @@ class UserController @Inject()(val controllerComponents: ControllerComponents, v } } - def logout(): Action[AnyContent] = { - Action { implicit request => - val sessionCookie = request.cookies.get("sessionId") - if (sessionCookie.isDefined) { - sessionManager.invalidateSession(sessionCookie.get.value) - } - NoContent.discardingCookies(DiscardingCookie("sessionId")) + // Pass the request-handling function directly to authAction (no nested Action) + def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val sessionCookie = request.cookies.get("sessionId") + if (sessionCookie.isDefined) { + sessionManager.invalidateSession(sessionCookie.get.value) } + NoContent.discardingCookies(DiscardingCookie("sessionId")) } } \ No newline at end of file diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 1549f8c..53e5560 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -3,14 +3,14 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic import de.knockoutwhist.control.GameState.Lobby -import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil, RoundUtil} +import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} import de.knockoutwhist.events.global.SessionClosed import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} -import exceptions.{CantPlayCardException, GameFullException, NotHostException, NotInThisGameException, NotInteractableException} +import exceptions.* import model.sessions.{InteractionType, UserSession} import model.users.User diff --git a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala index 88e046c..1efe06c 100644 --- a/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala +++ b/knockoutwhistweb/app/logic/user/impl/BaseSessionManager.scala @@ -1,7 +1,7 @@ package logic.user.impl -import com.auth0.jwt.{JWT, JWTVerifier} import com.auth0.jwt.algorithms.Algorithm +import com.auth0.jwt.{JWT, JWTVerifier} import com.github.benmanes.caffeine.cache.{Cache, Caffeine} import com.typesafe.config.Config import logic.user.SessionManager diff --git a/knockoutwhistweb/app/services/JwtKeyProvider.scala b/knockoutwhistweb/app/services/JwtKeyProvider.scala index 7e87c0d..f1fb46f 100644 --- a/knockoutwhistweb/app/services/JwtKeyProvider.scala +++ b/knockoutwhistweb/app/services/JwtKeyProvider.scala @@ -1,13 +1,13 @@ package services -import java.nio.file.{Files, Paths} -import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey} -import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec} -import java.util.Base64 -import javax.inject.* import play.api.Configuration +import java.nio.file.{Files, Paths} import java.security.interfaces.{RSAPrivateKey, RSAPublicKey} +import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec} +import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey} +import java.util.Base64 +import javax.inject.* @Singleton class JwtKeyProvider @Inject()(config: Configuration) { diff --git a/knockoutwhistweb/app/views/sessions.scala.html b/knockoutwhistweb/app/views/sessions.scala.html deleted file mode 100644 index d400832..0000000 --- a/knockoutwhistweb/app/views/sessions.scala.html +++ /dev/null @@ -1,13 +0,0 @@ -@import model.sessions.PlayerSession -@(sessions: List[PlayerSession]) - -@main("Sessions") { -
-

Knockout Whist sessions

-

Please select your session to jump inside the game!

- @for(session <- sessions) { - @session.name
- } -
-} - diff --git a/knockoutwhistweb/conf/application.conf b/knockoutwhistweb/conf/application.conf index c9d8a3d..d6d372a 100644 --- a/knockoutwhistweb/conf/application.conf +++ b/knockoutwhistweb/conf/application.conf @@ -6,9 +6,9 @@ auth { issuer = "knockoutwhistweb" audience = "ui" # ${?PUBLIC_KEY_FILE} - privateKeyFile = "/home/janis/Workspaces/IntelliJ/KnockOutWhist/Gitops/rsa512-private.pem" + privateKeyFile = "D:\\Workspaces\\Gitops\\rsa512-private.pem" privateKeyPem = ${?PUBLIC_KEY_PEM} #${?PUBLIC_KEY_FILE} - publicKeyFile = "/home/janis/Workspaces/IntelliJ/KnockOutWhist/Gitops/rsa512-public.pem" + publicKeyFile = "D:\\Workspaces\\Gitops\\rsa512-public.pem" publicKeyPem = ${?PUBLIC_KEY_PEM} } diff --git a/knockoutwhistweb/conf/routes b/knockoutwhistweb/conf/routes index d0b13eb..fa535ee 100644 --- a/knockoutwhistweb/conf/routes +++ b/knockoutwhistweb/conf/routes @@ -3,17 +3,19 @@ # https://www.playframework.com/documentation/latest/ScalaRouting # ~~~~ -# An example controller showing a sample home page -GET / controllers.HomeController.index() -GET /sessions controllers.HomeController.sessions() -GET /ingame/:id controllers.HomeController.ingame(id: String) -# Map static resources from the /public folder to the /assets URL path +# Primary routes +GET / controllers.MainMenuController.index() GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) -GET /rules controllers.HomeController.rules() +# Main menu routes +GET /mainmenu controllers.MainMenuController.mainMenu() +GET /rules controllers.MainMenuController.rules() -GET /mainmenu controllers.UserController.mainMenu() +# User authentication routes GET /login controllers.UserController.login() POST /login controllers.UserController.login_Post() -GET /logout controllers.UserController.logout() \ No newline at end of file +GET /logout controllers.UserController.logout() + +# In-game routes +# GET /ingame/:id controllers.MainMenuController.ingame(id: String) \ No newline at end of file -- 2.52.0 From 021cceaa5d4e712eab610377054e52b335926bd0 Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 1 Nov 2025 11:51:36 +0100 Subject: [PATCH 14/16] feat(user-sessions): refactor card rendering and remove unused login methods --- .../app/controllers/IngameController.scala | 52 +------------------ knockoutwhistweb/app/util/WebUIUtils.scala | 2 +- knockoutwhistweb/app/views/index.scala.html | 3 -- .../app/views/{ => ingame}/ingame.scala.html | 2 +- .../views/{ => ingame}/selecttrump.scala.html | 0 .../app/views/{ => ingame}/tie.scala.html | 0 .../views/{output => render}/card.scala.html | 0 .../views/{output => render}/text.scala.html | 0 knockoutwhistweb/app/views/tui.scala.html | 10 ---- 9 files changed, 3 insertions(+), 66 deletions(-) delete mode 100644 knockoutwhistweb/app/views/index.scala.html rename knockoutwhistweb/app/views/{ => ingame}/ingame.scala.html (93%) rename knockoutwhistweb/app/views/{ => ingame}/selecttrump.scala.html (100%) rename knockoutwhistweb/app/views/{ => ingame}/tie.scala.html (100%) rename knockoutwhistweb/app/views/{output => render}/card.scala.html (100%) rename knockoutwhistweb/app/views/{output => render}/text.scala.html (100%) delete mode 100644 knockoutwhistweb/app/views/tui.scala.html diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index ccf12c2..02544ee 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -15,61 +15,11 @@ import javax.inject.* @Singleton class IngameController @Inject()( val controllerComponents: ControllerComponents, - val sessionManager: SessionManager, - val userManager: UserManager, val authAction: AuthAction ) extends BaseController { - - // Pass the request-handling function directly to authAction (no nested Action) + def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => Ok("Main Menu for user: " + request.user.name) } - def login(): Action[AnyContent] = { - Action { implicit request => - val session = request.cookies.get("sessionId") - if (session.isDefined) { - val possibleUser = sessionManager.getUserBySession(session.get.value) - if (possibleUser.isDefined) { - Redirect("/mainmenu") - } else { - Ok(views.html.login()) - } - } else { - Ok(views.html.login()) - } - } - } - - def login_Post(): Action[AnyContent] = { - Action { implicit request => - val postData = request.body.asFormUrlEncoded - if (postData.isDefined) { - // Extract username and password from form data - val username = postData.get.get("username").flatMap(_.headOption).getOrElse("") - val password = postData.get.get("password").flatMap(_.headOption).getOrElse("") - val possibleUser = userManager.authenticate(username, password) - if (possibleUser.isDefined) { - Redirect("/mainmenu").withCookies( - Cookie("sessionId", sessionManager.createSession(possibleUser.get)) - ) - } else { - println("Failed login attempt for user: " + username) - Unauthorized("Invalid username or password") - } - } else { - BadRequest("Invalid form submission") - } - } - } - - // Pass the request-handling function directly to authAction (no nested Action) - def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => - val sessionCookie = request.cookies.get("sessionId") - if (sessionCookie.isDefined) { - sessionManager.invalidateSession(sessionCookie.get.value) - } - NoContent.discardingCookies(DiscardingCookie("sessionId")) - } - } \ No newline at end of file diff --git a/knockoutwhistweb/app/util/WebUIUtils.scala b/knockoutwhistweb/app/util/WebUIUtils.scala index 84e0558..b905462 100644 --- a/knockoutwhistweb/app/util/WebUIUtils.scala +++ b/knockoutwhistweb/app/util/WebUIUtils.scala @@ -29,6 +29,6 @@ object WebUIUtils { case Three => "3" case Two => "2" } - views.html.output.card.apply(f"images/cards/$cv$s.png")(card.toString) + views.html.render.card.apply(f"images/cards/$cv$s.png")(card.toString) } } diff --git a/knockoutwhistweb/app/views/index.scala.html b/knockoutwhistweb/app/views/index.scala.html deleted file mode 100644 index d4fdd74..0000000 --- a/knockoutwhistweb/app/views/index.scala.html +++ /dev/null @@ -1,3 +0,0 @@ -@main("Welcome to Play") { -

Welcome to Play!

-} diff --git a/knockoutwhistweb/app/views/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html similarity index 93% rename from knockoutwhistweb/app/views/ingame.scala.html rename to knockoutwhistweb/app/views/ingame/ingame.scala.html index 23eaf99..7ce7c26 100644 --- a/knockoutwhistweb/app/views/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -17,7 +17,7 @@ @if(logic.getCurrentTrick.get.firstCard.isDefined) { @util.WebUIUtils.cardtoImage(logic.getCurrentTrick.get.firstCard.get) } else { - @views.html.output.card.apply("images/cards/1B.png")("Blank Card") + @views.html.render.card.apply("../../../public/images/cards/1B.png")("Blank Card") }
diff --git a/knockoutwhistweb/app/views/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html similarity index 100% rename from knockoutwhistweb/app/views/selecttrump.scala.html rename to knockoutwhistweb/app/views/ingame/selecttrump.scala.html diff --git a/knockoutwhistweb/app/views/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html similarity index 100% rename from knockoutwhistweb/app/views/tie.scala.html rename to knockoutwhistweb/app/views/ingame/tie.scala.html diff --git a/knockoutwhistweb/app/views/output/card.scala.html b/knockoutwhistweb/app/views/render/card.scala.html similarity index 100% rename from knockoutwhistweb/app/views/output/card.scala.html rename to knockoutwhistweb/app/views/render/card.scala.html diff --git a/knockoutwhistweb/app/views/output/text.scala.html b/knockoutwhistweb/app/views/render/text.scala.html similarity index 100% rename from knockoutwhistweb/app/views/output/text.scala.html rename to knockoutwhistweb/app/views/render/text.scala.html diff --git a/knockoutwhistweb/app/views/tui.scala.html b/knockoutwhistweb/app/views/tui.scala.html deleted file mode 100644 index 8d8cc04..0000000 --- a/knockoutwhistweb/app/views/tui.scala.html +++ /dev/null @@ -1,10 +0,0 @@ -@(toRender: List[Html]) - -@main("Tui") { -
- @for(line <- toRender) { - @line - } -
-} - -- 2.52.0 From f32741d860d224c1fbb3709d292efa4bcf6bc91c Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 1 Nov 2025 13:38:51 +0100 Subject: [PATCH 15/16] chore(config): updated Bruno collection --- bruno/KnockOutWhist/Game/Create Game.bru | 16 ++++++++++++++++ bruno/KnockOutWhist/Game/Get Game.bru | 20 ++++++++++++++++++++ bruno/KnockOutWhist/Game/Start Game.bru | 20 ++++++++++++++++++++ bruno/KnockOutWhist/Game/folder.bru | 8 ++++++++ bruno/KnockOutWhist/collection.bru | 0 5 files changed, 64 insertions(+) create mode 100644 bruno/KnockOutWhist/Game/Create Game.bru create mode 100644 bruno/KnockOutWhist/Game/Get Game.bru create mode 100644 bruno/KnockOutWhist/Game/Start Game.bru create mode 100644 bruno/KnockOutWhist/Game/folder.bru create mode 100644 bruno/KnockOutWhist/collection.bru diff --git a/bruno/KnockOutWhist/Game/Create Game.bru b/bruno/KnockOutWhist/Game/Create Game.bru new file mode 100644 index 0000000..2402878 --- /dev/null +++ b/bruno/KnockOutWhist/Game/Create Game.bru @@ -0,0 +1,16 @@ +meta { + name: Create Game + type: http + seq: 1 +} + +post { + url: {{host}}/createGame + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Get Game.bru b/bruno/KnockOutWhist/Game/Get Game.bru new file mode 100644 index 0000000..6b6d6af --- /dev/null +++ b/bruno/KnockOutWhist/Game/Get Game.bru @@ -0,0 +1,20 @@ +meta { + name: Get Game + type: http + seq: 2 +} + +get { + url: {{host}}/game/:id + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/Start Game.bru b/bruno/KnockOutWhist/Game/Start Game.bru new file mode 100644 index 0000000..cc231bf --- /dev/null +++ b/bruno/KnockOutWhist/Game/Start Game.bru @@ -0,0 +1,20 @@ +meta { + name: Start Game + type: http + seq: 3 +} + +post { + url: {{host}}/game/:id/start + body: none + auth: inherit +} + +params:path { + id: uZDNZA +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/KnockOutWhist/Game/folder.bru b/bruno/KnockOutWhist/Game/folder.bru new file mode 100644 index 0000000..a279bdf --- /dev/null +++ b/bruno/KnockOutWhist/Game/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Game + seq: 3 +} + +auth { + mode: inherit +} diff --git a/bruno/KnockOutWhist/collection.bru b/bruno/KnockOutWhist/collection.bru new file mode 100644 index 0000000..e69de29 -- 2.52.0 From 3e6cbe7d2d9b79853e81a5be1b3874e7a81520da Mon Sep 17 00:00:00 2001 From: Janis Date: Sat, 1 Nov 2025 20:48:13 +0100 Subject: [PATCH 16/16] feat(user-sessions): implemented interactivity --- bruno/KnockOutWhist/Login.bru | 3 +- knockoutwhistweb/app/auth/Auth.scala | 6 +- .../app/controllers/IngameController.scala | 233 +++++++++++++++++- .../app/controllers/MainMenuController.scala | 24 +- .../app/controllers/UserController.scala | 4 +- .../exceptions/NotEnoughPlayersException.java | 7 + knockoutwhistweb/app/logic/PodManager.scala | 3 +- .../app/logic/game/GameLobby.scala | 33 ++- .../app/views/ingame/ingame.scala.html | 2 +- .../app/views/ingame/selecttrump.scala.html | 2 +- .../app/views/ingame/tie.scala.html | 2 +- .../app/views/{ => login}/login.scala.html | 2 +- .../app/views/{ => mainmenu}/rules.scala.html | 0 knockoutwhistweb/conf/routes | 11 +- 14 files changed, 296 insertions(+), 36 deletions(-) create mode 100644 knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java rename knockoutwhistweb/app/views/{ => login}/login.scala.html (95%) rename knockoutwhistweb/app/views/{ => mainmenu}/rules.scala.html (100%) diff --git a/bruno/KnockOutWhist/Login.bru b/bruno/KnockOutWhist/Login.bru index 4c266ed..2aecc28 100644 --- a/bruno/KnockOutWhist/Login.bru +++ b/bruno/KnockOutWhist/Login.bru @@ -1,7 +1,7 @@ meta { name: Login type: http - seq: 1 + seq: 2 } post { @@ -22,4 +22,5 @@ body:multipart-form { settings { encodeUrl: true + timeout: 0 } diff --git a/knockoutwhistweb/app/auth/Auth.scala b/knockoutwhistweb/app/auth/Auth.scala index 8ec161e..b956035 100644 --- a/knockoutwhistweb/app/auth/Auth.scala +++ b/knockoutwhistweb/app/auth/Auth.scala @@ -14,16 +14,14 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP extends ActionBuilder[AuthenticatedRequest, AnyContent] { override def executionContext: ExecutionContext = ec - - // This simulates checking if a user is logged in (e.g. via session) + private def getUserFromSession(request: RequestHeader): Option[User] = { val session = request.cookies.get("sessionId") if (session.isDefined) return sessionManager.getUserBySession(session.get.value) None } - - // Transform a normal request into an AuthenticatedRequest + override def invokeBlock[A]( request: Request[A], block: AuthenticatedRequest[A] => Future[Result] diff --git a/knockoutwhistweb/app/controllers/IngameController.scala b/knockoutwhistweb/app/controllers/IngameController.scala index 02544ee..ef588a8 100644 --- a/knockoutwhistweb/app/controllers/IngameController.scala +++ b/knockoutwhistweb/app/controllers/IngameController.scala @@ -1,11 +1,14 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} -import logic.user.{SessionManager, UserManager} +import de.knockoutwhist.control.GameState.{InGame, Lobby, SelectTrump, TieBreak} +import exceptions.{CantPlayCardException, GameFullException, NotEnoughPlayersException, NotHostException, NotInThisGameException} +import logic.PodManager import play.api.* import play.api.mvc.* import javax.inject.* +import scala.util.Try /** @@ -14,12 +17,228 @@ import javax.inject.* */ @Singleton class IngameController @Inject()( - val controllerComponents: ControllerComponents, - val authAction: AuthAction - ) extends BaseController { - - def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => - Ok("Main Menu for user: " + request.user.name) + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager + ) extends BaseController { + + def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + g.logic.getCurrentState match { + case Lobby => Ok("Lobby: " + gameId) + case InGame => + Ok(views.html.ingame.ingame( + g.getPlayerByUser(request.user), + g.logic + )) + case SelectTrump => + Ok(views.html.ingame.selecttrump( + g.getPlayerByUser(request.user), + g.logic + )) + case TieBreak => + Ok(views.html.ingame.tie( + g.getPlayerByUser(request.user), + g.logic + )) + case _ => + InternalServerError(s"Invalid game state for in-game view. GameId: $gameId" + s" State: ${g.logic.getCurrentState}") + } + case None => + NotFound("Game not found") + } + //NotFound(s"Reached end of game method unexpectedly. GameId: $gameId") + } + def startGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.startGame(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: NotHostException => + Forbidden(throwable.getMessage) + case _: NotEnoughPlayersException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + val result = Try { + game match { + case Some(g) => + g.addUser(request.user) + case None => + NotFound("Game not found") + } + } + if (result.isSuccess) { + Redirect(routes.IngameController.game(gameId)) + } else { + val throwable = result.failed.get + throwable match { + case _: GameFullException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + cardIdOpt match { + case Some(cardId) => + val result = Try { + g.playCard(g.getUserSession(request.user.id), cardId.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("cardId parameter is missing") + } + case None => + NotFound("Game not found") + } + } + } + def playDogCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => { + val game = podManager.getGame(gameId) + game match { + case Some(g) => { + val cardIdOpt = request.body.asFormUrlEncoded.flatMap(_.get("cardId").flatMap(_.headOption)) + val result = Try { + cardIdOpt match { + case Some(cardId) if cardId == "skip" => + g.playDogCard(g.getUserSession(request.user.id), -1) + case Some(cardId) => + g.playDogCard(g.getUserSession(request.user.id), cardId.toInt) + case None => + throw new IllegalArgumentException("cardId parameter is missing") + } + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: CantPlayCardException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + } + case None => + NotFound("Game not found") + } + } + } + def playTrump(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val trumpOpt = request.body.asFormUrlEncoded.flatMap(_.get("trump").flatMap(_.headOption)) + trumpOpt match { + case Some(trump) => + val result = Try { + g.selectTrump(g.getUserSession(request.user.id), trump.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("trump parameter is missing") + } + case None => + NotFound("Game not found") + } + } + def playTie(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val game = podManager.getGame(gameId) + game match { + case Some(g) => + val tieOpt = request.body.asFormUrlEncoded.flatMap(_.get("tie").flatMap(_.headOption)) + tieOpt match { + case Some(tie) => + val result = Try { + g.selectTie(g.getUserSession(request.user.id), tie.toInt) + } + if (result.isSuccess) { + NoContent + } else { + val throwable = result.failed.get + throwable match { + case _: IllegalArgumentException => + BadRequest(throwable.getMessage) + case _: NotInThisGameException => + BadRequest(throwable.getMessage) + case _: IllegalStateException => + BadRequest(throwable.getMessage) + case _ => + InternalServerError(throwable.getMessage) + } + } + case None => + BadRequest("tie parameter is missing") + } + case None => + NotFound("Game not found") + } } } \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/MainMenuController.scala b/knockoutwhistweb/app/controllers/MainMenuController.scala index 5d85e17..a7a1637 100644 --- a/knockoutwhistweb/app/controllers/MainMenuController.scala +++ b/knockoutwhistweb/app/controllers/MainMenuController.scala @@ -1,6 +1,7 @@ package controllers import auth.{AuthAction, AuthenticatedRequest} +import logic.PodManager import play.api.* import play.api.mvc.* @@ -13,23 +14,32 @@ import javax.inject.* */ @Singleton class MainMenuController @Inject()( - val controllerComponents: ControllerComponents, - val authAction: AuthAction + val controllerComponents: ControllerComponents, + val authAction: AuthAction, + val podManager: PodManager ) extends BaseController { // Pass the request-handling function directly to authAction (no nested Action) def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => Ok("Main Menu for user: " + request.user.name) } - + def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => - Redirect("/mainmenu") + Redirect(routes.MainMenuController.mainMenu()) } - + + def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => + val gameLobby = podManager.createGame( + host = request.user, + name = s"${request.user.name}'s Game", + maxPlayers = 4 + ) + Redirect(routes.IngameController.game(gameLobby.id)) + } + def rules(): Action[AnyContent] = { Action { implicit request => - Ok(views.html.rules()) + Ok(views.html.mainmenu.rules()) } } - } \ No newline at end of file diff --git a/knockoutwhistweb/app/controllers/UserController.scala b/knockoutwhistweb/app/controllers/UserController.scala index e10bffd..361826f 100644 --- a/knockoutwhistweb/app/controllers/UserController.scala +++ b/knockoutwhistweb/app/controllers/UserController.scala @@ -28,10 +28,10 @@ class UserController @Inject()( if (possibleUser.isDefined) { Redirect(routes.MainMenuController.mainMenu()) } else { - Ok(views.html.login()) + Ok(views.html.login.login()) } } else { - Ok(views.html.login()) + Ok(views.html.login.login()) } } } diff --git a/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java new file mode 100644 index 0000000..35c8d44 --- /dev/null +++ b/knockoutwhistweb/app/exceptions/NotEnoughPlayersException.java @@ -0,0 +1,7 @@ +package exceptions; + +public class NotEnoughPlayersException extends GameException { + public NotEnoughPlayersException(String message) { + super(message); + } +} diff --git a/knockoutwhistweb/app/logic/PodManager.scala b/knockoutwhistweb/app/logic/PodManager.scala index ebf046d..ad3e9c8 100644 --- a/knockoutwhistweb/app/logic/PodManager.scala +++ b/knockoutwhistweb/app/logic/PodManager.scala @@ -6,6 +6,7 @@ import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic import di.KnockOutWebConfigurationModule import logic.game.GameLobby import model.users.User +import util.GameUtil import javax.inject.Singleton import scala.collection.mutable @@ -27,7 +28,7 @@ class PodManager { ): GameLobby = { val gameLobby = GameLobby( logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), - id = java.util.UUID.randomUUID().toString, + id = GameUtil.generateCode(), internalId = java.util.UUID.randomUUID(), name = name, maxPlayers = maxPlayers, diff --git a/knockoutwhistweb/app/logic/game/GameLobby.scala b/knockoutwhistweb/app/logic/game/GameLobby.scala index 53e5560..7dadf4a 100644 --- a/knockoutwhistweb/app/logic/game/GameLobby.scala +++ b/knockoutwhistweb/app/logic/game/GameLobby.scala @@ -2,9 +2,9 @@ package logic.game import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.control.GameLogic -import de.knockoutwhist.control.GameState.Lobby +import de.knockoutwhist.control.GameState.{Lobby, MainMenu} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} -import de.knockoutwhist.events.global.SessionClosed +import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} @@ -46,9 +46,13 @@ class GameLobby private( event match { case event: PlayerEvent => users.get(event.playerId).foreach(session => session.updatePlayer(event)) + case event: GameStateChangeEvent => + if (event.oldState == MainMenu && event.newState == Lobby) { + return + } + users.values.foreach(session => session.updatePlayer(event)) case event: SessionClosed => users.values.foreach(session => session.updatePlayer(event)) - case event: SimpleEvent => users.values.foreach(session => session.updatePlayer(event)) } @@ -70,6 +74,9 @@ class GameLobby private( users.values.foreach { player => playerNamesList += PlayerFactory.createPlayer(player.name, player.id, HUMAN) } + if (playerNamesList.size < 2) { + throw new NotEnoughPlayersException("Not enough players to start the game!") + } logic.createMatch(playerNamesList.toList) logic.controlMatch() } @@ -150,13 +157,25 @@ class GameLobby private( //------------------- - private def getUserSession(userId: UUID): UserSession = { + def getUserSession(userId: UUID): UserSession = { val sessionOpt = users.get(userId) if (sessionOpt.isEmpty) { throw new NotInThisGameException("You are not in this game!") } sessionOpt.get } + + def getPlayerByUser(user: User): AbstractPlayer = { + getPlayerBySession(getUserSession(user.id)) + } + + 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 (!Thread.holdsLock(userSession.lock)) { @@ -165,11 +184,7 @@ class GameLobby private( if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) { throw new NotInteractableException("You can't play a card!") } - val playerOption = getMatch.totalplayers.find(_.id == userSession.id) - if (playerOption.isEmpty) { - throw new NotInThisGameException("You are not in this game!") - } - playerOption.get + getPlayerBySession(userSession) } private def getHand(player: AbstractPlayer): Hand = { diff --git a/knockoutwhistweb/app/views/ingame/ingame.scala.html b/knockoutwhistweb/app/views/ingame/ingame.scala.html index 7ce7c26..cb24ac0 100644 --- a/knockoutwhistweb/app/views/ingame/ingame.scala.html +++ b/knockoutwhistweb/app/views/ingame/ingame.scala.html @@ -1,4 +1,4 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Ingame") {
diff --git a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html index 9e2da55..8aef1e3 100644 --- a/knockoutwhistweb/app/views/ingame/selecttrump.scala.html +++ b/knockoutwhistweb/app/views/ingame/selecttrump.scala.html @@ -1,4 +1,4 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Selecting Trumpsuit...") {
diff --git a/knockoutwhistweb/app/views/ingame/tie.scala.html b/knockoutwhistweb/app/views/ingame/tie.scala.html index eede1be..505c68d 100644 --- a/knockoutwhistweb/app/views/ingame/tie.scala.html +++ b/knockoutwhistweb/app/views/ingame/tie.scala.html @@ -1,4 +1,4 @@ -@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) +@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic) @main("Tie") {
diff --git a/knockoutwhistweb/app/views/login.scala.html b/knockoutwhistweb/app/views/login/login.scala.html similarity index 95% rename from knockoutwhistweb/app/views/login.scala.html rename to knockoutwhistweb/app/views/login/login.scala.html index f2a5365..219707e 100644 --- a/knockoutwhistweb/app/views/login.scala.html +++ b/knockoutwhistweb/app/views/login/login.scala.html @@ -33,7 +33,7 @@
- +