Compare commits

..

1 Commits

Author SHA1 Message Date
TeamCity
c721edb022 ci: bump version to v2.0.0 2025-11-19 21:57:55 +00:00
166 changed files with 4259 additions and 2973 deletions

1
.gitignore vendored
View File

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

5
.gitmodules vendored
View File

@@ -2,8 +2,3 @@
path = knockoutwhist path = knockoutwhist
branch = main branch = main
url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist.git 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

@@ -111,7 +111,7 @@
* removed trailing ([c7dd72e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c7dd72ecc2786ef63daf2b4288093025a8e22bfd)) * removed trailing ([c7dd72e](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c7dd72ecc2786ef63daf2b4288093025a8e22bfd))
* removed trailing ([42a5adb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/42a5adbae01802587a48a9de15bad44b5ef014cf)) * removed trailing ([42a5adb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/42a5adbae01802587a48a9de15bad44b5ef014cf))
## (2025-11-20) ## (2025-11-19)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
@@ -124,292 +124,3 @@
* **game:** Fixed polling, SPA, Gameplayloop etc. ([#59](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/59)) ([a58b2e0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a58b2e03b11a54667d63ba6604f579a8e328c9d1)) * **game:** Fixed polling, SPA, Gameplayloop etc. ([#59](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/59)) ([a58b2e0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/a58b2e03b11a54667d63ba6604f579a8e328c9d1))
* **ui:** added js routing, updated ingame ui, added tricktable ([#50](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/50)) ([c220e54](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c220e54bb8d87f4f0f37a089bcd993e8df806123)), closes [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43) [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43) * **ui:** added js routing, updated ingame ui, added tricktable ([#50](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/50)) ([c220e54](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c220e54bb8d87f4f0f37a089bcd993e8df806123)), closes [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43) [#43](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/43)
* **ui:** implement tie & trump menu, fixed some critical bugs ([#52](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/52)) ([5d245d0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5d245d0011a5fb03193514303b45702cd8329224)) * **ui:** implement tie & trump menu, fixed some critical bugs ([#52](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/52)) ([5d245d0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5d245d0011a5fb03193514303b45702cd8329224))
### Bug Fixes
* **polling:** Improve polling mechanism and delay handling ([#60](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/60)) ([641c892](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/641c892981649eb85640527cc0fe325ff683fa77))
## (2025-11-22)
### Bug Fixes
* **api:** Fixed a bug where the game would reload on game start ([#81](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/81)) ([9738a04](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/9738a04b7a3c63c8cd1450e563ec04823fb3c35a))
## (2025-11-23)
### ⚠ BREAKING CHANGES
* **websocket:** Implement WebSocket connection and event handling (#82)
### Features
* **websocket:** Implement WebSocket connection and event handling ([#82](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/82)) ([8ca909d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/8ca909db522dd7108a3e40ce84811eaf8695eaa5))
## (2025-11-24)
## (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))

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 # Install Argon2 CLI and libraries
RUN apk add --no-cache bash argon2 argon2-libs 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 WORKDIR /opt/playapp
# Copy staged Play build # 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 the default Play port
EXPOSE 9000 EXPOSE 9000

View File

@@ -1,6 +1,6 @@
meta { meta {
name: Game name: Game
seq: 2 seq: 3
} }
auth { 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,19 +1,12 @@
ThisBuild / scalaVersion := "3.5.1" 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( lazy val commonSettings = Seq(
libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.19", libraryDependencies += "org.scalactic" %% "scalactic" % "3.2.18",
libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % "test", libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % "test",
libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.1.0", libraryDependencies += "io.github.mkpaz" % "atlantafx-base" % "2.0.1",
libraryDependencies += "org.scalafx" %% "scalafx" % "24.0.2-R36", libraryDependencies += "org.scalafx" %% "scalafx" % "22.0.0-R33",
libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.4.0", libraryDependencies += "org.scala-lang.modules" %% "scala-xml" % "2.3.0",
libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M9", libraryDependencies += "org.playframework" %% "play-json" % "3.1.0-M1",
libraryDependencies ++= { libraryDependencies ++= {
// Determine OS version of JavaFX binaries // Determine OS version of JavaFX binaries
lazy val osName = System.getProperty("os.name") match { lazy val osName = System.getProperty("os.name") match {
@@ -42,28 +35,12 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
.enablePlugins(PlayScala) .enablePlugins(PlayScala)
.dependsOn(knockoutwhist % "compile->compile;test->test") .dependsOn(knockoutwhist % "compile->compile;test->test")
.settings( .settings(
resolvers += "GitHub Packages" at "https://maven.pkg.github.com/16Janis12/KnockOutWhist-Web",
commonSettings, commonSettings,
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test,
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12", libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0", libraryDependencies += "com.auth0" % "java-jwt" % "4.3.0",
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3", libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.2",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2", JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
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"
) )
lazy val root = (project in file(".")) lazy val root = (project in file("."))

View File

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

View File

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

View File

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

View File

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

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,432 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, SelectTrump, TieBreak}
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 podManager: PodManager,
val authAction: AuthAction,
implicit val ec: ExecutionContext
) extends AbstractController(cc) {
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 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 =>
NotFound("Game not found")
}
}
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
))
} 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
))
} 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,35 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import logic.PodManager
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,
val podManager: PodManager
) 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.IngameController.startGame,
routes.javascript.IngameController.kickPlayer,
routes.javascript.IngameController.leaveGame,
routes.javascript.IngameController.playCard,
routes.javascript.IngameController.playDogCard,
routes.javascript.IngameController.playTrump,
routes.javascript.IngameController.playTie,
routes.javascript.IngameController.returnToLobby,
routes.javascript.PollingController.polling,
routes.javascript.UserController.login_Post
)
).as("text/javascript")
}
}

View File

@@ -16,9 +16,20 @@ import javax.inject.*
@Singleton @Singleton
class MainMenuController @Inject()( class MainMenuController @Inject()(
val controllerComponents: ControllerComponents, val controllerComponents: ControllerComponents,
val authAction: AuthAction val authAction: AuthAction,
val podManager: PodManager,
val ingameController: IngameController
) extends BaseController { ) 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] => def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson val jsonBody = request.body.asJson
if (jsonBody.isDefined) { if (jsonBody.isDefined) {
@@ -28,14 +39,15 @@ class MainMenuController @Inject()(
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String] val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
.getOrElse(throw new IllegalArgumentException("Player amount is required.")) .getOrElse(throw new IllegalArgumentException("Player amount is required."))
val gameLobby = PodManager.createGame( val gameLobby = podManager.createGame(
host = request.user, host = request.user,
name = gamename, name = gamename,
maxPlayers = playeramount.toInt maxPlayers = playeramount.toInt
) )
Ok(Json.obj( Ok(Json.obj(
"status" -> "success", "status" -> "success",
"gameId" -> gameLobby.id, "redirectUrl" -> routes.IngameController.game(gameLobby.id).url,
"content" -> ingameController.returnInnerHTML(gameLobby, request.user).toString
)) ))
} else { } else {
BadRequest(Json.obj( BadRequest(Json.obj(
@@ -43,22 +55,62 @@ class MainMenuController @Inject()(
"errorMessage" -> "Invalid form submission" "errorMessage" -> "Invalid form submission"
)) ))
} }
}
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 joinGame(gameId: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] => def rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val game = PodManager.getGame(gameId) Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
game match { }
case Some(g) =>
g.addUser(request.user) def navSPA(location: String) : Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
location match {
case "0" => // Main Menu
Ok(Json.obj( Ok(Json.obj(
"status" -> "success" "status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
)) ))
case None => case "1" => // Rules
NotFound(Json.obj( 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", "status" -> "failure",
"errorMessage" -> "No Game found" "errorMessage" -> "Invalid form submission"
)) ))
} }
} }
} }

