Compare commits

...

12 Commits
4.10.0 ... main

Author SHA1 Message Date
TeamCity
c3d10f31ac ci: bump version to v4.16.0 2025-12-18 08:51:00 +00:00
e4384ee894 feat(ui): FRO-36 PWA (#110)
Added localhost:3000 to CORS for PWA to work

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #110
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: lq64 <lq@blackhole.local>
Co-committed-by: lq64 <lq@blackhole.local>
2025-12-18 09:47:25 +01:00
TeamCity
d667a2f68c ci: bump version to v4.15.0 2025-12-14 14:15:11 +00:00
35f608513d feat: Update routing and websocket configuration for game state management (#109)
Reviewed-on: #109
2025-12-14 15:10:43 +01:00
TeamCity
13038b0cb9 ci: bump version to v4.14.0 2025-12-11 06:13:24 +00:00
b17aae5795 feat: FRO-31 Small backend changes (#108)
Force pushing Janis changes

Co-authored-by: Janis <janis.e.20@gmx.de>
Co-authored-by: Janis <janis-e@gmx.de>
Reviewed-on: #108
Co-authored-by: lq64 <lq@blackhole.local>
Co-committed-by: lq64 <lq@blackhole.local>
2025-12-11 07:10:24 +01:00
TeamCity
421f769cb6 ci: bump version to v4.13.0 2025-12-10 14:19:44 +00:00
bd7a055a09 feat(api): FRO-14 Create Game (#107)
Added functionality to create Game so that it creates a game in the Backend

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #107
Reviewed-by: Janis <janis-e@gmx.de>
Co-authored-by: lq64 <lq@blackhole.local>
Co-committed-by: lq64 <lq@blackhole.local>
2025-12-10 15:16:53 +01:00
TeamCity
e2a2b56174 ci: bump version to v4.12.0 2025-12-10 13:15:50 +00:00
2a29ca8cdd feat: FRO-20 Create scoreboard component (#106)
Reviewed-on: #106
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 14:12:48 +01:00
TeamCity
266cbe7509 ci: bump version to v4.11.0 2025-12-10 10:47:18 +00:00
e8b31b1748 feat: FRO-2 Implement Login Component (#105)
Reviewed-on: #105
Reviewed-by: lq64 <lq@blackhole.local>
Co-authored-by: Janis <janis.e.20@gmx.de>
Co-committed-by: Janis <janis.e.20@gmx.de>
2025-12-10 11:43:51 +01:00
20 changed files with 97 additions and 64 deletions

1
.gitignore vendored
View File

@@ -134,6 +134,7 @@ target
/.project /.project
/.settings /.settings
/RUNNING_PID /RUNNING_PID
/knockoutwhistwebfrontend/
/knockoutwhist/ /knockoutwhist/
/knockoutwhistweb/.g8/ /knockoutwhistweb/.g8/
/knockoutwhistweb/.bsp/ /knockoutwhistweb/.bsp/

View File

@@ -229,3 +229,33 @@
### Features ### Features
* BAC-27 Implemented endpoint which returns information about the current state ([#103](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/103)) ([dd5e8e6](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/dd5e8e65e55f02a7618b3c60e8fc7087774e5106)) * BAC-27 Implemented endpoint which returns information about the current state ([#103](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/103)) ([dd5e8e6](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/dd5e8e65e55f02a7618b3c60e8fc7087774e5106))
## (2025-12-10)
### Features
* FRO-2 Implement Login Component ([#105](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/105)) ([e8b31b1](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e8b31b174819b5f033034501856c4b1189c4c4ee))
## (2025-12-10)
### Features
* FRO-20 Create scoreboard component ([#106](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/106)) ([2a29ca8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2a29ca8cdd3ef55f6f66f00b5e7727e1b1af1458))
## (2025-12-10)
### Features
* **api:** FRO-14 Create Game ([#107](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/107)) ([bd7a055](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/bd7a055a0944a1c5219f21bb080bf658229f49e9))
## (2025-12-11)
### Features
* FRO-31 Small backend changes ([#108](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/108)) ([b17aae5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b17aae5795b35ce3805db87c9bf741a5a96cd5ac))
## (2025-12-14)
### Features
* Update routing and websocket configuration for game state management ([#109](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/109)) ([35f6085](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/35f608513dd80eece46d49b40ecf31c8e915d307))
## (2025-12-18)
### Features
* **ui:** FRO-36 PWA ([#110](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/110)) ([e4384ee](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e4384ee8945ac462fe1f3580215117e0a438f71a))

View File

@@ -23,12 +23,12 @@ class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyP
case Some(user) => case Some(user) =>
block(new AuthenticatedRequest(user, request)) block(new AuthenticatedRequest(user, request))
case None => case None =>
Future.successful(Results.Redirect(routes.UserController.login())) Future.successful(Results.Unauthorized)
} }
} }
protected def getUserFromSession(request: RequestHeader): Option[User] = { protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId") val session = request.cookies.get("accessToken")
if (session.isDefined) if (session.isDefined)
return sessionManager.getUserBySession(session.get.value) return sessionManager.getUserBySession(session.get.value)
None None

View File

@@ -44,8 +44,7 @@ class MainMenuController @Inject()(
) )
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameLobby.id).url, "gameId" -> gameLobby.id,
"content" -> IngameController.returnInnerHTML(gameLobby, gameLobby.logic.getCurrentState, request.user).toString
)) ))
} else { } else {
BadRequest(Json.obj( BadRequest(Json.obj(

View File

@@ -34,7 +34,7 @@ class StatusController @Inject()(
Json.obj( Json.obj(
"status" -> "authenticated", "status" -> "authenticated",
"username" -> user.name, "username" -> user.name,
"inGame" -> "false" "inGame" -> false
) )
) )
} else { } else {
@@ -43,7 +43,7 @@ class StatusController @Inject()(
Json.obj( Json.obj(
"status" -> "authenticated", "status" -> "authenticated",
"username" -> user.name, "username" -> user.name,
"inGame" -> "true", "inGame" -> true,
"gameId" -> game.id "gameId" -> game.id
) )
) )
@@ -80,7 +80,7 @@ class StatusController @Inject()(
}} }}
private def getUserFromSession(request: RequestHeader): Option[User] = { private def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId") val session = request.cookies.get("accessToken")
if (session.isDefined) if (session.isDefined)
return sessionManager.getUserBySession(session.get.value) return sessionManager.getUserBySession(session.get.value)
None None

View File

@@ -1,10 +1,13 @@
package controllers package controllers
import auth.{AuthAction, AuthenticatedRequest} import auth.{AuthAction, AuthenticatedRequest}
import dto.subDTO.UserDTO
import logic.user.{SessionManager, UserManager} import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.* import play.api.*
import play.api.libs.json.Json import play.api.libs.json.Json
import play.api.mvc.* import play.api.mvc.*
import play.api.mvc.Cookie.SameSite.{Lax, None, Strict}
import javax.inject.* import javax.inject.*
@@ -21,22 +24,6 @@ class UserController @Inject()(
val authAction: AuthAction val authAction: AuthAction
) extends BaseController { ) 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.main("Login")(views.html.login.login()))
}
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
}
}
def login_Post(): Action[AnyContent] = { def login_Post(): Action[AnyContent] = {
Action { implicit request => Action { implicit request =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
@@ -51,12 +38,17 @@ class UserController @Inject()(
val possibleUser = userManager.authenticate(username.get, password.get) val possibleUser = userManager.authenticate(username.get, password.get)
if (possibleUser.isDefined) { if (possibleUser.isDefined) {
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "user" -> Json.obj(
"redirectUrl" -> routes.MainMenuController.mainMenu().url, "id" -> possibleUser.get.id,
"content" -> views.html.mainmenu.creategame(possibleUser).toString "username" -> possibleUser.get.name
)).withCookies(
Cookie("sessionId", sessionManager.createSession(possibleUser.get))
) )
)).withCookies(Cookie(
name = "accessToken",
value = sessionManager.createSession(possibleUser.get),
httpOnly = true,
secure = false,
sameSite = Some(Lax)
))
} else { } else {
Unauthorized("Invalid username or password") Unauthorized("Invalid username or password")
} }
@@ -66,13 +58,20 @@ class UserController @Inject()(
} }
} }
// Pass the request-handling function directly to authAction (no nested Action) def getUserInfo(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => val user: User = request.user
val sessionCookie = request.cookies.get("sessionId") Ok(Json.obj(
"id" -> user.id,
"username" -> user.name
))
}
def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val sessionCookie = request.cookies.get("accessToken")
if (sessionCookie.isDefined) { if (sessionCookie.isDefined) {
sessionManager.invalidateSession(sessionCookie.get.value) sessionManager.invalidateSession(sessionCookie.get.value)
} }
Redirect(routes.UserController.login()).discardingCookies(DiscardingCookie("sessionId")) NoContent.discardingCookies(DiscardingCookie("accessToken"))
} }
} }

