feat!: implemented multigame support #34

Merged
Janis merged 16 commits from feat/5-create-user-sessions into main 2025-11-01 20:53:23 +01:00
61 changed files with 2996 additions and 262 deletions

1
.gitignore vendored
View File

@@ -137,3 +137,4 @@ target
/knockoutwhist/ /knockoutwhist/
/knockoutwhistweb/.g8/ /knockoutwhistweb/.g8/
/knockoutwhistweb/.bsp/ /knockoutwhistweb/.bsp/
/currentSnapshot.json

9
README.md Normal file
View File

@@ -0,0 +1,9 @@
## User Password Protection
All the User Passwords are encrypted using Argon2.

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Game
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,26 @@
meta {
name: Login
type: http
seq: 2
}
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
timeout: 0
}

View File

@@ -0,0 +1,9 @@
{
"version": "1",
"name": "KnockOutWhist",
"type": "collection",
"ignore": [
"node_modules",
".git"
]
}

View File

View File

@@ -0,0 +1,3 @@
vars {
host: http://localhost:9000
}

View File

@@ -37,7 +37,10 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
.dependsOn(knockoutwhist % "compile->compile;test->test") .dependsOn(knockoutwhist % "compile->compile;test->test")
.settings( .settings(
commonSettings, commonSettings,
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0",
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2"
) )
lazy val root = (project in file(".")) lazy val root = (project in file("."))

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
@import "light-mode.less"; @import "light-mode.less";
@import "dark-mode.less"; @import "dark-mode.less";
@import "login.less";
@background-image: var(--background-image); @background-image: var(--background-image);
@color: var(--color); @color: var(--color);
@@ -7,14 +8,16 @@
0% { transform: translateX(-100vw); } 0% { transform: translateX(-100vw); }
100% { transform: translateX(0); } 100% { transform: translateX(0); }
} }
body { .game-field-background {
background-image: @background-image; background-image: @background-image;
background-size: 100vw 100vh; background-size: 100vw 100vh;
background-repeat: no-repeat;
} }
html, body { .game-field {
height: 100vh; position: fixed;
margin: 0; inset: 0;
overflow: auto;
} }
#sessions { #sessions {
display: flex; display: flex;
@@ -31,8 +34,9 @@ html, body {
animation: slideIn 0.5s ease-out forwards; animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards; animation-fill-mode: backwards;
animation-delay: 1s; animation-delay: 1s;
} }
#sessions a, h1, p {
#sessions a, #sessions h1, #sessions p {
color: @color; color: @color;
font-size: 40px; font-size: 40px;
font-family: Arial, serif; font-family: Arial, serif;
@@ -44,6 +48,11 @@ html, body {
justify-content: flex-end; justify-content: flex-end;
height: 100%; height: 100%;
} }
#ingame a, #ingame h1, #ingame p {
color: @color;
font-size: 40px;
font-family: Arial, serif;
}
#playercards { #playercards {
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@@ -0,0 +1,37 @@
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
private def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
override def invokeBlock[A](
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()))
}
}
}

View File

@@ -1,13 +1,12 @@
package components package components
import controllers.WebUI
import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.components.DefaultConfiguration
import de.knockoutwhist.ui.UI import de.knockoutwhist.ui.UI
import de.knockoutwhist.utils.events.EventListener import de.knockoutwhist.utils.events.EventListener
class WebApplicationConfiguration extends DefaultConfiguration { class WebApplicationConfiguration extends DefaultConfiguration {
override def uis: Set[UI] = super.uis + WebUI override def uis: Set[UI] = Set()
override def listener: Set[EventListener] = super.listener + WebUI override def listener: Set[EventListener] = Set()
} }

View File

@@ -1,93 +0,0 @@
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}
import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic
import di.KnockOutWebConfigurationModule
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.apply())
}
}
def sessions(): Action[AnyContent] = {
Action { implicit request =>
Ok(views.html.sessions.apply(PodGameManager.listSessions()))
}
}
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"<p>Session with id $id not found!</p>"))))
}
} else {
val session = PodGameManager.identify(uuid).get
val player = session.asInstanceOf[AdvancedSession].player
val logic = WebUI.logic.get.asInstanceOf[BaseGameLogic]
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")
}
//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))
//}
}
}

View File

@@ -0,0 +1,244 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
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
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class IngameController @Inject()(
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")
}
}
}

View File

@@ -0,0 +1,45 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import logic.PodManager
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,
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(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.mainmenu.rules())
}
}
}

View File

@@ -1,37 +0,0 @@
package controllers
import controllers.sessions.PlayerSession
import de.knockoutwhist.utils.events.SimpleEvent
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
}
}

View File