View File

@@ -1,162 +0,0 @@
package controllers
import logic.user.{SessionManager, UserManager}
import model.users.User
import play.api.Configuration
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 {
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")
error match {
case Some(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) =>
// 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 =>
// 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 =>
Future.successful(Redirect("/login").flashing("error" -> "Failed to retrieve user information"))
}
case None =>
Future.successful(Redirect("/login").flashing("error" -> "Failed to exchange authorization code"))
}
}).getOrElse {
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

@@ -0,0 +1,152 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import controllers.PollingController.{scheduler, timeoutDuration}
import de.knockoutwhist.cards.Hand
import de.knockoutwhist.player.AbstractPlayer
import logic.PodManager
import logic.game.{GameLobby, PollingEvents}
import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
import model.sessions.UserSession
import model.users.User
import play.api.libs.json.{JsArray, JsValue, Json}
import play.api.mvc.{AbstractController, Action, AnyContent, ControllerComponents, Result}
import util.WebUIUtils
import javax.inject.{Inject, Singleton}
import scala.concurrent.{ExecutionContext, Future}
import java.util.concurrent.{Executors, ScheduledExecutorService, TimeUnit}
import scala.concurrent.duration.*
object PollingController {
private val scheduler: ScheduledExecutorService = Executors.newSingleThreadScheduledExecutor()
private val timeoutDuration = 25.seconds
}
@Singleton
class PollingController @Inject() (
val cc: ControllerComponents,
val podManager: PodManager,
val authAction: AuthAction,
val ingameController: IngameController,
implicit val ec: ExecutionContext
) extends AbstractController(cc) {
private def buildCardPlayResponse(game: GameLobby, hand: Option[Hand], player: AbstractPlayer, newRound: Boolean): JsValue = {
val currentRound = game.logic.getCurrentRound.get
val currentTrick = game.logic.getCurrentTrick.get
val trickCardsJson = Json.toJson(
currentTrick.cards.map { case (card, player) =>
Json.obj("cardId" -> WebUIUtils.cardtoString(card), "player" -> player.name)
}
)
val scoreTableJson = Json.toJson(
game.getLogic.getPlayerQueue.get.toList.map { player =>
Json.obj(
"name" -> player.name,
"tricks" -> currentRound.tricklist.count(_.winner.contains(player))
)
}
)
val stringHand = hand.map { h =>
val cardStrings = h.cards.map(WebUIUtils.cardtoString)
Json.toJson(cardStrings).as[JsArray]
}.getOrElse(Json.arr())
val firstCardId = currentTrick.firstCard.map(WebUIUtils.cardtoString).getOrElse("BLANK")
val nextPlayer = game.getLogic.getPlayerQueue.get.duplicate().nextPlayer().name
Json.obj(
"status" -> "cardPlayed",
"animation" -> newRound,
"handData" -> stringHand,
"dog" -> player.isInDogLife,
"currentPlayerName" -> game.logic.getCurrentPlayer.get.name,
"trumpSuit" -> currentRound.trumpSuit.toString,
"trickCards" -> trickCardsJson,
"scoreTable" -> scoreTableJson,
"firstCardId" -> firstCardId,
"nextPlayer" -> nextPlayer,
"yourTurn" -> (game.logic.getCurrentPlayer.get == player)
)
}
private def buildLobbyUsersResponse(game: GameLobby, userSession: UserSession): JsValue = {
Json.obj(
"status" -> "lobbyUpdate",
"host" -> userSession.host,
"users" -> game.getUsers.map(u => Json.obj(
"name" -> u.name,
"id" -> u.id,
"self" -> (u.id == userSession.id)
))
)
}
def handleEvent(event: PollingEvents, game: GameLobby, userSession: UserSession): Result = {
event match {
case NewRound =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, true)
Ok(jsonResponse)
case NewTrick =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, false)
Ok(jsonResponse)
case CardPlayed =>
val player = game.getPlayerByUser(userSession.user)
val hand = player.currentHand()
val jsonResponse = buildCardPlayResponse(game, hand, player, false)
Ok(jsonResponse)
case LobbyUpdate =>
Ok(buildLobbyUsersResponse(game, userSession))
case ReloadEvent =>
val jsonResponse = Json.obj(
"status" -> "reloadEvent",
"redirectUrl" -> routes.IngameController.game(game.id).url,
"content" -> ingameController.returnInnerHTML(game, userSession.user).toString
)
Ok(jsonResponse)
}
}
def polling(gameId: String): Action[AnyContent] = authAction.async { implicit request: AuthenticatedRequest[AnyContent] =>
val playerId = request.user.id
podManager.getGame(gameId) match {
case Some(game) =>
val playerEventQueue = game.getEventsOfPlayer(playerId)
if (playerEventQueue.nonEmpty) {
val event = playerEventQueue.dequeue()
Future.successful(handleEvent(event, game, game.getUserSession(playerId)))
} else {
val eventPromise = game.registerWaiter(playerId)
val scheduledFuture = scheduler.schedule(
new Runnable {
override def run(): Unit =
eventPromise.tryFailure(new java.util.concurrent.TimeoutException("Polling Timeout"))
},
timeoutDuration.toMillis,
TimeUnit.MILLISECONDS
)
eventPromise.future.map { event =>
scheduledFuture.cancel(false)
game.removeWaiter(playerId)
handleEvent(event, game, game.getUserSession(playerId))
}.recover {
case _: Throwable =>
game.removeWaiter(playerId)
NoContent
}
}
case None =>
Future.successful(NotFound("Game not found."))
}
}
}

