Compare commits

..

1 Commits

Author SHA1 Message Date
5136d14522 feat(api): Implement received hand event handling and UI updates 2025-11-23 17:40:23 +01:00
159 changed files with 3560 additions and 2378 deletions

1
.gitignore vendored
View File

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

5
.gitmodules vendored
View File

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

View File

@@ -142,294 +142,3 @@
### Features
* **websocket:** Implement WebSocket connection and event handling ([#82](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/82)) ([8ca909d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8ca909db522dd7108a3e40ce84811eaf8695eaa5))
## (2025-11-24)
## (2025-11-26)
### Features
* **api:** Implement received hand event handling and UI updates ([#83](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/83)) ([52e5033](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/52e5033afca344ae40a644196555a9655913710a)), closes [#76](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/76)
* **base:** Fixed logic for websockets and added GameStateEvent. Might've caused instability on other feature branches! ([#84](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/84)) ([b81bb3d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b81bb3d0aeb8500a9d7417a10e24e7f8a17d71d2))
## (2025-11-26)
### Features
* **api:** Implemented card played event via websocket ([#85](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/85)) ([3c0828f](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3c0828fdbeb507706b86f1662476c46e760533e4))
## (2025-11-26)
### Features
* **api:** Implemented session closed and kick event via websocket ([#87](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/87)) ([1ef5e8a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1ef5e8a72fdf8a3d1ae624c8c3d7c6595017bc6f))
## (2025-12-01)
### Features
* **api:** Implemented turn event via websocket ([#86](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/86)) ([2aee79b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2aee79bb6887008397aa0780d1d74ce96af1c202))
* GameState to Title Mapping BAC-1 ([#92](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/92)) ([6e17328](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/6e17328846745375482c97383b143d86a86e7f32))
* **ui:** Implement countless feature using the SJWP ([#89](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/89)) ([1f96290](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1f962903712163543fd4f98e696be5e7e29d88a6))
* **ui:** Popups ([#91](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/91)) ([0037820](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/003782090509bca1c5022c308231b7560dd9b23d))
### Bug Fixes
* **api:** Fixed websocket routing ([#88](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/88)) ([46c96d4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/46c96d4ceb935ac91fc515a1fdaef195e5ebc0a7))
* **api:** fixes - reimplemented animations ([#90](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/90)) ([cfcd967](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/cfcd967ce08ecf07f3f06826c337f684eb3b0c5f))
## (2025-12-01)
### Features
* **api:** BAC-10 Websockets - Kick Users ([#93](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/93)) ([0541bb5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/0541bb58d19efd98d134b3d0412f39b4b1001783))
## (2025-12-01)
### Features
* **api:** BAC-11 Websocket - Return to Lobby ([#94](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/94)) ([fd2467a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/fd2467a9ea22dca64d5152a5a3e6db86d9a6f345))
## (2025-12-01)
### Bug Fixes
* **api:** BAC-23 Remove old polling code ([#95](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/95)) ([a55f0b4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a55f0b4b6164a47e3524422650ed99d10f9c8b0d))
## (2025-12-01)
### Bug Fixes
* FRO-6 Websocket Close Handle ([#96](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/96)) ([3585566](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/358556612ec74601c8b31125e4e65f750abf8c4c))
## (2025-12-03)
### Features
* **ui:** FRO-7 Endscreen ([#97](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/97)) ([d57e6ef](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/d57e6efa985ca07c32f9f54595fe7393dbdf4d8a))
## (2025-12-03)
## (2025-12-03)
## (2025-12-04)
### Bug Fixes
* BAC-25 Race Condition: Websocket Promises ([#99](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/99)) ([f847424](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/f847424b9cea423ace5661d1efb6e4f01483c655))
## (2025-12-04)
### Features
* FRO-3 FRO-4 Added vue compontents to ingame and lobby ([#100](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/100)) ([194df56](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/194df5691ccda1c21ebe9157c4396a4a21aa921d))
## (2025-12-05)
### Bug Fixes
* BAC-29 Implement Mappers for Common Classes ([#101](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/101)) ([270f44c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/270f44cc1f3447ffcc33fb19a47c52391c69972b))
## (2025-12-06)
### Features
* BAC-30 Implement Jackson Mapping via DTOs ([#102](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/102)) ([8d697fd](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8d697fd311478cf792b4631377de4522ecbda9f7))
## (2025-12-10)
### Bug Fixes
* FRO-29 Websocket Communication ([#104](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/104)) ([fa3d21e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/fa3d21e3038eb07369764850a9ad9badd269ac57))
## (2025-12-10)
### 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))
## (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))
## (2026-01-06)
### Features
* Enhance win effects and animations in OfflineView component ([088dda8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/088dda8d528edb9f3fd420e7e69eb44144d39eff))
## (2026-01-07)
### Features
* Add GatewayModule for enhanced module configuration ([1d05bd4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1d05bd43b482564636345322a2cecf58f7d229d0))
* Enhance Dockerfile with secret management for GitHub credentials ([5f9ef2b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5f9ef2beb0a6e7d5e8caf436f545bbd78a6b242e))
* Simplify Dockerfile by removing multi-stage build and adjusting file copy paths ([e17f59e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e17f59e614a0060b70514c2337ac6fd84688546e))
* Update build.sbt and Dockerfile for improved GitHub credentials handling ([8126b46](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8126b46a0a1649d2b20b31975603f7610fafd18b))
* Update build.sbt and Dockerfile for improved GitHub credentials handling ([25dd926](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/25dd9264b57ffafc5b01587ff5dda2e2188b5fbd))
* Update Dockerfile for multi-platform support and add nginx configuration ([ccf993b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/ccf993bff25b4551a70c1f0263695f828df15a02))
* Update Dockerfile for multi-platform support and add nginx configuration ([3e06734](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3e067346ffe3bdc62dc936ea8e79ae9293d86351))
* Update Dockerfile for multi-platform support and add nginx configuration ([b2527ed](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b2527ed041568d20f880515b406fe0b0e10c12c1))
## (2026-01-07)
## (2026-01-07)
### Features
* Update configuration files for CORS settings and add production environment ([3b7a1e3](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3b7a1e3c646d870134d8d06b4962498b0e282cbd))
## (2026-01-07)
### Features
* Add caching headers for env.js in Nginx configuration ([dbad818](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/dbad818fdaeb237a05f583e5402773a4339e7aa1))
## (2026-01-07)
### Features
* Add logging to Gateway for pod synchronization and startup events ([6ef7401](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/6ef74014430673e725245bf37e44c5b90b81abb3))
## (2026-01-07)
### Features
* Add logging to Gateway for pod synchronization and startup events ([2615707](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/26157076d686a5dd3f8157ec2b2d1ae9d9e9eedf))
## (2026-01-07)
### Features
* Add Health and Login endpoints with updated Redis configuration ([4a5af36](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4a5af36ae0dcb540e02b7a1cd042e54cc6342c78))
## (2026-01-07)
### Features
* Update Gateway to use ArrayList for game IDs and bound users ([2f89951](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2f89951c25484d6bc412536a83019ee6d0b7f780))
## (2026-01-07)
### Features
* Update joinGame endpoint to accept gameId as a path parameter ([cf18549](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/cf1854976a51eb4931d50cf93640498ed18686fc))
## (2026-01-07)
### Features
* Enhance user state management with polling and WebSocket connection handling ([b4bf2ce](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b4bf2ceb4dc76ac388124b9705a1aa9e577582af))
### Bug Fixes
* Update knockoutwhistfrontend hash for consistency ([0e555cd](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/0e555cdfeb114464c9438bfd5dc397201a073867))
## (2026-01-07)
### Features
* Implement PlayDogCard functionality in user session and update Vue component ([859dfce](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/859dfce521b193b9208d0c70fca88016f8fe08f4))
* Implement PlayDogCard functionality in user session and update Vue component ([61ae9b5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/61ae9b5a5e7cd9fd82b77e9159814b0066874c2d))
## (2026-01-13)
### Features
* **ui:** Tie selection ([#111](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/111)) ([dc3da9a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/dc3da9a75c75597ce81ce4d023af5390197012c9))
## (2026-01-14)
### Features
* CORE-4 Rework the delay handler ([#113](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/113)) ([4b17af2](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4b17af2c2f50a9d67cf1cf49cafdaac8f807d4b6))
## (2026-01-14)
### Features
* CORE-4 Rework the delay handler ([3ce2b13](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/3ce2b133bccf4dd591b6d038d6fa0d409a907775))
* Update LobbyComponent to use icons for player removal buttons ([802b6bf](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/802b6bf764eb41b806888e1b46a3e6d379d31f1b))
## (2026-01-20)
### Features
* BAC-39 Authentication ([#114](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/114)) ([f6d3a18](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/f6d3a1845205318f43eb443601fd257613b7defb))
## (2026-01-20)
### Features
* Update persistence.xml and build.sbt for resource management ([a7292e3](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a7292e3b5df4788f2f8bea5a2ec7b209b7357608))
## (2026-01-20)
### Features
* Update connection provider in persistence.xml for HikariCP ([f4290b4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/f4290b44976fb6dcd4fc4b896614ba6062da73b1))
## (2026-01-20)
### Features
* Update Hibernate connection provider and database configuration ([71a549b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/71a549b7f059e748f7691bb9a27e2861b61c6f6f))
## (2026-01-20)
### Features
* Add HikariCP specific configuration to db.conf ([009b2b1](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/009b2b1ad9180f58a0b1434354f8a467b4e452ca))
## (2026-01-20)
### Features
* Add HikariCP specific configuration to db.conf ([4aa8709](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4aa8709eb593b03254efc616b6b04c23b23ab6ab))
## (2026-01-20)
### Features
* Enhance EntityManagerProvider to use Play configuration for database settings ([476db28](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/476db288216ed2c1013fe3ddb9b82472254e352b))
## (2026-01-20)
### Features
* Disable default JPA and Hibernate modules and enhance EntityManagerProvider for HikariCP integration ([9fa1e5e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/9fa1e5e07122aebd0391d47c3513013243a72a0f))
## (2026-01-20)
### Features
* Add logging for user management operations in HibernateUserManager ([9ca1813](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/9ca1813f06539cffeb573d0e00571e4f2d5144f1))
## (2026-01-20)
### Features
* Integrate UserManager and HibernateUserManager in session management ([e32f4eb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e32f4eb8fff9daec46f20284e28e94a59231d033))
## (2026-01-20)
### Features
* Implement transaction management for user addition and removal in HibernateUserManager ([45dec00](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/45dec00b86a1395457226ed62ac319c61e38739a))
## (2026-01-20)
### Features
* Add mainRoute configuration for OpenID in application and environment files ([4f52c1a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4f52c1a0f30cf0b917452149a52b53b94d82a7c9))
## (2026-01-20)
### Features
* Simplify authorization request creation in OpenIDConnectService and use environment variables for Keycloak configuration ([82a9706](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/82a9706deb97db193015e55a048830d496e76d83))
## (2026-01-20)
### Features
* Use environment variables for Keycloak client configuration in staging ([fccd94c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/fccd94cc11db43f99eded13207cc93fb59d8703e))
## (2026-01-20)
### Features
* Add logging for OpenID authentication process in OpenIDController ([b729344](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/b729344d1e79c631146dd988248d033d97e12d93))
## (2026-01-20)
### Features
* Add error logging for user info retrieval in OpenIDConnectService ([861f2f1](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/861f2f1184e860fdd164481167f941c11accfedd))
## (2026-01-20)
### Features
* Add idClaimName parameter to OpenIDConnectService for flexible ID claim mapping ([2f38855](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2f388554495d8bd44b4c533baa0f2fc27eea22c2))
## (2026-01-21)
### Features
* Change log level to warn for OpenID callback in OpenIDController ([23cdbe9](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/23cdbe9bd127bc26405fd216372bbb2d7e718a77))

View File

@@ -1,4 +1,26 @@
FROM --platform=$TARGETPLATFORM eclipse-temurin:22-jre-alpine
# === Stage 1: Build the Play application ===
FROM sbtscala/scala-sbt:eclipse-temurin-alpine-22_36_1.10.3_3.5.1 AS builder
WORKDIR /app
# Install Node.js and Less CSS preprocessor
USER root
RUN apk add --no-cache nodejs npm && \
npm install -g less
# Cache dependencies first
COPY project ./project
COPY build.sbt ./
RUN sbt -Dscoverage.skip=true update
# Copy the rest of the code
COPY . .
# Build the app and stage it
RUN sbt -Dscoverage.skip=true clean stage
# === Stage 2: Runtime image ===
FROM eclipse-temurin:21-jre-alpine
# Install Argon2 CLI and libraries
RUN apk add --no-cache bash argon2 argon2-libs
@@ -6,7 +28,7 @@ RUN apk add --no-cache bash argon2 argon2-libs
WORKDIR /opt/playapp
# Copy staged Play build
COPY ./knockoutwhistweb/target/universal/stage /opt/playapp
COPY --from=builder /app/knockoutwhistweb/target/universal/stage /opt/playapp
# Expose the default Play port
EXPOSE 9000

View File

@@ -1,6 +1,6 @@
meta {
name: Game
seq: 2
seq: 3
}
auth {

View File

@@ -1,16 +0,0 @@
meta {
name: Health
type: http
seq: 3
}
get {
url: {{host}}/health/simple
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,15 +0,0 @@
meta {
name: Request Status
type: http
seq: 1
}
get {
url: {{host}}/status
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

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

View File

@@ -1,3 +0,0 @@
vars {
host: https://knockout.janis-eccarius.de/api
}

View File

@@ -1,3 +0,0 @@
vars {
host: https://st.knockout.janis-eccarius.de/api
}

View File

@@ -1,12 +1,5 @@
ThisBuild / scalaVersion := "3.5.1"
credentials += Credentials(
"GitHub Package Registry",
"maven.pkg.github.com",
sys.env.getOrElse("GITHUB_USER", sys.error("GITHUB_USER not set")),
sys.env.getOrElse("GITHUB_TOKEN", sys.error("GITHUB_TOKEN not set"))
)
lazy val commonSettings = Seq(
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test",
@@ -42,28 +35,13 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
.enablePlugins(PlayScala)
.dependsOn(knockoutwhist % "compile->compile;test->test")
.settings(
resolvers += "GitHub Packages" at "https://maven.pkg.github.com/16Janis12/KnockOutWhist-Web",
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.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
libraryDependencies += "de.janis" % "knockoutwhist-data" % "1.0-SNAPSHOT",
libraryDependencies += "org.hibernate.orm" % "hibernate-core" % "6.4.4.Final",
libraryDependencies += "jakarta.persistence" % "jakarta.persistence-api" % "3.1.0",
libraryDependencies += "org.postgresql" % "postgresql" % "42.7.4",
libraryDependencies += "org.playframework" %% "play-jdbc" % "3.0.6",
libraryDependencies += "org.playframework" %% "play-java-jpa" % "3.0.6",
libraryDependencies += "com.nimbusds" % "oauth2-oidc-sdk" % "11.31.1",
libraryDependencies += "org.playframework" %% "play-ws" % "3.0.6",
libraryDependencies += "org.hibernate.orm" % "hibernate-hikaricp" % "7.2.1.Final",
libraryDependencies += ws,
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node,
PlayKeys.externalizeResourcesExcludes += baseDirectory.value / "conf" / "META-INF" / "persistence.xml"
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
)
lazy val root = (project in file("."))

View File

@@ -219,10 +219,10 @@ body {
}
#next-players-container {
#nextPlayers {
display: flex;
flex-direction: column;
align-items: flex-start;
align-items: center;
height: 0;
p {
@@ -231,6 +231,10 @@ body {
}
}
#invisible {
visibility: hidden;
}
#selecttrumpsuit {
display: flex;
flex-direction: column;

View File

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

View File

@@ -1,30 +0,0 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import dto.subDTO.UserDTO
import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.*
import play.api.libs.json.Json
import play.api.mvc.*
import play.api.mvc.Cookie.SameSite.{Lax, None, Strict}
import javax.inject.*
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class HealthController @Inject()(
val controllerComponents: ControllerComponents,
) extends BaseController {
def simple(): Action[AnyContent] = {
Action { implicit request =>
Ok("OK")
}
}
}

View File

@@ -0,0 +1,438 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import de.knockoutwhist.control.GameState.*
import exceptions.*
import logic.PodManager
import logic.game.GameLobby
import model.sessions.UserSession
import model.users.User
import play.api.*
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.*
import play.twirl.api.Html
import java.util.UUID
import javax.inject.*
import scala.concurrent.ExecutionContext
import scala.util.Try
@Singleton
class IngameController @Inject()(
val cc: ControllerComponents,
val authAction: AuthAction,
implicit val ec: ExecutionContext
) extends AbstractController(cc) {
def game(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val results = Try {
returnInnerHTML(g, request.user)
}
if (results.isSuccess) {
Ok(views.html.main("In-Game - Knockout Whist")(results.get))
} else {
InternalServerError(results.failed.get.getMessage)
}
case None =>
Redirect(routes.MainMenuController.mainMenu())
}
}
def returnInnerHTML(gameLobby: GameLobby, user: User): Html = {
gameLobby.logic.getCurrentState match {
case Lobby => views.html.lobby.lobby(Some(user), gameLobby)
case InGame =>
views.html.ingame.ingame(
gameLobby.getPlayerByUser(user),
gameLobby
)
case SelectTrump =>
views.html.ingame.selecttrump(
gameLobby.getPlayerByUser(user),
gameLobby
)
case TieBreak =>
views.html.ingame.tie(
gameLobby.getPlayerByUser(user),
gameLobby
)
case FinishedMatch =>
views.html.ingame.finishedMatch(
Some(user),
gameLobby
)
case _ =>
throw new IllegalStateException(s"Invalid game state for in-game view. GameId: ${gameLobby.id}" + s" State: ${gameLobby.logic.getCurrentState}")
}
}
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) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url,
"content" -> returnInnerHTML(game.get, request.user).toString()
))
} else {
val throwable = result.failed.get
throwable match {
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotHostException =>
Forbidden(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotEnoughPlayersException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
}
}
}
def kickPlayer(gameId: String, playerToKick: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
val playerToKickUUID = UUID.fromString(playerToKick)
val result = Try {
game.get.leaveGame(playerToKickUUID)
}
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url
))
} else {
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> "Something went wrong."
))
}
}
def leaveGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
val result = Try {
game.get.leaveGame(request.user.id)
}
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
))
} else {
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> "Something went wrong."
))
}
}
def playCard(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => {
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val jsonBody = request.body.asJson
val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "cardID").asOpt[String]
}
cardIdOpt match {
case Some(cardId) =>
var optSession: Option[UserSession] = None
val result = Try {
val session = g.getUserSession(request.user.id)
optSession = Some(session)
session.lock.lock()
g.playCard(session, cardId.toInt)
}
optSession.foreach(_.lock.unlock())
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success"
))
} else {
val throwable = result.failed.get
throwable match {
case _: CantPlayCardException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalArgumentException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInteractableException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
}
}
case None =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "cardId Parameter is missing"
))
}
case None =>
NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "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 jsonBody = request.body.asJson
val cardIdOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "cardID").asOpt[String]
}
var optSession: Option[UserSession] = None
val result = Try {
cardIdOpt match {
case Some(cardId) if cardId == "skip" =>
val session = g.getUserSession(request.user.id)
optSession = Some(session)
session.lock.lock()
g.playDogCard(session, -1)
case Some(cardId) =>
val session = g.getUserSession(request.user.id)
optSession = Some(session)
session.lock.lock()
g.playDogCard(session, cardId.toInt)
case None =>
throw new IllegalArgumentException("cardId parameter is missing")
}
}
optSession.foreach(_.lock.unlock())
if (result.isSuccess) {
NoContent
} else {
val throwable = result.failed.get
throwable match {
case _: CantPlayCardException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalArgumentException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> 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 jsonBody = request.body.asJson
val trumpOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "trump").asOpt[String]
}
trumpOpt match {
case Some(trump) =>
var optSession: Option[UserSession] = None
val result = Try {
val session = g.getUserSession(request.user.id)
optSession = Some(session)
session.lock.lock()
g.selectTrump(session, trump.toInt)
}
optSession.foreach(_.lock.unlock())
if (result.isSuccess) {
NoContent
} else {
val throwable = result.failed.get
throwable match {
case _: IllegalArgumentException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> 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 jsonBody = request.body.asJson
val tieOpt: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "tie").asOpt[String]
}
tieOpt match {
case Some(tie) =>
var optSession: Option[UserSession] = None
val result = Try {
val session = g.getUserSession(request.user.id)
optSession = Some(session)
session.lock.lock()
g.selectTie(g.getUserSession(request.user.id), tie.toInt)
}
optSession.foreach(_.lock.unlock())
if (result.isSuccess) {
NoContent
} else {
val throwable = result.failed.get
throwable match {
case _: IllegalArgumentException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
}
}
case None =>
BadRequest("tie parameter is missing")
}
case None =>
NotFound("Game not found")
}
}
def returnToLobby(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
val result = Try {
val session = g.getUserSession(request.user.id)
g.returnToLobby(session)
}
if (result.isSuccess) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameId).url
))
} else {
val throwable = result.failed.get
throwable match {
case _: NotInThisGameException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _: IllegalStateException =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
case _ =>
InternalServerError(Json.obj(
"status" -> "failure",
"errorMessage" -> throwable.getMessage
))
}
}
case None =>
NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "Game not found"
))
}
}
}

View File

@@ -0,0 +1,24 @@
package controllers
import auth.AuthAction
import play.api.mvc.{Action, AnyContent, BaseController, ControllerComponents}
import play.api.routing.JavaScriptReverseRouter
import javax.inject.Inject
class JavaScriptRoutingController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction,
) extends BaseController {
def javascriptRoutes(): Action[AnyContent] =
Action { implicit request =>
Ok(
JavaScriptReverseRouter("jsRoutes")(
routes.javascript.MainMenuController.createGame,
routes.javascript.MainMenuController.joinGame,
routes.javascript.MainMenuController.navSPA,
routes.javascript.UserController.login_Post
)
).as("text/javascript")
}
}

View File

@@ -16,9 +16,19 @@ import javax.inject.*
@Singleton
class MainMenuController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction
val authAction: AuthAction,
val ingameController: IngameController
) extends BaseController {
// Pass the request-handling function directly to authAction (no nested Action)
def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.main("Knockout Whist - Create Game")(views.html.mainmenu.creategame(Some(request.user))))
}
def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Redirect(routes.MainMenuController.mainMenu())
}
def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson
if (jsonBody.isDefined) {
@@ -35,7 +45,8 @@ class MainMenuController @Inject()(
)
Ok(Json.obj(
"status" -> "success",
"gameId" -> gameLobby.id,
"redirectUrl" -> routes.IngameController.game(gameLobby.id).url,
"content" -> ingameController.returnInnerHTML(gameLobby, request.user).toString
))
} else {
BadRequest(Json.obj(
@@ -46,19 +57,59 @@ class MainMenuController @Inject()(
}
def joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId)
game match {
case Some(g) =>
g.addUser(request.user)
def joinGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson
val gameId: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "gameId").asOpt[String]
}
if (gameId.isDefined) {
val game = PodManager.getGame(gameId.get)
game match {
case Some(g) =>
g.addUser(request.user)
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(g.id).url,
"content" -> ingameController.returnInnerHTML(g, request.user).toString
))
case None =>
NotFound(Json.obj(
"status" -> "failure",
"errorMessage" -> "No Game found"
))
}
} else {
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "Invalid form submission"
))
}
}
def rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
}
def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
location match {
case "0" => // Main Menu
Ok(Json.obj(
"status" -> "success"
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
))
case None =>
NotFound(Json.obj(
case "1" => // Rules
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.rules().url,
"content" -> views.html.mainmenu.rules(Some(request.user)).toString
))
case _ =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "No Game found"
"errorMessage" -> "Invalid form submission"
))
}
}
}

View File

@@ -1,172 +0,0 @@
package controllers
import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.{Configuration, Logger}
import play.api.libs.json.Json
import play.api.mvc.*
import play.api.mvc.Cookie.SameSite.Lax
import services.{OpenIDConnectService, OpenIDUserInfo}
import javax.inject.*
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class OpenIDController @Inject()(
val controllerComponents: ControllerComponents,
val openIDService: OpenIDConnectService,
val sessionManager: SessionManager,
val userManager: UserManager,
val config: Configuration
)(implicit ec: ExecutionContext) extends BaseController {
private val logger = Logger(this.getClass)
def loginWithProvider(provider: String): Action[AnyContent] = Action.async { implicit request =>
val state = openIDService.generateState()
val nonce = openIDService.generateNonce()
// Store state and nonce in session
openIDService.getAuthorizationUrl(provider, state, nonce) match {
case Some(authUrl) =>
Future.successful(Redirect(authUrl)
.withSession(
"oauth_state" -> state,
"oauth_nonce" -> nonce,
"oauth_provider" -> provider
))
case None =>
Future.successful(BadRequest(Json.obj("error" -> "Unsupported provider")))
}
}
def callback(provider: String): Action[AnyContent] = Action.async { implicit request =>
val sessionState = request.session.get("oauth_state")
val sessionNonce = request.session.get("oauth_nonce")
val sessionProvider = request.session.get("oauth_provider")
val returnedState = request.getQueryString("state")
val code = request.getQueryString("code")
val error = request.getQueryString("error")
logger.warn(s"Received callback from $provider with state $sessionState, nonce $sessionNonce, provider $sessionProvider, returned state $returnedState, code $code, error $error")
error match {
case Some(err) =>
logger.error(s"Authentication failed: $err")
Future.successful(Redirect("/login").flashing("error" -> s"Authentication failed: $err"))
case None =>
(for {
_ <- Option(sessionState.contains(returnedState.getOrElse("")))
_ <- Option(sessionProvider.contains(provider))
authCode <- code
} yield {
openIDService.exchangeCodeForTokens(provider, authCode, sessionState.get).flatMap {
case Some(tokenResponse) =>
openIDService.getUserInfo(provider, tokenResponse.accessToken).flatMap {
case Some(userInfo) =>
// Check if user already exists
userManager.authenticateOpenID(provider, userInfo.id) match {
case Some(user) =>
logger.info(s"User ${userInfo.name} (${userInfo.id}) already exists, logging them in")
// User already exists, log them in
val sessionToken = sessionManager.createSession(user)
Future.successful(Redirect(config.getOptional[String]("openid.mainRoute").getOrElse("/"))
.withCookies(Cookie(
name = "accessToken",
value = sessionToken,
httpOnly = true,
secure = false,
sameSite = Some(Lax)
))
.removingFromSession("oauth_state", "oauth_nonce", "oauth_provider", "oauth_access_token"))
case None =>
logger.info(s"User ${userInfo.name} (${userInfo.id}) not found, creating new user")
// New user, redirect to username selection
Future.successful(Redirect(config.get[String]("openid.selectUserRoute"))
.withSession(
"oauth_user_info" -> Json.toJson(userInfo).toString(),
"oauth_provider" -> provider,
"oauth_access_token" -> tokenResponse.accessToken
))
}
case None =>
logger.error("Failed to retrieve user information")
Future.successful(Redirect("/login").flashing("error" -> "Failed to retrieve user information"))
}
case None =>
logger.error("Failed to exchange authorization code")
Future.successful(Redirect("/login").flashing("error" -> "Failed to exchange authorization code"))
}
}).getOrElse {
logger.error("Invalid state parameter")
Future.successful(Redirect("/login").flashing("error" -> "Invalid state parameter"))
}
}
}
def selectUsername(): Action[AnyContent] = Action.async { implicit request =>
request.session.get("oauth_user_info") match {
case Some(userInfoJson) =>
val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo]
Future.successful(Ok(Json.obj(
"id" -> userInfo.id,
"email" -> userInfo.email,
"name" -> userInfo.name,
"picture" -> userInfo.picture,
"provider" -> userInfo.provider,
"providerName" -> userInfo.providerName
)))
case None =>
Future.successful(Redirect("/login").flashing("error" -> "No authentication information found"))
}
}
def submitUsername(): Action[AnyContent] = Action.async { implicit request =>
val username = request.body.asJson.flatMap(json => (json \ "username").asOpt[String])
.orElse(request.body.asFormUrlEncoded.flatMap(_.get("username").flatMap(_.headOption)))
val userInfoJson = request.session.get("oauth_user_info")
val provider = request.session.get("oauth_provider").getOrElse("unknown")
(username, userInfoJson) match {
case (Some(uname), Some(userInfoJson)) =>
val userInfo = Json.parse(userInfoJson).as[OpenIDUserInfo]
// Check if username already exists
val trimmedUsername = uname.trim
userManager.userExists(trimmedUsername) match {
case Some(_) =>
Future.successful(Conflict(Json.obj("error" -> "Username already taken")))
case None =>
// Create new user with OpenID info (no password needed)
val success = userManager.addOpenIDUser(trimmedUsername, userInfo)
if (success) {
// Get the created user and create session
userManager.userExists(trimmedUsername) match {
case Some(user) =>
val sessionToken = sessionManager.createSession(user)
Future.successful(Ok(Json.obj(
"message" -> "User created successfully",
"user" -> Json.obj(
"id" -> user.id,
"username" -> user.name
)
)).withCookies(Cookie(
name = "accessToken",
value = sessionToken,
httpOnly = true,
secure = false,
sameSite = Some(Lax)
)).removingFromSession("oauth_user_info", "oauth_provider", "oauth_access_token"))
case None =>
Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user session")))
}
} else {
Future.successful(InternalServerError(Json.obj("error" -> "Failed to create user")))
}
}
case _ =>
Future.successful(BadRequest(Json.obj("error" -> "Username is required")))
}
}
}

View File

@@ -1,96 +0,0 @@
package controllers
import auth.AuthAction
import logic.PodManager
import logic.game.GameLobby
import logic.user.SessionManager
import model.users.User
import play.api.libs.json.{JsValue, Json}
import play.api.mvc.*
import util.WebsocketEventMapper
import javax.inject.Inject
class StatusController @Inject()(
val controllerComponents: ControllerComponents,
val sessionManager: SessionManager,
val authAction: AuthAction
) extends BaseController {
def requestStatus(): Action[AnyContent] = {
Action { implicit request =>
val userOpt = getUserFromSession(request)
if (userOpt.isEmpty) {
Ok(
Json.obj(
"status" -> "unauthenticated"
)
)
} else {
val user = userOpt.get
val gameOpt = PodManager.identifyGameOfUser(user)
if (gameOpt.isEmpty) {
Ok(
Json.obj(
"status" -> "authenticated",
"username" -> user.name,
"userId" -> user.id,
"inGame" -> false
)
)
} else {
val game = gameOpt.get
Ok(
Json.obj(
"status" -> "authenticated",
"username" -> user.name,
"userId" -> user.id,
"inGame" -> true,
"gameId" -> game.id
)
)
}
}
}
}
def game(gameId: String): Action[AnyContent] = {
Action { implicit request =>
val userOpt = getUserFromSession(request)
if (userOpt.isEmpty) {
Unauthorized("User not authenticated")
} else {
val user = userOpt.get
val gameOpt = PodManager.getGame(gameId)
if (gameOpt.isEmpty) {
NotFound("Game not found")
} else {
val game = gameOpt.get
if (!game.getPlayers.contains(user.id)) {
Forbidden("User not part of this game")
} else {
Ok(
Json.obj(
"gameId" -> game.id,
"state" -> game.logic.getCurrentState.toString,
"data" -> mapGameState(game, user)
)
)
}
}
}
}}
private def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("accessToken")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
private def mapGameState(gameLobby: GameLobby, user: User): JsValue = {
val userSession = gameLobby.getUserSession(user.id)
WebsocketEventMapper.stateToJson(userSession)
}
}

View File

@@ -1,13 +1,10 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import dto.subDTO.UserDTO
import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.*
import play.api.libs.json.Json
import play.api.mvc.*
import play.api.mvc.Cookie.SameSite.{Lax, None, Strict}
import javax.inject.*
@@ -24,6 +21,22 @@ class UserController @Inject()(
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.main("Login")(views.html.login.login()))
}
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
}
}
def login_Post(): Action[AnyContent] = {
Action { implicit request =>
val jsonBody = request.body.asJson
@@ -38,17 +51,12 @@ class UserController @Inject()(
val possibleUser = userManager.authenticate(username.get, password.get)
if (possibleUser.isDefined) {
Ok(Json.obj(
"user" -> Json.obj(
"id" -> possibleUser.get.id,
"username" -> possibleUser.get.name
)
)).withCookies(Cookie(
name = "accessToken",
value = sessionManager.createSession(possibleUser.get),
httpOnly = true,
secure = false,
sameSite = Some(Lax)
))
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(possibleUser).toString
)).withCookies(
Cookie("sessionId", sessionManager.createSession(possibleUser.get))
)
} else {
Unauthorized("Invalid username or password")
}
@@ -57,62 +65,14 @@ class UserController @Inject()(
}
}
}
def getUserInfo(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val user: User = request.user
Ok(Json.obj(
"id" -> user.id,
"username" -> user.name
))
}
def register(): Action[AnyContent] = {
Action { implicit request =>
val jsonBody = request.body.asJson
val username: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "username").asOpt[String]
}
val password: Option[String] = jsonBody.flatMap { jsValue =>
(jsValue \ "password").asOpt[String]
}
if (username.isDefined && password.isDefined) {
// Validate input
if (username.get.trim.isEmpty || password.get.length < 6) {
BadRequest(Json.obj(
"error" -> "Invalid input",
"message" -> "Username must not be empty and password must be at least 6 characters"
))
} else {
// Try to register user
val registrationSuccess = userManager.addUser(username.get.trim, password.get)
if (registrationSuccess) {
Created(Json.obj(
"message" -> "User registered successfully",
"username" -> username.get.trim
))
} else {
Conflict(Json.obj(
"error" -> "User already exists",
"message" -> "Username is already taken"
))
}
}
} else {
BadRequest(Json.obj(
"error" -> "Invalid request",
"message" -> "Username and password are required"
))
}
}
}
def logoutPost(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val sessionCookie = request.cookies.get("accessToken")
// 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("accessToken"))
Redirect(routes.UserController.login()).discardingCookies(DiscardingCookie("sessionId"))
}
}

