Compare commits

..

1 Commits

Author SHA1 Message Date
LQ63
d9f7275854 Added link to click for player pov, started rendering with images 2025-10-20 15:17:17 +02:00
92 changed files with 577 additions and 5510 deletions

View File

@@ -1 +0,0 @@
.env

View File

@@ -1,46 +0,0 @@
---
name: "Epic"
about: "A large initiative or feature that consists of multiple User Stories or Subtasks"
title: "[Epic] <Epic title>"
labels: ["Type/Epic"]
---
## 🧩 Epic Summary
Provide a clear summary of what this Epic aims to achieve.
---
## 🎯 Goals
- [ ] Goal 1
- [ ] Goal 2
- [ ] Goal 3
---
## 📋 Description
Describe the high-level context, purpose, and expected outcome of this Epic.
---
## 🧵 Related User Stories / Subtasks
Link to related issues here:
- [ ] #<user-story-1>
- [ ] #<user-story-2>
- [ ] #<subtask-1>
---
## 📅 Milestone / Timeline
If applicable, note any key dates or milestones:
- Target Start:
- Target Completion:
---
## ⚠️ Risks / Dependencies
List any major risks or dependencies that could affect delivery.
---
## ✅ Acceptance Criteria
Define the success metrics or completion definition for the Epic.

View File

@@ -1,35 +0,0 @@
---
name: "Subtask"
about: "A smaller task that contributes to a User Story or Epic"
title: "[Subtask] <Task title>"
labels: ["Type/Subtask"]
---
## 🧾 Description
Briefly describe what needs to be done for this subtask.
---
## 🔗 Linked Issues
- Parent Story: #<user-story-number>
- Related Epic: #<epic-number>
---
## ⚙️ Steps / Tasks
- [ ] Task 1
- [ ] Task 2
- [ ] Task 3
---
## ✅ Definition of Done
What must be true for this subtask to be considered complete:
- [ ] Code implemented
- [ ] Tests passed
- [ ] Reviewed and merged
---
## 🧩 Estimated Size
Use label: `Size/XS` | `Size/S` | `Size/M` | `Size/L` | `Size/XL`

View File

@@ -1,42 +0,0 @@
---
name: "User Story"
about: "A feature or requirement from the user's perspective"
title: "[Story] <User story title>"
labels: ["Type/User Story"]
---
## 🧍‍♂️ User Story
**As a** [type of user]
**I want to** [perform an action]
**So that** [achieve a goal or value]
---
## 📋 Description
Provide additional context or business logic for this story.
---
## 🎯 Acceptance Criteria
List the specific, measurable criteria that define when this story is done:
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
---
## 🧱 Implementation Notes
Include technical notes, design references, or constraints.
---
## 🧵 Linked Issues
- Parent Epic: #<epic-number>
- Related Subtasks:
- [ ] #<subtask-1>
- [ ] #<subtask-2>
---
## 🧩 Estimated Size
Use label: `Size/XS` | `Size/S` | `Size/M` | `Size/L` | `Size/XL`

2
.gitignore vendored
View File

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

2
.gitmodules vendored
View File

@@ -1,4 +1,4 @@
[submodule "knockoutwhist"] [submodule "knockoutwhist"]
path = knockoutwhist path = knockoutwhist
branch = main branch = webapplication
url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist.git url = https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist.git

View File

@@ -1,2 +0,0 @@
-J--add-opens=java.base/java.util=ALL-UNNAMED
-J--add-opens=java.base/java.lang=ALL-UNNAMED

View File