View File

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

View File

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

View File

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

@@ -11,22 +11,20 @@ import util.GameUtil
import javax.inject.Singleton import javax.inject.Singleton
import scala.collection.mutable import scala.collection.mutable
object PodManager { @Singleton
class PodManager {
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
val podIp: String = System.getenv("POD_IP") val podIp: String = System.getenv("POD_IP")
val podName: String = System.getenv("POD_NAME") val podName: String = System.getenv("POD_NAME")
private val sessions: mutable.Map[String, GameLobby] = mutable.Map() private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule()) private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
private[logic] var redis: Option[Gateway] = None
def createGame( def createGame(
host: User, host: User,
name: String, name: String,
maxPlayers: Int maxPlayers: Int
): GameLobby = { ): GameLobby = {
val gameLobby = GameLobby( val gameLobby = GameLobby(
logic = BaseGameLogic(injector.getInstance(classOf[Configuration])), logic = BaseGameLogic(injector.getInstance(classOf[Configuration])),
@@ -37,45 +35,15 @@ object PodManager {
host = host host = host
) )
sessions += (gameLobby.id -> gameLobby) sessions += (gameLobby.id -> gameLobby)
registerUserToGame(host, gameLobby.id)
redis.foreach(gateway => gateway.syncPod())
gameLobby gameLobby
} }
def getGame(gameId: String): Option[GameLobby] = { def getGame(gameId: String): Option[GameLobby] = {
sessions.get(gameId) sessions.get(gameId)
} }
def registerUserToGame(user: User, gameId: String): Boolean = {
if (sessions.contains(gameId)) {
userSession += (user -> gameId)
redis.foreach(gateway => gateway.syncPod())
true
} else {
false
}
}
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
redis.foreach(gateway => gateway.redis.invalidateUser(user.id.toString))
}
def identifyGameOfUser(user: User): Option[GameLobby] = {
userSession.get(user) match {
case Some(gameId) => sessions.get(gameId)
case None => None
}
}
private[logic] def removeGame(gameId: String): Unit = { private[logic] def removeGame(gameId: String): Unit = {
sessions.remove(gameId) 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

@@ -2,40 +2,66 @@ package logic.game
import de.knockoutwhist.cards.{Hand, Suit} import de.knockoutwhist.cards.{Hand, Suit}
import de.knockoutwhist.control.GameLogic import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.control.GameState.{Lobby, MainMenu} import de.knockoutwhist.control.GameState.{InGame, Lobby, MainMenu}
import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil} import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil}
import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed} import de.knockoutwhist.events.global.tie.TieTurnEvent
import de.knockoutwhist.events.player.PlayerEvent import de.knockoutwhist.events.global.{CardPlayedEvent, GameStateChangeEvent, NewTrickEvent, SessionClosed}
import de.knockoutwhist.events.player.{PlayerEvent, ReceivedHandEvent}
import de.knockoutwhist.player.Playertype.HUMAN import de.knockoutwhist.player.Playertype.HUMAN
import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory} import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
import de.knockoutwhist.rounds.{Match, Round, Trick} import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.DelayHandler
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent} import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import events.{KickEvent, LeftEvent, LobbyUpdateEvent, UserEvent}
import exceptions.* import exceptions.*
import logic.PodManager import logic.game.PollingEvents.{CardPlayed, LobbyCreation, LobbyUpdate, NewRound, NewTrick, ReloadEvent}
import model.sessions.{InteractionType, UserSession} import model.sessions.{InteractionType, UserSession}
import model.users.User import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.{Timer, TimerTask, UUID} import java.util.UUID
import scala.collection.mutable import scala.collection.mutable
import scala.collection.mutable.ListBuffer import scala.collection.mutable.ListBuffer
import scala.util.Try import scala.concurrent.Promise as ScalaPromise
class GameLobby private( class GameLobby private(
val logic: GameLogic, val logic: GameLogic,
val id: String, val id: String,
val internalId: UUID, val internalId: UUID,
val name: String, val name: String,
val maxPlayers: Int val maxPlayers: Int
) extends EventListener { ) extends EventListener {
private val users: mutable.Map[UUID, UserSession] = mutable.Map() private val users: mutable.Map[UUID, UserSession] = mutable.Map()
logic.addListener(this) private val eventsPerPlayer: mutable.Map[UUID, mutable.Queue[PollingEvents]] = mutable.Map()
logic.addListener(DelayHandler) private val waitingPromises: mutable.Map[UUID, ScalaPromise[PollingEvents]] = mutable.Map()
logic.createSession() private val lock = new Object
lock.synchronized {
logic.addListener(this)
logic.createSession()
}
def registerWaiter(playerId: UUID): ScalaPromise[PollingEvents] = {
val promise = ScalaPromise[PollingEvents]()
lock.synchronized {
val queue = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue())
if (queue.nonEmpty) {
val evt = queue.dequeue()
promise.success(evt)
promise
} else {
waitingPromises.put(playerId, promise)
promise
}
}
}
def removeWaiter(playerId: UUID): Unit = {
lock.synchronized {
waitingPromises.remove(playerId)
}
}
def addUser(user: User): UserSession = { def addUser(user: User): UserSession = {
if (users.size >= maxPlayers) throw new GameFullException("The game is full!") if (users.size >= maxPlayers) throw new GameFullException("The game is full!")
@@ -43,34 +69,69 @@ class GameLobby private(
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!") if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
val userSession = new UserSession( val userSession = new UserSession(
user = user, user = user,
host = false, host = false
gameLobby = this
) )
users += (user.id -> userSession) users += (user.id -> userSession)
PodManager.registerUserToGame(user, id) addToQueue(LobbyUpdate)
logic.invoke(LobbyUpdateEvent())
userSession userSession
} }
override def listen(event: SimpleEvent): Unit = { override def listen(event: SimpleEvent): Unit = {
event match { event match {
case event: ReceivedHandEvent =>
addToQueue(NewRound)
users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: CardPlayedEvent =>
addToQueue(CardPlayed)
case event: TieTurnEvent =>
addToQueue(ReloadEvent)
users.get(event.player.id).foreach(session => session.updatePlayer(event))
case event: PlayerEvent => case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event)) users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: UserEvent => case event: NewTrickEvent =>
users.get(event.userId).foreach(session => session.updatePlayer(event)) addToQueue(NewTrick)
case event: GameStateChangeEvent => case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) { if (event.oldState == MainMenu && event.newState == Lobby) {
return return
} }
addToQueue(ReloadEvent)
users.values.foreach(session => session.updatePlayer(event))
case event: SessionClosed =>
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
case event: SimpleEvent => case event: SimpleEvent =>
users.values.foreach(session => session.updatePlayer(event)) users.values.foreach(session => session.updatePlayer(event))
} }
} }
private def addToQueue(event: PollingEvents): Unit = {
lock.synchronized {
users.keys.foreach { playerId =>
val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue())
q.enqueue(event)
}
val waiterIds = waitingPromises.keys.toList
waiterIds.foreach { playerId =>
val q = eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue())
if (q.nonEmpty) {
val evt = q.dequeue()
val p = waitingPromises.remove(playerId)
p.foreach(_.success(evt))
}
}
}
waitingPromises.keys.foreach { playerId =>
val queue = eventsPerPlayer(playerId)
if (queue.nonEmpty) {
val promise = waitingPromises(playerId)
promise.success(queue.dequeue())
waitingPromises.remove(playerId)
}
}
}
/** /**
* Start the game if the user is the host. * Start the game if the user is the host.
*
* @param user the user who wants to start the game. * @param user the user who wants to start the game.
*/ */
def startGame(user: User): Unit = { def startGame(user: User): Unit = {
@@ -97,39 +158,21 @@ class GameLobby private(
/** /**
* Remove the user from the game lobby. * Remove the user from the game lobby.
*
* @param user the user who wants to leave the game. * @param user the user who wants to leave the game.
* @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) val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) { if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!") throw new NotInThisGameException("You are not in this game!")
} }
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
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))
}
users.remove(userId) users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user) addToQueue(LobbyUpdate)
logic.invoke(LobbyUpdateEvent())
} }
/** /**
* Play a card from the player's hand. * Play a card from the player's hand.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand. * @param cardIndex the index of the card in the player's hand.
*/ */
def playCard(userSession: UserSession, cardIndex: Int): Unit = { def playCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.Card) val player = getPlayerInteractable(userSession, InteractionType.Card)
@@ -145,35 +188,10 @@ class GameLobby private(
logic.playerInputLogic.receivedCard(card) logic.playerInputLogic.receivedCard(card)
} }
private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand()
if (handOption.isEmpty) {
throw new IllegalStateException("You have no cards!")
}
handOption.get
}
private def getRound: Round = {
val roundOpt = logic.getCurrentRound
if (roundOpt.isEmpty) {
throw new IllegalStateException("No round is currently running!")
}
roundOpt.get
}
private def getTrick: Trick = {
val trickOpt = logic.getCurrentTrick
if (trickOpt.isEmpty) {
throw new IllegalStateException("No trick is currently running!")
}
trickOpt.get
}
/** /**
* Play a card from the player's hand while in dog life or skip the round. * Play a card from the player's hand while in dog life or skip the round.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round. * @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round.
*/ */
def playDogCard(userSession: UserSession, cardIndex: Int): Unit = { def playDogCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.DogCard) val player = getPlayerInteractable(userSession, InteractionType.DogCard)
@@ -195,9 +213,8 @@ class GameLobby private(
/** /**
* Select the trump suit for the round. * Select the trump suit for the round.
*
* @param userSession the user session of the player. * @param userSession the user session of the player.
* @param trumpIndex the index of the trump suit. * @param trumpIndex the index of the trump suit.
*/ */
def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = { def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit) val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit)
@@ -207,21 +224,8 @@ class GameLobby private(
logic.playerInputLogic.receivedTrumpSuit(selectedTrump) logic.playerInputLogic.receivedTrumpSuit(selectedTrump)
} }
//-------------------
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
/** /**
* *
* @param userSession * @param userSession
* @param tieNumber * @param tieNumber
*/ */
@@ -244,10 +248,9 @@ class GameLobby private(
logic.createSession() logic.createSession()
} }
def getPlayerByUser(user: User): AbstractPlayer = {
getPlayerBySession(getUserSession(user.id)) //-------------------
}
def getUserSession(userId: UUID): UserSession = { def getUserSession(userId: UUID): UserSession = {
val sessionOpt = users.get(userId) val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) { if (sessionOpt.isEmpty) {
@@ -256,20 +259,8 @@ class GameLobby private(
sessionOpt.get sessionOpt.get
} }
private def getPlayerBySession(userSession: UserSession): AbstractPlayer = { def getPlayerByUser(user: User): AbstractPlayer = {
val playerOption = getMatch.totalplayers.find(_.id == userSession.id) getPlayerBySession(getUserSession(user.id))
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
} }
def getPlayers: mutable.Map[UUID, UserSession] = { def getPlayers: mutable.Map[UUID, UserSession] = {
@@ -279,47 +270,62 @@ class GameLobby private(
def getLogic: GameLogic = { def getLogic: GameLogic = {
logic logic
} }
def getEventsOfPlayer(playerId: UUID): mutable.Queue[PollingEvents] = {
eventsPerPlayer.getOrElseUpdate(playerId, mutable.Queue())
}
private def getPlayerBySession(userSession: UserSession): AbstractPlayer = {
val playerOption = getMatch.totalplayers.find(_.id == userSession.id)
if (playerOption.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
playerOption.get
}
private def getPlayerInteractable(userSession: UserSession, iType: InteractionType): AbstractPlayer = {
if (!userSession.lock.isHeldByCurrentThread) {
throw new IllegalStateException("The user session is not locked!")
}
if (userSession.canInteract.isEmpty || userSession.canInteract.get != iType) {
throw new NotInteractableException("You can't play a card!")
}
getPlayerBySession(userSession)
}
private def getHand(player: AbstractPlayer): Hand = {
val handOption = player.currentHand()
if (handOption.isEmpty) {
throw new IllegalStateException("You have no cards!")
}
handOption.get
}
private def getMatch: Match = {
val matchOpt = logic.getCurrentMatch
if (matchOpt.isEmpty) {
throw new IllegalStateException("No match is currently running!")
}
matchOpt.get
}
private def getRound: Round = {
val roundOpt = logic.getCurrentRound
if (roundOpt.isEmpty) {
throw new IllegalStateException("No round is currently running!")
}
roundOpt.get
}
private def getTrick: Trick = {
val trickOpt = logic.getCurrentTrick
if (trickOpt.isEmpty) {
throw new IllegalStateException("No trick is currently running!")
}
trickOpt.get
}
def getUsers: Set[User] = { def getUsers: Set[User] = {
users.values.map(d => d.user).toSet users.values.map(d => d.user).toSet
} }
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 => {
session.websocketActor.foreach(act => act.transmitJsonToClient(event))
})
}
} }
@@ -341,8 +347,7 @@ object GameLobby {
) )
lobby.users += (host.id -> new UserSession( lobby.users += (host.id -> new UserSession(
user = host, user = host,
host = true, host = true
gameLobby = lobby
)) ))
lobby lobby
} }

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.{JWT, JWTVerifier} import com.auth0.jwt.{JWT, JWTVerifier}
import com.github.benmanes.caffeine.cache.{Cache, Caffeine} import com.github.benmanes.caffeine.cache.{Cache, Caffeine}
import com.typesafe.config.Config import com.typesafe.config.Config
import logic.user.{SessionManager, UserManager} import logic.user.SessionManager
import model.users.User import model.users.User
import scalafx.util.Duration import scalafx.util.Duration
import services.JwtKeyProvider import services.JwtKeyProvider
@@ -16,7 +16,7 @@ import javax.inject.{Inject, Singleton}
import scala.util.Try import scala.util.Try
@Singleton @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 algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey)
private val verifier: JWTVerifier = JWT.require(algorithm) 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 com.typesafe.config.Config
import logic.user.UserManager import logic.user.UserManager
import model.users.User import model.users.User
import services.OpenIDUserInfo
import util.UserHash import util.UserHash
import javax.inject.{Inject, Singleton} import javax.inject.{Inject, Singleton}
import scala.collection.mutable
@Singleton @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( "Janis" -> User(
internalId = 1L, internalId = 1L,
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"), id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
@@ -21,8 +19,8 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
), ),
"Leon" -> User( "Leon" -> User(
internalId = 2L, internalId = 2L,
id = java.util.UUID.randomUUID(), id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"),
name = "Jakob", name = "Leon",
passwordHash = UserHash.hashPW("password123") passwordHash = UserHash.hashPW("password123")
), ),
"Jakob" -> User( "Jakob" -> User(
@@ -34,26 +32,7 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
) )
override def addUser(name: String, password: String): Boolean = { override def addUser(name: String, password: String): Boolean = {
val newUser = User( throw new NotImplementedError("StubUserManager.addUser is not implemented")
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
} }
override def authenticate(name: String, password: String): Option[User] = { 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] = { override def userExists(name: String): Option[User] = {
user.get(name) user.get(name)
} }
@@ -79,6 +51,7 @@ class StubUserManager @Inject()(config: Config) extends UserManager {
} }
override def removeUser(name: String): Boolean = { override def removeUser(name: String): Boolean = {
user.remove(name).isDefined throw new NotImplementedError("StubUserManager.removeUser is not implemented")
} }
} }