View File

@@ -21,7 +21,7 @@ class WebsocketController @Inject()(
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) { )(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
def socket(): WebSocket = WebSocket.accept[String, String] { request => def socket(): WebSocket = WebSocket.accept[String, String] { request =>
val session = request.cookies.get("sessionId") val session = request.cookies.get("accessToken")
if (session.isEmpty) throw new Exception("No session cookie found") if (session.isEmpty) throw new Exception("No session cookie found")
val userOpt = sessionManger.getUserBySession(session.get.value) val userOpt = sessionManger.getUserBySession(session.get.value)
if (userOpt.isEmpty) throw new Exception("Invalid session") if (userOpt.isEmpty) throw new Exception("Invalid session")

View File

@@ -28,7 +28,7 @@ object GameInfoDTO {
hand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_)), hand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_)),
playerQueue = PlayerQueueDTO(lobby.logic), playerQueue = PlayerQueueDTO(lobby.logic),
currentTrick = lobby.logic.getCurrentTrick.map(TrickDTO(_)), currentTrick = lobby.logic.getCurrentTrick.map(TrickDTO(_)),
currentRound = lobby.logic.getCurrentRound.map(RoundDTO(_)) currentRound = lobby.logic.getCurrentRound.map(r => RoundDTO(r, lobby.logic.getCurrentMatch))
) )
} }