@@ -1,145 +0,0 @@
## (2025-11-07)
### ⚠ BREAKING CHANGES
* implemented multigame support (#34)
### Features
* **config:** add issue templates for Epics, User Stories, and Subtasks ([#4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/4)) ([d71809d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/d71809d6f4389b03ecc8ee9b857e58a4da413fa2))
* **docker:** added docker container support ([#47](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/47)) ([4d6ea54](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4d6ea54771c284d5ea0bb798fedaf0423a6cdd58))
* implemented multigame support ([#34](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/34)) ([afde6c0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/afde6c02da2c3055084ae90ab6b11b63b0e11cfb))
* **ui:** add Lobby and Main Menu Body ([#38](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/38)) ([051e740](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/051e7406e30385d3260631a783f507b90d9f94d1))
* **ui:** add main menu navbar and join game functionality ([#35](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/35)) ([32d4f9c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/32d4f9c6cefa20f10816dad9f33548a146d4c2c3))
* **ui:** added rules ([#12](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/12)) ([92e4851](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/92e4851219e64ad919d7f8bc7c30232ef7d6570f))
* **ui:** CSS Animations [#18](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/18) ([#27](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/27)) ([c0dadf8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c0dadf89274169b396089c2e7b3d69b043097186))
* **ui:** implement CSS variables for theme support ([#26](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/26)) ([1f377de](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1f377de0f43aaf17271d29678a1accf11cfe121c)), closes [#17](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/17)
* **ui:** LESS Integration ([72fcf78](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/72fcf783b8ca5ce260e4b152016353e7dc1edb69)), closes [#15-Create-a-default-theme-with-Less-](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/15-Create-a-default-theme-with-Less-) [#23](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/23)
* **ui:** UI now shows player names instead of their id ([#11](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/11)) ([c168ae7](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c168ae7dc05e99c1890661aa3b63f4d4d12779fb))
### Bug Fixes
* **base:** fixed persistence logic ([#21](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/21)) ([7f765b4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7f765b4514459202df3768c64a1df383fecb66e2))
* **config:** modified git module to use the main branch ([#10](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/10)) ([ccf44ed](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/ccf44ede41e10df4b973c9cecc09d1e7105143f5))
* disabled external node ([51d9c0b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/51d9c0b5f687d951f42480e333f37b3155d58dbd))
* ensure proper unlocking of user session locks in game actions ([#37](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/37)) ([44c88c8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/44c88c8f60abf4a54e721f8e7f6de44a6d28b258))
* **imports:** reorganized import statements for clarity and consistency ([#22](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/22)) ([1517d0c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1517d0c006247a95b02f66fa5bc53cc78b9d89fa))
* improve lock handling in user session interactions ([#36](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/36)) ([96c3846](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/96c38466d20203a561a5bc3bba560c9d9fd6d754))
* **ui:** add light mode styles, update font families and fixed ui ([#24](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/24)) ([729a4ee](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/729a4eec6b721f8118d0feffa44721262ccd595f))
* **ui:** added dark mode ([#25](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/25)) ([6c31fa0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/6c31fa0538e5d71c8be9a728eebf7576cb108782))
* **ui:** changed backgrounds ([#33](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/33)) ([bef96ba](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/bef96ba7e39a633c1972b01b1c1ebab9d1d3ee1c))
* update allowed hosts filter and adjust background color in login page ([#45](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/45)) ([aa83082](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/aa83082d095b30fd3eae336ad5f1aa5d409fbdab))
* update file paths and improve session handling in user interactions ([#39](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/39)) ([de565b5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/de565b52dca6b49b903eceaecd47cc3a5429608b))
### Reverts
* ci: bump version to v1.0.0 [skip ci] ([91d7f6c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/91d7f6ca003edb368aba76b7a82c8b42d22bdbfe))
* version bumb ([2e10059](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e10059a6756befe6f8e70c94fd34b865693efb8))
* version bump ([7879d1a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7879d1ab6ee8f227c19b69b467a28dd7b479ff73))
## (2025-11-07)
### ⚠ BREAKING CHANGES
* implemented multigame support (#34)
### Features
* **config:** add issue templates for Epics, User Stories, and Subtasks ([#4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/4)) ([d71809d](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/d71809d6f4389b03ecc8ee9b857e58a4da413fa2))
* **docker:** added docker container support ([#47](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/47)) ([4d6ea54](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/4d6ea54771c284d5ea0bb798fedaf0423a6cdd58))
* implemented multigame support ([#34](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/34)) ([afde6c0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/afde6c02da2c3055084ae90ab6b11b63b0e11cfb))
* **ui:** add Lobby and Main Menu Body ([#38](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/38)) ([051e740](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/051e7406e30385d3260631a783f507b90d9f94d1))
* **ui:** add main menu navbar and join game functionality ([#35](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/35)) ([32d4f9c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/32d4f9c6cefa20f10816dad9f33548a146d4c2c3))
* **ui:** added rules ([#12](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/12)) ([92e4851](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/92e4851219e64ad919d7f8bc7c30232ef7d6570f))
* **ui:** CSS Animations [#18](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/18) ([#27](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/27)) ([c0dadf8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c0dadf89274169b396089c2e7b3d69b043097186))
* **ui:** implement CSS variables for theme support ([#26](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/26)) ([1f377de](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1f377de0f43aaf17271d29678a1accf11cfe121c)), closes [#17](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/17)
* **ui:** LESS Integration ([72fcf78](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/72fcf783b8ca5ce260e4b152016353e7dc1edb69)), closes [#15-Create-a-default-theme-with-Less-](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/15-Create-a-default-theme-with-Less-) [#23](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/23)
* **ui:** UI now shows player names instead of their id ([#11](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/11)) ([c168ae7](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/c168ae7dc05e99c1890661aa3b63f4d4d12779fb))
### Bug Fixes
* **base:** fixed persistence logic ([#21](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/21)) ([7f765b4](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7f765b4514459202df3768c64a1df383fecb66e2))
* **config:** modified git module to use the main branch ([#10](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/10)) ([ccf44ed](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/ccf44ede41e10df4b973c9cecc09d1e7105143f5))
* disabled external node ([51d9c0b](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/51d9c0b5f687d951f42480e333f37b3155d58dbd))
* ensure proper unlocking of user session locks in game actions ([#37](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/37)) ([44c88c8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/44c88c8f60abf4a54e721f8e7f6de44a6d28b258))
* **imports:** reorganized import statements for clarity and consistency ([#22](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/22)) ([1517d0c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/1517d0c006247a95b02f66fa5bc53cc78b9d89fa))
* improve lock handling in user session interactions ([#36](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/36)) ([96c3846](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/96c38466d20203a561a5bc3bba560c9d9fd6d754))
* **ui:** add light mode styles, update font families and fixed ui ([#24](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/24)) ([729a4ee](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/729a4eec6b721f8118d0feffa44721262ccd595f))
* **ui:** added dark mode ([#25](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/25)) ([6c31fa0](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/6c31fa0538e5d71c8be9a728eebf7576cb108782))
* **ui:** changed backgrounds ([#33](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/33)) ([bef96ba](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/bef96ba7e39a633c1972b01b1c1ebab9d1d3ee1c))
* update allowed hosts filter and adjust background color in login page ([#45](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/45)) ([aa83082](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/aa83082d095b30fd3eae336ad5f1aa5d409fbdab))
* update file paths and improve session handling in user interactions ([#39](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/39)) ([de565b5](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/de565b52dca6b49b903eceaecd47cc3a5429608b))
### Reverts
* ci: bump version to v1.0.0 [skip ci] ([91d7f6c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/91d7f6ca003edb368aba76b7a82c8b42d22bdbfe))
* version bumb ([2e10059](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e10059a6756befe6f8e70c94fd34b865693efb8))
* version bump ([7879d1a](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7879d1ab6ee8f227c19b69b467a28dd7b479ff73))
## (2025-11-07)
### Bug Fixes
* ensure proper CMD syntax in Dockerfile ([#48](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/48)) ([5c6d3ac](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5c6d3ac436f6d23a36f58b6835c9bd50feddc789))
## (2025-11-07)
### Bug Fixes
* changelog syntax ([2e54880](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/2e548803020c99f62644283fcf3570048261173a))
## (2025-11-07)
### Bug Fixes
* removed trailing ([64a7a63](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/64a7a63ab3dff59e66f62328e3b5865bb177fcde))
## (2025-11-07)
### Bug Fixes
* traling ([54e3215](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/54e321512777f6722864694eb677eab0e8418a9f))
## (2025-11-07)
### Bug Fixes
* removed trailing ([5e503cb](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/5e503cbc364f7cb23926976acc6cee575eadd9d6))
## (2025-11-07)
### Bug Fixes
* trailing ([7adc8b8](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/7adc8b8645390cd18d63b4eee6db8ef448b7a46a))
## (2025-11-07)
### Bug Fixes
* 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))
## (2025-11-20)
### ⚠ BREAKING CHANGES
* **game:** Fixed polling, SPA, Gameplayloop etc. (#59)
### Features
* **ci:** Polling ([370de17](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/370de175db65edf87e7ab211190977b540a39d85))
* **ci:** Polling Added polling for when the game starts and a card gets played ([#58](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/issues/58)) ([e60fe7c](https://git.janis-eccarius.de/KnockOutWhist/KnockOutWhist-Web/commit/e60fe7c98dcab05949140a8a54ed6e4e2fbbc022))
* **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:** 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)

View File

@@ -1,41 +0,0 @@
# === Stage 1: Build the Play application ===
FROM sbtscala/scala-sbt:eclipse-temurin-alpine-22_36_1.10.3_3.5.1 AS builder
WORKDIR /app
# Install Node.js and Less CSS preprocessor
USER root
RUN apk add --no-cache nodejs npm && \
npm install -g less
# Cache dependencies first
COPY project ./project
COPY build.sbt ./
RUN sbt -Dscoverage.skip=true update
# Copy the rest of the code
COPY . .
# Build the app and stage it
RUN sbt -Dscoverage.skip=true clean stage
# === Stage 2: Runtime image ===
FROM eclipse-temurin:21-jre-alpine
# Install Argon2 CLI and libraries
RUN apk add --no-cache bash argon2 argon2-libs
WORKDIR /opt/playapp
# Copy staged Play build
COPY --from=builder /app/knockoutwhistweb/target/universal/stage /opt/playapp
# Expose the default Play port
EXPOSE 9000
# Set environment variables
ENV PLAY_HTTP_PORT=9000
# Run the Play app
ENTRYPOINT ["./bin/knockoutwhistweb"]
CMD ["-Dplay.server.pidfile.path=/dev/null"]

View File

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

View File

@@ -1,16 +0,0 @@
meta {
name: Create Game
type: http
seq: 1
}
post {
url: {{host}}/createGame
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,20 +0,0 @@
meta {
name: Get Game
type: http
seq: 2
}
get {
url: {{host}}/game/:id
body: none
auth: inherit
}
params:path {
id: BZvtJ3
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,20 +0,0 @@
meta {
name: Start Game
type: http
seq: 3
}
post {
url: {{host}}/game/:id/start
body: none
auth: inherit
}
params:path {
id: nR1o3n
}
settings {
encodeUrl: true
timeout: 0
}

View File

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

View File

@@ -1,26 +0,0 @@
meta {
name: Login
type: http
seq: 2
}
post {
url: {{host}}/login
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
username: Janis
password: password123
}
body:multipart-form {
username: Janis
password: password123
}
settings {
encodeUrl: true
timeout: 0
}

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
ThisBuild / scalaVersion := "3.5.1" ThisBuild / scalaVersion := "3.5.1"
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 {
@@ -19,6 +19,8 @@ lazy val commonSettings = Seq(
.map(m => "org.openjfx" % s"javafx-$m" % "21" classifier osName) .map(m => "org.openjfx" % s"javafx-$m" % "21" classifier osName)
}, },
libraryDependencies += guice, libraryDependencies += guice,
Test / testOptions += Tests.Filter(_.equals("de.knockoutwhist.TestSequence")),
coverageEnabled := true,
coverageFailOnMinimum := true, coverageFailOnMinimum := true,
coverageMinimumStmtTotal := 85, coverageMinimumStmtTotal := 85,
coverageMinimumBranchTotal := 100 coverageMinimumBranchTotal := 100
@@ -27,8 +29,7 @@ lazy val commonSettings = Seq(
lazy val knockoutwhist = project.in(file("knockoutwhist")) lazy val knockoutwhist = project.in(file("knockoutwhist"))
.settings( .settings(
commonSettings, commonSettings,
mainClass := Some("de.knockoutwhist.KnockOutWhist"), mainClass := Some("de.knockoutwhist.KnockOutWhist")
coverageExcludedPackages := "de.knockoutwhist.ui.*;de.knockoutwhist.utils.gui.*"
) )
lazy val knockoutwhistweb = project.in(file("knockoutwhistweb")) lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
@@ -36,12 +37,7 @@ lazy val knockoutwhistweb = project.in(file("knockoutwhistweb"))
.dependsOn(knockoutwhist % "compile->compile;test->test") .dependsOn(knockoutwhist % "compile->compile;test->test")
.settings( .settings(
commonSettings, commonSettings,
libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test, libraryDependencies += "org.scalatestplus.play" %% "scalatestplus-play" % "7.0.2" % Test
libraryDependencies += "de.mkammerer" % "argon2-jvm" % "2.12",
libraryDependencies += "com.auth0" % "java-jwt" % "4.5.0",
libraryDependencies += "com.github.ben-manes.caffeine" % "caffeine" % "3.2.3",
libraryDependencies += "tools.jackson.module" %% "jackson-module-scala" % "3.0.2",
JsEngineKeys.engineType := JsEngineKeys.EngineType.Node
) )
lazy val root = (project in file(".")) lazy val root = (project in file("."))

View File

@@ -1,116 +0,0 @@
{
"$schema": "https://github.com/lppedd/idea-conventional-commit/raw/master/src/main/resources/defaults/conventionalcommit.schema.json",
"types": {
"refactor": {
"description": "Changes which neither fix a bug nor add a feature"
},
"fix": {
"description": "Changes which patch a bug"
},
"feat": {
"description": "Changes which introduce a new feature"
},
"build": {
"description": "Changes which affect the build system or external dependencies.<br>Example scopes: gulp, broccoli, npm",
"scopes": {
"npm": {},
"gulp": {},
"broccoli": {}
}
},
"chore": {
"description": "Changes which are not user-facing"
},
"style": {
"description": "Changes which do not affect code logic, such as whitespaces, formatting, missing semicolons"
},
"test": {
"description": "Changes which add missing tests or fix existing ones"
},
"docs": {
"description": "Changes which affect documentation"
},
"perf": {
"description": "Changes which improve performance"
},
"ci": {
"description": "Changes which affect CI configuration files and scripts.<br>Example scopes: travis, circle, browser-stack, sauce-labs"
},
"revert": {
"description": "Changes which revert a previous commit"
}
},
"commonScopes": {
"api": {
"description": "Changes related to the API"
},
"auth": {
"description": "Changes related to authentication"
},
"config": {
"description": "Changes related to configuration"
},
"db": {
"description": "Changes related to the database"
},
"docs": {
"description": "Changes related to documentation"
},
"ui": {
"description": "Changes related to the user interface"
},
"ux": {
"description": "Changes related to the user experience"
},
"build": {
"description": "Changes related to the build system"
},
"ci": {
"description": "Changes related to continuous integration"
},
"deps": {
"description": "Changes related to dependencies"
},
"promotion": {
"description": "Kargo promotion changes"
},
"base": {
"description": "Changes related to the core functionality"
},
"release": {
"description": "A release commit"
}
},
"footerTypes": {
"BREAKING-CHANGE": {
"description": "The commit introduces breaking changes"
},
"Closes": {
"description": "The commit closes issues or pull requests"
},
"Implements": {
"description": "The commit implements new features"
},
"Author": {
"description": "The commit's author"
},
"Co-authored-by": {
"description": "The specified person co-authored the commit's changes"
},
"Signed-off-by": {
"description": "Certifies the committer has the rights to submit the work under the project's license or agrees to some contributor representation, such as a Developer Certificate of Origin"
},
"Reviewed-by": {
"description": "The specified person reviewed and is completely satisfied with the commit's changes"
},
"Tested-by": {
"description": "The specified person applied the commit's changes and found them to have the desired effect"
},
"Acked-by": {
"description": "The specified person liked the commit's changes"
},
"Refs": {
"description": "The commit references another commit by its hash ID.<br>For multiple hash IDs, use a comma as separator"
}
}
}

View File

@@ -1,14 +0,0 @@
@media (prefers-color-scheme: dark) {
:root {
--background-image: url('/assets/images/background.png') !important;
--color: #f8f9fa !important; /* Light text on dark bg */
--highlightscolor: rgba(131, 131, 131, 0.75) !important;
--background-color: #192734;
/* Bootstrap variable overrides for dark mode */
--bs-body-color: var(--color);
--bs-link-color: #66b2ff;
--bs-link-hover-color: #99ccff;
--bs-border-color: rgba(255, 255, 255, 0.2);
--bs-heading-color: var(--color);
}
}

View File

@@ -1,6 +0,0 @@
:root {
--background-image: url('/assets/images/img.png');
--color: black;
--highlightscolor: rgba(0, 0, 0, 0.75);
--background-color: rgba(228, 232, 237, 1);
}

View File

@@ -1,35 +0,0 @@
.login-box {
position: fixed; /* changed from absolute to fixed so it centers relative to the viewport */
align-items: center;
justify-content: center;
top: 50%;
left: 50%;
transform: translate(-50%, -50%); /* center exactly */
display: flex;
width: 100%;
max-width: 420px; /* keeps box from stretching too wide */
padding: 1rem;
z-index: 2; /* above particles */
}
.login-card {
max-width: 400px;
width: 100%;
border: none;
border-radius: 1rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative;
z-index: 3; /* ensure card sits above the particles */
}
#particles-js {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0; /* behind content */
pointer-events: none; /* allow clicks through particles */
background-repeat: no-repeat;
background-size: cover;
}

View File

@@ -1,300 +0,0 @@
@import "light-mode.less";
@import "dark-mode.less";
@import "login.less";
/* Provide default (light) variables so the site works even if light-mode.less fails */
:root {
--background-image: url('/assets/images/img.png');
--color: #212529; /* Bootstrap body text default */
/* Bootstrap variable overrides for light mode */
--bs-body-color: var(--color) !important;
--bs-link-color: #0d6efd !important;
--bs-link-hover-color: #0a58ca !important;
--bs-border-color: rgba(0, 0, 0, 0.125) !important;
--bs-heading-color: var(--color) !important;
}
@background-color: var(--background-color);
@highlightcolor: var(--highlightscolor);
@background-image: var(--background-image);
@color: var(--color);
@keyframes slideIn {
0% {
transform: translateX(-100vw);
}
100% {
transform: translateX(0);
}
}
.game-field-background {
background-image: @background-image;
background-repeat: no-repeat;
background-size: cover;
max-width: 1400px;
margin: 0 auto;
min-height: 100vh;
}
.lobby-background {
background-color: @background-color;
width: 100%;
height: 100vh;
}
.navbar-header {
text-align: center;
}
.navbar-toggle {
float: none;
margin-right: 0;
}
.handcard :hover {
box-shadow: 3px 3px 3px @highlightcolor;
}
.inactive::after {
content: "";
position: absolute;
inset: 0; /* cover the whole container */
background: rgba(0, 0, 0, 0.50);
z-index: 10;
border-radius: 6px;
pointer-events: none; /* user can't click through overlay */
}
.bottom-div {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
max-width: 1400px;
width: 100%;
margin: 0;
text-align: center;
padding: 10px;
}
/* Ensure body text color follows theme variable and works with Bootstrap */
body {
color: @color;
}
.footer {
width: 100%;
text-align: center;
font-size: 12px;
color: @color;
padding: 0.5rem 0;
flex-grow: 1; /* fill remaining vertical space as visual footer background */
}
.game-field {
position: fixed;
inset: 0;
overflow: auto;
}
.navbar-drop-shadow {
box-shadow: 0 1px 15px 0 #000000
}
.ingame-side-shadow {
box-shadow: 0 1px 15px 0 #000000
}
#sessions {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
text-align: center;
h1 {
animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards;
}
}
#textanimation {
animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards;
animation-delay: 1s;
}
#sessions a, #sessions h1, #sessions p {
color: @color;
font-size: 40px;
font-family: Arial, serif;
}
#ingame {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-end;
height: 100%;
}
#ingame a, #ingame h1, #ingame p {
color: @color;
font-size: 40px;
font-family: Arial, serif;
}
.ingame-cards-slide {
div {
animation: slideIn 0.5s ease-out forwards;
animation-fill-mode: backwards;
&:nth-child(1) {
animation-delay: 0.5s;
}
&:nth-child(2) {
animation-delay: 1s;
}
&: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 {
display: flex;
flex-direction: column;
justify-content: flex-end;
}
#playedcardplayer p {
font-size: 12px;
height: 4%;
}
#playedcardplayer img {
height: 90%;
}
#firstCard {
display: flex;
flex-direction: row;
height: 20%;
width: 100%;
justify-content: space-between;
}
#firstCardObject {
display: flex;
flex-direction: column;
margin-right: 4%;
}
#firstCardObject img {
height: 90%;
}
#firstCardObject p {
height: 10%;
font-size: 20px;
}
#nextPlayers {
display: flex;
flex-direction: column;
align-items: center;
height: 0;
p {
margin-top: 0;
margin-bottom: 0;
}
}
#invisible {
visibility: hidden;
}
#selecttrumpsuit {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
}
#rules {
color: @color;
font-size: 1.5em;
font-family: Arial, serif;
}
.score-table {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 8px;
padding: 10px;
margin-bottom: 20px;
backdrop-filter: blur(8px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.score-header {
font-weight: bold;
color: #000000;
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
}
.score-row {
color: #000000;
}
/* In-game centered stage and blurred sides overlay */
.ingame-stage {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
/* Wrapper that adds a backdrop blur to the background outside the centered card */
.blur-sides {
position: relative;
}
/* Create an overlay that blurs everything behind it, except the central content area */
.blur-sides::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
/* 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%);
}
@supports ((-webkit-backdrop-filter: blur(8px)) or (backdrop-filter: blur(8px))) {
.blur-sides::before {
background: rgba(0, 0, 0, 0.08);
-webkit-backdrop-filter: blur(10px) saturate(110%);
backdrop-filter: blur(10px) saturate(110%);
}
}

View File

@@ -1,37 +0,0 @@
package auth
import controllers.routes
import logic.user.SessionManager
import model.users.User
import play.api.mvc.*
import javax.inject.Inject
import scala.concurrent.{ExecutionContext, Future}
class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request)
class AuthAction @Inject()(val sessionManager: SessionManager, val parser: BodyParsers.Default)(implicit ec: ExecutionContext)
extends ActionBuilder[AuthenticatedRequest, AnyContent] {
override def executionContext: ExecutionContext = ec
override def invokeBlock[A](
request: Request[A],
block: AuthenticatedRequest[A] => Future[Result]
): Future[Result] = {
getUserFromSession(request) match {
case Some(user) =>
block(new AuthenticatedRequest(user, request))
case None =>
Future.successful(Results.Redirect(routes.UserController.login()))
}
}
protected def getUserFromSession(request: RequestHeader): Option[User] = {
val session = request.cookies.get("sessionId")
if (session.isDefined)
return sessionManager.getUserBySession(session.get.value)
None
}
}

View File

@@ -1,14 +1,13 @@
package components package components
import de.knockoutwhist.components.DefaultConfiguration import de.knockoutwhist.components.DefaultConfiguration
import controllers.Gamesession
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] = super.uis + Gamesession
override def listener: Set[EventListener] = super.listener + Gamesession
override def listener: Set[EventListener] = Set(DelayHandler)
} }

View File

@@ -0,0 +1,57 @@
package controllers
import controllers.sessions.{AdvancedSession, PlayerSession, SimpleSession}
import de.knockoutwhist.cards.{Card, CardValue, Hand, Suit}
import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic
import de.knockoutwhist.events.*
import de.knockoutwhist.events.player.{PlayCardEvent, PlayerEvent, ReceivedHandEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.player.AbstractPlayer
import de.knockoutwhist.rounds.Match
import de.knockoutwhist.ui.UI
import de.knockoutwhist.utils.CustomThread
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import java.util.UUID
import scala.collection.mutable
class Gamesession(id: UUID) extends CustomThread with EventListener with UI {
setName("Gamesession")
private val sessions: mutable.Map[UUID, PlayerSession] = mutable.Map()
var init = false
var logic: Option[GameLogic] = None
override def instance: CustomThread = this
override def listen(event: SimpleEvent): Unit = {
runLater {
event match {
case event: PlayCardEvent =>
PodGameManager.transmit(event.player.id, event)
case event: ReceivedHandEvent =>
PodGameManager.transmit(event.player.id, event)
case event: RequestTieChoiceEvent =>
PodGameManager.transmit(event.player.id, event)
case event: RequestTrumpSuitEvent =>
PodGameManager.transmit(event.player.id, event)
case _ =>
PodGameManager.transmitAll(event)
}
}
}
override def initial(gameLogic: GameLogic): Boolean = {
if (init) {
return false
}
this.logic = Some(gameLogic)
init = true
start()
true
}
def addSession(session: PlayerSession): Unit = {
sessions.put(session.id, session)
}
}

View File

@@ -0,0 +1,67 @@
package controllers
import controllers.sessions.SimpleSession
import controllers.sessions.AdvancedSession
import com.google.inject.{Guice, Injector}
import de.knockoutwhist.KnockOutWhist
import de.knockoutwhist.components.Configuration
import de.knockoutwhist.rounds.Match
import di.KnockOutWebConfigurationModule
import play.api.*
import play.api.mvc.*
import play.twirl.api.Html
import java.util.UUID
import javax.inject.*
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class HomeController @Inject()(val controllerComponents: ControllerComponents) extends BaseController {
private var initial = false
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
/**
* Create an Action to render an HTML page.
*
* The configuration in the `routes` file means that this method
* will be called when the application receives a `GET` request with
* a path of `/`.
*/
def index(): Action[AnyContent] = {
if (!initial) {
initial = true
KnockOutWhist.entry(injector.getInstance(classOf[Configuration]))
}
Action { implicit request =>
Redirect("/sessions")
}
}
def sessions(): Action[AnyContent] = {
Action { implicit request =>
Ok(views.html.sessions.apply(PodGameManager.listSessions().map(f => f.toString)))
}
}
def ingame(id: String): Action[AnyContent] = {
val uuid: UUID = UUID.fromString(id)
if (PodGameManager.identify(uuid).isEmpty) {
Action { implicit request =>
NotFound(views.html.tui.apply(List(Html(s"<p>Session with id $id not found!</p>"))))
}
} else {
val gamesession = PodGameManager.identify(uuid).get
val player = session.asInstanceOf[AdvancedSession].player
val logic =
Action { implicit request =>
Ok(views.html.matchy.apply(player, ))
}
}
}
}

View File

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

View File

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

View File

@@ -1,115 +0,0 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import logic.PodManager
import play.api.*
import play.api.libs.json.Json
import play.api.mvc.*
import javax.inject.*
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class MainMenuController @Inject()(
val controllerComponents: ControllerComponents,
val authAction: AuthAction,
val ingameController: IngameController
) extends BaseController {
// Pass the request-handling function directly to authAction (no nested Action)
def mainMenu(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.main("Knockout Whist - Create Game")(views.html.mainmenu.creategame(Some(request.user))))
}
def index(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Redirect(routes.MainMenuController.mainMenu())
}
def createGame(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val jsonBody = request.body.asJson
if (jsonBody.isDefined) {
val gamename: String = (jsonBody.get \ "lobbyname").asOpt[String]
.getOrElse(s"${request.user.name}'s Game")
val playeramount: String = (jsonBody.get \ "playeramount").asOpt[String]
.getOrElse(throw new IllegalArgumentException("Player amount is required."))
val gameLobby = PodManager.createGame(
host = request.user,
name = gamename,
maxPlayers = playeramount.toInt
)
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.IngameController.game(gameLobby.id).url,
"content" -> ingameController.returnInnerHTML(gameLobby, request.user).toString
))
} else {
BadRequest(Json.obj(
"status" -> "failure",
"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 rules(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
Ok(views.html.main("Knockout Whist - Rules")(views.html.mainmenu.rules(Some(request.user))))
}
def navSPA(location: String): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
location match {
case "0" => // Main Menu
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(Some(request.user)).toString
))
case "1" => // Rules
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.rules().url,
"content" -> views.html.mainmenu.rules(Some(request.user)).toString
))
case _ =>
BadRequest(Json.obj(
"status" -> "failure",
"errorMessage" -> "Invalid form submission"
))
}
}
}

View File

@@ -0,0 +1,41 @@
package controllers
import controllers.sessions.PlayerSession
import de.knockoutwhist.utils.events.SimpleEvent
import java.util.UUID
import scala.collection.mutable
object PodGameManager {
private val gamesession: mutable.Map[UUID, Gamesession] = mutable.Map()
def addGame(session: PlayerSession, gamesession: Gamesession): Unit = {
gamesession.put(session.id, gamesession)
}
def createGame(player: String): Unit = {
val game = Gamesession(UUID.randomUUID())
}
def clearSessions(): Unit = {
gamesession.clear()
}
def identify(id: UUID): Option[PlayerSession] = {
gamesession.get(id)
}
def transmit(id: UUID, event: SimpleEvent): Unit = {
identify(id).foreach(_.updatePlayer(event))
}
def transmitAll(event: SimpleEvent): Unit = {
gamesession.foreach(session => session._2.updatePlayer(event))
}
def listSessions(): List[UUID] = {
sessions.keys.toList
}
}

View File

@@ -1,78 +0,0 @@
package controllers
import auth.{AuthAction, AuthenticatedRequest}
import logic.user.{SessionManager, UserManager}
import play.api.*
import play.api.libs.json.Json
import play.api.mvc.*
import javax.inject.*
/**
* This controller creates an `Action` to handle HTTP requests to the
* application's home page.
*/
@Singleton
class UserController @Inject()(
val controllerComponents: ControllerComponents,
val sessionManager: SessionManager,
val userManager: UserManager,
val authAction: AuthAction
) extends BaseController {
def login(): Action[AnyContent] = {
Action { implicit request =>
val session = request.cookies.get("sessionId")
if (session.isDefined) {
val possibleUser = sessionManager.getUserBySession(session.get.value)
if (possibleUser.isDefined) {
Redirect(routes.MainMenuController.mainMenu())
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
} else {
Ok(views.html.main("Login")(views.html.login.login()))
}
}
}
def login_Post(): Action[AnyContent] = {
Action { implicit request =>
val jsonBody = request.body.asJson
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) {
// Extract username and password from form data
val possibleUser = userManager.authenticate(username.get, password.get)
if (possibleUser.isDefined) {
Ok(Json.obj(
"status" -> "success",
"redirectUrl" -> routes.MainMenuController.mainMenu().url,
"content" -> views.html.mainmenu.creategame(possibleUser).toString
)).withCookies(
Cookie("sessionId", sessionManager.createSession(possibleUser.get))
)
} else {
Unauthorized("Invalid username or password")
}
} else {
BadRequest("Invalid form submission")
}
}
}
// Pass the request-handling function directly to authAction (no nested Action)
def logout(): Action[AnyContent] = authAction { implicit request: AuthenticatedRequest[AnyContent] =>
val sessionCookie = request.cookies.get("sessionId")
if (sessionCookie.isDefined) {
sessionManager.invalidateSession(sessionCookie.get.value)
}
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("sessionId")
if (session.isEmpty) throw new Exception("No session cookie found")
val userOpt = sessionManger.getUserBySession(session.get.value)
if (userOpt.isEmpty) throw new Exception("Invalid session")
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

@@ -0,0 +1,13 @@
package controllers.sessions
import de.knockoutwhist.rounds.Match
import de.knockoutwhist.rounds.Round
import de.knockoutwhist.utils.events.SimpleEvent
import de.knockoutwhist.player.AbstractPlayer
import java.util.UUID
case class AdvancedSession(player: AbstractPlayer) extends PlayerSession {
override def id(): UUID = {
player.id
}
}

View File

@@ -1,4 +1,4 @@
package model.sessions package controllers.sessions
import de.knockoutwhist.utils.events.SimpleEvent import de.knockoutwhist.utils.events.SimpleEvent
@@ -7,9 +7,6 @@ import java.util.UUID
trait PlayerSession { trait PlayerSession {
def id: UUID def id: UUID
def name: String
def updatePlayer(event: SimpleEvent): Unit def updatePlayer(event: SimpleEvent): Unit
} }

View File

@@ -0,0 +1,260 @@
package controllers.sessions
import de.knockoutwhist.cards.{Card, CardValue, Hand}
import de.knockoutwhist.events.ERROR_STATUS.*
import de.knockoutwhist.events.GLOBAL_STATUS.*
import de.knockoutwhist.events.PLAYER_STATUS.*
import de.knockoutwhist.events.ROUND_STATUS.*
import de.knockoutwhist.events.{ShowErrorStatus, ShowGlobalStatus, ShowPlayerStatus, ShowRoundStatus}
import de.knockoutwhist.events.cards.{RenderHandEvent, ShowTieCardsEvent}
import de.knockoutwhist.events.round.ShowCurrentTrickEvent
import de.knockoutwhist.player.AbstractPlayer
import de.knockoutwhist.utils.events.SimpleEvent
import play.twirl.api.Html
import scalafx.scene.image.Image
import util.WebUIUtils
import java.util.UUID
case class SimpleSession(id: UUID, private var output: List[Html]) extends PlayerSession {
def get(): List[Html] = {
output
}
override def updatePlayer(event: SimpleEvent): Unit = {
event match {
case event: RenderHandEvent =>
renderHand(event)
case event: ShowTieCardsEvent =>
showtiecardseventmethod(event)
case event: ShowGlobalStatus =>
showglobalstatusmethod(event)
case event: ShowPlayerStatus =>
showplayerstatusmethod(event)
case event: ShowRoundStatus =>
showroundstatusmethod(event)
case event: ShowErrorStatus =>
showerrstatmet(event)
case event: ShowCurrentTrickEvent =>
showcurtrevmet(event)
}
}
private def clear(): Unit = {
output = List()
}
private def renderHand(event: RenderHandEvent): Unit = {
output = output :++ WebUICards.renderHandEvent(event.hand)
output = output :+ Html("<br>")
}
private def showtiecardseventmethod(event: ShowTieCardsEvent): Option[Boolean] = {
var l = List[Html]()
for ((player, card) <- event.card) {
l = l :+ Html(s"<p>${player.name}:</p>")
l = l :+ WebUIUtils.cardtoImage(card)
l = l :+ Html("<br>")
}
output = output :++ l
output = output :+ Html("<br>")
Some(true)
}
private def showglobalstatusmethod(event: ShowGlobalStatus): Option[Boolean] = {
event.status match {
case SHOW_TIE =>
println("It's a tie! Let's cut to determine the winner.")
Some(true)
case SHOW_TIE_WINNER =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[AbstractPlayer]) {
None
} else {
println(s"${event.objects.head.asInstanceOf[AbstractPlayer].name} wins the cut!")
Some(true)
}
case SHOW_TIE_TIE =>
println("It's a tie again! Let's cut again.")
Some(true)
case SHOW_START_MATCH =>
clear()
println("Starting a new match...")
output = output :+ Html("<br><br>")
Some(true)
case SHOW_TYPE_PLAYERS =>
println("Please enter the names of the players, separated by a comma.")
Some(true)
case SHOW_FINISHED_MATCH =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[AbstractPlayer]) {
None
} else {
clear()
println(s"The match is over. The winner is ${event.objects.head.asInstanceOf[AbstractPlayer]}")
Some(true)
}
}
}
private def showplayerstatusmethod(event: ShowPlayerStatus): Option[Boolean] = {
val player = event.player
event.status match {
case SHOW_PLAY_CARD =>
println("Which card do you want to play?")
Some(true)
case SHOW_DOG_PLAY_CARD =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[Boolean]) {
None
} else {
println("You are using your dog life. Do you want to play your final card now?")
if (event.objects.head.asInstanceOf[Boolean]) {
println("You have to play your final card this round!")
println("Please enter y to play your final card.")
Some(true)
} else {
println("Please enter y/n to play your final card.")
Some(true)
}
}
case SHOW_TIE_NUMBERS =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[Int]) {
None
} else {
println(s"${player.name} enter a number between 1 and ${event.objects.head.asInstanceOf[Int]}.")
Some(true)
}
case SHOW_TRUMPSUIT_OPTIONS =>
println("Which suit do you want to pick as the next trump suit?")
println("1: Hearts")
println("2: Diamonds")
println("3: Clubs")
println("4: Spades")
println()
Some(true)
case SHOW_NOT_PLAYED =>
println(s"Player ${event.player} decided to not play his card")
Some(true)
case SHOW_WON_PLAYER_TRICK =>
println(s"${event.player.name} won the trick.")
output = output :+ Html("<br><br>")
Some(true)
}
}
private def showroundstatusmethod(event: ShowRoundStatus): Option[Boolean] = {
event.status match {
case SHOW_TURN =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[AbstractPlayer]) {
None
} else {
println(s"It's ${event.objects.head.asInstanceOf[AbstractPlayer].name} turn.")
Some(true)
}
case SHOW_START_ROUND =>
clear()
println(s"Starting a new round. The trump suit is ${event.currentRound.trumpSuit}.")
output = output :+ Html("<br><br>")
Some(true)
case WON_ROUND =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[AbstractPlayer]) {
None
} else {
println(s"${event.objects.head.asInstanceOf[AbstractPlayer].name} won the round.")
Some(true)
}
case PLAYERS_OUT =>
println("The following players are out of the game:")
event.currentRound.playersout.foreach(p => {
println(p.name)
})
Some(true)
}
}
private def showerrstatmet(event: ShowErrorStatus): Option[Boolean] = {
event.status match {
case INVALID_NUMBER =>
println("Please enter a valid number.")
Some(true)
case NOT_A_NUMBER =>
println("Please enter a number.")
Some(true)
case INVALID_INPUT =>
println("Please enter a valid input")
Some(true)
case INVALID_NUMBER_OF_PLAYERS =>
println("Please enter at least two names.")
Some(true)
case IDENTICAL_NAMES =>
println("Please enter unique names.")
Some(true)
case INVALID_NAME_FORMAT =>
println("Please enter valid names. Those can not be empty, shorter than 2 or longer then 10 characters.")
Some(true)
case WRONG_CARD =>
if (event.objects.length != 1 || !event.objects.head.isInstanceOf[Card]) {
None
} else {
println(f"You have to play a card of suit: ${event.objects.head.asInstanceOf[Card].suit}\n")
Some(true)
}
}
}
private def showcurtrevmet(event: ShowCurrentTrickEvent): Option[Boolean] = {
clear()
val sb = new StringBuilder()
sb.append("Current Trick:\n")
sb.append("Trump-Suit: " + event.round.trumpSuit + "\n")
if (event.trick.firstCard.isDefined) {
sb.append(s"Suit to play: ${event.trick.firstCard.get.suit}\n")
}
for ((card, player) <- event.trick.cards) {
sb.append(s"${player.name} played ${card.toString}\n")
}
println(sb.toString())
Some(true)
}
private def println(s: String): Unit = {
var html = List[Html]()
for (line <- s.split("\n")) {
html = html :+ Html(line)
html = html :+ Html("<br>")
}
output = output :++ html
}
private def println(): Unit = {
output = output :+ Html("<br>")
}
object WebUICards {
def renderCardAsString(card: Card): Vector[String] = {
val lines = "│ │"
if (card.cardValue == CardValue.Ten) {
return Vector(
s"┌─────────┐",
s"${card.cardValue.cardType()}",
lines,
s"${card.suit.cardType()}",
lines,
s"${card.cardValue.cardType()}",
s"└─────────┘"
)
}
Vector(
s"┌─────────┐",
s"${card.cardValue.cardType()}",
lines,
s"${card.suit.cardType()}",
lines,
s"${card.cardValue.cardType()}",
s"└─────────┘"
)
}
def renderHandEvent(hand: Hand): List[Html] = {
hand.cards.map(WebUIUtils.cardtoImage)
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,73 +0,0 @@
package logic
import com.google.inject.{Guice, Injector}
import de.knockoutwhist.components.Configuration
import de.knockoutwhist.control.controllerBaseImpl.BaseGameLogic
import di.KnockOutWebConfigurationModule
import logic.game.GameLobby
import model.users.User
import util.GameUtil
import javax.inject.Singleton
import scala.collection.mutable
object PodManager {
val TTL: Long = System.currentTimeMillis() + 86400000L // 24 hours in milliseconds
val podIp: String = System.getenv("POD_IP")
val podName: String = System.getenv("POD_NAME")
private val sessions: mutable.Map[String, GameLobby] = mutable.Map()
private val userSession: mutable.Map[User, String] = mutable.Map()
private val injector: Injector = Guice.createInjector(KnockOutWebConfigurationModule())
def createGame(
host: User,
name: String,
maxPlayers: Int
): GameLobby = {
val gameLobby = GameLobby(
logic = BaseGameLogic(injector.getInstance(classOf[Configuration])),
id = GameUtil.generateCode(),
internalId = java.util.UUID.randomUUID(),
name = name,
maxPlayers = maxPlayers,
host = host
)
sessions += (gameLobby.id -> gameLobby)
userSession += (host -> gameLobby.id)
gameLobby
}
def getGame(gameId: String): Option[GameLobby] = {
sessions.get(gameId)
}
def registerUserToGame(user: User, gameId: String): Boolean = {
if (sessions.contains(gameId)) {
userSession += (user -> gameId)
true
} else {
false
}
}
def unregisterUserFromGame(user: User): Unit = {
userSession.remove(user)
}
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 = {
sessions.remove(gameId)
// Also remove all user sessions associated with this game
userSession.filterInPlace((_, v) => v != gameId)
}
}

View File

@@ -1,310 +0,0 @@
package logic.game
import de.knockoutwhist.cards.{Hand, Suit}
import de.knockoutwhist.control.GameLogic
import de.knockoutwhist.control.GameState.{Lobby, MainMenu}
import de.knockoutwhist.control.controllerBaseImpl.sublogic.util.{MatchUtil, PlayerUtil}
import de.knockoutwhist.events.global.{GameStateChangeEvent, SessionClosed}
import de.knockoutwhist.events.player.PlayerEvent
import de.knockoutwhist.player.Playertype.HUMAN
import de.knockoutwhist.player.{AbstractPlayer, PlayerFactory}
import de.knockoutwhist.rounds.{Match, Round, Trick}
import de.knockoutwhist.utils.events.{EventListener, SimpleEvent}
import exceptions.*
import logic.PodManager
import model.sessions.{InteractionType, UserSession}
import model.users.User
import play.api.libs.json.{JsObject, Json}
import java.util.UUID
import scala.collection.mutable
import scala.collection.mutable.ListBuffer
class GameLobby private(
val logic: GameLogic,
val id: String,
val internalId: UUID,
val name: String,
val maxPlayers: Int
) extends EventListener {
private val users: mutable.Map[UUID, UserSession] = mutable.Map()
logic.addListener(this)
logic.createSession()
def addUser(user: User): UserSession = {
if (users.size >= maxPlayers) throw new GameFullException("The game is full!")
if (users.contains(user.id)) throw new IllegalArgumentException("User is already in the game!")
if (logic.getCurrentState != Lobby) throw new IllegalStateException("The game has already started!")
val userSession = new UserSession(
user = user,
host = false,
gameLobby = this
)
users += (user.id -> userSession)
PodManager.registerUserToGame(user, id)
//TODO : transmit Lobby Update transmitToAll()
userSession
}
override def listen(event: SimpleEvent): Unit = {
event match {
case event: PlayerEvent =>
users.get(event.playerId).foreach(session => session.updatePlayer(event))
case event: GameStateChangeEvent =>
if (event.oldState == MainMenu && event.newState == Lobby) {
return
}
users.values.foreach(session => session.updatePlayer(event))
case event: SimpleEvent =>
users.values.foreach(session => session.updatePlayer(event))
}
}
/**
* Start the game if the user is the host.
*
* @param user the user who wants to start the game.
*/
def startGame(user: User): Unit = {
val sessionOpt = users.get(user.id)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
if (!sessionOpt.get.host) {
throw new NotHostException("Only the host can start the game!")
}
if (logic.getCurrentState != Lobby) {
throw new IllegalStateException("The game has already started!")
}
val playerNamesList = ListBuffer[AbstractPlayer]()
users.values.foreach { player =>
playerNamesList += PlayerFactory.createPlayer(player.name, player.id, HUMAN)
}
if (playerNamesList.size < 2) {
throw new NotEnoughPlayersException("Not enough players to start the game!")
}
logic.createMatch(playerNamesList.toList)
logic.controlMatch()
}
/**
* Remove the user from the game lobby.
*
* @param user the user who wants to leave the game.
*/
def leaveGame(userId: UUID): Unit = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
if (sessionOpt.get.host) {
logic.invoke(SessionClosed())
users.clear()
PodManager.removeGame(id)
return
}
sessionOpt.get.websocketActor.foreach(act => act.transmitJsonToClient(Json.obj(
"id" -> "-1",
"event" -> "SessionClosed",
"data" -> Json.obj(
"reason" -> "You left the game (or got kicked)."
)
)))
users.remove(userId)
PodManager.unregisterUserFromGame(sessionOpt.get.user)
//TODO: transmit Lobby Update transmitToAll()
}
/**
* Play a card from the player's hand.
*
* @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand.
*/
def playCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.Card)
if (player.isInDogLife) {
throw new CantPlayCardException("You are in dog life!")
}
val hand = getHand(player)
val card = hand.cards(cardIndex)
if (!PlayerUtil.canPlayCard(card, getRound, getTrick, player)) {
throw new CantPlayCardException("You can't play this card!")
}
userSession.resetCanInteract()
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.
*
* @param userSession the user session of the player.
* @param cardIndex the index of the card in the player's hand or -1 if the player wants to skip the round.
*/
def playDogCard(userSession: UserSession, cardIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.DogCard)
if (!player.isInDogLife) {
throw new CantPlayCardException("You are not in dog life!")
}
if (cardIndex == -1) {
if (MatchUtil.dogNeedsToPlay(getMatch, getRound)) {
throw new CantPlayCardException("You can't skip this round!")
}
logic.playerInputLogic.receivedDog(None)
return
}
val hand = getHand(player)
val card = hand.cards(cardIndex)
userSession.resetCanInteract()
logic.playerInputLogic.receivedDog(Some(card))
}
/**
* Select the trump suit for the round.
*
* @param userSession the user session of the player.
* @param trumpIndex the index of the trump suit.
*/
def selectTrump(userSession: UserSession, trumpIndex: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TrumpSuit)
val trumpSuits = Suit.values.toList
val selectedTrump = trumpSuits(trumpIndex)
userSession.resetCanInteract()
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 tieNumber
*/
def selectTie(userSession: UserSession, tieNumber: Int): Unit = {
val player = getPlayerInteractable(userSession, InteractionType.TieChoice)
userSession.resetCanInteract()
logic.playerTieLogic.receivedTieBreakerCard(tieNumber)
}
def returnToLobby(userSession: UserSession): Unit = {
if (!users.contains(userSession.id)) {
throw new NotInThisGameException("You are not in this game!")
}
val session = users(userSession.id)
if (session != userSession) {
throw new IllegalArgumentException("User session does not match!")
}
if (!session.host)
throw new NotHostException("Only the host can return to the lobby!")
logic.createSession()
}
def getPlayerByUser(user: User): AbstractPlayer = {
getPlayerBySession(getUserSession(user.id))
}
def getUserSession(userId: UUID): UserSession = {
val sessionOpt = users.get(userId)
if (sessionOpt.isEmpty) {
throw new NotInThisGameException("You are not in this game!")
}
sessionOpt.get
}
private def 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 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] = {
users.clone()
}
def getLogic: GameLogic = {
logic
}
def getUsers: Set[User] = {
users.values.map(d => d.user).toSet
}
private def transmitToAll(event: JsObject): Unit = {
users.values.foreach(session => {
session.websocketActor.foreach(act => act.transmitJsonToClient(event))
})
}
}
object GameLobby {
def apply(
logic: GameLogic,
id: String,
internalId: UUID,
name: String,
maxPlayers: Int,
host: User
): GameLobby = {
val lobby = new GameLobby(
logic = logic,
id = id,
internalId = internalId,
name = name,
maxPlayers = maxPlayers
)
lobby.users += (host.id -> new UserSession(
user = host,
host = true,
gameLobby = lobby
))
lobby
}
}

View File

@@ -1,16 +0,0 @@
package logic.user
import com.google.inject.ImplementedBy
import logic.user.impl.BaseSessionManager
import model.users.User
@ImplementedBy(classOf[BaseSessionManager])
trait SessionManager {
def createSession(user: User): String
def getUserBySession(sessionId: String): Option[User]
def invalidateSession(sessionId: String): Unit
}

View File

@@ -1,20 +0,0 @@
package logic.user
import com.google.inject.ImplementedBy
import logic.user.impl.StubUserManager
import model.users.User
@ImplementedBy(classOf[StubUserManager])
trait UserManager {
def addUser(name: String, password: String): Boolean
def authenticate(name: String, password: String): Option[User]
def userExists(name: String): Option[User]
def userExistsById(id: Long): Option[User]
def removeUser(name: String): Boolean
}

View File

@@ -1,70 +0,0 @@
package logic.user.impl
import com.auth0.jwt.algorithms.Algorithm
import com.auth0.jwt.{JWT, JWTVerifier}
import com.github.benmanes.caffeine.cache.{Cache, Caffeine}
import com.typesafe.config.Config
import logic.user.SessionManager
import model.users.User
import scalafx.util.Duration
import services.JwtKeyProvider
import java.time.Instant
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import javax.inject.{Inject, Singleton}
import scala.util.Try
@Singleton
class BaseSessionManager @Inject()(val keyProvider: JwtKeyProvider, val userManager: StubUserManager, val config: Config) extends SessionManager {
private val algorithm = Algorithm.RSA512(keyProvider.publicKey, keyProvider.privateKey)
private val verifier: JWTVerifier = JWT.require(algorithm)
.withIssuer(config.getString("auth.issuer"))
.withAudience(config.getString("auth.audience"))
.build()
//TODO reduce cache to a minimum amount, as JWT should be self-contained
private val cache: Cache[String, User] = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(5, TimeUnit.MINUTES).build()
override def createSession(user: User): String = {
//Write session identifier to cache and DB
val sessionId = JWT.create()
.withIssuer(config.getString("auth.issuer"))
.withAudience(config.getString("auth.audience"))
.withSubject(user.id.toString)
.withClaim("id", user.internalId)
.withExpiresAt(Instant.now.plus(7, ChronoUnit.DAYS))
.sign(algorithm)
//TODO write to Redis and DB
cache.put(sessionId, user)
sessionId
}
override def getUserBySession(sessionId: String): Option[User] = {
val cachedUser = cache.getIfPresent(sessionId)
if (cachedUser != null) {
Some(cachedUser)
} else {
val result = Try {
val decoded = verifier.verify(sessionId)
val user = userManager.userExistsById(decoded.getClaim("id").asLong())
user.foreach(u => cache.put(sessionId, u))
user
}
if (result.isSuccess) {
result.get
} else {
None
}
}
}
override def invalidateSession(sessionId: String): Unit = {
//TODO remove from Redis and DB
cache.invalidate(sessionId)
}
}

View File

@@ -1,57 +0,0 @@
package logic.user.impl
import com.typesafe.config.Config
import logic.user.UserManager
import model.users.User
import util.UserHash
import javax.inject.{Inject, Singleton}
@Singleton
class StubUserManager @Inject()(val config: Config) extends UserManager {
private val user: Map[String, User] = Map(
"Janis" -> User(
internalId = 1L,
id = java.util.UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
name = "Janis",
passwordHash = UserHash.hashPW("password123")
),
"Leon" -> User(
internalId = 2L,
id = java.util.UUID.fromString("223e4567-e89b-12d3-a456-426614174000"),
name = "Leon",
passwordHash = UserHash.hashPW("password123")
),
"Jakob" -> User(
internalId = 2L,
id = java.util.UUID.fromString("323e4567-e89b-12d3-a456-426614174000"),
name = "Jakob",
passwordHash = UserHash.hashPW("password123")
)
)
override def addUser(name: String, password: String): Boolean = {
throw new NotImplementedError("StubUserManager.addUser is not implemented")
}
override def authenticate(name: String, password: String): Option[User] = {
user.get(name) match {
case Some(u) if UserHash.verifyUser(password, u) => Some(u)
case _ => None
}
}
override def userExists(name: String): Option[User] = {
user.get(name)
}
override def userExistsById(id: Long): Option[User] = {
user.values.find(_.internalId == id)
}
override def removeUser(name: String): Boolean = {
throw new NotImplementedError("StubUserManager.removeUser is not implemented")
}
}

View File

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

View File

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

View File

@@ -1,51 +0,0 @@
package model.sessions
import de.knockoutwhist.events.player.{RequestCardEvent, RequestTieChoiceEvent, RequestTrumpSuitEvent}
import de.knockoutwhist.utils.events.SimpleEvent
import logic.game.GameLobby
import model.users.User
import play.api.libs.json.JsObject
import java.util.UUID
import java.util.concurrent.locks.ReentrantLock
import scala.util.Try
class UserSession(val user: User, val host: Boolean, val gameLobby: GameLobby) extends PlayerSession {
val lock: ReentrantLock = ReentrantLock()
var canInteract: Option[InteractionType] = None
var websocketActor: Option[UserWebsocketActor] = None
override def updatePlayer(event: SimpleEvent): Unit = {
event match {
case event: RequestTrumpSuitEvent =>
canInteract = Some(InteractionType.TrumpSuit)
case event: RequestTieChoiceEvent =>
canInteract = Some(InteractionType.TieChoice)
case event: RequestCardEvent =>
if (event.player.isInDogLife) canInteract = Some(InteractionType.DogCard)
else canInteract = Some(InteractionType.Card)
case _ =>
}
}
override def id: UUID = user.id
override def name: String = user.name
def resetCanInteract(): Unit = {
canInteract = None
}
def handleWebResponse(eventType: String, data: JsObject): Unit = {
lock.lock()
Try {
eventType match {
case "Ping" =>
// No action needed for Ping
()
}
}
lock.unlock()
}
}

View File

@@ -1,94 +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.util.{Failure, Success, Try}
class UserWebsocketActor(
out: ActorRef,
session: UserSession
) extends Actor {
if (session.websocketActor.isDefined) {
session.websocketActor.foreach(actor => actor.transmitTextToClient("Error: Multiple websocket connections detected. Closing this connection."))
context.stop(self)
} else {
session.websocketActor = Some(this)
}
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 = {
val idOpt = (json \ "id").asOpt[String]
if (idOpt.isEmpty) {
transmitJsonToClient(Json.obj(
"status" -> "error",
"error" -> "Missing 'id' field"
))
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"
))
return
}
val event = eventOpt.get
val data = (json \ "data").asOpt[JsObject].getOrElse(Json.obj())
val result = Try {
session.handleWebResponse(event, data)
}
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
))
}
}
def transmitJsonToClient(jsonObj: JsObject): Unit = {
out ! jsonObj.toString()
}
def transmitEventToClient(event: SimpleEvent): Unit = {
val jsonString = WebsocketEventMapper.toJsonString(event)
out ! jsonString
}
}

View File

@@ -1,20 +0,0 @@
package model.users
import java.util.UUID
case class User(
internalId: Long,
id: UUID,
name: String,
passwordHash: String
) {
def withName(newName: String): User = {
this.copy(name = newName)
}
private def withPasswordHash(newPasswordHash: String): User = {
this.copy(passwordHash = newPasswordHash)
}
}

View File

@@ -1,55 +0,0 @@
package services
import play.api.Configuration
import java.nio.file.{Files, Paths}
import java.security.interfaces.{RSAPrivateKey, RSAPublicKey}
import java.security.spec.{PKCS8EncodedKeySpec, RSAPublicKeySpec, X509EncodedKeySpec}
import java.security.{KeyFactory, KeyPair, PrivateKey, PublicKey}
import java.util.Base64
import javax.inject.*
@Singleton
class JwtKeyProvider @Inject()(config: Configuration) {
val publicKey: RSAPublicKey = {
val pemOpt = config.getOptional[String]("auth.publicKeyPem")
val fileOpt = config.getOptional[String]("auth.publicKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPublicKeyFromPem(pem)
case None => throw new RuntimeException("No RSA public key configured.")
}
}
val privateKey: RSAPrivateKey = {
val pemOpt = config.getOptional[String]("auth.privateKeyPem")
val fileOpt = config.getOptional[String]("auth.privateKeyFile")
pemOpt.orElse(fileOpt.map { path =>
new String(Files.readAllBytes(Paths.get(path)))
}) match {
case Some(pem) => loadPrivateKeyFromPem(pem)
case None => throw new RuntimeException("No RSA private key configured.")
}
}
private def loadPublicKeyFromPem(pem: String): RSAPublicKey = {
val decoded = Base64.getDecoder.decode(cleanPem(pem))
val spec = new X509EncodedKeySpec(decoded)
KeyFactory.getInstance("RSA").generatePublic(spec).asInstanceOf[RSAPublicKey]
}
private def loadPrivateKeyFromPem(pem: String): RSAPrivateKey = {
val decoded = Base64.getDecoder.decode(cleanPem(pem))
val spec = new PKCS8EncodedKeySpec(decoded)
KeyFactory.getInstance("RSA").generatePrivate(spec).asInstanceOf[RSAPrivateKey]
}
private def cleanPem(pem: String): String =
pem.replaceAll("-----BEGIN (.*)-----", "")
.replaceAll("-----END (.*)-----", "")
.replaceAll("\\s", "")
}

View File

@@ -1,29 +0,0 @@
package util
import scala.util.Random
object GameUtil {
private val CharPool: String = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
private val CodeLength: Int = 6
private val MaxRepetition: Int = 2
private val random = new Random()
def generateCode(): String = {
val freq = Array.fill(CharPool.length)(0)
val code = new StringBuilder(CodeLength)
for (_ <- 0 until CodeLength) {
var index = random.nextInt(CharPool.length)
// Pick a new character if it's already used twice
while (freq(index) >= MaxRepetition) {
index = random.nextInt(CharPool.length)
}
freq(index) += 1
code.append(CharPool.charAt(index))
}
code.toString()
}
}

View File

@@ -1,23 +0,0 @@
package util
import de.mkammerer.argon2.Argon2Factory
import de.mkammerer.argon2.Argon2Factory.Argon2Types
import model.users.User
object UserHash {
private val ITERATIONS: Int = 3
private val MEMORY: Int = 32_768
private val PARALLELISM: Int = 1
private val SALT_LENGTH: Int = 32
private val HASH_LENGTH: Int = 64
private val ARGON_2 = Argon2Factory.create(Argon2Types.ARGON2id, SALT_LENGTH, HASH_LENGTH)
def hashPW(password: String): String = {
ARGON_2.hash(ITERATIONS, MEMORY, PARALLELISM, password.toCharArray)
}
def verifyUser(password: String, user: User): Boolean = {
ARGON_2.verify(user.passwordHash, password.toCharArray)
}
}

View File

@@ -1,17 +1,13 @@
package util package util
import de.knockoutwhist.cards.Card import de.knockoutwhist.cards.Card
import de.knockoutwhist.cards.CardValue.* import de.knockoutwhist.cards.CardValue.{Ace, Eight, Five, Four, Jack, King, Nine, Queen, Seven, Six, Ten, Three, Two}
import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades} import de.knockoutwhist.cards.Suit.{Clubs, Diamonds, Hearts, Spades}
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 cardtoImage(card: Card): Html = {
views.html.render.card.apply(f"images/cards/${cardtoString(card)}.png")(card.toString)
}
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"
@@ -33,7 +29,6 @@ object WebUIUtils {
case Three => "3" case Three => "3"
case Two => "2" case Two => "2"
} }
f"$cv$s" views.html.output.card.apply(f"images/cards/$cv$s.png")(card.toString)
} }
} }

View File

@@ -1,20 +0,0 @@
package util
import de.knockoutwhist.utils.events.SimpleEvent
import tools.jackson.databind.json.JsonMapper
import tools.jackson.module.scala.ScalaModule
object WebsocketEventMapper {
private val scalaModule = ScalaModule.builder()
.addAllBuiltinModules()
.supportScala3Classes(true)
.build()
private val mapper = JsonMapper.builder().addModule(scalaModule).build()
def toJsonString(obj: SimpleEvent): String = {
mapper.writeValueAsString(obj)
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
@()
<div class="login-box">
<div class="card login-card p-4">
<div class="card-body">
<h3 class="text-center mb-4 text-body">Login</h3>
<form onsubmit="login(); return false;">
<div class="mb-3">
<label for="username" class="form-label text-body">Username</label>
<input type="text" class="form-control text-body" id="username" name="username" placeholder="Enter Username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label text-body">Password</label>
<input type="password" class="form-control text-body" id="password" name="password" placeholder="Enter password" required>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<a href="#" class="text-decoration-none">Forgot password?</a>
</div>
<div class="d-grid" >
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
<p class="text-center mt-3">
Dont have an account?
<a href="#" class="text-decoration-none">Sign up</a>
</p>
</div>
</div>
</div>
<script src="@routes.Assets.versioned("/javascripts/particles.js")"></script>
<script>
particlesJS.load('particles-js', 'assets/conf/particlesjs-config.json', function() {
console.log('callback - particles.js config loaded');
});
disconnectWebSocket();
</script>
<div id="particles-js" style="background-color: rgb(11, 8, 8);
background-size: cover;
background-repeat: no-repeat;
background-position: 50% 50%;"></div>

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,9 @@
@(toRender: List[Any])
@main("Match") {
<div id="match">
@for(line <- toRender) {
@line
}
</div>
}

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
@(toRender: List[String])
@main("Sessions") {
<div id="sessions">
@for(line <- toRender) {
<a href="@routes.HomeController.ingame(line)">@line</a><br>
}
</div>
}

View File

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

View File

@@ -1,15 +1 @@
# https://www.playframework.com/documentation/latest/Configuration # https://www.playframework.com/documentation/latest/Configuration
play.filters.disabled += play.filters.csrf.CSRFFilter
play.filters.disabled += play.filters.hosts.AllowedHostsFilter
play.http.secret.key="QCY?tAnfk?aZ?iwrNwnxIlR6CTf:G3gf:90Latabg@5241AB`R5W:1uDFN];Ik@n"
play.http.secret.key=${?APPLICATION_SECRET}
auth {
issuer = "knockoutwhistweb"
audience = "ui"
privateKeyFile = ${?PRIVATE_KEY_FILE}
privateKeyPem = ${?PRIVATE_KEY_PEM}
publicKeyFile = ${?PUBLIC_KEY_FILE}
publicKeyPem = ${?PUBLIC_KEY_PEM}
}

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

@@ -3,28 +3,10 @@
# https://www.playframework.com/documentation/latest/ScalaRouting # https://www.playframework.com/documentation/latest/ScalaRouting
# ~~~~ # ~~~~
# For the javascript routing # An example controller showing a sample home page
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 / controllers.HomeController.index()
GET /mainmenu controllers.MainMenuController.mainMenu() GET /sessions controllers.HomeController.sessions()
GET /rules controllers.MainMenuController.rules() GET /ingame/:id controllers.HomeController.ingame(id: String)
GET /navSPA/:pType controllers.MainMenuController.navSPA(pType) # Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
POST /createGame controllers.MainMenuController.createGame()
POST /joinGame controllers.MainMenuController.joinGame()
# User authentication routes
GET /login controllers.UserController.login()
POST /login controllers.UserController.login_Post()
GET /logout controllers.UserController.logout()
# In-game routes
GET /game/:id controllers.IngameController.game(id: String)
# Websocket
GET /websocket controllers.WebsocketController.socket()

View File

@@ -1,110 +0,0 @@
{
"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.

Before

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,219 +0,0 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active use')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('href', svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()
function createGameJS() {
let lobbyName = $('#lobbyname').val();
if ($.trim(lobbyName) === "") {
lobbyName = "DefaultLobby"
}
const jsonObj = {
lobbyname: lobbyName,
playeramount: $("#playeramount").val()
}
sendGameCreationRequest(jsonObj);
}
function sendGameCreationRequest(dataObject) {
const route = jsRoutes.controllers.MainMenuController.createGame();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
data: JSON.stringify(dataObject),
dataType: 'json',
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, "Knockout Whist - Lobby", data.redirectUrl);
}
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData && errorData.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
})
}
function exchangeBody(content, title = "Knockout Whist", url = null) {
if (url) {
window.history.pushState({}, title, url);
}
$("#main-body").html(content);
document.title = title;
}
function login() {
const username = $('#username').val();
const password = $('#password').val();
const jsonObj = {
username: username,
password: password
};
const route = jsRoutes.controllers.UserController.login_Post();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, 'Knockout Whist - Create Game', data.redirectUrl);
return
}
alert('Login failed. Please check your credentials and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
}
function joinGame() {
const gameId = $('#gameId').val();
const jsonObj = {
gameId: gameId
};
const route = jsRoutes.controllers.MainMenuController.joinGame();
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, "Knockout Whist - Lobby", data.redirectUrl);
return
}
alert('Could not join the game. Please check the Game ID and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
return false
}
function navSpa(page, title) {
const route = jsRoutes.controllers.MainMenuController.navSPA(page);
$.ajax({
url: route.url,
type: route.type,
contentType: 'application/json',
dataType: 'json',
data: JSON.stringify(jsonObj),
success: (data => {
if (data.status === 'success') {
exchangeBody(data.content, title, data.redirectUrl);
return
}
alert('Could not join the game. Please check the Game ID and try again.');
}),
error: ((jqXHR) => {
const errorData = JSON.parse(jqXHR.responseText);
if (errorData?.errorMessage) {
alert(`${errorData.errorMessage}`);
} else {
alert(`An unexpected error occurred. Please try again. Status: ${jqXHR.status}`);
}
})
});
return false
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,192 +0,0 @@
// javascript
let ws = null; // will be created by connectWebSocket()
const pending = new Map(); // id -> { resolve, reject, timer }
const handlers = new Map(); // eventType -> handler(data) -> (value|Promise)
let timer = null;
// helper to attach message/error/close handlers to a socket
function setupSocketHandlers(socket) {
socket.onmessage = (event) => {
console.debug("SERVER MESSAGE:", event.data);
let msg;
try {
msg = JSON.parse(event.data);
} catch (e) {
console.debug("Non-JSON message from server:", event.data, e);
return;
}
const id = msg.id;
const eventType = msg.event;
const status = msg.status;
const data = msg.data;
if (id && typeof status === "string") {
const entry = pending.get(id);
if (!entry) return;
clearTimeout(entry.timer);
pending.delete(id);
if (status === "success") {
entry.resolve(data === undefined ? {} : data);
} else {
entry.reject(new Error(msg.error || "Server returned error"));
}
return;
}
if (id && eventType) {
const handler = handlers.get(eventType);
const sendResponse = (respData) => {
const response = {id: id, event: eventType, data: respData === undefined ? {} : respData};
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(response));
} else {
console.warn("Cannot send response, websocket not open");
}
};
if (!handler) {
// no handler: respond with an error object in data so server can fail it
sendResponse({error: "No handler for event: " + eventType});
return;
}
try {
Promise.resolve(handler(data === undefined ? {} : data))
.then(result => sendResponse(result))
.catch(err => sendResponse({error: err?.message ? err.message : String(err)}));
} catch (err) {
sendResponse({error: err?.message ? err.message : String(err)});
}
}
};
socket.onerror = (error) => {
console.error("WebSocket Error:", error);
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket error/closed"));
pending.delete(id);
}
if (socket.readyState === WebSocket.OPEN) socket.close(1000, "Unexpected error.");
};
socket.onclose = (event) => {
if (timer) clearInterval(timer);
for (const [id, entry] of pending.entries()) {
clearTimeout(entry.timer);
entry.reject(new Error("WebSocket closed"));
pending.delete(id);
}
if (event.wasClean) {
console.log(`Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
console.warn('Connection died unexpectedly.');
}
};
}
// connect/disconnect helpers
function connectWebSocket(url = "ws://localhost:9000/websocket") {
if (ws && ws.readyState === WebSocket.OPEN) return Promise.resolve();
if (ws && ws.readyState === WebSocket.CONNECTING) {
// already connecting - return a promise that resolves on open
return new Promise((resolve, reject) => {
const prevOnOpen = ws.onopen;
const prevOnError = ws.onerror;
ws.onopen = (ev) => {
if (prevOnOpen) prevOnOpen(ev);
resolve();
};
ws.onerror = (err) => {
if (prevOnError) prevOnError(err);
reject(err);
};
});
}
ws = new WebSocket(url);
setupSocketHandlers(ws);
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log("WebSocket connection established!");
// start heartbeat
timer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
sendEventAndWait("ping", {}).then(
() => console.debug("PING RESPONSE RECEIVED"),
).catch(
(err) => console.warn("PING ERROR:", err.message),
);
console.debug("PING SENT");
}
}, 5000);
resolve();
};
ws.onerror = (err) => {
reject(err);
};
});
}
function disconnectWebSocket(code = 1000, reason = "Client disconnect") {
if (timer) {
clearInterval(timer);
timer = null;
}
if (ws) {
try {
ws.close(code, reason);
} catch (e) {
}
ws = null;
}
}
function sendEvent(eventType, eventData) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open. Unable to send message.");
return;
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
ws.send(JSON.stringify(message));
console.debug("SENT:", message);
}
function sendEventAndWait(eventType, eventData, timeoutMs = 10000) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
return Promise.reject(new Error("WebSocket is not open"));
}
const id = Date.now().toString(36) + Math.random().toString(36).substring(2, 9);
const message = {id: id, event: eventType, data: eventData};
const p = new Promise((resolve, reject) => {
const timerId = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
reject(new Error(`No response within ${timeoutMs}ms for id=${id}`));
}
}, timeoutMs);
pending.set(id, {resolve, reject, timer: timerId});
});
ws.send(JSON.stringify(message));
console.debug("SENT (await):", message);
return p;
}
function onEvent(eventType, handler) {
handlers.set(eventType, handler);
}
globalThis.sendEvent = sendEvent;
globalThis.sendEventAndWait = sendEventAndWait;
globalThis.onEvent = onEvent;
globalThis.connectWebSocket = connectWebSocket;
globalThis.disconnectWebSocket = disconnectWebSocket;
globalThis.isWebSocketConnected = () => !!ws && ws.readyState === WebSocket.OPEN;

View File

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

View File

@@ -4,7 +4,3 @@ addSbtPlugin("org.foundweekends.giter8" % "sbt-giter8-scaffold" % "0.18.0")
addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.2.1") addSbtPlugin("org.scoverage" % "sbt-scoverage" % "2.2.1")
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0") addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.3.0")
addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.3.1") addSbtPlugin("org.scoverage" % "sbt-coveralls" % "1.3.1")
addSbtPlugin("com.typesafe.sbt" % "sbt-less" % "1.1.2")
addSbtPlugin("nl.gn0s1s" % "sbt-dotenv" % "3.2.0")

View File

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