View File

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

View File

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

View File

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

View File

@@ -2,18 +2,14 @@ package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent} import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User import model.users.User
import play.api.libs.json.{JsObject, JsValue}
import java.util.UUID import java.util.UUID
import java.util.concurrent.locks.ReentrantLock import java.util.concurrent.locks.{Lock, ReentrantLock}
import scala.util.Try
class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession { class UserSession(val user: User, val host: Boolean) extends PlayerSession {
val lock: ReentrantLock = ReentrantLock()
var canInteract: Option[InteractionType] = None var canInteract: Option[InteractionType] = None
var websocketActor: Option[UserWebsocketActor] = None val lock: ReentrantLock = ReentrantLock()
override def updatePlayer(event: SimpleEvent): Unit = { override def updatePlayer(event: SimpleEvent): Unit = {
event match { event match {
@@ -26,81 +22,14 @@ class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) e
else canInteract = Some(InteractionType.Card) else canInteract = Some(InteractionType.Card)
case _ => case _ =>
} }
lock.lock()
websocketActor.foreach(_.solveRequests())
websocketActor.foreach(_.transmitEventToClient(event))
lock.unlock()
} }
override def id: UUID = user.id override def id: UUID = user.id
override def name: String = user.name override def name: String = user.name
def resetCanInteract(): Unit = { def resetCanInteract(): Unit = {
canInteract = None canInteract = None
} }
def handleWebResponse(eventType: String, data: JsObject): Unit = {
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)
}
}
} }