View File

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

View File

@@ -1,41 +0,0 @@
package di
import com.google.inject.{Inject, Provider}
import jakarta.inject.Singleton
import jakarta.persistence.{EntityManager, EntityManagerFactory, Persistence}
import play.api.Configuration
@Singleton
class EntityManagerProvider @Inject()(config: Configuration) extends Provider[EntityManager] {
private val emf: EntityManagerFactory = {
val dbConfig = config.get[Configuration]("db.default")
val props = new java.util.HashMap[String, Object]()
// Map Play configuration to Jakarta Persistence properties
props.put("jakarta.persistence.jdbc.driver", dbConfig.get[String]("driver"))
props.put("jakarta.persistence.jdbc.url", dbConfig.get[String]("url"))
props.put("jakarta.persistence.jdbc.user", dbConfig.get[String]("username"))
props.put("jakarta.persistence.jdbc.password", dbConfig.get[String]("password"))
// Also pass HikariCP settings if present
dbConfig.getOptional[Configuration]("hikaricp").foreach { hikariConfig =>
hikariConfig.keys.foreach { key =>
val value = hikariConfig.underlying.getValue(key).unwrapped()
props.put(s"hibernate.hikari.$key", value)
}
}
Persistence.createEntityManagerFactory("defaultPersistenceUnit", props)
}
override def get(): EntityManager = {
emf.createEntityManager()
}
def close(): Unit = {
if (emf.isOpen) {
emf.close()
}
}
}