@@ -0,0 +1,70 @@
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 UserController @Inject()(
val controllerComponents: ControllerComponents,
val sessionManager: SessionManager,
val userManager: UserManager,
val authAction: AuthAction
) 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(routes.MainMenuController.mainMenu())
} else {
Ok(views.html.login.login())
}
} else {
Ok(views.html.login.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(routes.MainMenuController.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"))
}
}

View File

@@ -1,49 +0,0 @@
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}
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}
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
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class CantPlayCardException extends GameException {
public CantPlayCardException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public abstract class GameException extends RuntimeException {
public GameException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class GameFullException extends GameException {
public GameFullException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class NotEnoughPlayersException extends GameException {
public NotEnoughPlayersException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class NotHostException extends GameException {
public NotHostException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class NotInThisGameException extends GameException {
public NotInThisGameException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package exceptions;
public class NotInteractableException extends GameException {
public NotInteractableException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,49 @@
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 util.GameUtil
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 = GameUtil.generateCode(),
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)
}
}

View File

@@ -0,0 +1,246 @@
package logic.game
import de.knockoutwhist.cards.{Hand, Suit}
import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.control.GameState.{Lobby, MainMenu}
import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil}
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}
import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import exceptions.*
import model.sessions.{InteractionType, UserSession}
import model.users.User
import java.util.UUID
import scala.collection.mutable
import scala.collection.mutable.ListBuffer
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()
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: 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))
}
}
/**
* 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) {
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)
}
if (playerNamesList.size < 2) {
throw new NotEnoughPlayersException("Not enough players to start the game!")
}
logic.createMatch(playerNamesList.toList)
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 = getPlayerInteractable(userSession, InteractionType.Card)
if (player.isInDogLife) {
throw new CantPlayCardException("You are in dog life!")
}
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 = getPlayerInteractable(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 = getPlayerInteractable(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 = getPlayerInteractable(userSession, InteractionType.TieChoice)
logic.playerTieLogic.receivedTieBreakerCard(tieNumber)
}
//-------------------
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)) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand()
if (handOption.isEmpty) {
throw new IllegalStateException("You have no cards!")
}
handOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
}
private def getRound: Round = {
val roundOpt = logic.getCurrentRound
if (roundOpt.isEmpty) {
throw new IllegalStateException("No round is currently running!")
}
roundOpt.get
}
private def getTrick: Trick = {
val trickOpt = logic.getCurrentTrick
if (trickOpt.isEmpty) {
throw new IllegalStateException("No trick is currently running!")
}
trickOpt.get
}
}
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
}
}

View File

@@ -0,0 +1,14 @@
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
def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit
}

View File

@@ -0,0 +1,16 @@
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 userExistsById(id: Long): Option[User]
def removeUser(name: String): Boolean
}

View File

@@ -0,0 +1,63 @@
package logic.user.impl
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
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 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.id.toString)
.withClaim("id", user.internalId)
.withExpiresAt(Instant.now.plus(7, ChronoUnit.DAYS))
.sign(algorithm)
//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
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)
}
}

View File

@@ -0,0 +1,51 @@
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 userExistsById(id: Long): Option[User] = {
user.values.find(_.internalId == id)
}
override def removeUser(name: String): Boolean = {
throw new NotImplementedError("StubUserManager.removeUser is not implemented")
}
}

View File

@@ -0,0 +1,10 @@
package model.sessions
enum InteractionType {
case TrumpSuit
case Card
case DogCard
case TieChoice
}

View File

@@ -1,4 +1,4 @@
package controllers.sessions package model.sessions
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent

View File

@@ -1,11 +1,11 @@
package controllers.sessions package model.sessions
import de.knockoutwhist.player.AbstractPlayer import de.knockoutwhist.player.AbstractPlayer
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID 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 def name: String = player.name

View File

@@ -0,0 +1,31 @@
package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
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: Option[InteractionType] = None
val lock: Lock = ReentrantLock()
override def updatePlayer(event: SimpleEvent): Unit = {
event match {
case event: RequestTrumpSuitEvent =>
canInteract = Some(InteractionType.TrumpSuit)
case event: RequestTieChoiceEvent =>
canInteract = Some(InteractionType.TieChoice)
case event: RequestCardEvent =>
if (event.player.isInDogLife) canInteract = Some(InteractionType.DogCard)
else canInteract = Some(InteractionType.Card)
case _ =>
}
}
override def id: UUID = user.id
override def name: String = user.name
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,56 @@
package services
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) {
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 PKCS8EncodedKeySpec(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.")
}
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -29,6 +29,6 @@ object WebUIUtils {
case Three => "3" case Three => "3"
case Two => "2" 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)
} }
} }

View File

@@ -1,3 +0,0 @@
@main("Welcome to Play") {
<h1>Welcome to Play!</h1>
}

View File

@@ -1,7 +1,7 @@
@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic)
@main("Ingame") { @main("Ingame") {
<div id="ingame"> <div id="ingame" class="game-field game-field-background">
<h1>Knockout Whist</h1> <h1>Knockout Whist</h1>
<div id="nextPlayers"> <div id="nextPlayers">
<p>Next Player:</p> <p>Next Player:</p>
@@ -17,7 +17,7 @@
@if(logic.getCurrentTrick.get.firstCard.isDefined) { @if(logic.getCurrentTrick.get.firstCard.isDefined) {
@util.WebUIUtils.cardtoImage(logic.getCurrentTrick.get.firstCard.get) @util.WebUIUtils.cardtoImage(logic.getCurrentTrick.get.firstCard.get)
} else { } else {
@views.html.output.card.apply("images/cards/1B.png")("Blank Card") @views.html.render.card.apply("../../../public/images/cards/1B.png")("Blank Card")
} }
</div> </div>
</div> </div>

View File

@@ -1,7 +1,7 @@
@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic)
@main("Selecting Trumpsuit...") { @main("Selecting Trumpsuit...") {
<div id="selecttrumpsuit"> <div id="selecttrumpsuit" class="game-field game-field-background">
@if(player.equals(logic.getCurrentMatch.get.roundlist.last.winner.get)) { @if(player.equals(logic.getCurrentMatch.get.roundlist.last.winner.get)) {
<h1>Knockout Whist</h1> <h1>Knockout Whist</h1>
<p>You (@player.toString) have won the last round! Select a trumpsuit for the next round!</p> <p>You (@player.toString) have won the last round! Select a trumpsuit for the next round!</p>

View File

@@ -1,7 +1,7 @@
@(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic) @(player: de.knockoutwhist.player.AbstractPlayer, logic: de.knockoutwhist.control.GameLogic)
@main("Tie") { @main("Tie") {
<div id="tie"> <div id="tie" class="game-field game-field-background">
<h1>Knockout Whist</h1> <h1>Knockout Whist</h1>
<p>The last Round was tied between <p>The last Round was tied between
@for(players <- logic.playerTieLogic.getTiedPlayers) { @for(players <- logic.playerTieLogic.getTiedPlayers) {

View File

@@ -0,0 +1,41 @@
@()
@main("Login") {
<div class="login-box">
<div class="card login-card p-4">
<div class="card-body">
<h3 class="text-center mb-4">Login</h3>
<form action="@routes.UserController.login_Post()" method="post">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" placeholder="Enter Username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" placeholder="Enter password" required>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="#" class="text-decoration-none">Forgot password?</a>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
<p class="text-center mt-3">
Dont have an account?
<a href="#" class="text-decoration-none">Sign up</a>
</p>
</div>
</div>
</div>
<script src="@routes.Assets.versioned("/javascripts/particles.js")"></script>
<div id="particles-js" style="background-color: rgb(182, 25, 36);
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;"></div>
}

View File

@@ -13,6 +13,8 @@
<title>@title</title> <title>@title</title>
<link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")"> <link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")"> <link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head> </head>
<body> <body>
@@ -21,5 +23,6 @@
@content @content
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script> <script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body> </body>
</html> </html>

View File

@@ -1,12 +0,0 @@
@(sessions: List[controllers.sessions.PlayerSession])
@main("Sessions") {
<div id="sessions">
<h1>Knockout Whist sessions</h1>
<p id="textanimation">Please select your session to jump inside the game!</p>
@for(session <- sessions) {
<a id="textanimation" href="@routes.HomeController.ingame(session.id.toString)">@session.name</a><br>
}
</div>
}

View File

@@ -1,10 +0,0 @@
@(toRender: List[Html])
@main("Tui") {
<div id="tui">
@for(line <- toRender) {
@line
}
</div>
}

View File

@@ -1 +1,14 @@
# https://www.playframework.com/documentation/latest/Configuration # https://www.playframework.com/documentation/latest/Configuration
play.filters.disabled += play.filters.csrf.CSRFFilter
auth {
issuer = "knockoutwhistweb"
audience = "ui"
# ${?PUBLIC_KEY_FILE}
privateKeyFile = "D:\\Workspaces\\Gitops\\rsa512-private.pem"
privateKeyPem = ${?PUBLIC_KEY_PEM}
#${?PUBLIC_KEY_FILE}
publicKeyFile = "D:\\Workspaces\\Gitops\\rsa512-public.pem"
publicKeyPem = ${?PUBLIC_KEY_PEM}
}

View File

@@ -3,12 +3,28 @@
# https://www.playframework.com/documentation/latest/ScalaRouting # https://www.playframework.com/documentation/latest/ScalaRouting
# ~~~~ # ~~~~
# An example controller showing a sample home page
GET / controllers.HomeController.index() # Primary routes
GET /sessions controllers.HomeController.sessions() GET / controllers.MainMenuController.index()
GET /ingame/:id controllers.HomeController.ingame(id: String)
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset) 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()
POST /createGame controllers.MainMenuController.createGame()
# User authentication routes
GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post()
GET /logout controllers.UserController.logout()
# In-game routes
GET /game/:id controllers.IngameController.game(id: String)
POST /game/:id/join controllers.IngameController.joinGame(id: String)
POST /game/:id/start controllers.IngameController.startGame(id: String)
POST /game/:id/playCard controllers.IngameController.playCard(id: String)

View File

@@ -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
}

View File

@@ -0,0 +1,3 @@
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
console.log('callback - particles.js config loaded');
});

File diff suppressed because it is too large Load Diff

View File

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