View File

@@ -1,133 +0,0 @@
package model.sessions
import de.knockoutwhist.utils.events.SimpleEvent
import org.apache.pekko.actor.{Actor, ActorRef}
import play.api.libs.json.{JsObject, JsValue, Json}
import util.WebsocketEventMapper
import scala.collection.mutable
import scala.util.{Failure, Success, Try}
class UserWebsocketActor(
out: ActorRef,
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.")
}
session.websocketActor = Some(this)
session.lock.unlock()
}
override def receive: Receive = {
case msg: String =>
val jsonObject = Try {
Json.parse(msg)
}
Try {
jsonObject match {
case Success(value) =>
handle(value)
case Failure(exception) =>
transmitTextToClient(s"Error parsing JSON: ${exception.getMessage}")
}
}.failed.foreach(
ex => transmitTextToClient(s"Error handling message: ${ex.getMessage}")
)
case other =>
}
private def transmitTextToClient(text: String): Unit = {
out ! text
}
private def handle(json: JsValue): Unit = {
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
val eventOpt = (json \ "event").asOpt[String]
if (eventOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> null,
"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,
"event" -> event,
"status" -> "success"
))
} else {
transmitJsonToClient(Json.obj(
"id" -> id,
"event" -> event,
"status" -> "error",
"error" -> result.failed.get.getMessage
))
}
session.lock.unlock()
}
def transmitJsonToClient(jsonObj: JsValue): Unit = {
transmitTextToClient(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"
))
}
}
}