View File

@@ -1,35 +0,0 @@
package dto
import dto.subDTO.*
import logic.game.GameLobby
import model.users.User
import scala.util.Try
case class GameInfoDTO(
gameId: String,
self: Option[PlayerDTO],
hand: Option[HandDTO],
playerQueue: PlayerQueueDTO,
currentTrick: Option[TrickDTO],
currentRound: Option[RoundDTO]
)
object GameInfoDTO {
def apply(lobby: GameLobby, user: User): GameInfoDTO = {
val selfPlayer = Try {
Some(lobby.getPlayerByUser(user))
}.getOrElse(None)
GameInfoDTO(
gameId = lobby.id,
self = selfPlayer.map(PlayerDTO(_)),
hand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_)),
playerQueue = PlayerQueueDTO(lobby.logic),
currentTrick = lobby.logic.getCurrentTrick.map(TrickDTO(_)),
currentRound = lobby.logic.getCurrentRound.map(r => RoundDTO(r, lobby.logic.getCurrentMatch))
)
}
}

View File

@@ -1,22 +0,0 @@
package dto
import dto.subDTO.UserDTO
import logic.game.GameLobby
import model.users.User
case class LobbyInfoDTO(gameId: String, users: List[UserDTO], self: UserDTO, maxPlayers: Int)
object LobbyInfoDTO {
def apply(lobby: GameLobby, user: User): LobbyInfoDTO = {
val session = lobby.getUserSession(user.id)
LobbyInfoDTO(
gameId = lobby.id,
users = lobby.getPlayers.values.map(user => UserDTO(user)).toList,
self = UserDTO(session),
maxPlayers = lobby.maxPlayers,
)
}
}

View File

@@ -1,27 +0,0 @@
package dto
import dto.subDTO.PlayerDTO
import logic.game.GameLobby
import model.users.User
import scala.util.Try
case class TieInfoDTO(gameId: String, currentPlayer: Option[PlayerDTO], self: Option[PlayerDTO], tiedPlayers: Seq[PlayerDTO], highestAmount: Int)
object TieInfoDTO {
def apply(lobby: GameLobby, user: User): TieInfoDTO = {
val selfPlayer = Try {
Some(lobby.getPlayerByUser(user))
}.getOrElse(None)
TieInfoDTO(
gameId = lobby.id,
currentPlayer = lobby.logic.playerTieLogic.currentTiePlayer().map(PlayerDTO.apply),
self = selfPlayer.map(PlayerDTO.apply),
tiedPlayers = lobby.logic.playerTieLogic.getTiedPlayers.map(PlayerDTO.apply),
highestAmount = lobby.logic.playerTieLogic.highestAllowedNumber()
)
}
}

View File

@@ -1,31 +0,0 @@
package dto
import dto.subDTO.{HandDTO, PlayerDTO}
import logic.game.GameLobby
import model.users.User
import scala.util.Try
case class TrumpInfoDTO(
gameId: String,
chooser: Option[PlayerDTO],
self: Option[PlayerDTO],
selfHand: Option[HandDTO],
)
object TrumpInfoDTO {
def apply(lobby: GameLobby, user: User): TrumpInfoDTO = {
val selfPlayer = Try {
Some(lobby.getPlayerByUser(user))
}.getOrElse(None)
TrumpInfoDTO(
gameId = lobby.id,
chooser = lobby.logic.getTrumpPlayer.map(PlayerDTO(_)),
self = selfPlayer.map(PlayerDTO(_)),
selfHand = selfPlayer.flatMap(_.currentHand()).map(HandDTO(_))
)
}
}

View File

@@ -1,34 +0,0 @@
package dto
import dto.subDTO.PodiumPlayerDTO
import logic.game.GameLobby
import model.users.User
case class WonInfoDTO(
gameId: String,
winner: Option[PodiumPlayerDTO],
allPlayers: Seq[PodiumPlayerDTO]
)
object WonInfoDTO {
def apply(lobby: GameLobby, user: User): WonInfoDTO = {
val matchImpl = lobby.logic.getCurrentMatch
if (matchImpl.isEmpty) {
throw new IllegalStateException("No current match available in game logic")
}
val allPlayersDTO: Seq[PodiumPlayerDTO] = matchImpl.get.totalplayers.map { player =>
PodiumPlayerDTO(lobby.logic, player)
}
val selfPlayerDTO = lobby.getPlayerByUser(user)
val winnerDTO = lobby.logic.getWinner
WonInfoDTO(
gameId = lobby.id,
winner = winnerDTO.map(player => PodiumPlayerDTO(lobby.logic, player)),
allPlayers = allPlayersDTO
)
}
}