View File

@@ -2,13 +2,13 @@ package dto.subDTO
import de.knockoutwhist.cards.Hand import de.knockoutwhist.cards.Hand
case class HandDTO(card: List[CardDTO]) case class HandDTO(cards: List[CardDTO])
object HandDTO { object HandDTO {
def apply(hand: Hand): HandDTO = { def apply(hand: Hand): HandDTO = {
HandDTO( HandDTO(
card = hand.cards.zipWithIndex.map { case (card, idx) => CardDTO(card, idx) } cards = hand.cards.zipWithIndex.map { case (card, idx) => CardDTO(card, idx) }
) )
} }

View File

@@ -2,14 +2,16 @@ package dto.subDTO
import de.knockoutwhist.cards.Card import de.knockoutwhist.cards.Card
import de.knockoutwhist.cards.CardValue.Ace import de.knockoutwhist.cards.CardValue.Ace
import de.knockoutwhist.rounds.{Match, Round}
case class RoundDTO(trumpSuit: CardDTO, firstRound: Boolean, trickList: List[TrickDTO]) case class RoundDTO(trumpSuit: CardDTO, playersIn: Seq[PlayerDTO], firstRound: Boolean, trickList: List[TrickDTO])
object RoundDTO { object RoundDTO {
def apply(round: de.knockoutwhist.rounds.Round): RoundDTO = { def apply(round: Round, matchImpl: Option[Match]): RoundDTO = {
RoundDTO( RoundDTO(
trumpSuit = CardDTO(Card(Ace, round.trumpSuit)), trumpSuit = CardDTO(Card(Ace, round.trumpSuit)),
playersIn = matchImpl.map(_.playersIn.map(PlayerDTO(_))).getOrElse(Seq.empty),
firstRound = round.firstRound, firstRound = round.firstRound,
trickList = round.tricklist.map(trick => TrickDTO(trick)) trickList = round.tricklist.map(trick => TrickDTO(trick))
) )

View File

@@ -8,7 +8,7 @@ object TrickDTO {
def apply(trick: Trick): TrickDTO = { def apply(trick: Trick): TrickDTO = {
TrickDTO( TrickDTO(
cards = trick.cards.map { case (card, player) => player.id.toString -> CardDTO(card) }, cards = trick.cards.map { case (card, player) => player.name -> CardDTO(card) },
firstCard = trick.firstCard.map(card => CardDTO(card)), firstCard = trick.firstCard.map(card => CardDTO(card)),
winner = trick.winner.map(player => PlayerDTO(player)) winner = trick.winner.map(player => PlayerDTO(player))
) )

View File

@@ -9,6 +9,7 @@ trait SessionManager {
def createSession(user: User): String def createSession(user: User): String
def getUserBySession(sessionId: String): Option[User] def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit def invalidateSession(sessionId: String): Unit

View File

@@ -46,13 +46,13 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
case "StartGame" => case "StartGame" =>
gameLobby.startGame(user) gameLobby.startGame(user)
case "PlayCard" => case "PlayCard" =>
val maybeCardIndex: Option[String] = (data \ "cardindex").asOpt[String] val maybeCardIndex: Option[Int] = (data \ "cardindex").asOpt[Int]
maybeCardIndex match { maybeCardIndex match {
case Some(index) => case Some(index) =>
val session = gameLobby.getUserSession(user.id) val session = gameLobby.getUserSession(user.id)
gameLobby.playCard(session, index.toInt) gameLobby.playCard(session, index)
case None => case None =>
println("Card Index not found or is not a number.") println("Card Index not found or is not a number." + data)
} }
case "PickTrumpsuit" => case "PickTrumpsuit" =>
val maybeSuitIndex: Option[Int] = (data \ "suitIndex").asOpt[Int] val maybeSuitIndex: Option[Int] = (data \ "suitIndex").asOpt[Int]
@@ -74,6 +74,10 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
} }
case "ReturnToLobby" => case "ReturnToLobby" =>
gameLobby.returnToLobby(this) gameLobby.returnToLobby(this)
case "LeaveGame" =>
gameLobby.leaveGame(user.id, false)
case _ =>
println("Unknown event type: " + eventType + " with data: " + data)
} }
} }

View File

@@ -57,7 +57,8 @@ object WebsocketEventMapper {
Json.obj( Json.obj(
"id" -> ("request-" + java.util.UUID.randomUUID().toString), "id" -> ("request-" + java.util.UUID.randomUUID().toString),
"event" -> obj.id, "event" -> obj.id,
"state" -> stateToJson(session), "state" -> session.gameLobby.getLogic.getCurrentState.toString,
"stateData" -> stateToJson(session),
"data" -> data "data" -> data
) )
} }

View File

@@ -45,15 +45,9 @@
<li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true"> <li><a class="dropdown-item disabled" href="#" tabindex="-1" aria-disabled="true">
Settings</a></li> Settings</a></li>
<li><hr class="dropdown-divider"></li> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="@routes.UserController.logout()">Logout</a></li>
</ul> </ul>
</li> </li>
</ul> </ul>
} else {
<div class="d-flex ms-auto">
<a class="btn btn-outline-primary me-2" href="@routes.UserController.login()">Login</a>
<a class="btn btn-primary" href="@routes.UserController.login()">Sign Up</a>
</div>
} }
</div> </div>

View File

@@ -13,3 +13,12 @@ auth {
publicKeyFile = ${?PUBLIC_KEY_FILE} publicKeyFile = ${?PUBLIC_KEY_FILE}
publicKeyPem = ${?PUBLIC_KEY_PEM} publicKeyPem = ${?PUBLIC_KEY_PEM}
} }
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.cors {
allowedOrigins = ["http://localhost:5173", "http://localhost:3000"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}

View File

@@ -18,10 +18,9 @@ POST /createGame controllers.MainMenuController.createGame()
POST /joinGame controllers.MainMenuController.joinGame() POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes # User authentication routes
GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post() POST /login controllers.UserController.login_Post()
POST /logout controllers.UserController.logoutPost()
GET /logout controllers.UserController.logout() GET /userInfo controllers.UserController.getUserInfo()
# In-game routes # In-game routes
GET /game/:id controllers.IngameController.game(id: String) GET /game/:id controllers.IngameController.game(id: String)

View File

@@ -1,11 +1,9 @@
// javascript let ws = null;
let ws = null; // will be created by connectWebSocket() const pending = new Map();
const pending = new Map(); // id -> { resolve, reject, timer } const handlers = new Map();
const handlers = new Map(); // eventType -> handler(data) -> (value|Promise)
let timer = null; let timer = null;
// helper to attach message/error/close handlers to a socket
function setupSocketHandlers(socket) { function setupSocketHandlers(socket) {
socket.onmessage = (event) => { socket.onmessage = (event) => {
console.debug("SERVER MESSAGE:", event.data); console.debug("SERVER MESSAGE:", event.data);
@@ -48,7 +46,6 @@ function setupSocketHandlers(socket) {
}; };
if (!handler) { if (!handler) {
// no handler: respond with an error object in data so server can fail it
console.warn("No handler for event:", eventType); console.warn("No handler for event:", eventType);
sendResponse({error: "No handler for event: " + eventType}); sendResponse({error: "No handler for event: " + eventType});
return; return;
@@ -91,7 +88,6 @@ function setupSocketHandlers(socket) {
}; };
} }
// connect/disconnect helpers
function connectWebSocket(url = null) { function connectWebSocket(url = null) {
if (!url) { if (!url) {
const loc = window.location; const loc = window.location;
@@ -100,7 +96,6 @@ function connectWebSocket(url = null) {
} }
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve(); if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
if (ws && ws.readyState === WebSocket.CONNECTING) { if (ws && ws.readyState === WebSocket.CONNECTING) {
// already connecting - return a promise that resolves on open
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const prevOnOpen = ws.onopen; const prevOnOpen = ws.onopen;
const prevOnError = ws.onerror; const prevOnError = ws.onerror;
@@ -121,7 +116,6 @@ function connectWebSocket(url = null) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
ws.onopen = () => { ws.onopen = () => {
console.log("WebSocket connection established!"); console.log("WebSocket connection established!");
// start heartbeat
timer = setInterval(() => { timer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
sendEventAndWait("ping", {}).then( sendEventAndWait("ping", {}).then(

View File

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