View File

@@ -1,13 +1,12 @@
package model.users package model.users
import java.util.UUID import java.util.UUID
case class User( case class User(
internalId: Long, internalId: Long,
id: UUID, id: UUID,
name: String, name: String,
passwordHash: String passwordHash: String
) { ) {
def withName(newName: String): User = { def withName(newName: String): User = {
@@ -17,4 +16,5 @@ case class User(
private def withPasswordHash(newPasswordHash: String): User = { private def withPasswordHash(newPasswordHash: String): User = {
this.copy(passwordHash = newPasswordHash) 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

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

View File

@@ -1,165 +0,0 @@
package services
import com.typesafe.config.Config
import play.api.libs.ws.WSClient
import play.api.Configuration
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")
)
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 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")
)
)
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 \ "id").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 {
None
}
}
.recover { case _ => 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 package util
import de.knockoutwhist.control.GameState
import de.knockoutwhist.control.GameState.{FinishedMatch, InGame, Lobby, MainMenu, SelectTrump, TieBreak}
import scala.util.Random import scala.util.Random
object GameUtil { object GameUtil {
@@ -29,15 +26,4 @@ object GameUtil {
code.toString() 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,17 @@
package util package util
import de.knockoutwhist.cards.Card
import de.knockoutwhist.cards.CardValue.* import de.knockoutwhist.cards.CardValue.*
import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades} 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 play.twirl.api.Html
import scalafx.scene.image.Image import scalafx.scene.image.Image
object WebUIUtils { object WebUIUtils {
def cardtoImage(card: Card): Html = {
def cardToPath(card: Card): String = { views.html.render.card.apply(f"images/cards/${cardtoString(card)}.png")(card.toString)
f"images/cards/${cardtoString(card)}.png"
} }
def cardtoString(card: Card): String = { def cardtoString(card: Card) = {
val s = card.suit match { val s = card.suit match {
case Spades => "S" case Spades => "S"
case Hearts => "H" case Hearts => "H"
@@ -37,48 +35,5 @@ object WebUIUtils {
} }
f"$cv$s" 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
* Per card it has the string and the index in the hand
* @param hand
* @return
*/
def handToJson(hand: Hand): JsArray = {
val cards = hand.cards
JsArray(
cards.zipWithIndex.map { case (card, index) =>
Json.obj(
"idx" -> index,
"card" -> cardtoString(card)
)
}
)
}
} }

View File

@@ -1,73 +0,0 @@
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 tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
import util.mapper.*
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]
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 def registerCustomMapper[T <: SimpleEvent](mapper: SimpleEventMapper[T]): Unit = {
specialMappers = specialMappers + (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
}
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,17 +0,0 @@
package util.mapper
import de.knockoutwhist.events.player.ReceivedHandEvent
import model.sessions.UserSession
import play.api.libs.json.{JsObject, Json}
import util.WebUIUtils
object ReceivedHandEventMapper extends SimpleEventMapper[ReceivedHandEvent] {
override def id: String = "ReceivedHandEvent"
override def toJson(event: ReceivedHandEvent, session: UserSession): 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 +0,0 @@
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
}

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,37 @@
@(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'));
</script>

View File

@@ -0,0 +1,107 @@
@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'));
</script>

View File

@@ -0,0 +1,71 @@
@(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'));
</script>

View File

@@ -0,0 +1,117 @@
@(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'));
</script>

View File

@@ -0,0 +1,81 @@
@(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">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'));
</script>

View File

@@ -0,0 +1,42 @@
@()
<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');
});
</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,30 @@
@*
* 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="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,30 @@
@(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>

View File

@@ -0,0 +1,57 @@
@(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,177 @@
@(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>

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.csrf.CSRFFilter
play.filters.disabled += play.filters.hosts.AllowedHostsFilter 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="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:G3gf:90Latabg@5241AB`R5W:1uDFN];Ik@n"
play.http.secret.key=${?APPLICATION_SECRET} play.http.secret.key=${?APPLICATION_SECRET}
@@ -16,38 +13,3 @@ auth {
publicKeyFile = ${?PUBLIC_KEY_FILE} publicKeyFile = ${?PUBLIC_KEY_FILE}
publicKeyPem = ${?PUBLIC_KEY_PEM} publicKeyPem = ${?PUBLIC_KEY_PEM}
} }
play.filters.enabled += "play.filters.cors.CORSFilter"
play.filters.cors {
allowedOrigins = ["http://localhost:5173"]
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

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

View File

@@ -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,41 @@
# Create game rounds # Routes
POST /createGame controllers.MainMenuController.createGame() # This file defines all application routes (Higher priority routes first)
POST /joinGame/:gameId controllers.MainMenuController.joinGame(gameId: String) # 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 controllers.MainMenuController.joinGame()
# User authentication routes # User authentication routes
POST /login controllers.UserController.login_Post() GET /login controllers.UserController.login()
POST /register controllers.UserController.register() POST /login controllers.UserController.login_Post()
POST /logout controllers.UserController.logoutPost()
GET /userInfo controllers.UserController.getUserInfo()
# OpenID Connect routes GET /logout controllers.UserController.logout()
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()
# Websocket # In-game routes
GET /websocket controllers.WebsocketController.socket() GET /game/:id controllers.IngameController.game(id: String)
POST /game/:id/start controllers.IngameController.startGame(id: String)
POST /game/:id/kickPlayer/:playerToKick controllers.IngameController.kickPlayer(id: String, playerToKick: String)
# Status POST /game/:id/trump controllers.IngameController.playTrump(id: String)
GET /status controllers.StatusController.requestStatus() POST /game/:id/tie controllers.IngameController.playTie(id: String)
GET /status/:gameId controllers.StatusController.game(gameId: String)
# Health GET /game/:id/leaveGame controllers.IngameController.leaveGame(id: String)
GET /health/simple controllers.HealthController.simple() POST /game/:id/playCard controllers.IngameController.playCard(id: String)
POST /game/:id/dogPlayCard controllers.IngameController.playDogCard(id: String)
POST /game/:id/returnToLobby controllers.IngameController.returnToLobby(id: String)
# Polling
GET /polling/:gameId controllers.PollingController.polling(gameId: String)

View File

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

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