View File

@@ -1,31 +0,0 @@
package dto.subDTO
import de.knockoutwhist.cards.Card
import util.WebUIUtils
case class CardDTO(identifier: String, path: String, idx: Option[Int]) {
def toCard: Card = {
WebUIUtils.stringToCard(identifier)
}
}
object CardDTO {
def apply(card: Card, index: Int): CardDTO = {
CardDTO(
identifier = WebUIUtils.cardtoString(card),
path = WebUIUtils.cardToPath(card),
idx = Some(index)
)
}
def apply(card: Card): CardDTO = {
CardDTO(
identifier = WebUIUtils.cardtoString(card),
path = WebUIUtils.cardToPath(card),
idx = None
)
}
}

View File

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

View File

@@ -1,17 +0,0 @@
package dto.subDTO
import de.knockoutwhist.player.AbstractPlayer
case class PlayerDTO(id: String, name: String, dogLife: Boolean)
object PlayerDTO {
def apply(player: AbstractPlayer): PlayerDTO = {
PlayerDTO(
id = player.id.toString,
name = player.name,
dogLife = player.isInDogLife
)
}
}

View File

@@ -1,19 +0,0 @@
package dto.subDTO
import de.knockoutwhist.control.GameLogic
case class PlayerQueueDTO(currentPlayer: Option[PlayerDTO], queue: Seq[PlayerDTO])
object PlayerQueueDTO {
def apply(logic: GameLogic): PlayerQueueDTO = {
val currentPlayerDTO = logic.getCurrentPlayer.map(PlayerDTO(_))
val queueDTO = logic.getPlayerQueue.map(_.duplicate().flatMap(player => Some(PlayerDTO(player))).toSeq)
if (queueDTO.isEmpty) {
PlayerQueueDTO(currentPlayerDTO, Seq.empty)
} else {
PlayerQueueDTO(currentPlayerDTO, queueDTO.get)
}
}
}

View File

@@ -1,47 +0,0 @@
package dto.subDTO
import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.player.AbstractPlayer
import de.knockoutwhist.rounds.Match
case class PodiumPlayerDTO(
player: PlayerDTO,
position: Int,
roundsWon: Int,
tricksWon: Int
)
object PodiumPlayerDTO {
def apply(gameLogic: GameLogic, player: AbstractPlayer): PodiumPlayerDTO = {
val matchImplOpt = gameLogic.getCurrentMatch
if (matchImplOpt.isEmpty) {
throw new IllegalStateException("No current match available in game logic")
}
val matchImpl: Match = matchImplOpt.get
var roundsWon = 0
var tricksWon = 0
for (round <- matchImpl.roundlist) {
if (round.winner.contains(player)) {
roundsWon += 1
}
for (trick <- round.tricklist) {
if (trick.winner.contains(player)) {
tricksWon += 1
}
}
}
PodiumPlayerDTO(
player = PlayerDTO(player),
position = if (gameLogic.getWinner.contains(player)) {
1
} else {
2
},
roundsWon = roundsWon,
tricksWon = tricksWon
)
}
}

View File

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

View File

@@ -1,17 +0,0 @@
package dto.subDTO
import de.knockoutwhist.rounds.Trick
case class TrickDTO(cards: Map[String, CardDTO], firstCard: Option[CardDTO], winner: Option[PlayerDTO])
object TrickDTO {
def apply(trick: Trick): TrickDTO = {
TrickDTO(
cards = trick.cards.map { case (card, player) => player.name -> CardDTO(card) },
firstCard = trick.firstCard.map(card => CardDTO(card)),
winner = trick.winner.map(player => PlayerDTO(player))
)
}
}

View File

@@ -1,17 +0,0 @@
package dto.subDTO
import model.sessions.UserSession
case class UserDTO(id: String, username: String, host: Boolean = false)
object UserDTO {
def apply(user: UserSession): UserDTO = {
UserDTO(
id = user.id.toString,
username = user.name,
host = user.host
)
}
}

View File

@@ -1,9 +0,0 @@
package events
import model.users.User
case class KickEvent(user: User) extends UserEvent(user) {
override def id: String = "KickEvent"
}

View File

@@ -1,9 +0,0 @@
package events
import model.users.User
case class LeftEvent(user: User) extends UserEvent(user) {
override def id: String = "LeftEvent"
}

View File

@@ -1,9 +0,0 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
case class LobbyUpdateEvent() extends SimpleEvent {
override def id: String = "LobbyUpdateEvent"
}

View File

@@ -1,12 +0,0 @@
package events
import de.knockoutwhist.utils.events.SimpleEvent
import model.users.User
import java.util.UUID
abstract class UserEvent(user: User) extends SimpleEvent {
def userId: UUID = user.id
}

View File

@@ -1,54 +0,0 @@
package logic
import de.knockoutwhist.data.Pod
import de.knockoutwhist.data.redis.RedisManager
import org.apache.pekko.actor.ActorSystem
import org.redisson.config.Config
import play.api.Logger
import play.api.inject.ApplicationLifecycle
import java.util
import java.util.UUID
import javax.inject.*
import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters.*
@Singleton
class Gateway @Inject()(
lifecycle: ApplicationLifecycle,
actorSystem: ActorSystem
)(implicit ec: ExecutionContext) {
private val logger = Logger(getClass.getName)
val redis: RedisManager = {
val config: Config = Config()
val url = "redis://" + sys.env.getOrElse("REDIS_HOST", "localhost") + ":" + sys.env.getOrElse("REDIS_PORT", "6379")
logger.info(s"Connecting to Redis at $url")
config.useSingleServer.setAddress(url)
RedisManager(config)
}
redis.continuousSyncPod(() => {
logger.info("Syncing pod with Redis")
createPod()
})
logger.info("Gateway started")
def syncPod(): Unit = {
redis.syncPod(createPod())
}
private def createPod(): Pod = {
Pod(
UUID.randomUUID().toString,
PodManager.podName,
PodManager.podIp,
9000,
new util.ArrayList[String](PodManager.getAllGameIds().asJava),
new util.ArrayList[String](PodManager.allBoundUsers().asJava)
)
}
}

View File

@@ -21,8 +21,6 @@ object PodManager {
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
private[logic] var redis: Option[Gateway] = None
def createGame(
host: User,
name: String,
@@ -37,8 +35,7 @@ object PodManager {
host = host
)
sessions += (gameLobby.id -> gameLobby)
registerUserToGame(host, gameLobby.id)
redis.foreach(gateway => gateway.syncPod())
userSession += (host -> gameLobby.id)
gameLobby
}
@@ -49,7 +46,6 @@ object PodManager {
def registerUserToGame(user: User, gameId: String): Boolean = {
if (sessions.contains(gameId)) {
userSession += (user -> gameId)
redis.foreach(gateway => gateway.syncPod())
true
} else {
false
@@ -58,7 +54,6 @@ object PodManager {
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
redis.foreach(gateway => gateway.redis.invalidateUser(user.id.toString))
}
def identifyGameOfUser(user: User): Option[GameLobby] = {
@@ -70,12 +65,9 @@ object PodManager {
private[logic] def removeGame(gameId: String): Unit = {
sessions.remove(gameId)
redis.foreach(gateway => gateway.redis.invalidateGame(gameId))
// Also remove all user sessions associated with this game
userSession.filterInPlace((_, v) => v != gameId)
}
private[logic] def getAllGameIds(): List[String] = sessions.keys.toList
private[logic] def allBoundUsers(): List[String] = userSession.keys.map(_.id.toString).toList
}

View File

@@ -9,19 +9,16 @@ 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.DelayHandler
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import events.{KickEvent, LeftEvent, LobbyUpdateEvent, UserEvent}
import exceptions.*
import logic.PodManager
import model.sessions.{InteractionType, UserSession}
import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.{Timer, TimerTask, UUID}
import java.util.UUID
import scala.collection.mutable
import scala.collection.mutable.ListBuffer
import scala.util.Try
class GameLobby private(
val logic: GameLogic,
@@ -34,7 +31,6 @@ class GameLobby private(
private val users: mutable.Map[UUID, UserSession] = mutable.Map()
logic.addListener(this)
logic.addListener(DelayHandler)
logic.createSession()
def addUser(user: User): UserSession = {
@@ -48,7 +44,7 @@ class GameLobby private(
)
users += (user.id -> userSession)
PodManager.registerUserToGame(user, id)
logic.invoke(LobbyUpdateEvent())
//TODO : transmit Lobby Update transmitToAll()
userSession
}
@@ -56,8 +52,6 @@ class GameLobby private(
event match {
case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: UserEvent =>
users.get(event.userId).foreach(session => session.updatePlayer(event))
case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) {
return
@@ -99,30 +93,28 @@ class GameLobby private(
* Remove the user from the game lobby.
*
* @param user the user who wants to leave the game.
* @param kicked whether the user was kicked or left voluntarily.
*/
def leaveGame(userId: UUID, kicked: Boolean): Unit = {
def leaveGame(userId: UUID): Unit = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
for (session <- users.values) {
PodManager.unregisterUserFromGame(session.user)
}
users.clear()
PodManager.removeGame(id)
return
}
if (kicked) {
logic.invoke(KickEvent(sessionOpt.get.user))
} else {
logic.invoke(LeftEvent(sessionOpt.get.user))
}
sessionOpt.get.websocketActor.foreach(act => act.transmitJsonToClient(Json.obj(
"id" -> "-1",
"event" -> "SessionClosed",
"data" -> Json.obj(
"reason" -> "You left the game (or got kicked)."
)
)))
users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user)
logic.invoke(LobbyUpdateEvent())
//TODO: transmit Lobby Update transmitToAll()
}
/**
@@ -283,37 +275,6 @@ class GameLobby private(
def getUsers: Set[User] = {
users.values.map(d => d.user).toSet
}
def getFinalRanking: List[(String, (Int, Int))] = {
Try {
val match1 = getMatch
if (!match1.isOver) {
List.empty
} else {
val winnerName = logic.getWinner.get.name
val allPlayerNames = match1.totalplayers.map(_.name)
val roundlist = match1.roundlist
val playerMetrics: Map[String, (Int, Int)] = allPlayerNames.map { name =>
val roundsWon = roundlist.count { round =>
round.winner.exists(_.name == name)
}
val totalTricksWon = roundlist.flatMap(_.tricklist).count { trick =>
trick.winner.exists(_.name == name)
}
name -> (roundsWon, totalTricksWon)
}.toMap
val winnerMetrics = playerMetrics(winnerName)
val remainingPlayersMetrics = playerMetrics.view.filterKeys(_ != winnerName).toList
val sortedRemainingPlayers = remainingPlayersMetrics.sortBy { case (_, (rounds, tricks)) =>
(-rounds, -tricks)
}
(winnerName, winnerMetrics) :: sortedRemainingPlayers
}
}.getOrElse(List())
}
private def transmitToAll(event: JsObject): Unit = {
users.values.foreach(session => {

View File

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

View File

@@ -3,19 +3,14 @@ package logic.user
import com.google.inject.ImplementedBy
import logic.user.impl.StubUserManager
import model.users.User
import services.OpenIDUserInfo
@ImplementedBy(classOf[StubUserManager])
trait UserManager {
def addUser(name: String, password: String): Boolean
def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean
def authenticate(name: String, password: String): Option[User]
def authenticateOpenID(provider: String, providerId: String): Option[User]
def userExists(name: String): Option[User]
def userExistsById(id: Long): Option[User]

View File

@@ -4,7 +4,7 @@ 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, UserManager}
import logic.user.SessionManager
import model.users.User
import scalafx.util.Duration
import services.JwtKeyProvider
@@ -16,7 +16,7 @@ import javax.inject.{Inject, Singleton}
import scala.util.Try
@Singleton
class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val userManager: UserManager, 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)

View File

@@ -1,204 +0,0 @@
package logic.user.impl
import com.typesafe.config.Config
import jakarta.inject.Inject
import jakarta.persistence.EntityManager
import logic.user.UserManager
import model.users.{User, UserEntity}
import play.api.Logger
import services.OpenIDUserInfo
import util.UserHash
import javax.inject.Singleton
import scala.jdk.CollectionConverters.*
@Singleton
class HibernateUserManager @Inject()(em: EntityManager, config: Config) extends UserManager {
private val logger = Logger(getClass.getName)
override def addUser(name: String, password: String): Boolean = {
val tx = em.getTransaction
try {
tx.begin()
// Check if user already exists
val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
.setParameter("username", name)
.getResultList
if (!existing.isEmpty) {
logger.warn(s"User $name already exists")
tx.rollback()
return false
}
// Create new user
val userEntity = UserEntity.fromUser(User(
internalId = 0L, // Will be set by database
id = java.util.UUID.randomUUID(),
name = name,
passwordHash = UserHash.hashPW(password)
))
em.persist(userEntity)
em.flush()
tx.commit()
true
} catch {
case e: Exception => {
if (tx.isActive) tx.rollback()
logger.error(s"Error adding user $name", e)
false
}
}
}
override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = {
val tx = em.getTransaction
try {
tx.begin()
// Check if user already exists
val existing = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
.setParameter("username", name)
.getResultList
if (!existing.isEmpty) {
logger.warn(s"User $name already exists")
tx.rollback()
return false
}
// Check if OpenID user already exists
val existingOpenID = em.createQuery(
"SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId",
classOf[UserEntity]
)
.setParameter("provider", userInfo.provider)
.setParameter("providerId", userInfo.id)
.getResultList
if (!existingOpenID.isEmpty) {
logger.warn(s"OpenID user ${userInfo.provider}_${userInfo.id} already exists")
tx.rollback()
return false
}
// Create new OpenID user
val userEntity = UserEntity.fromOpenIDUser(name, userInfo)
em.persist(userEntity)
em.flush()
tx.commit()
true
} catch {
case e: Exception => {
if (tx.isActive) tx.rollback()
logger.error(s"Error adding OpenID user ${userInfo.provider}_${userInfo.id}", e)
false
}
}
}
override def authenticate(name: String, password: String): Option[User] = {
try {
val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
.setParameter("username", name)
.getResultList
if (users.isEmpty) {
return None
}
val userEntity = users.get(0)
if (UserHash.verifyUser(password, userEntity.toUser)) {
Some(userEntity.toUser)
} else {
None
}
} catch {
case e: Exception => {
logger.error(s"Error authenticating user $name", e)
None
}
}
}
override def authenticateOpenID(provider: String, providerId: String): Option[User] = {
try {
val users = em.createQuery(
"SELECT u FROM UserEntity u WHERE u.openidProvider = :provider AND u.openidProviderId = :providerId",
classOf[UserEntity]
)
.setParameter("provider", provider)
.setParameter("providerId", providerId)
.getResultList
if (users.isEmpty) {
None
} else {
Some(users.get(0).toUser)
}
} catch {
case e: Exception => {
logger.error(s"Error authenticating OpenID user ${provider}_$providerId", e)
None
}
}
}
override def userExists(name: String): Option[User] = {
try {
val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
.setParameter("username", name)
.getResultList
if (users.isEmpty) {
None
} else {
Some(users.get(0).toUser)
}
} catch {
case e: Exception => {
logger.error(s"Error checking if user $name exists", e)
None
}
}
}
override def userExistsById(id: Long): Option[User] = {
try {
Option(em.find(classOf[UserEntity], id)).map(_.toUser)
} catch {
case e: Exception => {
logger.error(s"Error checking if user with ID $id exists", e)
None
}
}
}
override def removeUser(name: String): Boolean = {
val tx = em.getTransaction
try {
tx.begin()
val users = em.createQuery("SELECT u FROM UserEntity u WHERE u.username = :username", classOf[UserEntity])
.setParameter("username", name)
.getResultList
if (users.isEmpty) {
tx.rollback()
false
} else {
em.remove(users.get(0))
em.flush()
tx.commit()
true
}
} catch {
case _: Exception => {
if (tx.isActive) tx.rollback()
false
}
}
}
}

View File

@@ -3,16 +3,14 @@ package logic.user.impl
import com.typesafe.config.Config
import logic.user.UserManager
import model.users.User
import services.OpenIDUserInfo
import util.UserHash
import javax.inject.{Inject, Singleton}
import scala.collection.mutable
@Singleton
class StubUserManager @Inject()(config: Config) extends UserManager {
class StubUserManager @Inject()(val config: Config) extends UserManager {
private val user: mutable.Map[String, User] = mutable.Map(
private val user: Map[String, User] = Map(
"Janis" -> User(
internalId = 1L,
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
@@ -21,8 +19,8 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
),
"Leon" -> User(
internalId = 2L,
id = java.util.UUID.randomUUID(),
name = "Jakob",
id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"),
name = "Leon",
passwordHash = UserHash.hashPW("password123")
),
"Jakob" -> User(
@@ -34,26 +32,7 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
)
override def addUser(name: String, password: String): Boolean = {
val newUser = User(
internalId = user.size.toLong + 1,
id = java.util.UUID.randomUUID(),
name = name,
passwordHash = UserHash.hashPW(password)
)
user(name) = newUser
true
}
override def addOpenIDUser(name: String, userInfo: OpenIDUserInfo): Boolean = {
// For stub implementation, just add a user without password
val newUser = User(
internalId = user.size.toLong + 1,
id = java.util.UUID.randomUUID(),
name = name,
passwordHash = "" // No password for OpenID users
)
user(name) = newUser
true
throw new NotImplementedError("StubUserManager.addUser is not implemented")
}
override def authenticate(name: String, password: String): Option[User] = {
@@ -63,13 +42,6 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
}
}
override def authenticateOpenID(provider: String, providerId: String): Option[User] = {
user.values.find { u =>
// In a real implementation, this would check stored OpenID provider info
u.name.startsWith(s"${provider}_") && u.name.contains(providerId)
}
}
override def userExists(name: String): Option[User] = {
user.get(name)
}
@@ -79,6 +51,7 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
}
override def removeUser(name: String): Boolean = {
user.remove(name).isDefined
throw new NotImplementedError("StubUserManager.removeUser is not implemented")
}
}

View File

@@ -4,7 +4,7 @@ import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent,
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User
import play.api.libs.json.{JsObject, JsValue}
import play.api.libs.json.JsObject
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
@@ -26,11 +26,6 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
else canInteract = Some(InteractionType.Card)
case _ =>
}
lock.lock()
websocketActor.foreach(_.solveRequests())
websocketActor.foreach(_.transmitEventToClient(event))
lock.unlock()
}
override def id: UUID = user.id
@@ -42,65 +37,15 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
}
def handleWebResponse(eventType: String, data: JsObject): Unit = {
eventType match {
case "ping" =>
// No action needed for Ping
()
case "StartGame" =>
gameLobby.startGame(user)
case "PlayCard" =>
val maybeCardIndex: Option[Int] = (data \ "cardindex").asOpt[Int]
maybeCardIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.playCard(session, index)
case None =>
println("Card Index not found or is not a number." + data)
}
case "PlayDogCard" =>
val maybeCardIndex: Option[Int] = (data \ "cardindex").asOpt[Int]
maybeCardIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.playDogCard(session, index)
case None =>
val session = gameLobby.getUserSession(user.id)
gameLobby.playDogCard(session, -1)
}
case "PickTrumpsuit" =>
val maybeSuitIndex: Option[Int] = (data \ "suitIndex").asOpt[Int]
maybeSuitIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.selectTrump(session, index)
case None =>
println("Card Index not found or is not a number.")
}
case "PickTie" =>
val maybeCardIndex: Option[Int] = (data \ "cardIndex").asOpt[Int]
maybeCardIndex match {
case Some(index) =>
val session = gameLobby.getUserSession(user.id)
gameLobby.selectTie(session, index)
case None =>
println("Card Index not found or is not a number.")
}
case "KickPlayer" =>
val maybePlayerId: Option[String] = (data \ "playerId").asOpt[String]
maybePlayerId match {
case Some(id) =>
val playerUUID = UUID.fromString(id)
gameLobby.leaveGame(playerUUID, true)
case None =>
println("Player ID not found or is not a valid UUID.")
}
case "ReturnToLobby" =>
gameLobby.returnToLobby(this)
case "LeaveGame" =>
gameLobby.leaveGame(user.id, false)
case _ =>
println("Unknown event type: " + eventType + " with data: " + data)
lock.lock()
Try {
eventType match {
case "Ping" =>
// No action needed for Ping
()
}
}
lock.unlock()
}
}

View File

@@ -5,7 +5,6 @@ import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import scala.collection.mutable
import scala.util.{Failure, Success, Try}
class UserWebsocketActor(
@@ -13,21 +12,13 @@ class UserWebsocketActor(
session: UserSession
) extends Actor {
private val requests: mutable.Map[String, String] = mutable.Map()
{
session.lock.lock()
if (session.websocketActor.isDefined) {
val otherWebsocket = session.websocketActor.get
otherWebsocket.transmitTextToClient("Error: Multiple websocket connections detected. Closing your connection.")
context.stop(otherWebsocket.self)
transmitTextToClient("Previous websocket connection closed. You are now connected.")
}
if (session.websocketActor.isDefined) {
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection."))
context.stop(self)
} else {
session.websocketActor = Some(this)
session.lock.unlock()
}
override def receive: Receive = {
case msg: String =>
val jsonObject = Try {
@@ -51,14 +42,12 @@ class UserWebsocketActor(
}
private def handle(json: JsValue): Unit = {
session.lock.lock()
val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"status" -> "error",
"error" -> "Missing 'id' field"
))
session.lock.unlock()
return
}
val id = idOpt.get
@@ -70,25 +59,13 @@ class UserWebsocketActor(
"status" -> "error",
"error" -> "Missing 'event' field"
))
session.lock.unlock()
return
}
val statusOpt = (json \ "status").asOpt[String]
if (statusOpt.isDefined) {
session.lock.unlock()
return
}
val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
requests += (id -> event)
val result = Try {
session.handleWebResponse(event, data)
}
if (!requests.contains(id)) {
session.lock.unlock()
return
}
requests -= id
if (result.isSuccess) {
transmitJsonToClient(Json.obj(
"id" -> id,
@@ -103,31 +80,15 @@ class UserWebsocketActor(
"error" -> result.failed.get.getMessage
))
}
session.lock.unlock()
}
def transmitJsonToClient(jsonObj: JsValue): Unit = {
transmitTextToClient(jsonObj.toString())
def transmitJsonToClient(jsonObj: JsObject): Unit = {
out ! jsonObj.toString()
}
def transmitEventToClient(event: SimpleEvent): Unit = {
transmitJsonToClient(WebsocketEventMapper.toJson(event, session))
}
def solveRequests(): Unit = {
if (!session.lock.isHeldByCurrentThread)
return;
if (requests.isEmpty)
return;
val pendingRequests = requests.toMap
requests.clear()
pendingRequests.foreach { case (id, event) =>
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "success"
))
}
val jsonString = WebsocketEventMapper.toJson(event)
out ! jsonString
}
}

View File

@@ -1,6 +1,5 @@
package model.users
import java.util.UUID
case class User(
@@ -17,4 +16,5 @@ case class User(
private def withPasswordHash(newPasswordHash: String): User = {
this.copy(passwordHash = newPasswordHash)
}
}
}

View File

@@ -1,80 +0,0 @@
package model.users
import jakarta.persistence.*
import java.time.LocalDateTime
import java.util.UUID
import scala.compiletime.uninitialized
@Entity
@Table(name = "users")
class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
var id: Long = uninitialized
@Column(name = "uuid", nullable = false, unique = true)
var uuid: UUID = uninitialized
@Column(name = "username", nullable = false, unique = true)
var username: String = uninitialized
@Column(name = "password_hash", nullable = false)
var passwordHash: String = uninitialized
@Column(name = "openid_provider")
var openidProvider: String = uninitialized
@Column(name = "openid_provider_id")
var openidProviderId: String = uninitialized
@Column(name = "created_at", nullable = false)
var createdAt: LocalDateTime = uninitialized
@Column(name = "updated_at", nullable = false)
var updatedAt: LocalDateTime = uninitialized
@PrePersist
def onCreate(): Unit = {
val now = LocalDateTime.now()
createdAt = now
updatedAt = now
if (uuid == null) {
uuid = UUID.randomUUID()
}
}
@PreUpdate
def onUpdate(): Unit = {
updatedAt = LocalDateTime.now()
}
def toUser: User = {
User(
internalId = id,
id = uuid,
name = username,
passwordHash = passwordHash
)
}
}
object UserEntity {
def fromUser(user: User): UserEntity = {
val entity = new UserEntity()
entity.uuid = user.id
entity.username = user.name
entity.passwordHash = user.passwordHash
entity
}
def fromOpenIDUser(username: String, userInfo: services.OpenIDUserInfo): UserEntity = {
val entity = new UserEntity()
entity.username = username
entity.passwordHash = "" // No password for OpenID users
entity.openidProvider = userInfo.provider
entity.openidProviderId = userInfo.id
entity
}
}

View File

@@ -1,24 +0,0 @@
package modules
import com.google.inject.AbstractModule
import di.EntityManagerProvider
import jakarta.persistence.EntityManager
import logic.Gateway
import logic.user.UserManager
import logic.user.impl.HibernateUserManager
class GatewayModule extends AbstractModule {
override def configure(): Unit = {
bind(classOf[Gateway]).asEagerSingleton()
// Bind HibernateUserManager for production (when GatewayModule is used)
bind(classOf[UserManager])
.to(classOf[HibernateUserManager])
.asEagerSingleton()
// Bind EntityManager for JPA
bind(classOf[EntityManager])
.toProvider(classOf[EntityManagerProvider])
.asEagerSingleton()
}
}

View File

@@ -1,173 +0,0 @@
package services
import com.typesafe.config.Config
import play.api.libs.ws.WSClient
import play.api.{Configuration, Logger}
import play.api.libs.json.*
import java.net.URI
import javax.inject.*
import scala.concurrent.{ExecutionContext, Future}
import com.nimbusds.oauth2.sdk.*
import com.nimbusds.oauth2.sdk.id.*
import com.nimbusds.openid.connect.sdk.*
import play.api.libs.ws.DefaultBodyWritables.*
case class OpenIDUserInfo(
id: String,
email: Option[String],
name: Option[String],
picture: Option[String],
provider: String,
providerName: String
)
object OpenIDUserInfo {
implicit val writes: Writes[OpenIDUserInfo] = Json.writes[OpenIDUserInfo]
implicit val reads: Reads[OpenIDUserInfo] = Json.reads[OpenIDUserInfo]
}
case class OpenIDProvider(
name: String,
clientId: String,
clientSecret: String,
redirectUri: String,
authorizationEndpoint: String,
tokenEndpoint: String,
userInfoEndpoint: String,
scopes: Set[String] = Set("openid", "profile", "email"),
idClaimName: String = "id"
)
case class TokenResponse(
accessToken: String,
tokenType: String,
expiresIn: Option[Int],
refreshToken: Option[String],
idToken: Option[String]
)
@Singleton
class OpenIDConnectService@Inject(ws: WSClient, config: Configuration)(implicit ec: ExecutionContext) {
private val logger = Logger(this.getClass)
private val providers = Map(
"discord" -> OpenIDProvider(
name = "Discord",
clientId = config.get[String]("openid.discord.clientId"),
clientSecret = config.get[String]("openid.discord.clientSecret"),
redirectUri = config.get[String]("openid.discord.redirectUri"),
authorizationEndpoint = "https://discord.com/oauth2/authorize",
tokenEndpoint = "https://discord.com/api/oauth2/token",
userInfoEndpoint = "https://discord.com/api/users/@me",
scopes = Set("identify", "email")
),
"keycloak" -> OpenIDProvider(
name = "Identity",
clientId = config.get[String]("openid.keycloak.clientId"),
clientSecret = config.get[String]("openid.keycloak.clientSecret"),
redirectUri = config.get[String]("openid.keycloak.redirectUri"),
authorizationEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/auth",
tokenEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/token",
userInfoEndpoint = config.get[String]("openid.keycloak.authUrl") + "/protocol/openid-connect/userinfo",
scopes = Set("openid", "profile", "email"),
idClaimName = "sub"
)
)
def getAuthorizationUrl(providerName: String, state: String, nonce: String): Option[String] = {
providers.get(providerName).map { provider =>
val authRequest = new AuthorizationRequest.Builder(
new ResponseType(ResponseType.Value.CODE),
new com.nimbusds.oauth2.sdk.id.ClientID(provider.clientId)
)
.scope(new com.nimbusds.oauth2.sdk.Scope(provider.scopes.mkString(" ")))
.state(new com.nimbusds.oauth2.sdk.id.State(state))
.redirectionURI(URI.create(provider.redirectUri))
.endpointURI(URI.create(provider.authorizationEndpoint))
.build()
authRequest.toURI.toString
}
}
def exchangeCodeForTokens(providerName: String, code: String, state: String): Future[Option[TokenResponse]] = {
providers.get(providerName) match {
case Some(provider) =>
ws.url(provider.tokenEndpoint)
.withHttpHeaders(
"Accept" -> "application/json",
"Content-Type" -> "application/x-www-form-urlencoded"
)
.post(
Map(
"client_id" -> Seq(provider.clientId),
"client_secret" -> Seq(provider.clientSecret),
"code" -> Seq(code),
"grant_type" -> Seq("authorization_code"),
"redirect_uri" -> Seq(provider.redirectUri)
)
)
.map { response =>
if (response.status == 200) {
val json = response.json
Some(TokenResponse(
accessToken = (json \ "access_token").as[String],
tokenType = (json \ "token_type").as[String],
expiresIn = (json \ "expires_in").asOpt[Int],
refreshToken = (json \ "refresh_token").asOpt[String],
idToken = (json \ "id_token").asOpt[String]
))
} else {
None
}
}
.recover { case _ => None }
case None => Future.successful(None)
}
}
def getUserInfo(providerName: String, accessToken: String): Future[Option[OpenIDUserInfo]] = {
providers.get(providerName) match {
case Some(provider) =>
ws.url(provider.userInfoEndpoint)
.withHttpHeaders("Authorization" -> s"Bearer $accessToken")
.get()
.map { response =>
if (response.status == 200) {
val json = response.json
Some(OpenIDUserInfo(
id = (json \ provider.idClaimName).as[String],
email = (json \ "email").asOpt[String],
name = (json \ "name").asOpt[String].orElse((json \ "login").asOpt[String]),
picture = (json \ "picture").asOpt[String].orElse((json \ "avatar_url").asOpt[String]),
provider = providerName,
providerName = provider.name
))
} else {
logger.error(s"Failed to retrieve user info from ${provider.userInfoEndpoint}, status code ${response.status}")
None
}
}
.recover { case e => {
logger.error(s"Failed to retrieve user info from ${provider.userInfoEndpoint}", e)
None
}
}
case None => Future.successful(None)
}
}
def validateState(sessionState: String, returnedState: String): Boolean = {
sessionState == returnedState
}
def generateState(): String = {
java.util.UUID.randomUUID().toString.replace("-", "")
}
def generateNonce(): String = {
java.util.UUID.randomUUID().toString.replace("-", "")
}
}

View File

@@ -1,8 +1,5 @@
package util
import de.knockoutwhist.control.GameState
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, MainMenu, SelectTrump, TieBreak}
import scala.util.Random
object GameUtil {
@@ -28,16 +25,5 @@ object GameUtil {
code.toString()
}
def stateToTitle(gameState: GameState): String = {
gameState match {
case Lobby => "Lobby"
case MainMenu => "Main Menu"
case InGame => "In Game"
case SelectTrump => "Select Trump"
case TieBreak => "Tie Break"
case FinishedMatch => "Finished Match"
}
}
}

View File

@@ -1,19 +1,18 @@
package util
import de.knockoutwhist.cards.{Card, Hand}
import de.knockoutwhist.cards.CardValue.*
import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades}
import de.knockoutwhist.cards.{Card, Hand}
import play.api.libs.json.{JsArray, Json}
import play.twirl.api.Html
import scalafx.scene.image.Image
object WebUIUtils {
def cardToPath(card: Card): String = {
f"images/cards/${cardtoString(card)}.png"
def cardtoImage(card: Card): Html = {
views.html.render.card.apply(f"images/cards/${cardtoString(card)}.png")(card.toString)
}
def cardtoString(card: Card): String = {
def cardtoString(card: Card) = {
val s = card.suit match {
case Spades => "S"
case Hearts => "H"
@@ -37,31 +36,6 @@ object WebUIUtils {
}
f"$cv$s"
}
def stringToCard(cardStr: String): Card = {
val cv = cardStr.charAt(0) match {
case 'A' => Ace
case 'K' => King
case 'Q' => Queen
case 'J' => Jack
case 'T' => Ten
case '9' => Nine
case '8' => Eight
case '7' => Seven
case '6' => Six
case '5' => Five
case '4' => Four
case '3' => Three
case '2' => Two
}
val s = cardStr.charAt(1) match {
case 'S' => Spades
case 'H' => Hearts
case 'C' => Clubs
case 'D' => Diamonds
}
Card(cv, s)
}
/**
* Map a Hand to a JsArray of cards

View File

@@ -1,73 +1,38 @@
package util
import de.knockoutwhist.control.GameState
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, SelectTrump, TieBreak}
import de.knockoutwhist.utils.events.SimpleEvent
import dto.subDTO.{CardDTO, HandDTO, PlayerDTO, PlayerQueueDTO, PodiumPlayerDTO, RoundDTO, TrickDTO, UserDTO}
import dto.{GameInfoDTO, LobbyInfoDTO, TieInfoDTO, TrumpInfoDTO, WonInfoDTO}
import model.sessions.UserSession
import play.api.libs.json.{JsValue, Json, OFormat}
import play.api.libs.json.{JsValue, Json}
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
import util.mapper.*
import util.mapper.{ReceivedHandEventMapper, SimpleEventMapper}
object WebsocketEventMapper {
implicit val cardFormat: OFormat[CardDTO] = Json.format[CardDTO]
implicit val handFormat: OFormat[HandDTO] = Json.format[HandDTO]
implicit val playerFormat: OFormat[PlayerDTO] = Json.format[PlayerDTO]
implicit val queueFormat: OFormat[PlayerQueueDTO] = Json.format[PlayerQueueDTO]
implicit val podiumPlayerFormat: OFormat[PodiumPlayerDTO] = Json.format[PodiumPlayerDTO]
implicit val roundFormat: OFormat[RoundDTO] = Json.format[RoundDTO]
implicit val trickFormat: OFormat[TrickDTO] = Json.format[TrickDTO]
implicit val userFormat: OFormat[UserDTO] = Json.format[UserDTO]
private val scalaModule = ScalaModule.builder()
.addAllBuiltinModules()
.supportScala3Classes(true)
.build()
implicit val gameInfoDTOFormat: OFormat[GameInfoDTO] = Json.format[GameInfoDTO]
implicit val lobbyFormat: OFormat[LobbyInfoDTO] = Json.format[LobbyInfoDTO]
implicit val tieInfoFormat: OFormat[TieInfoDTO] = Json.format[TieInfoDTO]
implicit val trumpInfoFormat: OFormat[TrumpInfoDTO] = Json.format[TrumpInfoDTO]
implicit val wonInfoDTOFormat: OFormat[WonInfoDTO] = Json.format[WonInfoDTO]
private var specialMappers: Map[String,SimpleEventMapper[SimpleEvent]] = Map()
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
private var customMappers: Map[String,SimpleEventMapper[SimpleEvent]] = Map()
private def registerCustomMapper[T <: SimpleEvent](mapper: SimpleEventMapper[T]): Unit = {
specialMappers = specialMappers + (mapper.id -> mapper.asInstanceOf[SimpleEventMapper[SimpleEvent]])
customMappers = customMappers + (mapper.id -> mapper.asInstanceOf[SimpleEventMapper[SimpleEvent]])
}
// Register all custom mappers here
registerCustomMapper(ReceivedHandEventMapper)
registerCustomMapper(CardPlayedEventMapper)
registerCustomMapper(NewRoundEventMapper)
registerCustomMapper(NewTrickEventMapper)
registerCustomMapper(TrickEndEventMapper)
registerCustomMapper(RequestCardEventMapper)
registerCustomMapper(LobbyUpdateEventMapper)
registerCustomMapper(TurnEventMapper)
def toJson(obj: SimpleEvent, session: UserSession): JsValue = {
val data: Option[JsValue] = if (specialMappers.contains(obj.id)) {
Some(specialMappers(obj.id).toJson(obj, session))
}else {
None
}
def toJson(obj: SimpleEvent): JsValue = {
val data = if (customMappers.contains(obj.id)) {
return customMappers(obj.id).toJson(obj)
}else Json.parse(mapper.writeValueAsString(obj))
Json.obj(
"id" -> ("request-" + java.util.UUID.randomUUID().toString),
"event" -> obj.id,
"state" -> session.gameLobby.getLogic.getCurrentState.toString,
"stateData" -> stateToJson(session),
"data" -> data
)
}
def stateToJson(session: UserSession): JsValue = {
session.gameLobby.getLogic.getCurrentState match {
case Lobby => Json.toJson(LobbyInfoDTO(session.gameLobby, session.user))
case InGame => Json.toJson(GameInfoDTO(session.gameLobby, session.user))
case SelectTrump => Json.toJson(TrumpInfoDTO(session.gameLobby, session.user))
case TieBreak => Json.toJson(TieInfoDTO(session.gameLobby, session.user))
case FinishedMatch => Json.toJson(WonInfoDTO(session.gameLobby, session.user))
case _ => Json.obj()
}
}
}

View File

@@ -1,20 +0,0 @@
package util.mapper
import de.knockoutwhist.events.global.CardPlayedEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
import util.WebUIUtils
object CardPlayedEventMapper extends SimpleEventMapper[CardPlayedEvent]{
override def id: String = "CardPlayedEvent"
override def toJson(event: CardPlayedEvent, session: UserSession): JsObject = {
Json.obj(
"firstCard" -> (if (event.trick.firstCard.isDefined) WebUIUtils.cardtoString(event.trick.firstCard.get) else "BLANK"),
"playedCards" -> JsArray(event.trick.cards.map { case (card, player) =>
Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name)
}.toList)
)
}
}

View File

@@ -1,25 +0,0 @@
package util.mapper
import events.LobbyUpdateEvent
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
object LobbyUpdateEventMapper extends SimpleEventMapper[LobbyUpdateEvent] {
override def id: String = "LobbyUpdateEvent"
override def toJson(event: LobbyUpdateEvent, session: UserSession): JsObject = {
Json.obj(
"host" -> session.host,
"maxPlayers" -> session.gameLobby.maxPlayers,
"players" -> JsArray(session.gameLobby.getPlayers.values.map(player => {
Json.obj(
"id" -> player.id,
"name" -> player.name,
"self" -> (player.id == session.user.id)
)
}).toList)
)
}
}

View File

@@ -1,18 +0,0 @@
package util.mapper
import de.knockoutwhist.events.global.NewRoundEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object NewRoundEventMapper extends SimpleEventMapper[NewRoundEvent]{
override def id: String = "NewRoundEvent"
override def toJson(event: NewRoundEvent, session: UserSession): JsObject = {
val gameLobby = session.gameLobby
Json.obj(
"trumpsuit" -> gameLobby.getLogic.getCurrentRound.get.trumpSuit.toString,
"players" -> gameLobby.getLogic.getCurrentMatch.get.playersIn.map(player => player.toString)
)
}
}

View File

@@ -1,14 +0,0 @@
package util.mapper
import de.knockoutwhist.events.global.NewTrickEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object NewTrickEventMapper extends SimpleEventMapper[NewTrickEvent]{
override def id: String = "NewTrickEvent"
override def toJson(event: NewTrickEvent, session: UserSession): JsObject = {
Json.obj()
}
}

View File

@@ -1,14 +1,13 @@
package util.mapper
import de.knockoutwhist.events.player.ReceivedHandEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
import play.api.libs.json.{JsArray, JsObject, Json}
import util.WebUIUtils
object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] {
override def id: String = "ReceivedHandEvent"
override def toJson(event: ReceivedHandEvent, session: UserSession): JsObject = {
override def toJson(event: ReceivedHandEvent): JsObject = {
Json.obj(
"dog" -> event.player.isInDogLife,
"hand" -> event.player.currentHand().map(hand => WebUIUtils.handToJson(hand))

View File

@@ -1,16 +0,0 @@
package util.mapper
import de.knockoutwhist.events.player.RequestCardEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object RequestCardEventMapper extends SimpleEventMapper[RequestCardEvent]{
override def id: String = "RequestCardEvent"
override def toJson(event: RequestCardEvent, session: UserSession): JsObject = {
Json.obj(
"player" -> event.player.name
)
}
}

View File

@@ -1,20 +0,0 @@
package util.mapper
import controllers.routes
import de.knockoutwhist.events.global.RoundEndEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object RoundEndEventMapper extends SimpleEventMapper[RoundEndEvent] {
override def id: String = "RoundEndEvent"
override def toJson(event: RoundEndEvent, session: UserSession): JsObject = {
Json.obj(
"player" -> event.winner.name,
"tricks" -> event.amountOfTricks
)
}
}

View File

@@ -1,13 +1,11 @@
package util.mapper
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.JsObject
trait SimpleEventMapper[T <: SimpleEvent] {
def id: String
def toJson(event: T, session: UserSession): JsObject
def toJson(event: T): JsObject
}

View File

@@ -1,20 +0,0 @@
package util.mapper
import de.knockoutwhist.events.global.TrickEndEvent
import de.knockoutwhist.rounds.Trick
import logic.game.GameLobby
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
object TrickEndEventMapper extends SimpleEventMapper[TrickEndEvent]{
override def id: String = "TrickEndEvent"
override def toJson(event: TrickEndEvent, session: UserSession): JsObject = {
val gameLobby = session.gameLobby
Json.obj(
"playerwon" -> event.winner.name,
"playersin" -> gameLobby.getLogic.getCurrentMatch.get.playersIn.map(player => player.name),
"tricklist" -> gameLobby.getLogic.getCurrentRound.get.tricklist.map(trick => trick.winner.map(player => player.name).getOrElse("Trick in Progress"))
)
}
}

View File

@@ -1,35 +0,0 @@
package util.mapper
import de.knockoutwhist.events.global.TurnEvent
import de.knockoutwhist.player.AbstractPlayer
import model.sessions.UserSession
import play.api.libs.json.{JsArray, JsObject, Json}
object TurnEventMapper extends SimpleEventMapper[TurnEvent] {
override def id: String = "TurnEvent"
override def toJson(event: TurnEvent, session: UserSession): JsObject = {
val nextPlayers = if (session.gameLobby.logic.getPlayerQueue.isEmpty) {
Json.arr()
} else {
val queue = session.gameLobby.logic.getPlayerQueue.get
JsArray(
queue.duplicate().map(player => mapPlayer(player)).toList
)
}
Json.obj(
"currentPlayer" -> mapPlayer(event.player),
"nextPlayers" -> nextPlayers
)
}
private def mapPlayer(player: AbstractPlayer): JsObject = {
Json.obj(
"name" -> player.name,
"dog" -> player.isInDogLife
)
}
}

View File

@@ -0,0 +1,38 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
<main class="lobby-background vh-100" id="lobbybackground">
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);">
<div class="row">
<div class="col">
<div class="p-3 text-center fs-4">Winner: @gamelobby.getLogic.getWinner</div>
</div>
</div>
<div class="row justify-content-center align-items-center flex-grow-1">
@if((gamelobby.getUserSession(user.get.id).host)) {
<div class="col-12 text-center mb-5">
<div class="btn btn-success" onclick="backToLobby('@gamelobby.id')">Return to lobby</div>
</div>
} else {
<div class="col-12 text-center mt-3">
<div class="spinner-border mt-1" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
</div>
</div>
</main>
<script>
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
if (typeof window[name] === "function") {
clearInterval(timer);
resolve(window[name]);
}
}, checkInterval);
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -0,0 +1,113 @@
@import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.TrickUtil
@import de.knockoutwhist.utils.Implicits.*
@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
<div class="lobby-background vh-100">
<main class="game-field-background vh-100 ingame-side-shadow">
<div class="py-5 container-xxl">
<div class="row ms-4 me-4">
<div class="col-4 mt-5 text-start">
<h4 class="fw-semibold mb-1">Current Player</h4>
<p class="fs-5 text-primary" id="current-player-name">@gamelobby.getLogic.getCurrentPlayer.get.name</p>
@if(!TrickUtil.isOver(gamelobby.getLogic.getCurrentMatch.get, gamelobby.getLogic.getPlayerQueue.get)) {
<h4 class="fw-semibold mb-1">Next Player</h4>
@for(nextplayer <- gamelobby.getLogic.getPlayerQueue.get.duplicate()) {
<p class="fs-5 text-primary" id="next-player-name">@nextplayer</p>
}
}
</div>
<div class="col-4 text-center">
<div class="score-table mt-5" id="score-table-body">
<h4 class="fw-bold mb-3 text-black">Tricks Won</h4>
<div class="d-flex justify-content-between score-header pb-1">
<div style="width: 50%">PLAYER</div>
<div style="width: 50%">TRICKS</div>
</div>
@for(player <- gamelobby.getLogic.getPlayerQueue.get.toList.sortBy { p =>
-(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(p) }.size)
}) {
<div class="d-flex justify-content-between score-row pt-1">
<div style="width: 50%" class="text-truncate">@player.name</div>
<div style="width: 50%">
@(gamelobby.getLogic.getCurrentRound.get.tricklist.filter { trick => trick.winner.contains(player) }.size)
</div>
</div>
}
</div>
<div class="d-flex justify-content-center g-3 mb-5" id="trick-cards-container">
@for((cardplayed, player) <- gamelobby.getLogic.getCurrentTrick.get.cards) {
<div class="col-auto">
<div class="card text-center shadow-sm border-0 bg-transparent" style="width: 7rem;
backdrop-filter: blur(4px);">
<div class="p-2">
@util.WebUIUtils.cardtoImage(cardplayed) width="100%"/>
</div>
<div class="card-body p-2 bg-transparent">
<small class="fw-semibold text-secondary">@player</small>
</div>
</div>
</div>
}
</div>
</div>
<div class="col-4 mt-5 text-end">
<h4 class="fw-semibold mb-1">Trumpsuit</h4>
<p class="fs-5 text-primary" id="trumpsuit">@gamelobby.getLogic.getCurrentRound.get.trumpSuit</p>
<h5 class="fw-semibold mt-4 mb-1">First Card</h5>
<div class="d-inline-block border rounded shadow-sm p-1 bg-light" id="first-card-container">
@if(gamelobby.getLogic.getCurrentTrick.get.firstCard.isDefined) {
@util.WebUIUtils.cardtoImage(gamelobby.getLogic.getCurrentTrick.get.firstCard.get)
width="80px"/>
} else {
@views.html.render.card.apply("images/cards/1B.png")("Blank Card") width="80px"/>
}
</div>
</div>
</div>
<div class="row justify-content-center g-2 mt-4 bottom-div" style="backdrop-filter: blur(4px);
margin-left: 0;
margin-right: 0;">
<div class="row justify-content-center ingame-cards-slide @{
!gamelobby.logic.getCurrentPlayer.contains(player) ? "inactive" |: ""
}" id="card-slide">
@for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto handcard" style="border-radius: 6px">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-card-id="@i" style="border-radius: 6px" onclick="handlePlayCard(this, '@gamelobby.id', '@player.isInDogLife')">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div>
@if(player.isInDogLife) {
<div class="mt-2">
<button class="btn btn-danger" onclick="handleSkipDogLife(this, '@gamelobby.id')">
Skip Dog Life</button>
</div>
}
</div>
}
</div>
</div>
</div>
</main>
</div>
<script>
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
if (typeof window[name] === "function") {
clearInterval(timer);
resolve(window[name]);
}
}, checkInterval);
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -0,0 +1,77 @@
@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
<div id="selecttrumpsuit" class="game-field game-field-background">
<div class="ingame-stage blur-sides">
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header text-center">
<h3 class="mb-0">Select Trump Suit</h3>
</div>
<div class="card-body">
@if(player.equals(gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get)) {
<div class="alert alert-info" role="alert" aria-live="polite">
You (@player.toString) won the last round. Choose the trump suit for the next round.
</div>
<div class="row justify-content-center col-auto mb-5">
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="0" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Spades))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="1" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Hearts))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="2" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Diamonds))
width="120px" style="border-radius: 6px"/>
</div>
</div>
<div class="col-auto handcard">
<div class="btn btn-outline-light p-0 border-0 shadow-none" data-trump="3" style="border-radius: 6px" onclick="handleTrumpSelection(this, '@gamelobby.id')">
@util.WebUIUtils.cardtoImage(de.knockoutwhist.cards.Card(de.knockoutwhist.cards.CardValue.Ace, de.knockoutwhist.cards.Suit.Clubs))
width="120px" style="border-radius: 6px"/>
</div>
</div>
</div>
<div class="row justify-content-center ingame-cards-slide" id="card-slide">
@for(i <- player.currentHand().get.cards.indices) {
<div class="col-auto" style="border-radius: 6px">
@util.WebUIUtils.cardtoImage(player.currentHand().get.cards(i)) width="120px" style="border-radius: 6px"/>
</div>
}
</div>
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
@gamelobby.logic.getCurrentMatch.get.roundlist.last.winner.get.name
is choosing a trumpsuit. The new round will start once a suit is picked.
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
if (typeof window[name] === "function") {
clearInterval(timer);
resolve(window[name]);
}
}, checkInterval);
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -0,0 +1,125 @@
@(player: de.knockoutwhist.player.AbstractPlayer, gamelobby: logic.game.GameLobby)
<div id="tie" class="game-field game-field-background">
<div class="ingame-stage blur-sides">
<div class="container py-4">
<div class="row justify-content-center">
<div class="col-12 col-md-10 col-lg-8">
<div class="card shadow-sm">
<div class="card-header text-center">
<h3 class="mb-0">Tie Break</h3>
</div>
<div class="card-body">
<div class="mb-3">
<p class="card-text">
The last round was tied between:
<span class="ms-1">
@for(players <- gamelobby.logic.playerTieLogic.getTiedPlayers) {
<span class="badge text-bg-secondary me-1">@players</span>
}
</span>
</p>
</div>
@if(player.equals(gamelobby.logic.playerTieLogic.currentTiePlayer())) {
@defining(gamelobby.logic.playerTieLogic.highestAllowedNumber()) { maxNum =>
<div class="alert alert-info" role="alert" aria-live="polite">
Pick a number between 1 and @{
maxNum + 1
}.
The resulting card will be your card for the cut.
</div>
<div class="row g-2 align-items-center">
<div class="col-auto">
<label for="tieNumber" class="col-form-label">Your number</label>
</div>
<div class="col-auto">
<input type="number" id="tieNumber" class="form-control" name="tie" min="1" max="@{
maxNum + 1
}" placeholder="1" required>
</div>
<div class="col-auto">
<button onclick="selectTie('@gamelobby.id')" class="btn btn-primary">
Confirm</button>
</div>
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div>
}
} else {
<div class="alert alert-warning" role="alert" aria-live="polite">
<strong>@gamelobby.logic.playerTieLogic.currentTiePlayer()</strong>
is currently picking a number for the cut.
</div>
<h6 class="mt-4 mb-3">Currently Picked Cards</h6>
<div id="cardsplayed" class="row g-3 justify-content-center">
@if(gamelobby.logic.playerTieLogic.getSelectedCard.nonEmpty) {
@for((player, card) <- gamelobby.logic.playerTieLogic.getSelectedCard) {
<div class="col-6 col-sm-4 col-md-3 col-lg-2">
<div class="card shadow-sm border-0 h-100 text-center">
<div class="card-body d-flex flex-column justify-content-between">
<p class="card-text fw-semibold mb-2 text-primary">@player</p>
<div class="card-img-top">
@util.WebUIUtils.cardtoImage(card)
</div>
</div>
</div>
</div>
}
} else {
<div class="col-12">
<div class="alert alert-info text-center" role="alert">
<i class="bi bi-info-circle me-2"></i>
No cards have been selected yet.
</div>
</div>
}
</div>
}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
if (typeof window[name] === "function") {
clearInterval(timer);
resolve(window[name]);
}
}, checkInterval);
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -0,0 +1,84 @@
@(user: Option[model.users.User], gamelobby: logic.game.GameLobby)
<main class="lobby-background vh-100" id="lobbybackground">
<div class="container d-flex flex-column" style="height: calc(100vh - 1rem);">
<div class="row">
<div class="col">
<div class="p-3 fs-1 d-flex align-items-center">
<div class="text-center" style="flex-grow: 1;">
Lobby-Name: @gamelobby.name
</div>
<div class="btn btn-danger ms-auto" onclick="leaveGame('@gamelobby.id')">Exit</div>
</div>
</div>
</div>
<div class="row">
<div class="col">
<div class="p-3 text-center fs-4" id="playerAmount">
Playeramount: @gamelobby.getPlayers.size / @gamelobby.maxPlayers</div>
</div>
</div>
<div class="row justify-content-center align-items-center flex-grow-1">
@if((gamelobby.getUserSession(user.get.id).host)) {
<div id="players" class="justify-content-center align-items-center d-flex">
@for(playersession <- gamelobby.getPlayers.values) {
<div class="col-auto my-auto m-3">
<div class="card" style="width: 18rem;">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
@if(playersession.id == user.get.id) {
<h5 class="card-title">@playersession.name (You)</h5>
<a href="#" class="btn btn-danger disabled" aria-disabled="true" tabindex="-1">Remove</a>
} else {
<h5 class="card-title">@playersession.name</h5>
<div class="btn btn-danger" onclick="removePlayer('@gamelobby.id', '@playersession.id')">
Remove</div>
}
</div>
</div>
</div>
}
</div>
<div class="col-12 text-center mb-5">
<div class="btn btn-success" onclick="startGame('@gamelobby.id')">Start Game</div>
</div>
} else {
<div id="players" class="justify-content-center align-items-center d-flex">
@for(playersession <- gamelobby.getPlayers.values) {
<div class="col-auto my-auto m-3"> <div class="card" style="width: 18rem;">
<img src="@routes.Assets.versioned("images/profile.png")" alt="Profile" class="card-img-top w-50 mx-auto mt-3" />
<div class="card-body">
@if(playersession.id == user.get.id) {
<h5 class="card-title">@playersession.name (You)</h5>
} else {
<h5 class="card-title">@playersession.name</h5>
}
</div>
</div>
</div>
}
</div>
<div class="col-12 text-center mt-3">
<p class="fs-4">Waiting for the host to start the game...</p>
<div class="spinner-border mt-1" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
}
</div>
</div>
</main>
<script>
function waitForFunction(name, checkInterval = 100) {
return new Promise(resolve => {
const timer = setInterval(() => {
if (typeof window[name] === "function") {
clearInterval(timer);
resolve(window[name]);
}
}, checkInterval);
});
}
waitForFunction("pollForUpdates").then(fn => fn('@gamelobby.id'));
connectWebSocket()
</script>

View File

@@ -0,0 +1,43 @@
@()
<div class="login-box">
<div class="card login-card p-4">
<div class="card-body">
<h3 class="text-center mb-4 text-body">Login</h3>
<form onsubmit="login(); return false;">
<div class="mb-3">
<label for="username" class="form-label text-body">Username</label>
<input type="text" class="form-control text-body" id="username" name="username" placeholder="Enter Username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label text-body">Password</label>
<input type="password" class="form-control text-body" 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>
<script>
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
console.log('callback - particles.js config loaded');
});
disconnectWebSocket();
</script>
<div id="particles-js" style="background-color: rgb(11, 8, 8);
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;"></div>

View File

@@ -0,0 +1,33 @@
@*
* This template is called from the `index` template. This template
* handles the rendering of the page header and body tags. It takes
* two arguments, a `String` for the title of the page and an `Html`
* object to insert into the body of the page.
*@
@(title: String)(content: Html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@* Here's where we render the page title `String`. *@
<title>@title</title>
<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 href="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head>
<body class="d-flex flex-column min-vh-100" id="main-body">
@* And here's where we render the `Html` object containing
* the page content. *@
@content
</body>
<script src="@routes.JavaScriptRoutingController.javascriptRoutes()" type="text/javascript"></script>
<script src="@routes.Assets.versioned("javascripts/main.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("../../public/javascripts/websocket.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("../../public/javascripts/events.js")" type="text/javascript"></script>
<script src="@routes.Assets.versioned("../../public/javascripts/interact.js")" type="text/javascript"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</html>

View File

@@ -0,0 +1,33 @@
@(user: Option[model.users.User])
@navbar(user)
<main class="lobby-background flex-grow-1">
<div class="w-25 mx-auto">
<div class="mt-3">
<label for="lobbyname" class="form-label">Lobby-Name</label>
<input type="text" class="form-control" id="lobbyname" name="lobbyname" placeholder="Lobby 1" required>
</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="visibilityswitch" disabled>
<label class="form-check-label" for="visibilityswitch">public/private</label>
</div>
<div class="mt-3">
<label for="playeramount" class="form-label">Playeramount:</label>
<input type="range" class="form-range text-body" min="2" max="7" value="2" id="playeramount" name="playeramount">
<div class="d-flex justify-content-between">
<span>2</span>
<span>3</span>
<span>4</span>
<span>5</span>
<span>6</span>
<span>7</span>
</div>
</div>
<div class="mt-3 text-center">
<div class="btn btn-success" onclick="createGameJS()">Create Game</div>
</div>
</div>
</main>
<script>
disconnectWebSocket();
</script>

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
@(src: String)(alt: String)
<img src="@routes.Assets.versioned(src)" alt="@alt"

View File

@@ -0,0 +1,3 @@
@(text: String)
<p>@text</p>

View File

@@ -1,32 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://jakarta.ee/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="https://jakarta.ee/xml/ns/persistence
https://jakarta.ee/xml/ns/persistence/persistence_3_0.xsd"
version="3.0">
<persistence-unit name="defaultPersistenceUnit">
<provider>org.hibernate.jpa.HibernatePersistenceProvider</provider>
<class>model.users.UserEntity</class>
<properties>
<!-- Hibernate specific settings -->
<property name="hibernate.hbm2ddl.auto" value="update"/>
<property name="hibernate.archive.autodetection" value="class"/>
<property name="hibernate.show_sql" value="false"/>
<property name="hibernate.format_sql" value="true"/>
<property name="hibernate.use_sql_comments" value="true"/>
<!-- Connection pool settings -->
<property name="hibernate.connection.provider_class" value="org.hibernate.hikaricp.internal.HikariCPConnectionProvider"/>
<property name="hibernate.hikari.maximumPoolSize" value="20"/>
<property name="hibernate.hikari.minimumIdle" value="5"/>
<property name="hibernate.hikari.connectionTimeout" value="30000"/>
<property name="hibernate.hikari.idleTimeout" value="600000"/>
<property name="hibernate.hikari.maxLifetime" value="1800000"/>
<property name="hibernate.hikari.poolName" value="KnockOutWhistPool"/>
</properties>
</persistence-unit>
</persistence>

View File

@@ -2,9 +2,6 @@
play.filters.disabled += play.filters.csrf.CSRFFilter
play.filters.disabled += play.filters.hosts.AllowedHostsFilter
# Disable default JPA and Hibernate modules to use custom EntityManagerProvider
play.modules.disabled += "play.db.jpa.JPAModule"
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:G3gf:90Latabg@5241AB`R5W:1uDFN];Ik@n"
play.http.secret.key=${?APPLICATION_SECRET}
@@ -16,38 +13,3 @@ auth {
publicKeyFile = ${?PUBLIC_KEY_FILE}
publicKeyPem = ${?PUBLIC_KEY_PEM}
}
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.cors {
allowedOrigins = ["http://localhost:5173"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}
# Local Development OpenID Connect Configuration
openid {
selectUserRoute="http://localhost:5173/select-username"
mainRoute="http://localhost:5173/"
discord {
clientId = ${?DISCORD_CLIENT_ID}
clientId = "1462555597118509126"
clientSecret = ${?DISCORD_CLIENT_SECRET}
clientSecret = "xZZrdd7_tNpfJgnk-6phSG53DSTy-eMK"
redirectUri = ${?DISCORD_REDIRECT_URI}
redirectUri = "http://localhost:9000/auth/discord/callback"
}
keycloak {
clientId = ${?KEYCLOAK_CLIENT_ID}
clientId = "your-keycloak-client-id"
clientSecret = ${?KEYCLOAK_CLIENT_SECRET}
clientSecret = "your-keycloak-client-secret"
redirectUri = ${?KEYCLOAK_REDIRECT_URI}
redirectUri = "http://localhost:9000/auth/keycloak/callback"
authUrl = ${?KEYCLOAK_AUTH_URL}
authUrl = "http://localhost:8080/realms/master"
}
}

View File

@@ -1,39 +0,0 @@
# Database configuration - PostgreSQL with environment variables
db.default.driver="org.postgresql.Driver"
db.default.url="jdbc:postgresql://localhost:5432/knockoutwhist"
db.default.url=${?DATABASE_URL}
db.default.username="kw_user"
db.default.username=${?DB_USER}
db.default.password="postgres"
db.default.password=${?DB_PASSWORD}
# HikariCP specific configuration
db.default.hikaricp.driverClassName="org.postgresql.Driver"
db.default.hikaricp.jdbcUrl="jdbc:postgresql://localhost:5432/knockoutwhist"
db.default.hikaricp.jdbcUrl=${?DATABASE_URL}
db.default.hikaricp.username="kw_user"
db.default.hikaricp.username=${?DB_USER}
db.default.hikaricp.password="postgres"
db.default.hikaricp.password=${?DB_PASSWORD}
# JPA/Hibernate configuration
jpa.default=defaultPersistenceUnit
# Hibernate specific settings
hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
hibernate.hbm2ddl.auto=update
hibernate.show_sql=false
hibernate.format_sql=true
hibernate.use_sql_comments=true
# Connection pool settings
db.default.hikaricp.maximumPoolSize=20
db.default.hikaricp.minimumIdle=5
db.default.hikaricp.connectionTimeout=30000
db.default.hikaricp.idleTimeout=600000
db.default.hikaricp.maxLifetime=1800000
# PostgreSQL specific settings
db.default.hikaricp.connectionTestQuery="SELECT 1"
db.default.hikaricp.poolName="KnockOutWhistPool"

View File

@@ -1,37 +0,0 @@
include "application.conf"
include "db.conf"
play.http.secret.key="zg8^v0R*:7-m.>^8T2B1q)sE3MV_9=M{K9zx8,<3}"
play.http.context="/api"
play.modules.enabled += "modules.GatewayModule"
play.filters.cors {
allowedOrigins = ["http://localhost:5173"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}
# OpenID Connect Configuration
openid {
selectUserRoute="https://knockout.janis-eccarius.de/select-username"
mainRoute="https://knockout.janis-eccarius.de/"
discord {
clientId = ${?DISCORD_CLIENT_ID}
clientSecret = ${?DISCORD_CLIENT_SECRET}
redirectUri = ${?DISCORD_REDIRECT_URI}
redirectUri = "https://knockout.janis-eccarius.de/api/auth/discord/callback"
}
keycloak {
clientId = ${?KEYCLOAK_CLIENT_ID}
clientSecret = ${?KEYCLOAK_CLIENT_SECRET}
redirectUri = "https://knockout.janis-eccarius.de/api/auth/keycloak/callback"
authUrl = ${?KEYCLOAK_AUTH_URL}
authUrl = "https://identity.janis-eccarius.de/realms/master"
}
}

View File

@@ -1,25 +1,30 @@
# Create game rounds
# Routes
# This file defines all application routes (Higher priority routes first)
# https://www.playframework.com/documentation/latest/ScalaRouting
# ~~~~
# For the javascript routing
GET /assets/js/routes controllers.JavaScriptRoutingController.javascriptRoutes()
# Primary routes
GET / controllers.MainMenuController.index()
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
# Main menu routes
GET /mainmenu controllers.MainMenuController.mainMenu()
GET /rules controllers.MainMenuController.rules()
GET /navSPA/:pType controllers.MainMenuController.navSPA(pType)
POST /createGame controllers.MainMenuController.createGame()
POST /joinGame/:gameId controllers.MainMenuController.joinGame(gameId: String)
POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes
GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post()
POST /register controllers.UserController.register()
POST /logout controllers.UserController.logoutPost()
GET /userInfo controllers.UserController.getUserInfo()
# OpenID Connect routes
GET /auth/:provider controllers.OpenIDController.loginWithProvider(provider: String)
GET /auth/:provider/callback controllers.OpenIDController.callback(provider: String)
GET /select-username controllers.OpenIDController.selectUsername()
POST /submit-username controllers.OpenIDController.submitUsername()
GET /logout controllers.UserController.logout()
# In-game routes
GET /game/:id controllers.IngameController.game(id: String)
# Websocket
GET /websocket controllers.WebsocketController.socket()
# Status
GET /status controllers.StatusController.requestStatus()
GET /status/:gameId controllers.StatusController.game(gameId: String)
# Health
GET /health/simple controllers.HealthController.simple()
GET /websocket controllers.WebsocketController.socket()

View File

@@ -1,34 +0,0 @@
include "application.conf"
include "db.conf"
play.http.context="/api"
play.modules.enabled += "modules.GatewayModule"
play.filters.cors {
allowedOrigins = ["https://st.knockout.janis-eccarius.de"]
allowedCredentials = true
allowedHttpMethods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allowedHttpHeaders = ["Accept", "Content-Type", "Origin", "X-Requested-With"]
}
openid {
selectUserRoute="https://st.knockout.janis-eccarius.de/select-username"
mainRoute="https://st.knockout.janis-eccarius.de/"
discord {
clientId = ${?DISCORD_CLIENT_ID}
clientSecret = ${?DISCORD_CLIENT_SECRET}
redirectUri = ${?DISCORD_REDIRECT_URI}
redirectUri = "https://st.knockout.janis-eccarius.de/api/auth/discord/callback"
}
keycloak {
clientId = ${?KEYCLOAK_CLIENT_ID}
clientSecret = ${?KEYCLOAK_CLIENT_SECRET}
redirectUri = "https://st.knockout.janis-eccarius.de/api/auth/keycloak/callback"
authUrl = ${?KEYCLOAK_AUTH_URL}
authUrl = "https://identity.janis-eccarius.de/realms/master"
}
}

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
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Some files were not shown because too many files have changed in this diff Show More