Compare commits
415 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b205b25b48 | |||
| df31c307fe | |||
| f3ecbd12f5 | |||
| 5d4d2655ef | |||
| d855f2d568 | |||
| ca782b389e | |||
| 04bf7ba545 | |||
| 42b5344059 | |||
| 76e62b3cff | |||
| 1070b5d4b9 | |||
| 4daf6ff4a2 | |||
| 3153f7027d | |||
| b5d19d915b | |||
| 09c19c2602 | |||
| 77c7f7c0a5 | |||
| 6f80501ea2 | |||
| 45cd54b981 | |||
| 3427c661cf | |||
| ff07843e0a | |||
| cc33411911 | |||
| 948484853c | |||
| 521982f7fc | |||
| 0e9cf6ae74 | |||
| 120b55f5cc | |||
| 5dd620116a | |||
| f4c20e1725 | |||
| 0c6595a58b | |||
| 936463c8f1 | |||
| 4c203b279c | |||
| 983f2cca5e | |||
| 56ba5b81ae | |||
| 20e8491cff | |||
| 3b7b53d973 | |||
| df0f8ca44c | |||
| ae98334c56 | |||
| 4deeace547 | |||
| 68e8bc76b2 | |||
| cb65ad2ed0 | |||
| 1d322f5cd8 | |||
| 40ea45460e | |||
| 60f326c4d5 | |||
| 2906c59f25 | |||
| 6736345cbe | |||
| 307c234c91 | |||
| 2ad2981724 | |||
| 945418798b | |||
| 34d782e252 | |||
| 665e006b89 | |||
| da23a30ca5 | |||
| d762f7bb2b | |||
| 605260f0d0 | |||
| 27913854c5 | |||
| 56667bfb12 | |||
| 3a0248e0c3 | |||
| ecd66a4ee9 | |||
| ae8eccb158 | |||
| 7a29ce0b3b | |||
| 865d5bb0dc | |||
| 8ee5114d37 | |||
| c3bf2d4bb0 | |||
| 874df242ab | |||
| 06e49a756c | |||
| b88fdceb9f | |||
| 595800a469 | |||
| 701fa9b6ed | |||
| b0b8e3b1d0 | |||
| 3af362a9e5 | |||
| 65db63d3a1 | |||
| 9a81c6f1af | |||
| 972e46f4d0 | |||
| 4e9082f106 | |||
| e210bd1d49 | |||
| 238cf937f4 | |||
| e07f107384 | |||
| 600d10286f | |||
| 2cd4324c68 | |||
| 45a6b87303 | |||
| c6ac3d4aff | |||
| 73a80deb12 | |||
| 2203f80490 | |||
| 059b706485 | |||
| b588f38cd5 | |||
| 0930fbe990 | |||
| 86b4a6c191 | |||
| 6e446b665a | |||
| 442a846c71 | |||
| e0b791589c | |||
| cf0cd2666c | |||
| ba6ff36ee2 | |||
| ed24ba7214 | |||
| 52daf4f2e0 | |||
| 050b349638 | |||
| d4b5010158 | |||
| f9519a6eb5 | |||
| 8b8ac9ccc0 | |||
| b10b736321 | |||
| d027696580 | |||
| 4a4af3807a | |||
| 978f54a57b | |||
| 9d4afdc3e8 | |||
| f9b866fc40 | |||
| f841eecb93 | |||
| 5ee3f34625 | |||
| f467b2861f | |||
| cb6c9cb339 | |||
| 9390c3503a | |||
| d990e37a68 | |||
| e6b62726bd | |||
| 2feb0579e2 | |||
| 160ec55957 | |||
| ed91494129 | |||
| db9ea45530 | |||
| 8d571172d5 | |||
| 1f4ccb1033 | |||
| fc4a88ee95 | |||
| 1547eda410 | |||
| c1cf6c1f83 | |||
| 8313a3b777 | |||
| dd721bda3f | |||
| 4b187fac27 | |||
| 58d06fad39 | |||
| 8ed1bab067 | |||
| a9ffb4daeb | |||
| 9a6ad9f1bb | |||
| 628e86a964 | |||
| d25f533ee9 | |||
| a520f69f3e | |||
| 52c347d233 | |||
| 2004740541 | |||
| fd17b49ed9 | |||
| d9f6838e05 | |||
| 69a5a94028 | |||
| f2f5a24bd7 | |||
| 68891815ee | |||
| 5a4cc25f19 | |||
| 9da31ac4dc | |||
| 6248935355 | |||
| 97571d6fe0 | |||
| 72ef67c47d | |||
| a772f2ce01 | |||
| c19e3f3229 | |||
| 085f97b37e | |||
| d51f080bad | |||
| 4430ddb0f3 | |||
| 92e4fcb269 | |||
| 6f1b6361bc | |||
| b0d7dc8b30 | |||
| 4234149b58 | |||
| d1510ddf8e | |||
| edc1b29884 | |||
| cdd5de620b | |||
| 34ad803073 | |||
| f4476ae1e8 | |||
| db22f12ed5 | |||
| 89753a7479 | |||
| 03b6c30625 | |||
| 9766fe52c5 | |||
| 696542003f | |||
| 676216d291 | |||
| d0b431b650 | |||
| a281f74789 | |||
| 02c82c4e95 | |||
| 796827cbb4 | |||
| e7c9063a5d | |||
| bcfbe06532 | |||
| 68c7003c5d | |||
| eb63f6d39b | |||
| 0f58b4e998 | |||
| 6324c4a28c | |||
| 900bb4f26e | |||
| f3703e4dbd | |||
| b4f34e00f2 | |||
| 8d72a67b5e | |||
| e2e01addaa | |||
| 94fcd88223 | |||
| 0bb5a1e940 | |||
| c6833b71e8 | |||
| b19e923356 | |||
| f79f7f0ae2 | |||
| 2ed7ae4e5d | |||
| 107870ff78 | |||
| 9e8ea37e88 | |||
| 699b9053eb | |||
| 35da4d2770 | |||
| 761cf4a760 | |||
| 11a7f0036c | |||
| 7713069496 | |||
| 26a1ba945a | |||
| 738b97a849 | |||
| 72da952e5a | |||
| 4dcade3daf | |||
| f78e9dfa38 | |||
| af37e33f07 | |||
| 3d8bad2427 | |||
| c88c809a2e | |||
| ab851113e0 | |||
| 4dd9c56e02 | |||
| 0e7507f2c4 | |||
| 247f16e26e | |||
| ec5ebe5d42 | |||
| 8db7986131 | |||
| 355b1cdbd4 | |||
| c10aaf15b5 | |||
| 7b4fd4aeae | |||
| f612c0b9a1 | |||
| 918002c8cb | |||
| 4df8befd27 | |||
| 0ff4e320ba | |||
| 090d77054c | |||
| 1106c891aa | |||
| 2442e5a39f | |||
| 52bbbd6181 | |||
| 1727aec42c | |||
| 8639fa420f | |||
| 85f89a91f1 | |||
| 08b85af147 | |||
| ab82208283 | |||
| c9884bf7fa | |||
| 7ffeca75fa | |||
| 3581406833 | |||
| e1500845eb | |||
| 97c2dc6a02 | |||
| 62bfa51af5 | |||
| de4a424396 | |||
| 63a9974de2 | |||
| 039e3751bf | |||
| 5e74b83a1f | |||
| 5401ead198 | |||
| 98df1dc227 | |||
| d6468c9595 | |||
| 3809b7e0e2 | |||
| 892f0553f6 | |||
| e533262c38 | |||
| 0d8f1ac4f7 | |||
| 54fd43142b | |||
| 5bd88445d2 | |||
| e6c9c12e3b | |||
| be124f4a05 | |||
| 85d604b978 | |||
| 47544c4f1b | |||
| 46b1388cad | |||
| 68827e177e | |||
| 0a5eb5d11c | |||
| 8fae4789ad | |||
| 8dd99726ce | |||
| 1223e1a4a9 | |||
| 9d9b6ae2c7 | |||
| a7cdf995c4 | |||
| 82582dae83 | |||
| 7c5a4675dd | |||
| 2979c91ee0 | |||
| 8263a1355a | |||
| 118869f461 | |||
| f7f570638b | |||
| 80c7e1c25d | |||
| 135e6a5327 | |||
| 4e853cf35f | |||
| f6c889c250 | |||
| 8f7c92684b | |||
| a5f2ac635d | |||
| 7ca0bcc3e0 | |||
| aa508dfd6c | |||
| 9c3f62db24 | |||
| 99a7154701 | |||
| 2d2bdddfa8 | |||
| 0bfcbe95b0 | |||
| 94a2e1f977 | |||
| 1bfbb5c75d | |||
| 61f0a23bfa | |||
| 08dc94b4b0 | |||
| ca11c135f1 | |||
| 60f59e03d4 | |||
| 1e794337b5 | |||
| 3509a967a6 | |||
| 4c0e6aeb36 | |||
| 3e138ab84e | |||
| 45fd696fec | |||
| b898357d76 | |||
| 830c04a706 | |||
| d242aeef7c | |||
| 71d6ac9ecc | |||
| 8415e3f155 | |||
| a15a0a0044 | |||
| e3e1da0082 | |||
| d1bf8e4c9a | |||
| f3c34dfa7f | |||
| 2af3fe7c35 | |||
| d01bbdec7b | |||
| 58c473fc9e | |||
| c411e9c560 | |||
| 1c5c4c8a34 | |||
| 9376b1d406 | |||
| cb98c70562 | |||
| d8bfc1c8e9 | |||
| 14c18df0eb | |||
| 208a284204 | |||
| ef2a00268f | |||
| d403908341 | |||
| fa54fd75c1 | |||
| 50d75e2f03 | |||
| 3dbd3a6c15 | |||
| 1aafc0bc4b | |||
| 2f7040485a | |||
| 736cf230fb | |||
| 3ce0a44a18 | |||
| 59494ea2d4 | |||
| 34c89b671e | |||
| bb0f805b4e | |||
| 573c5fd36d | |||
| 09ebacc1e4 | |||
| 7be2229280 | |||
| 3a8dc98e91 | |||
| b731f48ba7 | |||
| 9a88cfc945 | |||
| e22446bffc | |||
| 59a4648ad6 | |||
| ec4d8bfb07 | |||
| 1fe6d9ce5d | |||
| 8f5d90c538 | |||
| 8610f77386 | |||
| c38f177a16 | |||
| 815caff372 | |||
| c441318df9 | |||
| 7d2486d3be | |||
| 18d8b68fc8 | |||
| 4ecbe21602 | |||
| e718083010 | |||
| b810b2187b | |||
| 5b6b58f1cc | |||
| 6e35c599da | |||
| 66f840b269 | |||
| 39b6c5e13e | |||
| 000a5c0d9a | |||
| 0157102ae5 | |||
| a7bfb59e2f | |||
| 37faa39f10 | |||
| 147f84665e | |||
| 8aa71a4430 | |||
| cfeea5222d | |||
| fb02faffc9 | |||
| 815b60232c | |||
| 28f8d17237 | |||
| d311553de7 | |||
| e797470ca3 | |||
| 18f17564d5 | |||
| 57010d2872 | |||
| 004b2e2313 | |||
| 9b40396eed | |||
| 4bcb9a9874 | |||
| 9c3114b5f3 | |||
| 1cefe9c9d9 | |||
| 0e30e08247 | |||
| 38133e6761 | |||
| 4231ca3e97 | |||
| 75567c59b2 | |||
| a39775bd04 | |||
| 49019075e4 | |||
| 97a860df9e | |||
| 0b51f4f45a | |||
| 711de2e6aa | |||
| 74566f99cf | |||
| c301039bfc | |||
| e1029932d8 | |||
| db7892d9c4 | |||
| 7b32fe1147 | |||
| 54588276d1 | |||
| fc8a92789a | |||
| 7fcb47ed6f | |||
| d4bf69dc59 | |||
| b073760eef | |||
| 48d77b6cbe | |||
| 59765e33bb | |||
| fa95ce1418 | |||
| d2407d61f4 | |||
| d414c27ff2 | |||
| f4a32b1649 | |||
| 5407e245f7 | |||
| 02dc75c331 | |||
| 4989e26ac6 | |||
| 89d4cad336 | |||
| c0b6a5424a | |||
| f9b32bc979 | |||
| 25d145cffd | |||
| d47084d6f5 | |||
| c87e6f1463 | |||
| 23faf0f371 | |||
| 268cf283bd | |||
| 5ff4dcec53 | |||
| dac05de331 | |||
| b1ab8f3982 | |||
| 82dd5a9b8a | |||
| 18331a7cc3 | |||
| a2256b2c1e | |||
| 85c872df23 | |||
| 2b012aa256 | |||
| 4879b1b464 | |||
| a5204eaf29 | |||
| 14384dea5b | |||
| e5e93c1a65 | |||
| f1b6243d6d | |||
| 7daae485da | |||
| a7e43619f8 | |||
| 34cb17d0e4 | |||
| a6afbeeae1 | |||
| a5272b99ab | |||
| fb6f14371f | |||
| 32e4b04c87 | |||
| 22990fa35e | |||
| ac9e7eecff | |||
| c1a4b436fe | |||
| 9c291e4e3b | |||
| 91a513e214 | |||
| 66925bc665 | |||
| ff38c9d6ef | |||
| e6b5cac53c |
@@ -119,3 +119,7 @@ $RECYCLE.BIN/
|
||||
|
||||
## IntelliJ
|
||||
.idea
|
||||
|
||||
## bin
|
||||
bin/
|
||||
gradle.properties
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
stages:
|
||||
- lint
|
||||
- report
|
||||
- build
|
||||
- test
|
||||
|
||||
# Reusable definitions
|
||||
.gradle-cache: &gradle-cache
|
||||
variables:
|
||||
GRADLE_USER_HOME: '$CI_PROJECT_DIR/.gradle-home'
|
||||
cache:
|
||||
key: '$CI_PROJECT_ID-gradle'
|
||||
paths:
|
||||
- .gradle/
|
||||
- .gradle-home/
|
||||
|
||||
.on-commit: &on-commit
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
when: never
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
allow_failure: true
|
||||
|
||||
.on-mr: &on-mr
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
allow_failure: false
|
||||
|
||||
# Jobs
|
||||
checkstyle:
|
||||
<<: [*gradle-cache, *on-commit]
|
||||
stage: lint
|
||||
image: gradle:9.3.1-jdk25
|
||||
script:
|
||||
- gradle checkstyleMain checkstyleTest --configuration-cache --configuration-cache-problems=warn
|
||||
allow_failure: true
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- build/reports/checkstyle/main.html
|
||||
- build/reports/checkstyle/main.xml
|
||||
- build/reports/checkstyle/test.html
|
||||
- build/reports/checkstyle/test.xml
|
||||
expose_as: 'Checkstyle Report'
|
||||
expire_in: 1 week
|
||||
|
||||
checkstyle-mr:
|
||||
<<: [*gradle-cache, *on-mr]
|
||||
stage: lint
|
||||
image: gradle:9.3.1-jdk25
|
||||
script:
|
||||
- gradle checkstyleMain checkstyleTest --configuration-cache --configuration-cache-problems=warn
|
||||
allow_failure: false
|
||||
artifacts:
|
||||
when: always
|
||||
paths:
|
||||
- build/reports/checkstyle/main.html
|
||||
- build/reports/checkstyle/main.xml
|
||||
- build/reports/checkstyle/test.html
|
||||
- build/reports/checkstyle/test.xml
|
||||
expose_as: 'Checkstyle Report'
|
||||
expire_in: 1 week
|
||||
|
||||
checkstyle-report:
|
||||
stage: report
|
||||
image: python:3.14
|
||||
needs:
|
||||
- job: checkstyle-mr
|
||||
artifacts: true
|
||||
optional: true
|
||||
when: always
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
script:
|
||||
- python3 scripts/convert-checkstyle-gl-report.py
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
codequality: gl-code-quality-report.json
|
||||
expire_in: 1 week
|
||||
|
||||
compile-check:
|
||||
<<: *gradle-cache
|
||||
stage: build
|
||||
image: gradle:9.3.1-jdk25
|
||||
script:
|
||||
- gradle compileTestJava --configuration-cache --configuration-cache-problems=warn
|
||||
needs: []
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
allow_failure: false
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
allow_failure: false
|
||||
|
||||
javadoc-check:
|
||||
<<: [*gradle-cache, *on-mr]
|
||||
stage: build
|
||||
image: gradle:9.3.1-jdk25
|
||||
script:
|
||||
- gradle javaDoc --configuration-cache --configuration-cache-problems=warn
|
||||
needs: []
|
||||
|
||||
test:
|
||||
<<: *gradle-cache
|
||||
stage: test
|
||||
image: gradle:9.3.1-jdk25
|
||||
script:
|
||||
- gradle test --configuration-cache --configuration-cache-problems=warn
|
||||
artifacts:
|
||||
when: always
|
||||
reports:
|
||||
junit: build/test-results/test/*.xml
|
||||
paths:
|
||||
- build/reports/tests/test/
|
||||
expose_as: 'Test Report'
|
||||
expire_in: 1 week
|
||||
rules:
|
||||
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
|
||||
allow_failure: false
|
||||
- if: '$CI_COMMIT_BRANCH'
|
||||
allow_failure: true
|
||||
@@ -0,0 +1,40 @@
|
||||
## Bug Report
|
||||
<!-- The reccommended type is: Issue -->
|
||||
|
||||
### Environment
|
||||
- **Branch & Commit:**
|
||||
- **Operating system:**
|
||||
- **How was execution started (Gradle task / IDE debug / IDE run):**
|
||||
- **Java version:** `25`
|
||||
- **Gradle version**: `9.3.1`
|
||||
|
||||
### Summary
|
||||
<!-- Short, precise description of the bug -->
|
||||
|
||||
### Expected Behavior
|
||||
<!-- What you expected to happen -->
|
||||
|
||||
### Actual Behavior
|
||||
<!-- What actually happened instead -->
|
||||
|
||||
### Steps to Reproduce
|
||||
<!--
|
||||
1. Do this first
|
||||
2. Then do that next
|
||||
3. Lastly, see the unexprected ...
|
||||
-->
|
||||
|
||||
### Stack Trace / Error Message
|
||||
```
|
||||
<!-- Paste stack trace or log output here -->
|
||||
```
|
||||
|
||||
### Possible Cause / Notes
|
||||
<!-- Optional: your own hypothesis about the root cause -->
|
||||
|
||||
### Checklist
|
||||
- [ ] I reproduced the problem using the steps above
|
||||
- [ ] I searched documentation for relevant information
|
||||
- [ ] I added relevant labels
|
||||
|
||||
/label ~bug
|
||||
@@ -0,0 +1,18 @@
|
||||
## Feature Request
|
||||
<!-- The reccommended type is: Task -->
|
||||
|
||||
### Summary
|
||||
<!-- Brief description of the desired functionality -->
|
||||
|
||||
### Required workarround
|
||||
<!-- Workarround required to achieve x (if applicable) -->
|
||||
|
||||
### Description
|
||||
<!-- Detailed description of the desired behavior -->
|
||||
|
||||
### Checklist
|
||||
- [ ] I have described the function in detail
|
||||
- [ ] I searched docs for alternative implementations matching my needs
|
||||
- [ ] I added relevant labels
|
||||
|
||||
/label ~enhancement
|
||||
@@ -0,0 +1,24 @@
|
||||
## Task
|
||||
<!-- The reccommended type is: Task -->
|
||||
|
||||
### Summary
|
||||
<!-- What needs to be implemented? -->
|
||||
|
||||
### Context
|
||||
<!-- Which part of the system does it belong to? -->
|
||||
<!-- Examples: Networking, Game-Engine, User interface, ... -->
|
||||
|
||||
### Current Progress
|
||||
- [ ]
|
||||
- [ ]
|
||||
- [ ]
|
||||
|
||||
### Notes
|
||||
<!-- Optional: Implementation hints, links to prior design discussions, etc. -->
|
||||
|
||||
### Checklist
|
||||
- [ ] I outlined checkpoints describing phases of my work related to this task
|
||||
- [ ] I added relevant labels
|
||||
- [ ] I linked to other issues or branches that must be completed first
|
||||
|
||||
/label ~task
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"java.configuration.updateBuildConfiguration": "automatic"
|
||||
}
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
# Contribution Guidelines
|
||||
This document describes the conventions and workflows everyone must follow to keep the codebase consistent and the collaboration smooth.
|
||||
If you notice a violation, speak to the person involved respectfully.
|
||||
|
||||
Since this project is part of a course at the University of Basel, the [Code of Conduct](https://www.unibas.ch/de/Universitaet/Administration-Services/Vizerektorat-People-And-Culture/Persoenliche-Integritaet/Code-of-Conduct.html) applies.
|
||||
|
||||
|
||||
## Table of Contents
|
||||
- [Contribution Guidelines](#contribution-guidelines)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Issues \& Tasks](#issues--tasks)
|
||||
- [Creating an issue](#creating-an-issue)
|
||||
- [During implementation](#during-implementation)
|
||||
- [Collaborative work](#collaborative-work)
|
||||
- [Git Workflow](#git-workflow)
|
||||
- [Creating a branch](#creating-a-branch)
|
||||
- [Working on a branch](#working-on-a-branch)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [Rules](#rules)
|
||||
- [Examples](#examples)
|
||||
- [Code Style](#code-style)
|
||||
- [Linter](#linter)
|
||||
- [Formatter](#formatter)
|
||||
- [General guidelines](#general-guidelines)
|
||||
- [CI/CD Pipeline](#cicd-pipeline)
|
||||
- [Before pushing](#before-pushing)
|
||||
- [Merge Requests](#merge-requests)
|
||||
- [Opening a MR](#opening-a-mr)
|
||||
- [Merging](#merging)
|
||||
- [After merging](#after-merging)
|
||||
- [Be human](#be-human)
|
||||
|
||||
|
||||
## Issues & Tasks
|
||||
Every piece of work - whether a new feature, a bug fix, or a refactoring - must be tracked as an
|
||||
Issue or Task in GitLab **before** any implementation begins.
|
||||
|
||||
### Creating an issue
|
||||
1. Open a new Issue or Task using the **relevant template** provided in the repository.
|
||||
2. Fill in **all fields** specified by the template thoughtfully and completely. A well-written issue is the single source of truth for the work being done - treat it accordingly.
|
||||
3. Work through the **checklist** in the template before marking the issue as ready. Do not skip items.
|
||||
|
||||
### During implementation
|
||||
- If you encounter a problem or an unexpected finding while working on an issue, record it as a **comment** on the issue. This keeps the history intact and visible to the whole team.
|
||||
- **Do not restructurally edit the original description** to incorporate new information. The description reflects the intent at the time the issue was created; comments document what happened along the way.
|
||||
|
||||
### Collaborative work
|
||||
- When multiple people are working on the same issue, **prefer issue comments over private messages** for coordination. This keeps the current status, decisions, and open questions
|
||||
centrally visible and searchable.
|
||||
- Before starting work that overlaps with an existing issue, check its comment thread first to avoid duplicating effort.
|
||||
|
||||
|
||||
## Git Workflow
|
||||
We use a **feature branch -> main** strategy. The `main` branch is always in a releasable state.
|
||||
|
||||
### Creating a branch
|
||||
We follow the [**Conventional Branch**](https://conventional-branch.github.io/) specification. Branch names follow this pattern:
|
||||
|
||||
```
|
||||
<type>/<short-description>
|
||||
```
|
||||
|
||||
| Type | When to use |
|
||||
|------------|--------------------------------------------------|
|
||||
| `feat` | New feature or capability |
|
||||
| `fix` | Bug fix |
|
||||
| `refactor` | Restructuring without behaviour change |
|
||||
| `test` | Adding or fixing tests |
|
||||
| `ci` | Pipeline, Gradle, or tooling changes |
|
||||
| `docs` | Documentation only |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```
|
||||
feat/reconnect-command
|
||||
fix/session-writer-flush
|
||||
refactor/user-registry-cleanup
|
||||
docs/contributing
|
||||
```
|
||||
|
||||
### Working on a branch
|
||||
Keep branches short-lived. A branch should represent one cohesive unit of work.
|
||||
|
||||
It is permissible to commit changes within a feature branch that cause the program to become non-functional, but these should be fixed as soon as possible. In any case, the code that is merged into `main` must be functional.
|
||||
|
||||
And most importantly: **Do not commit directly to `main`**.
|
||||
|
||||
|
||||
## Commit Messages
|
||||
We follow the [**Conventional Commits**](https://www.conventionalcommits.org/) specification. Every commit message must have the form:
|
||||
|
||||
```
|
||||
<type>: <short summary>
|
||||
|
||||
[optional body]
|
||||
```
|
||||
|
||||
### Rules
|
||||
The summary line must be **<= 72 characters**, written in the **imperative mood** (e.g. "add", not "added" or "adds").
|
||||
|
||||
### Examples
|
||||
```
|
||||
Reat: Add RECONNECT command handler
|
||||
|
||||
The handler re-associates an existing User with a new Session after
|
||||
a connection drop, preserving in-flight state.
|
||||
```
|
||||
|
||||
```
|
||||
Fix: Flush output stream before closing
|
||||
```
|
||||
|
||||
```
|
||||
Ci: Tighten Checkstyle failure policy to allow_failure: false
|
||||
```
|
||||
|
||||
```
|
||||
Refactor: Replace ArrayList with CopyOnWriteArrayList
|
||||
```
|
||||
|
||||
|
||||
## Code Style
|
||||
Code formatting is enforced automatically. **Do not submit a MR with formatting violations.**
|
||||
|
||||
### Linter
|
||||
We use **Checkstyle** as our linter with a custom set of rules tailored to our project.
|
||||
|
||||
Checkstyle runs on every pipeline. Fix all violations locally before pushing:
|
||||
|
||||
```bash
|
||||
./gradlew checkstyleMain checkstyleTest
|
||||
```
|
||||
|
||||
|
||||
### Formatter
|
||||
We use **Spotless** with the **[Google](https://google.github.io/styleguide/javaguide.html) / [AOSP Java style](https://source.android.com/docs/core/architecture/hidl/code-style?hl=en)**:
|
||||
|
||||
- **Indentation:** 4 spaces (no tabs)
|
||||
- **Line length:** 100 characters
|
||||
- No decorative blank lines directly after opening braces `{`
|
||||
- Blank lines are reserved for separating logical sections within a block
|
||||
|
||||
Run the formatter before committing:
|
||||
|
||||
```bash
|
||||
./gradlew spotlessApply
|
||||
```
|
||||
|
||||
Check without applying:
|
||||
|
||||
```bash
|
||||
./gradlew spotlessCheck
|
||||
```
|
||||
|
||||
### General guidelines
|
||||
- Add **JavaDoc** docstrings to classes, interfaces, records and methods.
|
||||
- Exercise **clean architecture**
|
||||
- Prefer **stateless components**
|
||||
- Use **`record` types** for immutable data carriers.
|
||||
- Log with **Log4J 2** (`log4j-api`). Use the appropriate level (`DEBUG` for pipeline internals, `INFO` for lifecycle events, `WARN`/`ERROR` for recoverable/unrecoverable problems).
|
||||
|
||||
|
||||
## CI/CD Pipeline
|
||||
The pipeline runs automatically on every push. It has four stages:
|
||||
|
||||
```
|
||||
lint > report > build > test
|
||||
```
|
||||
|
||||
| Stage | Jobs |
|
||||
|----------|--------------------------------------------|
|
||||
| `lint` | Spotless check, Checkstyle |
|
||||
| `report` | Code Quality JSON conversion (only for mr) |
|
||||
| `build` | `./gradlew assemble` |
|
||||
| `test` | `./gradlew test` + JUnit result reporting |
|
||||
|
||||
### Before pushing
|
||||
Run the full check suite locally to avoid a broken pipeline:
|
||||
|
||||
```bash
|
||||
./gradlew spotlessCheck checkstyleMain checkstyleTest build test
|
||||
```
|
||||
|
||||
A red pipeline blocks merging. Fix failures before creating your merge request.
|
||||
|
||||
|
||||
## Merge Requests
|
||||
|
||||
### Opening a MR
|
||||
- Target branch is always **`main`**.
|
||||
- Fill in the MR description: what changed and why. Link the relevant issue (with 'Closing #x') if one exists.
|
||||
|
||||
### Merging
|
||||
A MR can be merged when **all main CI pipeline stages are green** (lint, build, test).
|
||||
|
||||
No explicit peer approval is required, but leaving a note or question in the MR thread for non-trivial changes is encouraged.
|
||||
If you spot a problem in someone else's open MR, comment - **do not push directly to their branch** and try to fix the issue yourself.
|
||||
|
||||
### After merging
|
||||
Delete the feature branch after the MR is merged. GitLab can do this automatically via the
|
||||
"Delete source branch" checkbox in the MR.
|
||||
|
||||
|
||||
## Be human
|
||||
We are all human.
|
||||
We all forget things or make mistakes sometimes.
|
||||
|
||||
It’s important that we look out for one another and **work together as a team**.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 194 KiB |
+59
-1
@@ -2,6 +2,8 @@ plugins {
|
||||
id 'application'
|
||||
id 'java'
|
||||
id 'org.openjfx.javafxplugin' version '0.1.0'
|
||||
id 'checkstyle'
|
||||
id 'com.diffplug.spotless' version '7.0.2'
|
||||
}
|
||||
|
||||
group = 'ch.unibas.dmi.dbis'
|
||||
@@ -23,18 +25,44 @@ repositories {
|
||||
|
||||
javafx {
|
||||
version = "25.0.2"
|
||||
modules = ['javafx.controls', 'javafx.fxml', 'javafx.base']
|
||||
modules = ['javafx.controls', 'javafx.fxml', 'javafx.base', 'javafx.graphics', 'javafx.web']
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Source: https://mvnrepository.com/artifact/org.apache.logging.log4j
|
||||
implementation("org.apache.logging.log4j:log4j-api:2.25.3")
|
||||
runtimeOnly("org.apache.logging.log4j:log4j-core:2.25.3")
|
||||
|
||||
// Source: https://mvnrepository.com/artifact/org.fusesource.jansi/jansi
|
||||
runtimeOnly("org.fusesource.jansi:jansi:2.4.2")
|
||||
|
||||
testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher:1.10.0")
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
checkstyle {
|
||||
toolVersion = '10.21.0'
|
||||
configFile = file('config/checkstyle/checkstyle.xml')
|
||||
configProperties = [
|
||||
'suppressionFile': file('config/checkstyle/suppressions.xml').absolutePath
|
||||
]
|
||||
}
|
||||
|
||||
spotless {
|
||||
java {
|
||||
googleJavaFormat('1.35.0').aosp()
|
||||
importOrder()
|
||||
removeUnusedImports()
|
||||
trimTrailingWhitespace()
|
||||
endWithNewline()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named('jar', Jar) {
|
||||
manifest {
|
||||
attributes('Main-Class': application.mainClass.get())
|
||||
@@ -42,6 +70,22 @@ tasks.named('jar', Jar) {
|
||||
}
|
||||
|
||||
|
||||
// Dieses JVM-Argument erlaubt JavaFX und anderen Libraries den Zugriff auf native Methoden.
|
||||
// Ohne diese Einstellung erscheinen Warnungen und zukünftige Java-Versionen könnten den Zugriff blockieren.
|
||||
// Siehe: https://openjdk.org/jeps/472
|
||||
tasks.withType(JavaExec) {
|
||||
jvmArgs += '--enable-native-access=ALL-UNNAMED'
|
||||
}
|
||||
|
||||
|
||||
|
||||
tasks.register('cruntest', JavaExec) {
|
||||
classpath = sourceSets.main.runtimeClasspath
|
||||
mainClass = 'ch.unibas.dmi.dbis.cs108.casono.Main'
|
||||
jvmArgs '--enable-native-access=ALL-UNNAMED'
|
||||
args 'client', '0.0.0.0:1234'
|
||||
}
|
||||
|
||||
tasks.register('fatJar', Jar) {
|
||||
group = 'build'
|
||||
description = 'Assembles a runnable fat JAR including runtime dependencies.'
|
||||
@@ -64,3 +108,17 @@ tasks.register('fatJar', Jar) {
|
||||
configurations.runtimeClasspath.collect { it.isDirectory() ? it : zipTree(it) }
|
||||
})
|
||||
}
|
||||
|
||||
tasks.register('javadocJar', Jar) {
|
||||
group = 'build'
|
||||
description = 'Assembles a Javadoc JAR.'
|
||||
dependsOn tasks.named('javadoc')
|
||||
archiveClassifier = 'javadoc'
|
||||
from(tasks.javadoc.destinationDir)
|
||||
}
|
||||
|
||||
tasks.register('build-cs108') {
|
||||
group = 'build'
|
||||
description = 'Produces executable JAR and Javadoc JAR for CS108.'
|
||||
dependsOn tasks.named('fatJar'), tasks.named('javadocJar')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE module PUBLIC
|
||||
"-//Checkstyle//DTD Checkstyle Configuration 1.3//EN"
|
||||
"https://checkstyle.org/dtds/configuration_1_3.dtd">
|
||||
|
||||
<module name="Checker">
|
||||
<!-- Fail build if checkstyle finds violations -->
|
||||
<!-- https://checkstyle.sourceforge.io/config.html#Properties_1 -->
|
||||
<property name="severity" value="error"/>
|
||||
|
||||
<!-- Only check java files -->
|
||||
<!-- https://checkstyle.sourceforge.io/config.html#Properties_1 -->
|
||||
<property name="fileExtensions" value="java"/>
|
||||
|
||||
<module name="SuppressionFilter">
|
||||
<property name="file" value="${suppressionFile}"/>
|
||||
</module>
|
||||
|
||||
<!-- Only allow spaces -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/whitespace/filetabcharacter.html -->
|
||||
<module name="FileTabCharacter"/>
|
||||
|
||||
<!-- Maximale Zeilenlänge (AOSP enforces 100 lines, the Google Java Style Guide 80)-->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/sizes/linelength.html -->
|
||||
<module name="LineLength">
|
||||
<property name="max" value="100"/>
|
||||
</module>
|
||||
|
||||
<module name="TreeWalker">
|
||||
<!-- Allows for emptylines BETWEEN methods and classes but DISALLOWS newlines after an opening bracket -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/whitespace/emptylineseparator.html -->
|
||||
<module name="EmptyLineSeparator">
|
||||
<property name="allowNoEmptyLineBetweenFields" value="true"/>
|
||||
<property name="tokens" value="
|
||||
METHOD_DEF,
|
||||
CTOR_DEF,
|
||||
STATIC_INIT,
|
||||
INSTANCE_INIT,
|
||||
CLASS_DEF,
|
||||
INTERFACE_DEF,
|
||||
ENUM_DEF
|
||||
"/>
|
||||
</module>
|
||||
|
||||
<!-- No wildcard imports (import x.*) -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/imports/avoidstarimport.html -->
|
||||
<module name="AvoidStarImport"/>
|
||||
|
||||
<!-- No unused imports -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/imports/unusedimports.html -->
|
||||
<module name="UnusedImports"/>
|
||||
|
||||
<!-- No unordered / ungrouped imports -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/imports/importorder.html -->
|
||||
<module name="ImportOrder">
|
||||
<property name="option" value="top"/>
|
||||
<property name="groups" value="/^import static\..+/,*"/>
|
||||
<property name="separated" value="true"/>
|
||||
<property name="separatedStaticGroups" value="true"/>
|
||||
<property name="sortStaticImportsAlphabetically" value="true"/>
|
||||
</module>
|
||||
|
||||
<!-- Classes, enums, records, ... as PascalCase -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/typename.html -->
|
||||
<module name="TypeName"/>
|
||||
|
||||
<!-- Method names as camelCase -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/methodname.html -->
|
||||
<module name="MethodName"/>
|
||||
|
||||
<!-- Parameter names as camelCase -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/parametername.html -->
|
||||
<module name="ParameterName"/>
|
||||
|
||||
<!-- Lokal variables as camelCase -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/localvariablename.html -->
|
||||
<module name="LocalVariableName"/>
|
||||
|
||||
<!-- Constants as UPPER_SNAKE_CASE -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/constantname.html -->
|
||||
<module name="ConstantName"/>
|
||||
|
||||
<!-- Field names as camelCase -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/naming/membername.html -->
|
||||
<module name="MemberName">
|
||||
<property name="format" value="^[a-z][a-zA-Z0-9]*$"/>
|
||||
</module>
|
||||
|
||||
<!-- Restrict length of method name -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/sizes/methodlength.html -->
|
||||
<module name="MethodLength">
|
||||
<property name="max" value="60"/>
|
||||
<property name="countEmpty" value="false"/>
|
||||
</module>
|
||||
|
||||
<!-- Restrict number of parameters -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/sizes/parameternumber.html -->
|
||||
<module name="ParameterNumber">
|
||||
<property name="max" value="5"/>
|
||||
</module>
|
||||
|
||||
<!-- Modifier-Reihenfolge: public static final ... -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/modifier/modifierorder.html -->
|
||||
<module name="ModifierOrder"/>
|
||||
|
||||
<!-- Require braces arround code block (no single instruction after if/else/while/...)-->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/needbraces.html -->
|
||||
<module name="NeedBraces"/>
|
||||
|
||||
<!-- Require the left curly braces at the same line -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/leftcurly.html -->
|
||||
<module name="LeftCurly"/>
|
||||
|
||||
<!-- Require right curly braces at the same line as following instructions -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/rightcurly.html -->
|
||||
<module name="RightCurly"/>
|
||||
|
||||
<!-- Disallow empty code blocks -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/emptyblock.html -->
|
||||
<module name="EmptyBlock">
|
||||
<property name="option" value="text"/>
|
||||
</module>
|
||||
|
||||
<!-- Disallow empty code blocks after catch -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/emptycatchblock.html -->
|
||||
<module name="EmptyCatchBlock">
|
||||
<property name="exceptionVariableName" value="expected|ignored"/>
|
||||
</module>
|
||||
|
||||
<!-- Require string literals to be compared with .equals and not == -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/coding/stringliteralequality.html -->
|
||||
<module name="StringLiteralEquality"/>
|
||||
|
||||
<!-- Disallow standalone numbers in code -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/coding/magicnumber.html -->
|
||||
<module name="MagicNumber">
|
||||
<property name="ignoreNumbers" value="-1, 0, 1, 2"/>
|
||||
<property name="ignoreAnnotation" value="true"/>
|
||||
</module>
|
||||
|
||||
<!-- Disallow nested code blocks dangling in code -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/blocks/avoidnestedblocks.html -->
|
||||
<module name="AvoidNestedBlocks"/>
|
||||
|
||||
<!-- Disallow System.out.println -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/regexp/regexp.html -->
|
||||
<module name="Regexp">
|
||||
<property name="id" value="SystemOutErr"/>
|
||||
<property name="format" value="System\.(out|err)\.print"/>
|
||||
<property name="illegalPattern" value="true"/>
|
||||
<property name="message" value="No `System.out.print(err)` allowed — use a logger."/>
|
||||
</module>
|
||||
|
||||
<!-- Disallow whitespace before semicolon or bracket -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/whitespace/nowhitespacebefore.html -->
|
||||
<module name="NoWhitespaceBefore"/>
|
||||
|
||||
<!-- Disallow whitespace after code -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/whitespace/whitespaceafter.html -->
|
||||
<module name="WhitespaceAfter"/>
|
||||
|
||||
<!-- No trailing whitespace at line ending -->
|
||||
<!-- https://checkstyle.sourceforge.io/checks/regexp/regexp.html -->
|
||||
<module name="Regexp">
|
||||
<property name="format" value=" +$"/>
|
||||
<property name="illegalPattern" value="true"/>
|
||||
<property name="message" value="No trailing whitespace."/>
|
||||
</module>
|
||||
</module>
|
||||
</module>
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0"?>
|
||||
<!DOCTYPE suppressions PUBLIC
|
||||
"-//Checkstyle//DTD SuppressionFilter Configuration 1.2//EN"
|
||||
"https://checkstyle.org/dtds/suppressions_1_2.dtd">
|
||||
|
||||
<suppressions>
|
||||
<!-- Allow literal, for example assertEquals(42, life.answer()) -->
|
||||
<suppress checks="MagicNumber" files=".*Test\.java"/>
|
||||
|
||||
<!-- Allow longer method names as the are usually descriptive of the test -->
|
||||
<suppress checks="MethodLength" files=".*Test\.java"/>
|
||||
|
||||
<!-- Allow methods with more parameters -->
|
||||
<suppress checks="ParameterNumber" files=".*Test\.java"/>
|
||||
|
||||
<!-- Allow for System.out.println in tests -->
|
||||
<suppress id="SystemOutErr" files=".*Test\.java"/>
|
||||
|
||||
<!-- Allow wildcard imports (import x.*) -->
|
||||
<suppress checks="AvoidStarImport" files=".*Test\.java"/>
|
||||
|
||||
<!-- Allow for compacter line seperators -->
|
||||
<suppress checks="EmptyLineSeparator" files=".*Test\.java"/>
|
||||
</suppressions>
|
||||
@@ -0,0 +1,13 @@
|
||||
# MS2 Meeting
|
||||
|
||||
> 14.03.2025 (14:00)
|
||||
|
||||
In einem Meeting haben wir die Struktur zwischen **LobbyScreen**, **GameScreen**
|
||||
und der **Chatbox** besprochen. Dabei ging es vor allem darum, wie der Chat in beide
|
||||
UIs eingebettet wird und wie die Verbindung zwischen den Komponenten umgesetzt wird.
|
||||
|
||||
Außerdem wurde festgelegt, dass der **Endscreen** erst nach MS3 umgesetzt wird.
|
||||
Zunächst werden die Texturen fertiggestellt und im GameScreen integriert.
|
||||
Falls sich die Struktur (z. B. FXML oder CSS) noch ändert, kann der Endscreen
|
||||
später aus der bestehenden Struktur abgeleitet werden, ohne Anpassungen doppelt
|
||||
durchführen zu müssen.
|
||||
@@ -0,0 +1,63 @@
|
||||
This Document states the Network-Protocol as it is currently implemented
|
||||
|
||||
## GET_MESSAGE_COUNT
|
||||
This Command gets the number of messages, currently stored in the queue for the specific client.
|
||||
(Is used together with GET_NEXT_MESSAGE, to get all messages that are currently in the queue)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
GET_MESSAGE_COUNT
|
||||
|
||||
1
|
||||
+OK
|
||||
```
|
||||
|
||||
## GET_NEXT_MESSAGE
|
||||
This Command gets the next Message that is being stored in the queue for the client.
|
||||
(Command is being sent the amount of times, the GET_MESSAGE_COUNT Command returns)
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
GET_NEXT_MESSAGE
|
||||
|
||||
TYPE=LOBBY GAME=1 USER=player1 TARGET=null TIME=9:30 TEXT="Guten Tag"
|
||||
+OK
|
||||
```
|
||||
|
||||
## SEND_MESSAGE
|
||||
This Command gets sent if the user of that client writes a message to one of the three possible chats
|
||||
|
||||
(The Arguments / Parameters of the Command describe all the parameters of the Object "Message" being used internally by both the Client and the Server)
|
||||
|
||||
Example:
|
||||
```
|
||||
SEND_MESSAGE TYPE=LOBBY GAME=1 USER=player1 TARGET=null TIME=10:30 TEXT="Hallo Welt"
|
||||
|
||||
+OK
|
||||
```
|
||||
|
||||
## LOG_IN
|
||||
This Command is used to create a user on the server and associate that user with a username
|
||||
Server returns the username, slightly changed if it is already used by someone else, and an ID to identify the client.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
LOG_IN USERNAME="Peter"
|
||||
|
||||
USERNAME="Peter" ID=<random UUID>
|
||||
+OK
|
||||
```
|
||||
|
||||
## LOG_OUT
|
||||
This Command is used to quit the connection between client and server.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
LOG_OUT
|
||||
|
||||
+OK
|
||||
```
|
||||
@@ -0,0 +1,249 @@
|
||||
# Client Nework Architecture
|
||||
<!-- vim-markdown-toc GFM -->
|
||||
|
||||
* [Architecture Overview](#architecture-overview)
|
||||
* [network/Card.java](#networkcardjava)
|
||||
* [network/GameState.java](#networkgamestatejava)
|
||||
* [network/Player.java](#networkplayerjava)
|
||||
* [network/ChatClient.java](#networkchatclientjava)
|
||||
* [ChatClient(ClientService clientService)](#chatclientclientservice-clientservice)
|
||||
* [sendMessage(Message message)](#sendmessagemessage-message)
|
||||
* [getMessages()](#getmessages)
|
||||
* [network/ClientService.java](#networkclientservicejava)
|
||||
* [ClientService(String ip, int port)](#clientservicestring-ip-int-port)
|
||||
* [processCommand(String message)](#processcommandstring-message)
|
||||
* [sendRequest(Runnable request)](#sendrequestrunnable-request)
|
||||
* [getRuntimeException(Exception e)](#getruntimeexceptionexception-e)
|
||||
* [closeSocket()](#closesocket)
|
||||
* [writeToTransport(String s) throws IOException](#writetotransportstring-s-throws-ioexception)
|
||||
* [network/CoreClient.java](#networkcoreclientjava)
|
||||
* [CoreClient(ClientService clientservice)](#coreclientclientservice-clientservice)
|
||||
* [ping()](#ping)
|
||||
* [login(String user)](#loginstring-user)
|
||||
* [network/GameClient.java](#networkgameclientjava)
|
||||
* [GameClient(ClientService client)](#gameclientclientservice-client)
|
||||
* [getGameState()](#getgamestate)
|
||||
* [parseGameState(String input)](#parsegamestatestring-input)
|
||||
* [Example server response](#example-server-response)
|
||||
* [network/LobbyClient.java](#networklobbyclientjava)
|
||||
* [LobbyClient(ClientService client)](#lobbyclientclientservice-client)
|
||||
* [fetchLobbyStatusString(int lobbyId)](#fetchlobbystatusstringint-lobbyid)
|
||||
* [createLobby()](#createlobby)
|
||||
* [getLobbyId()](#getlobbyid)
|
||||
* [joinLobby(int lobbyId)](#joinlobbyint-lobbyid)
|
||||
|
||||
<!-- vim-markdown-toc -->
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```text
|
||||
client/
|
||||
├── game/
|
||||
│ ├── Card.java
|
||||
│ ├── GameState.java
|
||||
│ └── Player.java
|
||||
│
|
||||
└── network/
|
||||
├── ChatClient.java
|
||||
├── ClientService.java
|
||||
├── CoreClient.java
|
||||
├── GameClient.java
|
||||
└── LobbyClient.java
|
||||
```
|
||||
|
||||
### game/Card.java
|
||||
|
||||
Represents a playing card with a value and suit.
|
||||
|
||||
### game/GameState.java
|
||||
|
||||
Represents the current state of the poker game, including the phase, pot size, current bet, dealer position, active player, community cards, and player information.
|
||||
|
||||
### game/Player.java
|
||||
|
||||
Represents a player in the poker game, including their name, chip count, current bet, state (e.g., `active`, `folded`), and their hole cards.
|
||||
|
||||
### network/ChatClient.java
|
||||
|
||||
The ChatClient class is responsible for sending messages to the server and retrieving messages from the server. It uses the ClientService to send commands and receive responses from the server.
|
||||
|
||||
#### ChatClient(ClientService clientService)
|
||||
|
||||
Constructs a ChatClient with the given ClientService for communication.
|
||||
|
||||
- **Parameter (`clientService`)**: The ClientService instance used to send commands and receive responses from the server.
|
||||
|
||||
#### sendMessage(Message message)
|
||||
|
||||
Send a Message to the server by converting it to a string format and sending a `SEND_MESSAGE` command with the message content as arguments.
|
||||
|
||||
- **Parameter (`message`)**: message The Message object to be sent to the server.
|
||||
|
||||
#### getMessages()
|
||||
|
||||
Retrieve messages from the server by first sending a `GET_MESSAGE_COUNT` command to determine how many messages are available and then sending `GET_NEXT_MESSAGE` commands in a loop to retrieve each message. The retrieved messages are parsed into Message objects and returned as a list.
|
||||
|
||||
- **Parameter (`A`)**: list of Message objects representing the messages retrieved from the server.
|
||||
|
||||
### network/ClientService.java
|
||||
|
||||
The ClientService class is responsible for managing the connection to the server, sending commands, and receiving responses. It uses a TcpTransport to
|
||||
communicate with the server and an ExecutorService to handle asynchronous requests.
|
||||
|
||||
#### ClientService(String ip, int port)
|
||||
|
||||
Constructs a ClientService with the given server IP and port. It establishes a socket connection to the server and initializes the TcpTransport and ExecutorService for communication.
|
||||
|
||||
- **Parameter (`ip`)**: The IP address of the server to connect to.
|
||||
- **Parameter (`port`)**: The port number of the server to connect to.
|
||||
|
||||
#### processCommand(String message)
|
||||
|
||||
Sends a command to the server and waits for the response. The command is sent using the TcpTransport, and the response is read in a loop until a valid response is received. The method handles `+OK` and `-ERROR` responses from the server and returns the actual response content.
|
||||
|
||||
- **Parameter (`message`)**: The command message to be sent to the server.
|
||||
- **Return**: The response from the server as a string.
|
||||
|
||||
#### sendRequest(Runnable request)
|
||||
|
||||
Helper method to send a request to the server using the ExecutorService. It submits the request as a Runnable task and waits for its completion. If the task is interrupted or encounters an execution exception, it throws a
|
||||
RuntimeException with the appropriate cause.
|
||||
|
||||
- **Parameter (`request`)**: The Runnable task representing the request to be sent to the server.
|
||||
|
||||
#### getRuntimeException(Exception e)
|
||||
|
||||
Helper method to extract the cause of an exception and return it as a RuntimeException. If the cause is null, it returns the original exception as a RuntimeException. If the cause is already a RuntimeException, it returns it directly. Otherwise, it wraps the cause in a new RuntimeException and returns it.
|
||||
|
||||
- **Parameter (`e`)**: The exception from which to extract the cause.
|
||||
- **Return**: A RuntimeException representing the cause of the original exception.
|
||||
|
||||
#### closeSocket()
|
||||
|
||||
Closes the socket connection to the server and shuts down the ExecutorService. It also closes the TcpTransport used for communication. If any IOException occurs during this process, it prints the exception to the console.
|
||||
|
||||
#### writeToTransport(String s) throws IOException
|
||||
|
||||
Helper method to write a command string to the TcpTransport. It generates a unique ID for the command using the idGenerator and sends a RawPacket containing the ID and the command string to the server. If an IOException occurs during this process, it throws a RuntimeException with the cause.
|
||||
|
||||
- **Parameter (`s`)**: The command string to be sent to the server.
|
||||
- **Throws IOException**: If an I/O error occurs while writing to the transport.
|
||||
|
||||
### network/CoreClient.java
|
||||
|
||||
The CoreClient class provides basic functionalities for communicating with the server, such as sending a ping command to check connectivity and logging in with a username. It uses the ClientService to send commands and receive responses from the server.
|
||||
|
||||
#### CoreClient(ClientService clientservice)
|
||||
|
||||
Constructs a CoreClient with the given ClientService for communication.
|
||||
|
||||
- **Parameter (`clientservice`)**: The ClientService instance used to send commands and receive responses from the server.
|
||||
|
||||
#### ping()
|
||||
|
||||
Sends a `PING` command to the server to check connectivity. The server should respond with a `PONG` message if the connection is successful.
|
||||
|
||||
#### login(String user)
|
||||
|
||||
Logs in to the server with the given username by sending a `LOGIN` command.
|
||||
|
||||
- **Parameter (`user`)**: The username to log in with.
|
||||
|
||||
### network/GameClient.java
|
||||
|
||||
The GameClient class is responsible for communicating with the server to retrieve the current game state. It sends a command to the server and parses the response into a structured GameState object.
|
||||
|
||||
#### GameClient(ClientService client)
|
||||
|
||||
Constructs a GameClient with the given ClientService for communication.
|
||||
|
||||
- **Parameter (`client`)**: The ClientService instance used to send commands and receive responses from the server.
|
||||
|
||||
#### getGameState()
|
||||
|
||||
Retrieves the current game state from the server by sending a command and parsing the response.
|
||||
|
||||
- **Return**: A GameState object representing the current state of the game.
|
||||
|
||||
#### parseGameState(String input)
|
||||
|
||||
Parses the raw response from the server into a structured GameState object.
|
||||
|
||||
- **Parameter (`input`)**: The raw response string from the server.
|
||||
- **Return**: A GameState object representing the current state of the game.
|
||||
|
||||
#### Example server response
|
||||
|
||||
```text
|
||||
+OK
|
||||
PHASE=FLO P
|
||||
POT=150
|
||||
CURRENT_BET=50
|
||||
DEALER=0
|
||||
ACTIVE_PLAYER=1
|
||||
CARDS
|
||||
CARD
|
||||
VALUE=10
|
||||
SUIT=H
|
||||
CARD
|
||||
VALUE=7
|
||||
SUIT=S
|
||||
CARD
|
||||
VALUE=A
|
||||
SUIT=D
|
||||
PLAYERS
|
||||
PLAYER
|
||||
NAME=Max
|
||||
CHIPS=1200
|
||||
BET=50
|
||||
STATE=ACTIVE
|
||||
CARDS
|
||||
CARD
|
||||
VALUE=K
|
||||
SUIT=H
|
||||
CARD
|
||||
VALUE=3
|
||||
SUIT=C
|
||||
PLAYER
|
||||
NAME=Anna
|
||||
CHIPS=800
|
||||
BET=0
|
||||
STATE=FOLDED
|
||||
CARDS
|
||||
END
|
||||
```
|
||||
|
||||
### network/LobbyClient.java
|
||||
|
||||
The LobbyClient class is responsible for communicating with the server to manage game lobbies. It provides methods to create a lobby, join a lobby, and fetch the current status of a lobby by sending appropriate commands to the server and processing the responses.
|
||||
|
||||
#### LobbyClient(ClientService client)
|
||||
|
||||
Constructs a LobbyClient with the given ClientService for communication.
|
||||
|
||||
- **Parameter (`client`)**: The ClientService instance used to send commands and receive responses from the server.
|
||||
|
||||
#### fetchLobbyStatusString(int lobbyId)
|
||||
|
||||
Fetch the current status of the lobby with the given id from the server.
|
||||
|
||||
- **Parameter (`lobbyId`)**: The id of the lobby to fetch the status for.
|
||||
- **Return**: A string representing the current status of the lobby, as returned by the server.
|
||||
|
||||
#### createLobby()
|
||||
|
||||
Request the server to create a new lobby and return the id of the newly created lobby.
|
||||
|
||||
- **Return**: The id of the newly created lobby, as returned by the server.
|
||||
|
||||
#### getLobbyId()
|
||||
|
||||
Request the server to return the id of the lobby that the client is currently in.
|
||||
|
||||
- **Return**: The id of the lobby that the client is currently in, as returned by the server.
|
||||
|
||||
#### joinLobby(int lobbyId)
|
||||
|
||||
Request the server to join the lobby with the given id.
|
||||
|
||||
- **Parameter (`lobbyId`)**: The id of the lobby to join.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Server-Side Networking — How It All Fits Together
|
||||
|
||||
This doc is meant to give a solid mixed-level understanding of how our server-side networking works. No deep dives — just a clear picture of what's happening under the hood when a client connects and sends a command.
|
||||
|
||||
> **Note:** This is v1. Annotations and Reflection-based dispatching are on the roadmap but not covered here yet.
|
||||
|
||||
> **Disclamer:** This document has been written by Claude. I modified certain parts and verified its contents for correctness.
|
||||
|
||||
## Our Protocol at a Glance
|
||||
|
||||
Our protocol is inspired by **POP3** - a classic, text-based protocol that communicates over a raw TCP connection. The idea is simple: the client sends a command as a plain-text string, and the server responds with either a success or an error.
|
||||
|
||||
A typical exchange looks something like this:
|
||||
|
||||
```
|
||||
Client → GET_DELTA SINCE=42
|
||||
Server → +OK
|
||||
PLAYER_FOLDED playerId=3
|
||||
POT 240
|
||||
NEXT_TURN playerId=1
|
||||
.
|
||||
```
|
||||
|
||||
Responses start with `+OK` on success or `-ERR` when something goes wrong. Commands are short, uppercase strings - sometimes followed by arguments.
|
||||
|
||||
We don't use HTTP, there's no JSON body, no headers. Just a raw socket, a text stream, and a clearly defined set of commands.
|
||||
|
||||
## Core Components & Their Roles
|
||||
|
||||

|
||||
|
||||
Here's a quick rundown of the main building blocks:
|
||||
|
||||
### `NetworkManager`
|
||||
This is the entry point. It binds to a specific port and listens for incoming TCP connections.
|
||||
Once a client connects, it creates a `Session` adds it to the `SessionManager` and goes back to waiting. Its not the responsibility of the `NetworkManager` to recieve and send data from and to each connected client.
|
||||
|
||||
### `Session`
|
||||
Every client that connects has its own `Session` instance running on its own thread. This component owns the lifecycle of that connection: it reads incoming data, passes it along for processing, and writes responses back.
|
||||
|
||||
### `SessionId`
|
||||
The `SessionId` is used to uniquely identify each session. Essentially its a wrapper arround the UUID type.
|
||||
|
||||
### `SessionManager`
|
||||
The `SessionManager` stores all currently active sessions. It allows for the retrieval of a specific session by its id.
|
||||
|
||||
### `ProtocolParser`
|
||||
Raw text (encapsulated in a `RawRequest`) coming off the socket isn't immediately useful — the `ProtocolParser` turns it with the help of the `Tokenizer` into a `PrimitiveRequest`.
|
||||
|
||||
### `RawRequest`
|
||||
The `RawRequest` object is created after a message has been recieved.
|
||||
It holds both a reference to the `Session` that recieved the message and the message itself.
|
||||
|
||||
### `PrimitiveRequest`
|
||||
The `PrimitiveRequest` object is created by the `ProtocolParser` after the content of the `RawRequest` has been tokenized by the `Tokenizer`.
|
||||
The command is stored in its own attribute and the arguments are accessible as a dictionary.
|
||||
|
||||
### `Tokenizer`
|
||||
The job of the `tokenizer` is it, to take the raw string and turn it into tokens.
|
||||
For example, the command `CHAT_LOBBY GAME=12 MESSAGE='All-in?'` will be decoded as `"CHAT_LOBBY", "GAME", "=", "12", "MESSAGE", "=", "All-in?"`.
|
||||
It is unaware about the meaning.
|
||||
|
||||
### `CommandParser` (per command)
|
||||
Takes in the `PrimitiveRequest` and checks if all required fields are provided with the correct value.
|
||||
Creates a command specific `Request` object containing command arguments in a structured manner.
|
||||
|
||||
### `Request` (per command)
|
||||
Has fields common along each implementation such as a reference to the `Session` that recieved the request.
|
||||
Furthermore each implementation has fields unique to each command. For example, the `ChatLobbyRequest` has fields for the game and the message.
|
||||
|
||||
### `CommandRouter`
|
||||
By identifying the type of class of the `Request`, the `CommandRouter` routes the request to the matching `CommandHandler`.
|
||||
|
||||
### `CommandHandler` (per command)
|
||||
Each supported command has its own handler — a small, focused class that contains the logic for that specific command. They have access to the domain, containing inner parts of the game itself.
|
||||
|
||||
### `Response` (per response)
|
||||
The interface has two sub-interfaces for either failed `ErrorResponse` or successfull `SuccessResponse` execution of commands.
|
||||
|
||||
Each step has exactly one responsibility. Increasing the ability to test and extend different components later on.
|
||||
|
||||
## Concurrency - Handling Multiple Clients
|
||||
|
||||
Every incoming connection spawns a new **Thread**. This means multiple clients can be served simultaneously without blocking each other.
|
||||
|
||||
As each `Session` runs entirely on its own thread, there's no shared mutable state between sessions, with exception of the `SessionManager` and other key components explained later.
|
||||
@@ -0,0 +1,96 @@
|
||||
@startuml
|
||||
skinparam classAttributeIconSize 0
|
||||
skinparam packageStyle rectangle
|
||||
skinparam linetype ortho
|
||||
skinparam backgroundColor transparent
|
||||
|
||||
' Network Layer
|
||||
class NetworkManager {
|
||||
+ start(port: int)
|
||||
}
|
||||
|
||||
class Session {
|
||||
- id: SessionId
|
||||
+ Session(id: SessionId)
|
||||
+ getId(): SessionId
|
||||
+ run()
|
||||
+ send(response: String)
|
||||
}
|
||||
|
||||
class SessionId {
|
||||
- value: UUID
|
||||
+ SessionId()
|
||||
+ SessionId(value: UUID)
|
||||
}
|
||||
|
||||
class SessionManager {
|
||||
- sessions: Map<UUID, Session>
|
||||
+ add(session: Session)
|
||||
+ getById(id: UUID): Session
|
||||
+ remove(id: UUID)
|
||||
}
|
||||
|
||||
NetworkManager --> Session : creates
|
||||
NetworkManager --> SessionManager : adds session to
|
||||
Session --> SessionId : identified by
|
||||
Session --> RawRequest : creates
|
||||
Session ..> Response : sends
|
||||
SessionManager o-- Session : manages
|
||||
|
||||
' Protocol Layer
|
||||
class RawRequest {
|
||||
- session: Session
|
||||
- rawMessage: String
|
||||
}
|
||||
|
||||
class Tokenizer {
|
||||
+ tokenize(input: String): List<String>
|
||||
}
|
||||
|
||||
class ProtocolParser {
|
||||
+ parse(raw: RawRequest): PrimitiveRequest
|
||||
}
|
||||
|
||||
class PrimitiveRequest {
|
||||
- command: String
|
||||
- arguments: Map<String, String>
|
||||
}
|
||||
|
||||
RawRequest --> ProtocolParser : passed to
|
||||
ProtocolParser --> Tokenizer : uses
|
||||
ProtocolParser --> PrimitiveRequest : produces
|
||||
|
||||
' Command Layer
|
||||
interface CommandParser <<per command>> {
|
||||
+ parse(primitive: PrimitiveRequest): Request
|
||||
}
|
||||
|
||||
interface Request <<per command>> {
|
||||
- session: Session
|
||||
}
|
||||
|
||||
class CommandRouter {
|
||||
+ route(request: Request)
|
||||
}
|
||||
|
||||
interface CommandHandler <<per command>> {
|
||||
+ handle(request: Request)
|
||||
}
|
||||
|
||||
PrimitiveRequest --> CommandParser : passed to
|
||||
CommandParser --> Request : produces
|
||||
CommandRouter --> CommandHandler : dispatches to
|
||||
CommandRouter ..> Request : receives
|
||||
|
||||
' Response Layer
|
||||
interface Response {
|
||||
+ encode(): String
|
||||
}
|
||||
|
||||
interface SuccessResponse
|
||||
|
||||
interface ErrorResponse
|
||||
|
||||
Response <|-- SuccessResponse
|
||||
Response <|-- ErrorResponse
|
||||
@enduml
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
# Skript to convert findings of checkstyle into code quality report for gitlab
|
||||
import xml.etree.ElementTree as ET, json, hashlib
|
||||
|
||||
findings = []
|
||||
for source in ["main", "test"]:
|
||||
try:
|
||||
tree = ET.parse(f"build/reports/checkstyle/{source}.xml")
|
||||
for file in tree.findall("file"):
|
||||
path = file.get("name")
|
||||
for error in file.findall("error"):
|
||||
line = int(error.get("line", 1))
|
||||
msg = error.get("message", "")
|
||||
fingerprint = hashlib.md5(f"{path}{line}{msg}".encode()).hexdigest()
|
||||
findings.append({
|
||||
"type": "issue",
|
||||
"check_name": error.get("source", "checkstyle"),
|
||||
"description": msg,
|
||||
"severity": "minor",
|
||||
"fingerprint": fingerprint,
|
||||
"location": {
|
||||
"path": "src/" + path.split("/src/")[1] if "/src/" in path else path,
|
||||
"lines": {"begin": line}
|
||||
}
|
||||
})
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with open("gl-code-quality-report.json", "w") as f:
|
||||
json.dump(findings, f)
|
||||
@@ -2,9 +2,16 @@ package ch.unibas.dmi.dbis.cs108.casono;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.ClientApp;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.ServerApp;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/** Main entry point for Casono application. Handles client and server startup. */
|
||||
public final class Main {
|
||||
|
||||
/**
|
||||
* Main entry point for Casono.
|
||||
*
|
||||
* @param args Command line arguments
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
if (!isValid(args)) {
|
||||
printUsage();
|
||||
@@ -33,10 +40,12 @@ public final class Main {
|
||||
}
|
||||
|
||||
private static void printUsage() {
|
||||
System.err.println("""
|
||||
Usage:
|
||||
java -jar xyz.jar server <listenPort>
|
||||
java -jar xyz.jar client <serverIp>:<serverPort>
|
||||
""");
|
||||
Logger logger = LogManager.getLogger(Main.class);
|
||||
logger.fatal(
|
||||
"""
|
||||
Usage:
|
||||
java -jar xyz.jar server <listenPort>
|
||||
java -jar xyz.jar client <serverIp>:<serverPort>
|
||||
""");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client;
|
||||
|
||||
/**
|
||||
* Entry point for the Casono client application. Handles client startup and connection parameters.
|
||||
*/
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.ui.Launcher;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Entry point for the Casono client application. Handles client startup and connection parameters.
|
||||
*
|
||||
* <p>Default constructor for the application.
|
||||
*/
|
||||
public class ClientApp {
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(ClientApp.class);
|
||||
|
||||
/** Default constructor. */
|
||||
public ClientApp() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the client application with the given address.
|
||||
*
|
||||
* @param arg Address in the format "ip:port".
|
||||
* @throws IllegalArgumentException if the address format is invalid.
|
||||
*/
|
||||
public static void start(String arg) {
|
||||
String[] parts = arg.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
@@ -9,6 +35,13 @@ public class ClientApp {
|
||||
String host = parts[0];
|
||||
int port = Integer.parseInt(parts[1]);
|
||||
|
||||
System.out.println("You've selected the client. It will connect port " + port + " at host " + host);
|
||||
LOGGER.info("You've selected the client. It will connect port {} at host {}", port, host);
|
||||
// Expose the chosen host/port to the UI via system properties so controllers
|
||||
// (which read System.getProperty("casono.server.host"/"casono.server.port"))
|
||||
// can obtain the correct connection information.
|
||||
System.setProperty("casono.server.host", host);
|
||||
System.setProperty("casono.server.port", Integer.toString(port));
|
||||
// Forward the original address argument to the launcher as well.
|
||||
Launcher.main(new String[] { arg });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.ChatClient;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* responsible for the transferring of messages from the server to the ChatModel
|
||||
* or from the ChatViewController to the server
|
||||
*/
|
||||
public class ChatController {
|
||||
|
||||
private final String username;
|
||||
private final ClientService clientService;
|
||||
private final ChatClient chatClient;
|
||||
|
||||
private ChatModel chatModel;
|
||||
|
||||
private int game_id;
|
||||
|
||||
public ChatController(String username, ClientService clientService) {
|
||||
this.username = username;
|
||||
this.clientService = clientService;
|
||||
this.chatClient = new ChatClient(this.clientService);
|
||||
}
|
||||
|
||||
public void createChat(int game_id) {
|
||||
chatModel = new ChatModel(ChatModel.ChatType.GLOBAL, username);
|
||||
this.game_id = game_id;
|
||||
}
|
||||
|
||||
public ChatModel getChatModel() {
|
||||
return chatModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* method to send a message, the ChatViewController received to the server
|
||||
*/
|
||||
public void sendMessage(String msg, String username) {
|
||||
Message message = new Message(Message.MessageType.GLOBAL, 0, username, null, msg);
|
||||
onSendToNetwork(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* method to get all messages from the server
|
||||
*/
|
||||
public Boolean receiveMessage() {
|
||||
List<Message> newMessages = chatClient.getMessages();
|
||||
if (!newMessages.isEmpty()) {
|
||||
for (Message msg : newMessages) {
|
||||
chatModel.addMessage(msg);
|
||||
}
|
||||
return true;
|
||||
} else { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* method to send a message to the server
|
||||
* @param message
|
||||
*/
|
||||
public void onSendToNetwork(Message message) {
|
||||
chatClient.sendMessage(message);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
||||
/**
|
||||
* ChatModel, stores the data for a specific chat
|
||||
*
|
||||
* Holds the current state of a chat
|
||||
*/
|
||||
|
||||
public class ChatModel {
|
||||
|
||||
public ArrayList<Message> messages;
|
||||
|
||||
public ChatType chattype;
|
||||
|
||||
public String username;
|
||||
|
||||
public int count;
|
||||
|
||||
public enum ChatType {
|
||||
GLOBAL,
|
||||
LOBBY,
|
||||
WHISPER
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ChatModel, given a username of the client
|
||||
* @param chattype
|
||||
* @param username
|
||||
*/
|
||||
|
||||
public ChatModel(ChatType chattype, String username) {
|
||||
this.messages = new ArrayList<Message>();
|
||||
this.chattype = chattype;
|
||||
this.username = username;
|
||||
this.count = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* method, used by the ChatViewController, to access all new messages, that are stored in the ChatModel
|
||||
*/
|
||||
public synchronized String viewNextMessage() {
|
||||
count--;
|
||||
Message msg = messages.getLast();
|
||||
return String.format("[%s] %s: %s", msg.timestamp, msg.user, msg.getMessage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a new message
|
||||
* method used by the ChatController
|
||||
* @param msg
|
||||
*/
|
||||
public synchronized void addMessage(Message msg) {
|
||||
messages.add(msg);
|
||||
count++;
|
||||
}
|
||||
|
||||
/**
|
||||
* method to send all current messages to the ChatViewController, if needed
|
||||
*/
|
||||
public void addCompleteChat() {
|
||||
for (int i = 0; i < this.messages.size(); i++) {
|
||||
Message msg = this.messages.get(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.chat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Message Object for internal handling of Chat-Messages
|
||||
* TODO: Should be used on both sides of the network
|
||||
*/
|
||||
|
||||
public class Message {
|
||||
private final MessageType type;
|
||||
private final String message;
|
||||
public String user;
|
||||
public String timestamp;
|
||||
public int game_id = 0;
|
||||
public String target = null;
|
||||
public enum MessageType {
|
||||
GLOBAL, LOBBY, WHISPER
|
||||
};
|
||||
|
||||
/**
|
||||
* Constructor for creating Messages with all information given
|
||||
* @param type - Either global, local or whisper
|
||||
* @param game_id - lobby id, or null, if the type is global
|
||||
* @param user - username
|
||||
* @param target - username of the target user, for whisper chat
|
||||
* @param message
|
||||
*/
|
||||
|
||||
public Message(MessageType type, int game_id, String user, String target, String timestamp, String message) {
|
||||
this.type = type;
|
||||
this.game_id = game_id;
|
||||
this.user = user;
|
||||
this.target = target;
|
||||
this.timestamp = timestamp;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor for creating the Messages of the current user, using this client -> time of writing is being recorded
|
||||
* @param type - Either global, local or whisper
|
||||
* @param game_id - lobby id, or null, if the type is global
|
||||
* @param user - username
|
||||
* @param target - username of the target user, for whisper chat
|
||||
* @param message
|
||||
*/
|
||||
|
||||
public Message(MessageType type, int game_id, String user, String target, String message) {
|
||||
this.type = type;
|
||||
this.game_id = game_id;
|
||||
this.user = user;
|
||||
this.target = target;
|
||||
this.message = message;
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm");
|
||||
this.timestamp = now.format(formatter);
|
||||
}
|
||||
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public MessageType getMessageType() {
|
||||
return type;
|
||||
}
|
||||
|
||||
/*
|
||||
* Method to test the system
|
||||
* @return - String representation of the Message instance, as for example "player1: Hello World"
|
||||
|
||||
public String toString() {
|
||||
return String.format("%s: %s", this.user, this.message);
|
||||
}
|
||||
*/
|
||||
/**
|
||||
* Method to create the request representation of the message object, to be sent to the server
|
||||
* @return - request as specified in the network protocol, as String
|
||||
*/
|
||||
public String toArgsString() {
|
||||
return String.format("TYPE=%s GAME=%d USER=%s TARGET=%s TIME=%s TEXT=%s",
|
||||
this.type.toString(), this.game_id, this.user, this.target, this.timestamp, this.message);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Pattern, to analyze the response String with the given parameters
|
||||
*/
|
||||
public static Pattern msgRex = Pattern.compile(
|
||||
"TYPE=(?<type>\\w+) GAME=(?<game>\\w+) USER=(?<user>\\w+) TARGET=(?<target>\\w+) TIME=(?<time>[0-9:.]+) TEXT=(?<text>.*)$");
|
||||
|
||||
/**
|
||||
* Method to create a Message Object, from the information given by the String
|
||||
* @param response - String that got sent as a response from the server
|
||||
* @return - New Message Object
|
||||
*/
|
||||
public static Message toMessage(String response) {
|
||||
Matcher m = msgRex.matcher(response);
|
||||
if (! m.matches()) {
|
||||
throw new RuntimeException("Can not parse message: '" + response+"'");
|
||||
}
|
||||
String typeString=m.group("type");
|
||||
|
||||
switch (typeString) {
|
||||
case "GLOBAL":
|
||||
return new Message(MessageType.GLOBAL,
|
||||
0,
|
||||
m.group("user"),
|
||||
null,
|
||||
m.group("time"),
|
||||
m.group("text"));
|
||||
|
||||
case "LOBBY":
|
||||
return new Message(MessageType.LOBBY,
|
||||
Integer.parseInt(m.group("game")),
|
||||
m.group("user"),
|
||||
null,
|
||||
m.group("time"),
|
||||
m.group("text"));
|
||||
|
||||
case "WHISPER":
|
||||
return new Message(MessageType.WHISPER,
|
||||
Integer.parseInt(m.group("game")),
|
||||
m.group("user"),
|
||||
m.group("target"),
|
||||
m.group("time"),
|
||||
m.group("text"));
|
||||
|
||||
default:
|
||||
throw new RuntimeException("Unknown message type " + typeString);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.game;
|
||||
|
||||
/**
|
||||
* Represents a playing card with a value and suit.
|
||||
*/
|
||||
public class Card {
|
||||
public String value;
|
||||
public String suit;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.game;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.game.Card;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents the current state of the poker game, including the phase, pot
|
||||
* size, current bet, dealer position,
|
||||
* active player, community cards, and player information.
|
||||
*/
|
||||
public class GameState {
|
||||
public String phase;
|
||||
public int pot;
|
||||
public int currentBet;
|
||||
public int dealer;
|
||||
public int activePlayer;
|
||||
|
||||
public List<Card> communityCards = new ArrayList<>();
|
||||
public List<Player> players = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.game;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Represents a player in the poker game, including their name, chip count,
|
||||
* current bet, state (e.g., "active", "folded"), and their hole cards.
|
||||
*/
|
||||
public class Player {
|
||||
public String name;
|
||||
public int chips;
|
||||
public int bet;
|
||||
public String state;
|
||||
|
||||
public List<Card> cards = new ArrayList<>();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.network;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.chat.Message;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The ChatClient class is responsible for sending messages to the server and
|
||||
* retrieving messages from the server. It uses the ClientService to send
|
||||
* commands and receive responses from the server.
|
||||
*/
|
||||
public class ChatClient {
|
||||
|
||||
private ClientService clientService;
|
||||
|
||||
/**
|
||||
* Constructs a ChatClient with the given ClientService for communication.
|
||||
*
|
||||
* @param clientService The ClientService instance used to send commands and
|
||||
* receive responses from the server.
|
||||
*/
|
||||
public ChatClient(ClientService clientService) {
|
||||
this.clientService = clientService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Message to the server by converting it to a string format and
|
||||
* sending a "SEND_MESSAGE" command with the message content as arguments.
|
||||
*
|
||||
* @param message The Message object to be sent to the server.
|
||||
*/
|
||||
public void sendMessage(Message message) {
|
||||
String request = "SEND_MESSAGE " + message.toArgsString();
|
||||
clientService.processCommand(request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve messages from the server by first sending a "GET_MESSAGE_COUNT"
|
||||
* command to determine how many messages are available and then sending
|
||||
* "GET_NEXT_MESSAGE" commands in a loop to retrieve each message. The
|
||||
* retrieved messages are parsed into Message objects and returned as a list.
|
||||
*
|
||||
* @return A list of Message objects representing the messages retrieved from
|
||||
* the server.
|
||||
*/
|
||||
public List<Message> getMessages() {
|
||||
String countStr = clientService.processCommand("GET_MESSAGE_COUNT");
|
||||
int count = Integer.parseInt(countStr);
|
||||
System.out.println("Got " + count + " messages");
|
||||
List<Message> messages = new ArrayList<>();
|
||||
for (int i = 0; i < count; i++) {
|
||||
String message = clientService.processCommand("GET_NEXT_MESSAGE");
|
||||
if (message != null) {
|
||||
Message message1 = Message.toMessage(message);
|
||||
messages.add(message1);
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.network;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TcpTransport;
|
||||
import java.net.Socket;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* The ClientService class is responsible for managing the connection to the
|
||||
* server,
|
||||
* sending commands, and receiving responses. It uses a TcpTransport to
|
||||
* communicate
|
||||
* with the server and an ExecutorService to handle asynchronous requests.
|
||||
*/
|
||||
public class ClientService {
|
||||
|
||||
private final TcpTransport clienttcptransport;
|
||||
private final Socket socket;
|
||||
|
||||
private final ExecutorService executor;
|
||||
private final boolean offlineMode;
|
||||
|
||||
public static ArrayList<String> response;
|
||||
private final AtomicInteger idGenerator;
|
||||
|
||||
/**
|
||||
* Constructs a ClientService with the given server IP and port. It establishes
|
||||
* a socket connection to the server and initializes the TcpTransport and
|
||||
* ExecutorService for communication.
|
||||
*
|
||||
* @param ip The IP address of the server to connect to.
|
||||
* @param port The port number of the server to connect to.
|
||||
*/
|
||||
public ClientService(String ip, int port) {
|
||||
|
||||
this.idGenerator = new AtomicInteger(0);
|
||||
|
||||
this.offlineMode = false;
|
||||
try {
|
||||
socket = new Socket(ip, port);
|
||||
clienttcptransport = new TcpTransport(socket);
|
||||
} catch (IOException i) {
|
||||
throw new RuntimeException(i);
|
||||
}
|
||||
|
||||
executor = Executors.newSingleThreadExecutor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a ClientService in offline mode. No network connection will be
|
||||
* attempted and calls to processCommand will throw a RuntimeException.
|
||||
*
|
||||
* @param offline true to create an offline (no-network) client service
|
||||
*/
|
||||
public ClientService(boolean offline) {
|
||||
this.idGenerator = new AtomicInteger(0);
|
||||
this.offlineMode = offline;
|
||||
this.socket = null;
|
||||
this.clienttcptransport = null;
|
||||
this.executor = Executors.newSingleThreadExecutor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this ClientService is running in offline mode (no network).
|
||||
*/
|
||||
public boolean isOffline() {
|
||||
return offlineMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to the server and waits for the response. The command is
|
||||
* sent using the TcpTransport, and the response is read in a loop until a
|
||||
* valid response is received. The method handles "+OK" and "-ERROR" responses
|
||||
* from the server and returns the actual response content.
|
||||
*
|
||||
* @param message The command message to be sent to the server.
|
||||
* @return The response from the server as a string.
|
||||
*/
|
||||
protected String processCommand(String message) {
|
||||
if (offlineMode) {
|
||||
throw new RuntimeException("ClientService is offline: cannot process command");
|
||||
}
|
||||
AtomicReference<String> response = new AtomicReference<>();
|
||||
sendRequest(() -> {
|
||||
try {
|
||||
writeToTransport(message);
|
||||
String responseLine = null;
|
||||
do {
|
||||
responseLine = clienttcptransport.read().payload();
|
||||
System.out.println("Raw message '" + responseLine + "'");
|
||||
if ("+OK".equals(responseLine)) {
|
||||
return;
|
||||
} else if (("-ERROR").equals(responseLine)) {
|
||||
throw new RuntimeException(responseLine);
|
||||
}
|
||||
response.set(responseLine);
|
||||
} while (true);
|
||||
} catch (Exception e) {
|
||||
throw getRuntimeException(e);
|
||||
}
|
||||
});
|
||||
return response.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to send a request to the server using the ExecutorService. It
|
||||
* submits the request as a Runnable task and waits for its completion. If
|
||||
* the task is interrupted or encounters an execution exception, it throws a
|
||||
* RuntimeException with the appropriate cause.
|
||||
*
|
||||
* @param request The Runnable task representing the request to be sent to the
|
||||
* server.
|
||||
*/
|
||||
private void sendRequest(Runnable request) {
|
||||
Future<?> future = executor.submit(request);
|
||||
try {
|
||||
future.get();
|
||||
} catch (InterruptedException e) {
|
||||
throw new RuntimeException(e);
|
||||
} catch (ExecutionException e) {
|
||||
throw getRuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to extract the cause of an exception and return it as a
|
||||
* RuntimeException. If the cause is null, it returns the original exception as
|
||||
* a RuntimeException. If the cause is already a RuntimeException, it returns
|
||||
* it directly. Otherwise, it wraps the cause in a new RuntimeException and
|
||||
* returns it.
|
||||
*
|
||||
* @param e The exception from which to extract the cause.
|
||||
* @return A RuntimeException representing the cause of the original exception.
|
||||
*/
|
||||
private static RuntimeException getRuntimeException(Exception e) {
|
||||
Throwable reason = e.getCause();
|
||||
RuntimeException re;
|
||||
if (reason == null) {
|
||||
reason = e;
|
||||
} else if (reason instanceof RuntimeException rte) {
|
||||
re = rte;
|
||||
}
|
||||
re = new RuntimeException(reason);
|
||||
return re;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the socket connection to the server and shuts down the
|
||||
* ExecutorService.
|
||||
* It also closes the TcpTransport used for communication. If any IOException
|
||||
* occurs during this process, it prints the exception to the console.
|
||||
*/
|
||||
public void closeSocket() {
|
||||
try {
|
||||
executor.shutdown();
|
||||
if (clienttcptransport != null) {
|
||||
clienttcptransport.close();
|
||||
}
|
||||
if (socket != null) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException j) {
|
||||
System.out.println(j);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to write a command string to the TcpTransport. It generates a
|
||||
* unique ID for the command using the idGenerator and sends a RawPacket
|
||||
* containing the ID and the command string to the server. If an IOException
|
||||
* occurs during this process, it throws a RuntimeException with the cause.
|
||||
*
|
||||
* @param s The command string to be sent to the server.
|
||||
* @throws IOException If an I/O error occurs while writing to the transport.
|
||||
*/
|
||||
private void writeToTransport(String s) throws IOException {
|
||||
int id = this.idGenerator.incrementAndGet();
|
||||
this.clienttcptransport.write(new RawPacket(id, s));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.network;
|
||||
|
||||
/**
|
||||
* The CoreClient class provides basic functionalities for communicating with
|
||||
* the
|
||||
* server, such as sending a ping command to check connectivity and logging in
|
||||
* with a username. It uses the ClientService to send commands and receive
|
||||
* responses from the server.
|
||||
*/
|
||||
public class CoreClient {
|
||||
private final ClientService clientService;
|
||||
|
||||
/**
|
||||
* Constructs a CoreClient with the given ClientService for communication.
|
||||
*
|
||||
* @param clientservice The ClientService instance used to send commands and
|
||||
* receive responses from the server.
|
||||
*/
|
||||
public CoreClient(ClientService clientservice) {
|
||||
this.clientService = clientservice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a "PING" command to the server to check connectivity. The server
|
||||
* should respond with a "PONG" message if the connection is successful.
|
||||
*/
|
||||
public void ping() {
|
||||
clientService.processCommand("PING");
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in to the server with the given username by sending a "LOGIN" command.
|
||||
*
|
||||
* @param user The username to log in with.
|
||||
*/
|
||||
public void login(String user) {
|
||||
clientService.processCommand("LOGIN USERNAME=" + user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.network;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.game.Card;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.game.GameState;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.game.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The GameClient class is responsible for communicating with the server to
|
||||
* retrieve the current game state. It sends a command to the server and
|
||||
* parses the response into a structured GameState object.
|
||||
*/
|
||||
public class GameClient {
|
||||
|
||||
private final ClientService client;
|
||||
|
||||
/**
|
||||
* Constructs a GameClient with the given ClientService for communication.
|
||||
*
|
||||
* @param client The ClientService instance used to send commands and receive
|
||||
* responses from the server.
|
||||
*/
|
||||
public GameClient(ClientService client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the current game state from the server by sending a command and
|
||||
* parsing the response.
|
||||
*
|
||||
* @return A GameState object representing the current state of the game.
|
||||
*/
|
||||
public GameState getGameState() {
|
||||
String response = client.processCommand("GET_GAME_STATE");
|
||||
return parseGameState(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw response from the server into a structured GameState object.
|
||||
*
|
||||
* @param input The raw response string from the server.
|
||||
* @return A GameState object representing the current state of the game.
|
||||
*/
|
||||
private GameState parseGameState(String input) {
|
||||
|
||||
GameState state = new GameState();
|
||||
|
||||
String[] lines = input.split("\n");
|
||||
|
||||
Player currentPlayer = null;
|
||||
Card currentCard = null;
|
||||
|
||||
for (String rawLine : lines) {
|
||||
|
||||
String line = rawLine.trim();
|
||||
|
||||
if (line.startsWith("+OK") || line.equals("END")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("PHASE=")) {
|
||||
state.phase = line.split("=")[1];
|
||||
}
|
||||
|
||||
else if (line.startsWith("POT=")) {
|
||||
state.pot = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("CURRENT_BET=")) {
|
||||
state.currentBet = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("DEALER=")) {
|
||||
state.dealer = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("ACTIVE_PLAYER=")) {
|
||||
state.activePlayer = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("PLAYER")) {
|
||||
currentPlayer = new Player();
|
||||
state.players.add(currentPlayer);
|
||||
}
|
||||
|
||||
else if (line.startsWith("NAME=") && currentPlayer != null) {
|
||||
currentPlayer.name = line.split("=")[1];
|
||||
}
|
||||
|
||||
else if (line.startsWith("CHIPS=") && currentPlayer != null) {
|
||||
currentPlayer.chips = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("BET=") && currentPlayer != null) {
|
||||
currentPlayer.bet = Integer.parseInt(line.split("=")[1]);
|
||||
}
|
||||
|
||||
else if (line.startsWith("STATE=") && currentPlayer != null) {
|
||||
currentPlayer.state = line.split("=")[1];
|
||||
}
|
||||
|
||||
else if (line.startsWith("CARD")) {
|
||||
currentCard = new Card();
|
||||
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer.cards.add(currentCard);
|
||||
} else {
|
||||
state.communityCards.add(currentCard);
|
||||
}
|
||||
}
|
||||
|
||||
else if (line.startsWith("VALUE=") && currentCard != null) {
|
||||
currentCard.value = line.split("=")[1];
|
||||
}
|
||||
|
||||
else if (line.startsWith("SUIT=") && currentCard != null) {
|
||||
currentCard.suit = line.split("=")[1];
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.network;
|
||||
|
||||
/**
|
||||
* The LobbyClient class is responsible for communicating with the server to
|
||||
* manage game lobbies. It provides methods to create a lobby, join a lobby,
|
||||
* and fetch the current status of a lobby by sending appropriate commands to
|
||||
* the server and processing the responses.
|
||||
*/
|
||||
public class LobbyClient {
|
||||
private final ClientService client;
|
||||
|
||||
/**
|
||||
* Constructs a LobbyClient with the given ClientService for communication.
|
||||
*
|
||||
* @param client The ClientService instance used to send commands and receive
|
||||
* responses from the server.
|
||||
*/
|
||||
public LobbyClient(ClientService client) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
public ClientService getClientService() {
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the current status of the lobby with the given id from the server.
|
||||
*
|
||||
* @param lobbyId The id of the lobby to fetch the status for.
|
||||
* @return A string representing the current status of the lobby, as returned
|
||||
* by the server.
|
||||
*/
|
||||
public String fetchLobbyStatusString(int lobbyId) {
|
||||
return client.processCommand("GET_LOBBY_STATUS ID=" + lobbyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the server to create a new lobby and return the id of the newly
|
||||
* created lobby.
|
||||
*
|
||||
* @return The id of the newly created lobby, as returned by the server.
|
||||
*/
|
||||
public int createLobby() {
|
||||
String response = client.processCommand("CREATE_LOBBY");
|
||||
return Integer.parseInt(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the server to return the id of the lobby that the client is
|
||||
* currently in.
|
||||
*
|
||||
* @return The id of the lobby that the client is currently in, as returned by
|
||||
* the server.
|
||||
*/
|
||||
public int getLobbyId() {
|
||||
String response = client.processCommand("GET_LOBBY_ID");
|
||||
return Integer.parseInt(response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request the server to join the lobby with the given id.
|
||||
*
|
||||
* @param lobbyId The id of the lobby to join.
|
||||
*/
|
||||
public void joinLobby(int lobbyId) {
|
||||
client.processCommand("JOIN_LOBBY ID=" + lobbyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in to the server with the given username by sending a "LOGIN" command.
|
||||
*
|
||||
* @param user The username to log in with.
|
||||
*/
|
||||
public void login(String user) {
|
||||
client.processCommand("LOGIN USERNAME=" + user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui;
|
||||
import javafx.application.Application;
|
||||
|
||||
/**
|
||||
* Launcher for the Casono main UI.
|
||||
*
|
||||
* <p>Default constructor for the application.
|
||||
*/
|
||||
public class Launcher {
|
||||
|
||||
/** Default constructor. */
|
||||
public Launcher() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for launching the UI.
|
||||
*
|
||||
* @param args Command line arguments
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
Application.launch(Casinomainui.class, args);
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.chatui;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatController;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.chat.ChatModel;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.ScrollPane;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
import java.util.Timer;
|
||||
import java.util.TimerTask;
|
||||
|
||||
/**
|
||||
* Responsible for the presentation of the ChatModel to the Client
|
||||
*/
|
||||
|
||||
public class ChatViewController {
|
||||
|
||||
private final ChatModel chatmodel;
|
||||
private final String username;
|
||||
private final ChatController controller;
|
||||
private final Timer timer;
|
||||
|
||||
@FXML
|
||||
private VBox chatVBox;
|
||||
|
||||
@FXML
|
||||
private TextField inputField;
|
||||
|
||||
@FXML
|
||||
private ScrollPane chatScrollPane;
|
||||
|
||||
@FXML
|
||||
private Button sendButton;
|
||||
|
||||
@FXML
|
||||
private Button refreshButton;
|
||||
|
||||
private static final int CHAT_PADDING = 20;
|
||||
|
||||
public ChatViewController() {
|
||||
this.username = null;
|
||||
this.chatmodel = null;
|
||||
this.controller = null;
|
||||
this.timer = new Timer();
|
||||
}
|
||||
|
||||
public ChatViewController(String username, ChatModel chatmodel, ChatController controller) {
|
||||
this.username = username;
|
||||
this.chatmodel = chatmodel;
|
||||
this.controller = controller;
|
||||
this.timer = new Timer();
|
||||
if (this.controller != null) {
|
||||
timer.schedule(new TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
System.err.println("tick");
|
||||
if (ChatViewController.this.controller.receiveMessage()) {
|
||||
showMessage();
|
||||
}
|
||||
}
|
||||
}, 0, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
@FXML
|
||||
public void initialize() {
|
||||
if (inputField != null) {
|
||||
inputField.setOnAction(event -> sendMessage());
|
||||
}
|
||||
if (sendButton != null) {
|
||||
sendButton.setOnAction(event -> sendMessage());
|
||||
}
|
||||
if (refreshButton != null) {
|
||||
refreshButton.setOnAction(event -> showMessage());
|
||||
}
|
||||
if (chatScrollPane != null && chatVBox != null) {
|
||||
chatScrollPane.vvalueProperty().bind(chatVBox.heightProperty());
|
||||
}
|
||||
}
|
||||
|
||||
public void showMessage() {
|
||||
while (chatmodel.count > 0) {
|
||||
String msg = chatmodel.viewNextMessage();
|
||||
Label label = new Label(msg);
|
||||
label.getStyleClass().add("info-text");
|
||||
label.setWrapText(true);
|
||||
label.maxWidthProperty().bind(chatVBox.widthProperty().subtract(CHAT_PADDING));
|
||||
chatVBox.getChildren().add(label);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public void sendMessage() {
|
||||
String message = inputField.getText().trim();
|
||||
if (!message.isEmpty()) {
|
||||
inputField.clear();
|
||||
this.controller.sendMessage(message, username);
|
||||
}
|
||||
}
|
||||
|
||||
public void endController() {
|
||||
timer.cancel();
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
|
||||
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.layout.VBox;
|
||||
|
||||
/**
|
||||
* Controller für die Casino-Spielfläche.
|
||||
*
|
||||
* <p>Verantwortlich für: - die Darstellung des Pokertisches und der Spieleroberfläche, - die
|
||||
* Verarbeitung von Benutzereingaben, - die Schnittstelle zur GameEngine und zum Netzwerkprotokoll.
|
||||
*
|
||||
* <p>Hinweise: - Die Methode `onTableClick()` dient aktuell nur als Test-Logik. Sie ist ggf. nicht
|
||||
* mehr funktionsfähig und wird zukünftig durch die finale Spielinteraktion ersetzt.
|
||||
*/
|
||||
public class CasinoGameController {
|
||||
|
||||
/** Standardkonstruktor. Wird von FXML verwendet. */
|
||||
public CasinoGameController() {
|
||||
// default constructor for FXML
|
||||
}
|
||||
|
||||
@FXML private Label welcomeText;
|
||||
@FXML private VBox casinoTable;
|
||||
|
||||
// TODO: Test-Logik: wird durch echte Spielinteraktionen ersetzt,
|
||||
// sobald die GameEngine fertig ist
|
||||
|
||||
/**
|
||||
* Temporäre Test-Methode, die bei Klick auf den Tisch eine Platzhalteraktion ausführt.
|
||||
*
|
||||
* <p>Wird in der finalen Implementierung durch die Spiel-Logik ersetzt.
|
||||
*/
|
||||
@FXML
|
||||
public void onTableClick() {
|
||||
welcomeText.setText("Einsatz akzeptiert!");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui;
|
||||
|
||||
import java.io.IOException;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
/**
|
||||
* Main class for the casino game UI.
|
||||
*
|
||||
* <p>Starts the JavaFX application, loads the graphical interface from the FXML file,
|
||||
* and initializes the main stage for the game.
|
||||
*
|
||||
* <p>Responsibilities:
|
||||
* - Loads the FXML interface "/ui-structure/Casinogameui.fxml".
|
||||
* - Loads the application icon from "/images/logoinverted.png".
|
||||
* - Starts the application in fullscreen mode.
|
||||
*/
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
|
||||
|
||||
public class CasinoGameUI extends Application {
|
||||
|
||||
// Static field for ClientService (workaround for JavaFX Application launch)
|
||||
private static ClientService staticClientService;
|
||||
|
||||
public static void setClientService(ClientService clientService) {
|
||||
staticClientService = clientService;
|
||||
}
|
||||
|
||||
public static ClientService getClientService() {
|
||||
return staticClientService;
|
||||
}
|
||||
|
||||
/** Default no-arg constructor. */
|
||||
public CasinoGameUI() {
|
||||
// default no-arg constructor
|
||||
}
|
||||
|
||||
private static final int DEFAULT_WIDTH = 1200;
|
||||
private static final int DEFAULT_HEIGHT = 800;
|
||||
|
||||
/**
|
||||
* Starts the main stage of the application.
|
||||
*
|
||||
* @param stage The main stage provided by the system.
|
||||
* @throws IOException If the FXML file or resources cannot be loaded.
|
||||
*/
|
||||
@Override
|
||||
public void start(Stage stage) throws IOException {
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(CasinoGameUI.class.getResource("/ui-structure/Casinogameui.fxml"));
|
||||
Scene scene = new Scene(fxmlLoader.load(), DEFAULT_WIDTH, DEFAULT_HEIGHT);
|
||||
stage.setTitle("Casono (GAME)");
|
||||
|
||||
String iconPath = getClass().getResource("/images/logoinverted.png").toExternalForm();
|
||||
stage.getIcons().add(new javafx.scene.image.Image(iconPath));
|
||||
stage.setScene(scene);
|
||||
stage.setFullScreen(true);
|
||||
stage.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point of the application.
|
||||
*
|
||||
* @param args Command line arguments.
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
launch();
|
||||
}
|
||||
}
|
||||
+550
@@ -0,0 +1,550 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
|
||||
|
||||
import java.net.CookieHandler;
|
||||
import java.net.CookieManager;
|
||||
import java.net.CookiePolicy;
|
||||
import java.net.URI;
|
||||
import java.util.HashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import javafx.application.Platform;
|
||||
import javafx.geometry.Insets;
|
||||
import javafx.geometry.Pos;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.ButtonType;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.layout.HBox;
|
||||
import javafx.scene.layout.Priority;
|
||||
import javafx.scene.layout.StackPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import javafx.scene.web.WebEngine;
|
||||
import javafx.scene.web.WebView;
|
||||
import javafx.stage.Stage;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Experimenteller integrierter Browser für Casono.
|
||||
*
|
||||
* <p>Diese Klasse implementiert einen einfachen eingebetteten Webbrowser auf Basis von {@link
|
||||
* javafx.scene.web.WebView}. Der Browser dient primär als Hilfswerkzeug innerhalb des Spiels, um
|
||||
* externe Inhalte wie Webseiten oder Videos anzuzeigen.
|
||||
*
|
||||
* <p>Status Der Browser befindet sich derzeit in einer experimentellen Phase. Einige
|
||||
* Sicherheitsmechanismen basieren auf experimentellen KI-gestützten Empfehlungen und können sich in
|
||||
* zukünftigen Versionen noch ändern.
|
||||
*
|
||||
* <p>Zweck Der Browser wird aktuell experimentell genutzt, um: Pokerregeln direkt im Spiel zu
|
||||
* erklären Hilfeseiten oder Dokumentationen anzuzeigen Videos (z.B. Tutorials oder Erklärungen)
|
||||
* über Plattformen wie YouTube abzuspielen
|
||||
*
|
||||
* <p>Sicherheitsmechanismen Da externe Webseiten geladen werden können, wurden einige grundlegende
|
||||
* Schutzmaßnahmen integriert: - HTTPS-Zwang für Webseiten - Whitelist für bekannte Domains -
|
||||
* Warnung bei unbekannten Webseiten - JavaScript standardmäßig deaktiviert (man kann es jedoch für
|
||||
* Google etc. einschalten) - Popup-Blocker - Automatische Cookie-Löschung beim Schließen
|
||||
*/
|
||||
public class CasinoBrowserController {
|
||||
|
||||
/** Standardkonstruktor. Initialisiert den CasinoBrowserController. */
|
||||
public CasinoBrowserController() {
|
||||
// Intentionally left blank; controller initialization is FXML-driven.
|
||||
}
|
||||
|
||||
private static final Set<String> TRUSTED_DOMAINS = new HashSet<>();
|
||||
|
||||
private static final CookieManager COOKIE_MANAGER =
|
||||
new CookieManager(null, CookiePolicy.ACCEPT_ORIGINAL_SERVER);
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class);
|
||||
|
||||
private static final int LOGO_HEIGHT = 40;
|
||||
private static final int CORNER_RADIUS = 40;
|
||||
private static final int HBOX_SPACIN = 10;
|
||||
private static final int VBOX_SPACING = 10;
|
||||
private static final int PADDING_SIZE = 10;
|
||||
private static final int WINDOW_WIDTH = 1200;
|
||||
private static final int WINDOW_HEIGHT = 800;
|
||||
private static final int ALERT_WIDTH = 600;
|
||||
private static final int ALERT_HEIGHT = 300;
|
||||
private static final String LOGO_PATH = "/images/logoinverted.png";
|
||||
private static final String LOGO_PATH_MAIN = "/images/logo.png";
|
||||
|
||||
static {
|
||||
CookieHandler.setDefault(COOKIE_MANAGER);
|
||||
|
||||
TRUSTED_DOMAINS.add("wikipedia.org");
|
||||
TRUSTED_DOMAINS.add("github.com");
|
||||
TRUSTED_DOMAINS.add("oracle.com");
|
||||
TRUSTED_DOMAINS.add("stackoverflow.com");
|
||||
TRUSTED_DOMAINS.add("docs.oracle.com");
|
||||
TRUSTED_DOMAINS.add("developer.mozilla.org");
|
||||
TRUSTED_DOMAINS.add("maven.apache.org");
|
||||
TRUSTED_DOMAINS.add("gradle.org");
|
||||
TRUSTED_DOMAINS.add("spring.io");
|
||||
TRUSTED_DOMAINS.add("jetbrains.com");
|
||||
TRUSTED_DOMAINS.add("google.com");
|
||||
TRUSTED_DOMAINS.add("bing.com");
|
||||
TRUSTED_DOMAINS.add("duckduckgo.com");
|
||||
TRUSTED_DOMAINS.add("metager.de");
|
||||
TRUSTED_DOMAINS.add("unibas.ch");
|
||||
TRUSTED_DOMAINS.add("mojeek.com");
|
||||
TRUSTED_DOMAINS.add("searx.be ");
|
||||
TRUSTED_DOMAINS.add("startpage.com");
|
||||
}
|
||||
|
||||
private static boolean javascriptEnabled = false;
|
||||
|
||||
/** Löscht alle gespeicherten Cookies der aktuellen Browser-Sitzung. */
|
||||
private static void clearCookies() {
|
||||
try {
|
||||
COOKIE_MANAGER.getCookieStore().removeAll();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet ein neues Browserfenster und lädt eine angegebene Webseite.
|
||||
*
|
||||
* <p>Falls die Webseite nicht zur Liste vertrauenswürdiger Domains gehört, wird der Benutzer
|
||||
* gefragt, ob die Seite dennoch geladen werden soll.
|
||||
*
|
||||
* @param url die Startadresse der Webseite, die geladen werden soll
|
||||
*/
|
||||
public static void open(String url) {
|
||||
Platform.runLater(
|
||||
() -> {
|
||||
Stage stage = new Stage();
|
||||
|
||||
WebView webView = new WebView();
|
||||
WebEngine engine = webView.getEngine();
|
||||
|
||||
engine.setJavaScriptEnabled(false);
|
||||
|
||||
configurePopupBlocker(engine);
|
||||
|
||||
webView.getStyleClass().add("web-view");
|
||||
|
||||
StackPane webContainer = createWebContainer(webView);
|
||||
|
||||
Rectangle clip = new Rectangle();
|
||||
clip.setArcWidth(CORNER_RADIUS);
|
||||
clip.setArcHeight(CORNER_RADIUS);
|
||||
webView.setClip(clip);
|
||||
|
||||
webView.widthProperty()
|
||||
.addListener((o, a, b) -> clip.setWidth(b.doubleValue()));
|
||||
webView.heightProperty()
|
||||
.addListener((o, a, b) -> clip.setHeight(b.doubleValue()));
|
||||
|
||||
ImageView browserLogo = loadLogos(stage);
|
||||
|
||||
TextField urlField = createUrlField(url);
|
||||
Label securityLabel = createSecurityLabel();
|
||||
|
||||
Button jsToggle = createJsToggle(engine);
|
||||
Button backBtn = createBackButton(engine);
|
||||
Button fwdBtn = createForwardButton(engine);
|
||||
Button reloadBtn = createReloadButton(engine);
|
||||
Button closeBtn = createCloseButton(stage, webView);
|
||||
configureUrlEvents(engine, urlField, securityLabel);
|
||||
|
||||
HBox taskbar =
|
||||
new HBox(
|
||||
HBOX_SPACIN,
|
||||
browserLogo,
|
||||
backBtn,
|
||||
fwdBtn,
|
||||
reloadBtn,
|
||||
urlField,
|
||||
jsToggle,
|
||||
securityLabel,
|
||||
closeBtn);
|
||||
|
||||
taskbar.getStyleClass().add("taskbar-browser");
|
||||
taskbar.setAlignment(Pos.CENTER_LEFT);
|
||||
|
||||
Scene scene = createScene(taskbar, webContainer);
|
||||
|
||||
configureKeyEvents(scene, engine);
|
||||
|
||||
stage.setScene(scene);
|
||||
stage.setTitle("Casono Browser");
|
||||
loadUrlSafely(engine, url, securityLabel);
|
||||
stage.show();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebView Container
|
||||
*
|
||||
* @param webView WebView Inhalt
|
||||
* @return StackPane Container
|
||||
*/
|
||||
public static StackPane createWebContainer(WebView webView) {
|
||||
StackPane webContainer = new StackPane(webView);
|
||||
webContainer.getStyleClass().add("browser-web-view");
|
||||
VBox.setVgrow(webContainer, Priority.ALWAYS);
|
||||
|
||||
return webContainer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Popup Blocker
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
*/
|
||||
public static void configurePopupBlocker(WebEngine engine) {
|
||||
engine.setCreatePopupHandler(
|
||||
config -> {
|
||||
Alert alert = new Alert(Alert.AlertType.WARNING);
|
||||
alert.setTitle("Popup blockiert");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText("Popup wurde aus Sicherheitsgründen blockiert.");
|
||||
|
||||
try {
|
||||
var stream = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH);
|
||||
Image logo = new Image(stream);
|
||||
|
||||
// Variable 'streamM' abgekürzt, um das 100-Zeichen-Limit (LineLength)
|
||||
// einzuhalten
|
||||
var streamM =
|
||||
CasinoBrowserController.class.getResourceAsStream(LOGO_PATH_MAIN);
|
||||
Image logomain = new Image(streamM);
|
||||
|
||||
if (logo != null) {
|
||||
Stage alertStage = (Stage) alert.getDialogPane().getScene().getWindow();
|
||||
if (alertStage != null) {
|
||||
alertStage.getIcons().add(logo);
|
||||
}
|
||||
}
|
||||
|
||||
if (logomain != null) {
|
||||
ImageView logoView = new ImageView(logomain);
|
||||
logoView.setFitHeight(LOGO_HEIGHT);
|
||||
logoView.setPreserveRatio(true);
|
||||
alert.setGraphic(logoView);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Logo konnte nicht geladen werden");
|
||||
}
|
||||
|
||||
alert.showAndWait();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Logos laden
|
||||
*
|
||||
* @param stage Fenster Stage
|
||||
* @return ImageView Logo
|
||||
*/
|
||||
private static ImageView loadLogos(Stage stage) {
|
||||
ImageView browserLogo = new ImageView();
|
||||
|
||||
try {
|
||||
var stream = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH);
|
||||
Image logo = new Image(stream);
|
||||
|
||||
// Variable 'streamM' abgekürzt, um das 100-Zeichen-Limit (LineLength)
|
||||
// einzuhalten
|
||||
var streamM = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH_MAIN);
|
||||
Image logomain = new Image(streamM);
|
||||
|
||||
if (logo != null) {
|
||||
stage.getIcons().add(logo);
|
||||
}
|
||||
|
||||
if (logomain != null) {
|
||||
browserLogo.setImage(logomain);
|
||||
browserLogo.setFitHeight(LOGO_HEIGHT);
|
||||
browserLogo.setPreserveRatio(true);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Logo konnte nicht geladen werden");
|
||||
}
|
||||
|
||||
return browserLogo;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL Feld
|
||||
*
|
||||
* @param url Start URL
|
||||
* @return TextField Eingabe
|
||||
*/
|
||||
public static TextField createUrlField(String url) {
|
||||
TextField urlField = new TextField(url);
|
||||
urlField.getStyleClass().add("gray-input-field");
|
||||
HBox.setHgrow(urlField, Priority.ALWAYS);
|
||||
|
||||
return urlField;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sicherheits Label
|
||||
*
|
||||
* @return Label Anzeige
|
||||
*/
|
||||
public static Label createSecurityLabel() {
|
||||
Label securityLabel = new Label("SICHER");
|
||||
securityLabel.getStyleClass().add("security-label");
|
||||
|
||||
return securityLabel;
|
||||
}
|
||||
|
||||
/**
|
||||
* JS Toggle
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
* @return Button Umschalten
|
||||
*/
|
||||
public static Button createJsToggle(WebEngine engine) {
|
||||
Button jsToggle = new Button("JS EINSCHALTEN");
|
||||
jsToggle.getStyleClass().add("red-button");
|
||||
|
||||
jsToggle.setOnAction(
|
||||
e -> {
|
||||
javascriptEnabled = !javascriptEnabled;
|
||||
engine.setJavaScriptEnabled(javascriptEnabled);
|
||||
|
||||
if (javascriptEnabled) {
|
||||
jsToggle.setText("JS AUSSCHALTEN");
|
||||
jsToggle.getStyleClass().removeAll("red-button");
|
||||
jsToggle.getStyleClass().add("yellow-button");
|
||||
|
||||
} else {
|
||||
jsToggle.setText("JS EINSCHALTEN");
|
||||
jsToggle.getStyleClass().removeAll("yellow-button");
|
||||
jsToggle.getStyleClass().add("red-button");
|
||||
}
|
||||
});
|
||||
|
||||
return jsToggle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zurück Button
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
* @return Button Zurück
|
||||
*/
|
||||
public static Button createBackButton(WebEngine engine) {
|
||||
Button backBtn = new Button("<");
|
||||
backBtn.getStyleClass().add("gray-button");
|
||||
backBtn.setOnAction(
|
||||
e -> {
|
||||
if (engine.getHistory().getCurrentIndex() > 0) {
|
||||
engine.getHistory().go(-1);
|
||||
}
|
||||
});
|
||||
|
||||
return backBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorwärts Button
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
* @return Button Vorwärts
|
||||
*/
|
||||
public static Button createForwardButton(WebEngine engine) {
|
||||
Button fwdBtn = new Button(">");
|
||||
fwdBtn.getStyleClass().add("gray-button");
|
||||
fwdBtn.setOnAction(
|
||||
e -> {
|
||||
int currentIndex = engine.getHistory().getCurrentIndex();
|
||||
int lastIndex = engine.getHistory().getEntries().size() - 1;
|
||||
if (currentIndex < lastIndex) {
|
||||
engine.getHistory().go(1);
|
||||
}
|
||||
});
|
||||
|
||||
return fwdBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload Button
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
* @return Button Reload
|
||||
*/
|
||||
public static Button createReloadButton(WebEngine engine) {
|
||||
Button reloadBtn = new Button("⟳");
|
||||
reloadBtn.getStyleClass().add("gray-button");
|
||||
reloadBtn.setOnAction(e -> engine.reload());
|
||||
return reloadBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close Button
|
||||
*
|
||||
* @param stage Fenster Stage
|
||||
* @param webView WebView Inhalt
|
||||
* @return Button Schließen
|
||||
*/
|
||||
public static Button createCloseButton(Stage stage, WebView webView) {
|
||||
Button closeBtn = new Button("X");
|
||||
closeBtn.getStyleClass().add("red-button");
|
||||
closeBtn.setOnAction(
|
||||
e -> {
|
||||
webView.getEngine().load("about:blank");
|
||||
clearCookies();
|
||||
stage.close();
|
||||
});
|
||||
|
||||
return closeBtn;
|
||||
}
|
||||
|
||||
/**
|
||||
* URL Events
|
||||
*
|
||||
* @param engine WebEngine nutzen
|
||||
* @param urlField URL Textfeld
|
||||
* @param securityLabel Sicherheits Label
|
||||
*/
|
||||
public static void configureUrlEvents(
|
||||
WebEngine engine, TextField urlField, Label securityLabel) {
|
||||
urlField.setOnAction(e -> loadUrlSafely(engine, urlField.getText(), securityLabel));
|
||||
engine.locationProperty().addListener((obs, o, n) -> urlField.setText(n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene erstellen
|
||||
*
|
||||
* @param taskbar Taskbar HBox
|
||||
* @param webContainer Web Container
|
||||
* @return Scene Fenster
|
||||
*/
|
||||
public static Scene createScene(HBox taskbar, StackPane webContainer) {
|
||||
VBox root = new VBox(VBOX_SPACING, taskbar, webContainer);
|
||||
root.getStyleClass().add("browser-root");
|
||||
root.setPadding(new Insets(PADDING_SIZE));
|
||||
|
||||
Scene scene = new Scene(root, WINDOW_WIDTH, WINDOW_HEIGHT);
|
||||
|
||||
var css = CasinoBrowserController.class.getResource("/ui-structure/Casinogameui.css");
|
||||
|
||||
if (css != null) {
|
||||
scene.getStylesheets().add(css.toExternalForm());
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Key Events
|
||||
*
|
||||
* @param scene Scene Fenster
|
||||
* @param engine WebEngine nutzen
|
||||
*/
|
||||
public static void configureKeyEvents(Scene scene, WebEngine engine) {
|
||||
scene.setOnKeyPressed(
|
||||
event -> {
|
||||
if (event.getCode() == KeyCode.F5) {
|
||||
engine.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt eine URL in den Browser, nachdem grundlegende Sicherheitsprüfungen durchgeführt wurden.
|
||||
*
|
||||
* <p>Vor dem Laden einer Seite werden folgende Prüfungen durchgeführt: - Überprüfung des
|
||||
* Protokolls (nur HTTPS erlaubt) - Überprüfung der Domain gegen eine Whitelist - Schutz vor
|
||||
* Domain-Spoofing (Domain-Vortäuschung)
|
||||
*
|
||||
* <p>Falls eine Domain nicht als vertrauenswürdig eingestuft wird, muss der Benutzer
|
||||
* bestätigen, dass die Seite dennoch geöffnet werden darf.
|
||||
*
|
||||
* @param engine der WebEngine-Renderer des Browsers
|
||||
* @param url die zu ladende Webadresse
|
||||
* @param securityLabel Label zur Anzeige des aktuellen Sicherheitsstatus
|
||||
*/
|
||||
private static void loadUrlSafely(WebEngine engine, String url, Label securityLabel) {
|
||||
try {
|
||||
if (!url.startsWith("http")) {
|
||||
url = "https://" + url;
|
||||
}
|
||||
|
||||
URI uri = new URI(url);
|
||||
|
||||
// HTTPS Pflicht
|
||||
if (!"https".equalsIgnoreCase(uri.getScheme())) {
|
||||
securityLabel.setText("BLOCKIERT");
|
||||
return;
|
||||
}
|
||||
|
||||
String host = uri.getHost();
|
||||
if (host == null) {
|
||||
securityLabel.setText("ERROR");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sicherer Domain Check
|
||||
boolean trusted =
|
||||
TRUSTED_DOMAINS.stream()
|
||||
.anyMatch(domain -> host.equals(domain) || host.endsWith("." + domain));
|
||||
|
||||
if (!trusted) {
|
||||
if (!showUnknownWebsiteAlert(host)) {
|
||||
securityLabel.setText("BLOCKIERT");
|
||||
return;
|
||||
}
|
||||
securityLabel.setText("UNBEKANNT");
|
||||
} else {
|
||||
securityLabel.setText("SICHER");
|
||||
}
|
||||
engine.load(uri.toString());
|
||||
} catch (Exception e) {
|
||||
securityLabel.setText("ERROR");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeigt Warnung an.
|
||||
*
|
||||
* @param host Website Host
|
||||
* @return OK gedrückt
|
||||
*/
|
||||
private static boolean showUnknownWebsiteAlert(String host) {
|
||||
Alert alert = new Alert(Alert.AlertType.CONFIRMATION);
|
||||
|
||||
alert.setTitle("Unbekannte Website");
|
||||
alert.setHeaderText("Diese Website ist nicht bekannt");
|
||||
String content =
|
||||
host
|
||||
+ "\n\nDiese Seite ist nicht vom Casono Browser verifiziert.\n"
|
||||
+ "Möchten Sie sie trotzdem öffnen?";
|
||||
alert.setContentText(content);
|
||||
|
||||
var stream = CasinoBrowserController.class.getResourceAsStream(LOGO_PATH);
|
||||
Image logo = new Image(stream);
|
||||
if (logo != null) {
|
||||
Stage stage = (Stage) alert.getDialogPane().getScene().getWindow();
|
||||
if (stage != null) {
|
||||
stage.getIcons().add(logo);
|
||||
}
|
||||
ImageView logomain = new ImageView(logo);
|
||||
logomain.setFitHeight(LOGO_HEIGHT);
|
||||
logomain.setPreserveRatio(true);
|
||||
alert.setGraphic(logomain);
|
||||
}
|
||||
|
||||
alert.getDialogPane().setPrefSize(ALERT_WIDTH, ALERT_HEIGHT);
|
||||
Optional<ButtonType> result = alert.showAndWait();
|
||||
|
||||
if (result.isEmpty() || result.get() != ButtonType.OK) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.gameuicomponents;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui.Casinomainui;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.input.KeyCode;
|
||||
import javafx.scene.input.KeyEvent;
|
||||
import javafx.scene.input.MouseEvent;
|
||||
import javafx.scene.layout.HBox;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Controller für die interaktive Taskleiste innerhalb der Poker-UI.
|
||||
*
|
||||
* <p>Verantwortlich für: - Drag-and-Drop-Verschieben der Taskleiste, - Eingabe und Verwaltung von
|
||||
* Spieleinsätzen, - Steuerung allgemeiner Menüfunktionen wie Exit.
|
||||
*/
|
||||
public class TaskbarController {
|
||||
|
||||
/** Standardkonstruktor. Wird von FXML verwendet. */
|
||||
public TaskbarController() {
|
||||
// default constructor for FXML
|
||||
}
|
||||
|
||||
private static final Logger LOGGER = LogManager.getLogger(CasinoBrowserController.class);
|
||||
|
||||
@FXML private HBox taskbar;
|
||||
@FXML private TextField taskbarInput;
|
||||
|
||||
private double xOffset = 0;
|
||||
private double yOffset = 0;
|
||||
private static final double TASKBAR_SCALE = 0.9;
|
||||
private static final int MIN_CREDITS = 5;
|
||||
private static final int MAX_CREDITS = 100000;
|
||||
private static final int CREDIT_STEP = 5;
|
||||
|
||||
/**
|
||||
* Wird aufgerufen, wenn die Taskleiste mit der Maus gedrückt wird. Speichert die relative
|
||||
* Position, um später korrekt zu verschieben.
|
||||
*
|
||||
* @param event Das Mausereignis
|
||||
*/
|
||||
@FXML
|
||||
private void onTaskbarPressed(MouseEvent event) {
|
||||
xOffset = event.getSceneX() - taskbar.getLayoutX();
|
||||
yOffset = event.getSceneY() - taskbar.getLayoutY();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird aufgerufen, während die Taskleiste mit der Maus gezogen wird. Aktualisiert die Position
|
||||
* und skaliert die Taskleiste leicht zur visuellen Rückmeldung.
|
||||
*
|
||||
* <p>TODO: Es muss noch gefixt werden, dass die Taskleiste nicht aus dem Fenster verschwinden
|
||||
* kann.
|
||||
*
|
||||
* @param event Das Mausereignis
|
||||
*/
|
||||
@FXML
|
||||
private void onTaskbarDragged(MouseEvent event) {
|
||||
taskbar.setLayoutX(event.getSceneX() - xOffset);
|
||||
taskbar.setLayoutY(event.getSceneY() - yOffset);
|
||||
|
||||
taskbar.setScaleX(TASKBAR_SCALE);
|
||||
taskbar.setScaleY(TASKBAR_SCALE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird aufgerufen, wenn die Maus über der Taskleiste losgelassen wird. Setzt die Skalierung der
|
||||
* Taskleiste wieder auf Normalgröße.
|
||||
*
|
||||
* @param event Das Mausereignis
|
||||
*/
|
||||
@FXML
|
||||
private void onTaskbarReleased(MouseEvent event) {
|
||||
taskbar.setScaleX(1.0);
|
||||
taskbar.setScaleY(1.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird aufgerufen, wenn im Textfeld die Enter-Taste gedrückt wird.
|
||||
*
|
||||
* @param event Das Tastaturereignis
|
||||
*/
|
||||
@FXML
|
||||
private void onInputSubmitted(KeyEvent event) {
|
||||
if (event.getCode() == KeyCode.ENTER) {
|
||||
processBet();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wird aufgerufen, wenn der Submit-Button in der Taskleiste gedrückt wird. Löst die
|
||||
* Verarbeitung des Einsatzes aus.
|
||||
*/
|
||||
@FXML
|
||||
private void onInputSubmittedAction() {
|
||||
processBet();
|
||||
}
|
||||
|
||||
@FXML
|
||||
private void onExitButtonClick() {
|
||||
javafx.application.Platform.runLater(
|
||||
() -> {
|
||||
// Close game stage
|
||||
javafx.stage.Stage currentStage =
|
||||
(javafx.stage.Stage) taskbar.getScene().getWindow();
|
||||
currentStage.close();
|
||||
// Lobby-UI starten
|
||||
try {
|
||||
new Casinomainui().start(new javafx.stage.Stage());
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Fehler beim Starten der Lobby-UI: {}", e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet den im Textfeld eingegebenen Einsatz. Es werden ausschließlich ganzzahlige Werte
|
||||
* im Bereich von 5 bis 100.000 Credits akzeptiert, die einem Vielfachen von 5 entsprechen
|
||||
* (5er-Schritte). Der Einsatz wird aktuell nur auf der Konsole ausgegeben.
|
||||
*/
|
||||
private void processBet() {
|
||||
String input = taskbarInput.getText();
|
||||
try {
|
||||
int credits = Integer.parseInt(input.trim());
|
||||
|
||||
if (credits >= MIN_CREDITS && credits <= MAX_CREDITS && credits % CREDIT_STEP == 0) {
|
||||
// TODO: Credits müssen an die GameEngine gesendet werden
|
||||
LOGGER.info("Einsatz gesetzt: {} Casono Credits", credits);
|
||||
taskbarInput.clear();
|
||||
} else {
|
||||
LOGGER.info("Fehler: Nur 5er-Schritte (5, 10, ... 100.000) erlaubt!");
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
LOGGER.info("Fehler: Bitte nur eine Zahl eingeben!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den integrierten Casono Webbrowser.
|
||||
*
|
||||
* <p>TODO: Ersetze die Start-URL durch die offizielle Projekt-Website (z.B. Tipps & Tricks
|
||||
* Seite), sobald die Inhalte für Strategien und Support bereitstehen.
|
||||
*/
|
||||
@FXML
|
||||
private void onBrowserButtonClick() {
|
||||
CasinoBrowserController.open("wikipedia.org");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
|
||||
|
||||
/** Main UI application for Casono. Loads the main FXML layout and sets up the stage. */
|
||||
import java.io.IOException;
|
||||
import javafx.application.Application;
|
||||
import javafx.fxml.FXMLLoader;
|
||||
import javafx.scene.Scene;
|
||||
import javafx.stage.Stage;
|
||||
|
||||
/**
|
||||
* JavaFX Application class for the Casono main UI.
|
||||
*
|
||||
* <p>Default constructor for the application.
|
||||
*/
|
||||
public class Casinomainui extends Application {
|
||||
|
||||
/** Default constructor. */
|
||||
public Casinomainui() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
private static final int SCENE_WIDTH = 1200;
|
||||
private static final int SCENE_HEIGHT = 800;
|
||||
|
||||
@Override
|
||||
/**
|
||||
* Starts the JavaFX application and loads the main UI.
|
||||
*
|
||||
* @param stage The primary stage for this application.
|
||||
* @throws IOException If loading the FXML fails.
|
||||
*/
|
||||
public void start(Stage stage) throws IOException {
|
||||
// If the launcher passed an address argument (ip:port), expose it as
|
||||
// system properties so controllers can read it without embedding defaults.
|
||||
var raw = getParameters().getRaw();
|
||||
if (raw != null && raw.size() > 0) {
|
||||
String arg = raw.get(0);
|
||||
String[] parts = arg.split(":", 2);
|
||||
if (parts.length == 2) {
|
||||
System.setProperty("casono.server.host", parts[0]);
|
||||
System.setProperty("casono.server.port", parts[1]);
|
||||
}
|
||||
}
|
||||
FXMLLoader fxmlLoader = new FXMLLoader(getClass().getResource("/ui-structure/Casinomainui.fxml"));
|
||||
Scene scene = new Scene(fxmlLoader.load(), SCENE_WIDTH, SCENE_HEIGHT);
|
||||
stage.setTitle("Casono");
|
||||
javafx.scene.image.Image icon = new javafx.scene.image.Image(
|
||||
getClass().getResource("/images/logoinverted.png").toExternalForm());
|
||||
stage.getIcons().add(icon);
|
||||
stage.setScene(scene);
|
||||
stage.setFullScreen(true);
|
||||
stage.show();
|
||||
}
|
||||
|
||||
/**
|
||||
* Main entry point for launching the application.
|
||||
*
|
||||
* @param args Command line arguments
|
||||
*/
|
||||
public static void main(String[] args) {
|
||||
launch();
|
||||
}
|
||||
}
|
||||
+156
@@ -0,0 +1,156 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
|
||||
|
||||
import javafx.application.Platform;
|
||||
import javafx.fxml.FXML;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.control.TextField;
|
||||
import javafx.scene.control.Alert;
|
||||
import javafx.scene.control.Alert.AlertType;
|
||||
import javafx.scene.control.Label;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.AnchorPane;
|
||||
import javafx.scene.layout.VBox;
|
||||
import javafx.scene.shape.Rectangle;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
|
||||
|
||||
/**
|
||||
* Controller for the Casono main UI lobby. Handles UI initialization and user
|
||||
* actions.
|
||||
*/
|
||||
public class CasinomainuiController {
|
||||
private static final Logger LOGGER = LogManager.getLogger(CasinomainuiController.class);
|
||||
|
||||
@FXML
|
||||
private AnchorPane rootPane;
|
||||
@FXML
|
||||
private Label titleLabel;
|
||||
@FXML
|
||||
private Label subtitleLabel;
|
||||
@FXML
|
||||
private ImageView logoView;
|
||||
@FXML
|
||||
private Rectangle greenBox;
|
||||
@FXML
|
||||
private Button exitbutton;
|
||||
@FXML
|
||||
private VBox casinoTable;
|
||||
@FXML
|
||||
private TextField usernameField;
|
||||
@FXML
|
||||
private Button loginButton;
|
||||
|
||||
private LobbyButtonTranslationManager translationManager;
|
||||
private LobbyButtonGridManager gridManager;
|
||||
private int nextButtonId = 1;
|
||||
private LobbyClient lobbyClient;
|
||||
|
||||
/** Default constructor for dependency injection by FXMLLoader. */
|
||||
public CasinomainuiController() {
|
||||
// Default constructor
|
||||
}
|
||||
|
||||
/** Initializes the UI components and sets default values. */
|
||||
@FXML
|
||||
public void initialize() {
|
||||
titleLabel.setText("Casono");
|
||||
subtitleLabel.setText("Texas Hold'em Poker");
|
||||
logoView.setImage(new Image(getClass().getResource("/images/logo.png").toExternalForm()));
|
||||
|
||||
translationManager = LobbyButtonTranslationManager.getInstance();
|
||||
String host = System.getProperty("casono.server.host");
|
||||
int port = Integer.parseInt(System.getProperty("casono.server.port"));
|
||||
ClientService clientService;
|
||||
try {
|
||||
clientService = new ClientService(host, port);
|
||||
} catch (RuntimeException e) {
|
||||
LOGGER.warn("Could not connect to server {}:{} — starting in offline mode: {}", host, port, e.getMessage());
|
||||
clientService = new ClientService(true); // offline mode
|
||||
}
|
||||
gridManager = new LobbyButtonGridManager(new javafx.scene.layout.GridPane(), translationManager, clientService);
|
||||
// LobbyClient will use the provided ClientService; in offline mode calls will
|
||||
// fail with RuntimeException
|
||||
lobbyClient = new LobbyClient(clientService);
|
||||
casinoTable.getChildren().clear();
|
||||
casinoTable.getChildren().add(gridManager.getGridPane());
|
||||
gridManager.renderLobbyButtons();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles the login button action. Validates input and calls
|
||||
* LobbyClient.login().
|
||||
*/
|
||||
@FXML
|
||||
public void handleLoginButton() {
|
||||
String username = usernameField.getText();
|
||||
if (username == null || username.isBlank()) {
|
||||
showAlert("Please enter a username.");
|
||||
return;
|
||||
}
|
||||
// Only allow alphanumeric, _ and -
|
||||
if (!username.matches("[a-zA-Z0-9_-]+")) {
|
||||
showAlert("Only letters, numbers, '_' and '-' are allowed!");
|
||||
return;
|
||||
}
|
||||
if (lobbyClient.getClientService().isOffline()) {
|
||||
showAlert("Offline mode: cannot send login to server.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
lobbyClient.login(username);
|
||||
showAlert("Login sent: " + username);
|
||||
} catch (RuntimeException e) {
|
||||
LOGGER.error("Login failed: {}", e.getMessage());
|
||||
showAlert("Login failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an alert dialog with the given message.
|
||||
*/
|
||||
private void showAlert(String message) {
|
||||
Alert alert = new Alert(AlertType.INFORMATION);
|
||||
alert.setTitle("Info");
|
||||
alert.setHeaderText(null);
|
||||
alert.setContentText(message);
|
||||
alert.showAndWait();
|
||||
casinoTable.getChildren().clear();
|
||||
casinoTable.getChildren().add(gridManager.getGridPane());
|
||||
gridManager.renderLobbyButtons();
|
||||
}
|
||||
|
||||
/** Handles the exit button action to close the application. */
|
||||
@FXML
|
||||
public void handleexitbutton() {
|
||||
Platform.exit();
|
||||
}
|
||||
|
||||
/** Handles creation of a new lobby button. */
|
||||
@FXML
|
||||
public void handleCreateLobbyButton() {
|
||||
if (translationManager.isFull()) {
|
||||
LOGGER.warn("Grid is full! No more lobbies available.");
|
||||
return;
|
||||
}
|
||||
int buttonId = nextButtonId++;
|
||||
try {
|
||||
String username = usernameField != null ? usernameField.getText() : "<unknown>";
|
||||
LOGGER.info("Creating lobby for user: {}", username);
|
||||
// avoid attempting to create a lobby when offline
|
||||
if (lobbyClient.getClientService().isOffline()) {
|
||||
LOGGER.warn("Cannot create lobby while offline");
|
||||
showAlert("Offline mode: cannot create lobby.");
|
||||
return;
|
||||
}
|
||||
int lobbyId = gridManager.createLobby();
|
||||
translationManager.addLobbyButton(buttonId, lobbyId);
|
||||
LOGGER.info("ButtonID: {}, LobbyID: {}", buttonId, lobbyId);
|
||||
gridManager.renderLobbyButtons();
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to create or add lobby: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+410
@@ -0,0 +1,410 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
|
||||
|
||||
/**
|
||||
* Manages the grid for lobby buttons and rendering. Uses LobbyButtonTranslationManager for mapping
|
||||
* ButtonID to LobbyID.
|
||||
*/
|
||||
import java.util.Map;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.LobbyClient;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.client.network.ClientService;
|
||||
import javafx.scene.Node;
|
||||
import javafx.scene.control.Button;
|
||||
import javafx.scene.image.Image;
|
||||
import javafx.scene.image.ImageView;
|
||||
import javafx.scene.layout.GridPane;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Manages the grid for lobby buttons and rendering. Uses
|
||||
* LobbyButtonTranslationManager for mapping
|
||||
* ButtonID to LobbyID.
|
||||
*/
|
||||
public class LobbyButtonGridManager {
|
||||
private static final double BUTTON_WIDTH_MARGIN = 20.0;
|
||||
private static final double BUTTON_MIN_SIZE = 10.0;
|
||||
private static final Logger LOGGER = LogManager.getLogger(LobbyButtonGridManager.class);
|
||||
|
||||
/** GridPane for the button grid. */
|
||||
private final GridPane gridPane;
|
||||
|
||||
/** Manager for mapping ButtonID to LobbyID. */
|
||||
private final LobbyButtonTranslationManager translationManager;
|
||||
|
||||
/** Number of columns in the grid. */
|
||||
private static final int COLS = 4;
|
||||
|
||||
/** Image for a lobby in CREATED state. */
|
||||
/** Default fallback image. */
|
||||
private static final String BUTTON_FALLBACK_IMAGE = "/images/lobbypictures/error.png";
|
||||
|
||||
/**
|
||||
* Template for per-button images. Use: button index and status
|
||||
* (created|running). Example:
|
||||
* /images/lobby_1_created.png
|
||||
*/
|
||||
private static final String BUTTON_IMAGE_TEMPLATE = "/images/lobbypictures/lobby_%d_%s.png";
|
||||
|
||||
/** Cache for loaded Images keyed by resource path. */
|
||||
private final ConcurrentHashMap<String, Image> imageCache = new ConcurrentHashMap<>();
|
||||
|
||||
private final LobbyClient lobbyClient;
|
||||
|
||||
/** Executor for background status/network tasks. */
|
||||
private final ExecutorService executor = Executors.newCachedThreadPool();
|
||||
/** Scheduler for periodic refresh of lobby mappings. */
|
||||
private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
|
||||
|
||||
/**
|
||||
* Constructor for the GridManager.
|
||||
*
|
||||
* @param gridPane the GridPane for rendering
|
||||
* @param translationManager the manager for mapping ButtonID to LobbyID
|
||||
*/
|
||||
public LobbyButtonGridManager(
|
||||
GridPane gridPane, LobbyButtonTranslationManager translationManager, LobbyClient lobbyClient) {
|
||||
this.gridPane = gridPane;
|
||||
// Always use the singleton
|
||||
this.translationManager = LobbyButtonTranslationManager.getInstance();
|
||||
this.lobbyClient = lobbyClient;
|
||||
// Start periodic refresh to keep mapping in sync with server
|
||||
startPeriodicRefresh(5, 5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience constructor: accept a {@link ClientService} and build a
|
||||
* {@link LobbyClient} from it. This avoids any host/port System.getProperty
|
||||
* lookups elsewhere — caller controls the ClientService.
|
||||
*/
|
||||
public LobbyButtonGridManager(
|
||||
GridPane gridPane, LobbyButtonTranslationManager translationManager, ClientService clientService) {
|
||||
this(gridPane, translationManager, new LobbyClient(clientService));
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic refresh of lobby mappings.
|
||||
*
|
||||
* @param initialDelay initial delay in seconds
|
||||
* @param period period in seconds
|
||||
*/
|
||||
private void startPeriodicRefresh(long initialDelay, long period) {
|
||||
scheduler.scheduleAtFixedRate(this::refreshMappings, initialDelay, period, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh mappings by checking each stored lobby id on the server. If a lobby
|
||||
* no longer exists (or an error occurs), remove it from the translation map
|
||||
* and update the UI.
|
||||
*/
|
||||
private void refreshMappings() {
|
||||
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
|
||||
if (mapping.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Make a copy of entries to avoid concurrent modification
|
||||
List<Map.Entry<Integer, Integer>> entries = new ArrayList<>(mapping.entrySet());
|
||||
for (Map.Entry<Integer, Integer> e : entries) {
|
||||
int buttonId = e.getKey();
|
||||
int lobbyId = e.getValue();
|
||||
CompletableFuture.supplyAsync(() -> {
|
||||
try {
|
||||
String status = lobbyClient.fetchLobbyStatusString(lobbyId);
|
||||
return status;
|
||||
} catch (Exception ex) {
|
||||
LOGGER.info("Lobby {} appears missing or error: {}", lobbyId, ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
}, executor).thenAccept(status -> {
|
||||
if (status == null) {
|
||||
// remove mapping and update UI
|
||||
translationManager.removeLobbyButton(buttonId);
|
||||
javafx.application.Platform.runLater(() -> {
|
||||
updateLobbyButtonImages();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default client creation removed to avoid implicit IP/port configuration.
|
||||
// Applications must construct and provide a LobbyClient or ClientService
|
||||
// explicitly.
|
||||
|
||||
/**
|
||||
* Renders all lobby buttons in the grid. Creates a button for each mapping with
|
||||
* image and event
|
||||
* handler.
|
||||
*/
|
||||
public void renderLobbyButtons() {
|
||||
gridPane.getChildren().clear();
|
||||
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
|
||||
if (mapping.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Integer> buttonIds = new ArrayList<>(mapping.keySet());
|
||||
Collections.sort(buttonIds);
|
||||
int index = 0;
|
||||
for (Integer buttonId : buttonIds) {
|
||||
int lobbyId = mapping.get(buttonId);
|
||||
Button btn = new Button();
|
||||
btn.setId("lobbyBtn-" + buttonId);
|
||||
// placeholder image so UI remains responsive
|
||||
Image placeholder = safeLoadImage(BUTTON_FALLBACK_IMAGE);
|
||||
ImageView imageView = new ImageView(placeholder);
|
||||
imageView.setPreserveRatio(true);
|
||||
imageView.fitWidthProperty().bind(gridPane.widthProperty().divide(COLS).subtract(BUTTON_WIDTH_MARGIN));
|
||||
imageView.setSmooth(true);
|
||||
btn.setGraphic(imageView);
|
||||
btn.setMaxWidth(Double.MAX_VALUE);
|
||||
btn.setMaxHeight(Double.MAX_VALUE);
|
||||
btn.setMinWidth(BUTTON_MIN_SIZE);
|
||||
btn.setMinHeight(BUTTON_MIN_SIZE);
|
||||
GridPane.setHgrow(btn, javafx.scene.layout.Priority.ALWAYS);
|
||||
GridPane.setVgrow(btn, javafx.scene.layout.Priority.ALWAYS);
|
||||
final int bId = buttonId;
|
||||
btn.setOnAction(e -> {
|
||||
Integer targetLobbyId = translationManager.getLobbyIdForButton(bId);
|
||||
if (targetLobbyId != null) {
|
||||
joinLobby(targetLobbyId);
|
||||
}
|
||||
});
|
||||
// async fetch status and update image
|
||||
CompletableFuture.supplyAsync(() -> lobbyClient.fetchLobbyStatusString(lobbyId), executor)
|
||||
.thenAccept(statusStr -> {
|
||||
LobbyStatus status = parseLobbyStatus(statusStr);
|
||||
String path = getImagePathForButton(buttonId, status == null ? LobbyStatus.CREATED : status);
|
||||
Image img = safeLoadImage(path);
|
||||
javafx.application.Platform.runLater(() -> {
|
||||
ImageView iv = new ImageView(img);
|
||||
iv.setPreserveRatio(true);
|
||||
iv.fitWidthProperty()
|
||||
.bind(gridPane.widthProperty().divide(COLS).subtract(BUTTON_WIDTH_MARGIN));
|
||||
iv.setSmooth(true);
|
||||
btn.setGraphic(iv);
|
||||
});
|
||||
});
|
||||
int row = index / COLS;
|
||||
int col = index % COLS;
|
||||
gridPane.add(btn, col, row);
|
||||
index++;
|
||||
}
|
||||
}
|
||||
|
||||
/** Possible lobby statuses. */
|
||||
private enum LobbyStatus {
|
||||
CREATED,
|
||||
RUNNING
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current status of a lobby.
|
||||
*
|
||||
* @param lobbyId the lobby id to query
|
||||
* @return the lobby status (mapped from server string; CREATED or RUNNING)
|
||||
*/
|
||||
public LobbyStatus getLobbyStatus(int lobbyId) {
|
||||
String serverStatus = lobbyClient.fetchLobbyStatusString(lobbyId);
|
||||
LobbyStatus parsed = parseLobbyStatus(serverStatus);
|
||||
if (parsed == null) {
|
||||
// Defensive fallback
|
||||
LOGGER.error("Unrecognized lobby status '{}' for lobby {}. Defaulting to CREATED.", serverStatus, lobbyId);
|
||||
return LobbyStatus.CREATED;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a status string returned by the server into the local enum.
|
||||
* Accepts case-insensitive values like "created" / "CREATED" / "running".
|
||||
* Returns null if the string is not recognized.
|
||||
*/
|
||||
private LobbyStatus parseLobbyStatus(String statusStr) {
|
||||
if (statusStr == null)
|
||||
return null;
|
||||
try {
|
||||
return LobbyStatus.valueOf(statusStr.trim().toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getImagePathForButton(int buttonId, LobbyStatus status) {
|
||||
String statusStr = status == LobbyStatus.CREATED ? "created" : "running";
|
||||
return String.format(BUTTON_IMAGE_TEMPLATE, buttonId, statusStr);
|
||||
}
|
||||
|
||||
private Image safeLoadImage(String path) {
|
||||
// Return cached image if present
|
||||
Image cached = imageCache.get(path);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
// Attempt to load the requested resource
|
||||
java.io.InputStream is = getClass().getResourceAsStream(path);
|
||||
if (is == null) {
|
||||
LOGGER.debug(
|
||||
"Image resource not found: {}. Falling back to {}",
|
||||
path,
|
||||
BUTTON_FALLBACK_IMAGE);
|
||||
is = getClass().getResourceAsStream(BUTTON_FALLBACK_IMAGE);
|
||||
}
|
||||
Image loaded = null;
|
||||
try {
|
||||
if (is != null) {
|
||||
loaded = new Image(is);
|
||||
} else {
|
||||
LOGGER.error(
|
||||
"Both requested image '{}' and fallback '{}' are missing. No image will be set.",
|
||||
path,
|
||||
BUTTON_FALLBACK_IMAGE);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to load image '{}'", path, e);
|
||||
}
|
||||
if (loaded == null) {
|
||||
// leave
|
||||
// null
|
||||
}
|
||||
if (loaded != null) {
|
||||
imageCache.put(path, loaded);
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
/** Update all lobby buttons' images according to the current lobby statuses. */
|
||||
public void updateLobbyButtonImages() {
|
||||
Map<Integer, Integer> mapping = translationManager.getButtonIdToLobbyId();
|
||||
if (mapping.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<Integer> buttonIds = new ArrayList<>(mapping.keySet());
|
||||
Collections.sort(buttonIds);
|
||||
for (Integer buttonId : buttonIds) {
|
||||
int lobbyId = mapping.get(buttonId);
|
||||
CompletableFuture.supplyAsync(() -> getLobbyStatus(lobbyId), executor)
|
||||
.thenAccept(status -> {
|
||||
String path = getImagePathForButton(buttonId, status);
|
||||
javafx.application.Platform.runLater(() -> {
|
||||
for (Node node : gridPane.getChildren()) {
|
||||
if (node instanceof Button && ("lobbyBtn-" + buttonId).equals(node.getId())) {
|
||||
Button btn = (Button) node;
|
||||
ImageView iv = new ImageView(safeLoadImage(path));
|
||||
iv.setPreserveRatio(true);
|
||||
iv.fitWidthProperty()
|
||||
.bind(
|
||||
gridPane.widthProperty()
|
||||
.divide(COLS)
|
||||
.subtract(BUTTON_WIDTH_MARGIN));
|
||||
iv.setSmooth(true);
|
||||
btn.setGraphic(iv);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new lobby via the LobbyClient.
|
||||
*
|
||||
* @return The generated lobbyId
|
||||
*/
|
||||
public int createLobby() {
|
||||
try {
|
||||
int lobbyId = lobbyClient.createLobby();
|
||||
LOGGER.info("Lobby created via LobbyClient: {}", lobbyId);
|
||||
if (lobbyId <= 0) {
|
||||
throw new RuntimeException("LobbyClient returned invalid lobby id: " + lobbyId);
|
||||
}
|
||||
return lobbyId;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to create lobby via LobbyClient: {}", e.getMessage());
|
||||
throw new RuntimeException("Failed to create lobby", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for joining a lobby.
|
||||
*
|
||||
* @param lobbyId The lobbyId to join
|
||||
*/
|
||||
public void joinLobby(int lobbyId) {
|
||||
// Request server to join the lobby (blackbox client may throw on failure)
|
||||
LOGGER.info("Joining lobby: {}", lobbyId);
|
||||
try {
|
||||
lobbyClient.joinLobby(lobbyId);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("LobbyClient failed to join lobby {}: {}", lobbyId, e.getMessage());
|
||||
return;
|
||||
}
|
||||
javafx.application.Platform.runLater(
|
||||
() -> {
|
||||
// Hide lobby stage (do not close) so we can return later
|
||||
javafx.scene.Scene scene = gridPane.getScene();
|
||||
javafx.stage.Stage currentStage = (javafx.stage.Stage) scene.getWindow();
|
||||
currentStage.hide();
|
||||
// Prepare game stage and set a handler so that when it is closed the lobby is
|
||||
// shown and updated
|
||||
javafx.stage.Stage gameStage = new javafx.stage.Stage();
|
||||
gameStage.setOnHidden(
|
||||
ev -> {
|
||||
try {
|
||||
currentStage.show();
|
||||
// refresh mappings immediately when returning from game
|
||||
refreshMappings();
|
||||
updateLobbyButtonImages();
|
||||
} catch (Exception ex) {
|
||||
LOGGER.error(
|
||||
"Error while returning to lobby: {}", ex.getMessage());
|
||||
}
|
||||
});
|
||||
// Start the Game UI using the prepared stage
|
||||
try {
|
||||
// ClientService an GameUI übergeben
|
||||
ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI
|
||||
.setClientService(lobbyClient.getClientService());
|
||||
new ch.unibas.dmi.dbis.cs108.casono.client.ui.gameui.CasinoGameUI().start(gameStage);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Error starting Game UI: {}", e.getMessage());
|
||||
// If starting fails, show the lobby again
|
||||
currentStage.show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter for the GridPane.
|
||||
*
|
||||
* @return The GridPane for the button grid
|
||||
*/
|
||||
public javafx.scene.layout.GridPane getGridPane() {
|
||||
return gridPane;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose the configured LobbyClient so callers can invoke its methods
|
||||
* directly (createLobby, fetchLobbyStatusString, joinLobby, ...).
|
||||
*/
|
||||
public LobbyClient getLobbyClient() {
|
||||
return lobbyClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger an immediate refresh of mappings (poll server and remove missing
|
||||
* lobbies). Public so callers can force a refresh when UI focus returns.
|
||||
*/
|
||||
public void refreshNow() {
|
||||
refreshMappings();
|
||||
}
|
||||
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.client.ui.lobbyui;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Manages the mapping between Button IDs and Lobby IDs in memory only. No file operations,
|
||||
* runtime-only data structure.
|
||||
*/
|
||||
public class LobbyButtonTranslationManager {
|
||||
|
||||
// Singleton instance
|
||||
private static LobbyButtonTranslationManager instance;
|
||||
|
||||
// Singleton access
|
||||
/**
|
||||
* Returns the singleton instance of the manager.
|
||||
*
|
||||
* @return the single instance of {@code LobbyButtonTranslationManager}
|
||||
*/
|
||||
public static LobbyButtonTranslationManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new LobbyButtonTranslationManager();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
/** Maximum number of buttons/lobbies */
|
||||
private static final int MAX_BUTTONS = 8;
|
||||
|
||||
/** Mapping ButtonID → LobbyID */
|
||||
private final Map<Integer, Integer> buttonIdToLobbyId = new HashMap<>();
|
||||
|
||||
/** Private constructor for the singleton pattern */
|
||||
private LobbyButtonTranslationManager() {
|
||||
// Mapping is empty at startup
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the grid is full (MAX_BUTTONS reached).
|
||||
*
|
||||
* @return true if the grid is full; otherwise false
|
||||
*/
|
||||
public boolean isFull() {
|
||||
return buttonIdToLobbyId.size() >= MAX_BUTTONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a mapping ButtonID → LobbyID.
|
||||
*
|
||||
* @param buttonId the ID of the button
|
||||
* @param lobbyId the ID of the lobby
|
||||
* @throws Exception when the grid is full
|
||||
*/
|
||||
public void addLobbyButton(int buttonId, int lobbyId) throws Exception {
|
||||
if (isFull()) {
|
||||
throw new Exception("Grid is full!");
|
||||
}
|
||||
buttonIdToLobbyId.put(buttonId, lobbyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the mapping for the given ButtonID.
|
||||
*
|
||||
* @param buttonId the ID of the button to remove
|
||||
*/
|
||||
public void removeLobbyButton(int buttonId) {
|
||||
buttonIdToLobbyId.remove(buttonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the LobbyID for a given ButtonID.
|
||||
*
|
||||
* @param buttonId the ButtonID
|
||||
* @return the associated LobbyID or null if not present
|
||||
*/
|
||||
public Integer getLobbyIdForButton(int buttonId) {
|
||||
return buttonIdToLobbyId.get(buttonId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full mapping ButtonID → LobbyID.
|
||||
*
|
||||
* @return Map of all mappings
|
||||
*/
|
||||
public Map<Integer, Integer> getButtonIdToLobbyId() {
|
||||
return buttonIdToLobbyId;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,63 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionDisconnectJob;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
|
||||
import java.time.Duration;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/** Application class for starting the server. */
|
||||
public class ServerApp {
|
||||
private static final int USER_CLEANUP_JOB_DELAY = 0;
|
||||
private static final int USER_CLEANUP_JOB_PERIOD = 10;
|
||||
private static final int USER_CLEANUP_JOB_RECONNECT_THRESHOLD = 10;
|
||||
private static final int SESSION_DISCONNECT_JOB_DELAY = 0;
|
||||
private static final int SESSION_DISCONNECT_JOB_PERIOD = 2;
|
||||
private static final int SESSION_DISCONNECT_JOB_TIMEOUT = 5;
|
||||
|
||||
public static void start(String arg) {
|
||||
int port = Integer.parseInt(arg);
|
||||
System.out.println("You've selected the server. It will accept connections at port " + port);
|
||||
|
||||
Logger logger = LogManager.getLogger(ServerApp.class);
|
||||
logger.info("Starting server at port {}", port);
|
||||
|
||||
EventBus eventBus = new EventBus();
|
||||
CommandParserDispatcher dispatcher = new CommandParserDispatcher();
|
||||
CommandRouter router = new CommandRouter();
|
||||
|
||||
SessionManager sessionManager = new SessionManager(eventBus, dispatcher, router);
|
||||
eventBus.subscribe(DisconnectEvent.class, event -> sessionManager.onDisconnect(event));
|
||||
NetworkManager networkManager = new NetworkManager(port, sessionManager);
|
||||
|
||||
UserRegistry userRegistry = new UserRegistry();
|
||||
eventBus.subscribe(
|
||||
DisconnectEvent.class, event -> userRegistry.onDisconnect(event.sessionId()));
|
||||
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
|
||||
scheduler.scheduleAtFixedRate(
|
||||
new UserCleanupJob(
|
||||
userRegistry, Duration.ofSeconds(USER_CLEANUP_JOB_RECONNECT_THRESHOLD)),
|
||||
USER_CLEANUP_JOB_DELAY,
|
||||
USER_CLEANUP_JOB_PERIOD,
|
||||
TimeUnit.SECONDS);
|
||||
scheduler.scheduleAtFixedRate(
|
||||
new SessionDisconnectJob(
|
||||
sessionManager,
|
||||
eventBus,
|
||||
Duration.ofSeconds(SESSION_DISCONNECT_JOB_TIMEOUT)),
|
||||
SESSION_DISCONNECT_JOB_DELAY,
|
||||
SESSION_DISCONNECT_JOB_PERIOD,
|
||||
TimeUnit.SECONDS);
|
||||
|
||||
networkManager.start();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.message;
|
||||
|
||||
/** Represents a message sent by someone */
|
||||
public record Message(String sender, String content) {}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.message;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
|
||||
|
||||
public class MessageManager {
|
||||
private final UserRegistry userRegistry;
|
||||
|
||||
public MessageManager(UserRegistry userRegistry) {
|
||||
this.userRegistry = userRegistry;
|
||||
}
|
||||
|
||||
public void broadcast(Message message) {
|
||||
userRegistry.getAllUsers().forEach(user -> user.enqueueMessage(message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.domain.message.Message;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/** Represents an authenticated user on the server. */
|
||||
public class User {
|
||||
private final UserId id;
|
||||
private final String name;
|
||||
private SessionId sessionId;
|
||||
private Instant disconnectedAt;
|
||||
private final Queue<Message> messages;
|
||||
|
||||
/**
|
||||
* Creates a new User with the given ID, name and session.
|
||||
*
|
||||
* @param id the unique identifier for this user
|
||||
* @param name the display name of this user
|
||||
* @param sessionId the session currently associated with this user
|
||||
*/
|
||||
public User(UserId id, String name, SessionId sessionId) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.sessionId = sessionId;
|
||||
this.disconnectedAt = null;
|
||||
this.messages = new ConcurrentLinkedQueue<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of this user.
|
||||
*
|
||||
* @return the user ID
|
||||
*/
|
||||
public UserId getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display name of this user.
|
||||
*
|
||||
* @return the user name
|
||||
*/
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the session currently associated with this user, if any.
|
||||
*
|
||||
* @return an Optional containing the session ID, or empty if disconnected
|
||||
*/
|
||||
public Optional<SessionId> getSessionId() {
|
||||
return Optional.ofNullable(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the time at which this user disconnected, if applicable.
|
||||
*
|
||||
* @return an Optional containing the disconnect timestamp, or empty if connected
|
||||
*/
|
||||
public Optional<Instant> getDisconnectedAt() {
|
||||
return Optional.ofNullable(disconnectedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Associates this user with a new session, clearing the disconnect timestamp.
|
||||
*
|
||||
* @param sessionId the new session ID
|
||||
*/
|
||||
public void reassignSession(SessionId sessionId) {
|
||||
this.sessionId = sessionId;
|
||||
this.disconnectedAt = null;
|
||||
}
|
||||
|
||||
/** Marks this user as disconnected by clearing the session and recording the timestamp. */
|
||||
public void markDisconnected() {
|
||||
this.sessionId = null;
|
||||
this.disconnectedAt = Instant.now();
|
||||
}
|
||||
|
||||
public synchronized void enqueueMessage(Message message) {
|
||||
messages.add(message);
|
||||
}
|
||||
|
||||
public synchronized List<Message> dequeueAllMessages(Message message) {
|
||||
List<Message> allMessages = new ArrayDeque<>(messages).stream().toList();
|
||||
messages.clear();
|
||||
return allMessages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/**
|
||||
* Periodically periodicly run job to remove disconnected users who have exceeded the reconnect
|
||||
* threshold.
|
||||
*/
|
||||
public class UserCleanupJob implements Runnable {
|
||||
private final Logger logger;
|
||||
private final UserRegistry registry;
|
||||
private final Duration reconnectThreshold;
|
||||
|
||||
public UserCleanupJob(UserRegistry registry, Duration reconnectThreshold) {
|
||||
this.logger = LogManager.getLogger(UserCleanupJob.class);
|
||||
this.registry = registry;
|
||||
this.reconnectThreshold = reconnectThreshold;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
logger.debug("Job started.");
|
||||
Instant threshold = Instant.now().minus(reconnectThreshold);
|
||||
|
||||
for (User user : registry.getAllUsers()) {
|
||||
if (user.getDisconnectedAt().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Instant disconnectedAt = user.getDisconnectedAt().get();
|
||||
if (disconnectedAt.isBefore(threshold)) {
|
||||
if (registry.removeIfStillDisconnected(user.getId())) {
|
||||
logger.info(
|
||||
"Removed expired user {} ({})", user.getName(), user.getId().value());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Job finished.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/** Creates new users, resolving name conflicts automatically. */
|
||||
public class UserFactory {
|
||||
private final UserRegistry registry;
|
||||
|
||||
/**
|
||||
* Creates a new UserFactory backed by the given registry.
|
||||
*
|
||||
* @param registry the registry to register new users to
|
||||
*/
|
||||
public UserFactory(UserRegistry registry) {
|
||||
this.registry = registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and registers a new user with the given name and session. If the name is already
|
||||
* taken, a numeric suffix is appended and incremented until a free name is found (e.g.
|
||||
* Lars_001, Lars_002, ...).
|
||||
*
|
||||
* @param desiredName the preferred display name
|
||||
* @param sessionId the session to associate with the new user
|
||||
* @return the newly created and registered user
|
||||
*/
|
||||
public User create(String desiredName, SessionId sessionId) {
|
||||
var result = registry.registerIfAvailable(desiredName, sessionId);
|
||||
if (result.isPresent()) {
|
||||
return result.get();
|
||||
}
|
||||
|
||||
int suffix = 1;
|
||||
while (true) {
|
||||
String candidate = desiredName + "_" + String.format("%03d", suffix);
|
||||
result = registry.registerIfAvailable(candidate, sessionId);
|
||||
if (result.isPresent()) {
|
||||
return result.get();
|
||||
}
|
||||
suffix++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Represents a unique identifier for a user. */
|
||||
public class UserId {
|
||||
private final UUID value;
|
||||
|
||||
/** Creates a new UserId with a randomly generated UUID. */
|
||||
public UserId() {
|
||||
this.value = UUID.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new UserId with the specified UUID.
|
||||
*
|
||||
* @param value the UUID to use for this UserId
|
||||
*/
|
||||
public UserId(UUID value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UUID value of this UserId.
|
||||
*
|
||||
* @return the UUID value
|
||||
*/
|
||||
public UUID value() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.domain.user;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** Manages all active users on the server. */
|
||||
public class UserRegistry {
|
||||
private final ConcurrentHashMap<UserId, User> byId = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, User> byName = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<SessionId, User> bySessionId = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Attempts to register a user under the given name atomically. Returns the registered user, or
|
||||
* empty if the name is already taken.
|
||||
*
|
||||
* @param name the desired display name
|
||||
* @param sessionId the session to associate with the new user
|
||||
* @return an Optional containing the new user, or empty if the name was taken
|
||||
*/
|
||||
public synchronized Optional<User> registerIfAvailable(String name, SessionId sessionId) {
|
||||
if (byName.containsKey(name)) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
User user = new User(new UserId(), name, sessionId);
|
||||
byId.put(user.getId(), user);
|
||||
byName.put(user.getName(), user);
|
||||
bySessionId.put(sessionId, user);
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the user with the given ID, but only if they are still disconnected. This prevents
|
||||
* removing a user who has reconnected between the cleanup job's check and its removal call.
|
||||
*
|
||||
* @param userId the ID of the user to remove
|
||||
*/
|
||||
public synchronized boolean removeIfStillDisconnected(UserId userId) {
|
||||
User user = byId.get(userId);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (user.getSessionId().isPresent()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
byId.remove(user.getId());
|
||||
byName.remove(user.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the user associated with the given session as disconnected, clearing the session
|
||||
* association and recording the disconnect timestamp.
|
||||
*
|
||||
* @param sessionId the session ID of the disconnected client
|
||||
*/
|
||||
public synchronized void onDisconnect(SessionId sessionId) {
|
||||
User user = bySessionId.remove(sessionId);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.markDisconnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassociates a user with a new session, effectively restoring them after a reconnect.
|
||||
*
|
||||
* @param userId the ID of the user to reconnect
|
||||
* @param sessionId the new session ID
|
||||
* @return an Optional containing the user, or empty if the user was not found
|
||||
*/
|
||||
public synchronized Optional<User> reassignSession(UserId userId, SessionId sessionId) {
|
||||
User user = byId.get(userId);
|
||||
if (user == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
user.reassignSession(sessionId);
|
||||
bySessionId.put(sessionId, user);
|
||||
return Optional.of(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up a user by their session ID.
|
||||
*
|
||||
* @param sessionId the session ID to look up
|
||||
* @return an Optional containing the user, or empty if no user is associated with this session
|
||||
*/
|
||||
public Optional<User> findBySessionId(SessionId sessionId) {
|
||||
return Optional.ofNullable(bySessionId.get(sessionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all currently registered users.
|
||||
*
|
||||
* @return a collection of all users
|
||||
*/
|
||||
public Collection<User> getAllUsers() {
|
||||
return byId.values();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TcpTransport;
|
||||
import java.io.IOException;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/** Creates and manages the server socket. Accepts new incoming connections and creates sessions. */
|
||||
public class NetworkManager implements Runnable {
|
||||
private Integer port;
|
||||
private Logger logger;
|
||||
private Thread thread;
|
||||
private Boolean running;
|
||||
private SessionManager sessionManager;
|
||||
|
||||
/**
|
||||
* Creates a new NetworkManager with the given port, session manager, and event bus.
|
||||
*
|
||||
* @param port the port to listen on
|
||||
* @param sessionManager the session manager to use
|
||||
*/
|
||||
public NetworkManager(Integer port, SessionManager sessionManager) {
|
||||
this.port = port;
|
||||
this.logger = LogManager.getLogger(NetworkManager.class);
|
||||
this.thread = new Thread(this, "networkManager");
|
||||
this.running = true;
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/** Starts the internal thread to accept new connections. */
|
||||
public void start() {
|
||||
logger.debug("Starting at port {}", port);
|
||||
thread.start();
|
||||
}
|
||||
|
||||
/** Runs the network manager loop, accepting connections. */
|
||||
@Override
|
||||
public void run() {
|
||||
try (ServerSocket serverSocket = new ServerSocket(port)) {
|
||||
while (running) {
|
||||
Socket clientSocket = serverSocket.accept();
|
||||
|
||||
logger.debug("Accepted connection from {}", clientSocket.getRemoteSocketAddress());
|
||||
|
||||
sessionManager.create(new TcpTransport(clientSocket));
|
||||
}
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.fatal(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
|
||||
|
||||
public interface CommandHandler<T extends Request> {
|
||||
void execute(T request);
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
public class CommandRouter {
|
||||
private final Map<Class<? extends Request>, CommandHandler<?>> handlers = new HashMap<>();
|
||||
|
||||
public <T extends Request> void register(Class<T> request, CommandHandler<T> handler) {
|
||||
handlers.put(request, handler);
|
||||
}
|
||||
|
||||
// Safe, because during registration, it's ensured that the provided CommandHandler only
|
||||
// receives requests it can handle.
|
||||
@SuppressWarnings("unchecked")
|
||||
public void execute(Request request) {
|
||||
CommandHandler<Request> handler =
|
||||
(CommandHandler<Request>) handlers.get(request.getClass());
|
||||
|
||||
if (handler == null) {
|
||||
String requestName = request.getClass().toString();
|
||||
throw new UnknownRequestException(
|
||||
"Unable to execute request " + requestName + ". Type unknown", requestName);
|
||||
}
|
||||
handler.execute(request);
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution;
|
||||
|
||||
public class UnknownRequestException extends RuntimeException {
|
||||
private final String requestName;
|
||||
|
||||
public UnknownRequestException(String message, String requestName) {
|
||||
super(message);
|
||||
this.requestName = requestName;
|
||||
}
|
||||
|
||||
public String getRequestName() {
|
||||
return requestName;
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
|
||||
|
||||
/**
|
||||
* Parser to convert the PrimitiveRequest to a Request and performing checks for required fields and
|
||||
* data types
|
||||
*/
|
||||
public interface CommandParser<T extends Request> {
|
||||
/**
|
||||
* Parses the provided PrimitiveRequest into a command-specific request
|
||||
*
|
||||
* @param primitiveRequest
|
||||
* @return
|
||||
*/
|
||||
T parse(PrimitiveRequest primitiveRequest);
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/** Dispatcher all CommandParser are registered in */
|
||||
public class CommandParserDispatcher {
|
||||
private final Map<String, CommandParser> parsers = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Register a new CommandParser
|
||||
*
|
||||
* @param command
|
||||
* @param parser the parser class
|
||||
*/
|
||||
public void register(String command, CommandParser parser) {
|
||||
parsers.put(command, parser);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the PrimitiveRequest into a Request using the appropriate CommandParser
|
||||
*
|
||||
* @param primitiveRequest the PrimitiveRequest to parse
|
||||
* @return the parsed Request object
|
||||
*/
|
||||
public Request parse(PrimitiveRequest primitiveRequest) {
|
||||
String command = primitiveRequest.command();
|
||||
CommandParser parser = parsers.get(command);
|
||||
|
||||
if (parser == null) {
|
||||
throw new UnknownCommandException(command);
|
||||
}
|
||||
|
||||
return parser.parse(primitiveRequest);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing;
|
||||
|
||||
/** Used in the PrimitiveRequest class to store the key of a parameter with its respective value */
|
||||
public record RequestParameter(String key, String value) {}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing;
|
||||
|
||||
/**
|
||||
* Exception thrown when the CommandParserDispatcher has no registered handler for the provided
|
||||
* request
|
||||
*/
|
||||
public class UnknownCommandException extends RuntimeException {
|
||||
public UnknownCommandException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.events;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/** Represents a disconnect event for a session. */
|
||||
public record DisconnectEvent(SessionId sessionId) implements Event {}
|
||||
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.events;
|
||||
|
||||
/** Marker interface for events in the event bus system. */
|
||||
interface Event {}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.events;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/** A simple event bus for publishing and subscribing to events. */
|
||||
public class EventBus {
|
||||
private final Map<Class<?>, List<Consumer<Object>>> handlers = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Subscribes a handler to a specific event type.
|
||||
*
|
||||
* @param eventType the class of the event to subscribe to
|
||||
* @param handler the consumer to handle the event
|
||||
*/
|
||||
@SuppressWarnings(
|
||||
"unchecked") // This cast is safe, because handlers only get passed the type they
|
||||
// subscribed to
|
||||
public <T extends Event> void subscribe(Class<T> eventType, Consumer<T> handler) {
|
||||
handlers.computeIfAbsent(eventType, k -> new CopyOnWriteArrayList<>())
|
||||
.add((Consumer<Object>) (Consumer<?>) handler);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes an event to all subscribed handlers.
|
||||
*
|
||||
* @param event the event to publish
|
||||
*/
|
||||
public <T extends Event> void publish(T event) {
|
||||
Objects.requireNonNull(event, "event must not be null");
|
||||
List<Consumer<Object>> subscribers = handlers.get(event.getClass());
|
||||
if (subscribers != null) {
|
||||
subscribers.forEach(h -> h.accept(event));
|
||||
}
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.RawToken;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.Token;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.TokenClassifier;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.TokenType;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.Tokenizer;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RawRequest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.NoSuchElementException;
|
||||
|
||||
/** ProtocolParser, used to parse RawPacket into a PrimitiveRequest */
|
||||
public class ProtocolParser {
|
||||
/**
|
||||
* Parses the payload of the provided RawPacket
|
||||
*
|
||||
* @param payload the payload to parse and check for syntax
|
||||
* @return created PrimitiveRequest
|
||||
*/
|
||||
public static RawRequest parse(String payload) {
|
||||
List<RawToken> rawTokens = Tokenizer.tokenize(payload);
|
||||
List<Token> tokens = TokenClassifier.classify(rawTokens);
|
||||
|
||||
Iterator<Token> iterator = tokens.iterator();
|
||||
String command = readCommand(iterator);
|
||||
List<RequestParameter> parameters = readParameters(iterator);
|
||||
|
||||
return new RawRequest(command, parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the first token, which is ensured to be the command of the request
|
||||
*
|
||||
* @param iterator
|
||||
* @return read command
|
||||
*/
|
||||
private static String readCommand(Iterator<Token> iterator) {
|
||||
Token token = iterator.next();
|
||||
if (token.type() != TokenType.COMMAND) {
|
||||
throw new ProtocolParserException("Expected COMMAND got " + token.type());
|
||||
}
|
||||
|
||||
return token.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterates over the remaining tokens, calling helperfunctions to validate token type
|
||||
*
|
||||
* @param iterator
|
||||
* @return list containing all parsed parameters
|
||||
*/
|
||||
private static List<RequestParameter> readParameters(Iterator<Token> iterator) {
|
||||
List<RequestParameter> parameters = new ArrayList<>();
|
||||
|
||||
try {
|
||||
while (iterator.hasNext()) {
|
||||
Token nextToken = iterator.next();
|
||||
|
||||
if (nextToken.type() == TokenType.EOF) {
|
||||
break;
|
||||
}
|
||||
|
||||
String key = readKey(nextToken);
|
||||
readSeperator(iterator.next());
|
||||
String value = readValue(iterator.next());
|
||||
|
||||
parameters.add(new RequestParameter(key, value));
|
||||
}
|
||||
} catch (NoSuchElementException e) {
|
||||
throw new ProtocolParserException("Ran out of tokens while reading parameter");
|
||||
}
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param token the current token
|
||||
* @return
|
||||
*/
|
||||
private static String readKey(Token token) {
|
||||
if (token.type() != TokenType.KEY) {
|
||||
throw new ProtocolParserException("Expected KEY got " + token.type());
|
||||
}
|
||||
|
||||
return token.value();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param token the current token
|
||||
*/
|
||||
private static void readSeperator(Token token) {
|
||||
if (token.type() != TokenType.SEPARATOR) {
|
||||
throw new ProtocolParserException("Expected SEPARATOR got " + token.type());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param token the current token
|
||||
* @return
|
||||
*/
|
||||
private static String readValue(Token token) {
|
||||
if (token.type() != TokenType.VALUE) {
|
||||
throw new ProtocolParserException("Expected VALUE got " + token.type());
|
||||
}
|
||||
|
||||
return token.value();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser;
|
||||
|
||||
public class ProtocolParserException extends RuntimeException {
|
||||
public ProtocolParserException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
/** Represents a raw (unclassified) token in the tokenizer. */
|
||||
public record RawToken(RawTokenType type, String value, int line, int column) {}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
public enum RawTokenType {
|
||||
WORD,
|
||||
STRING,
|
||||
SEPARATOR,
|
||||
NEWLINE,
|
||||
EOF
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Internal state for the tokenizer. */
|
||||
class State {
|
||||
final String input;
|
||||
int pos;
|
||||
int line;
|
||||
int column;
|
||||
final List<RawToken> tokens;
|
||||
|
||||
State(String input) {
|
||||
this.input = input;
|
||||
this.pos = 0;
|
||||
this.line = 1;
|
||||
this.column = 1;
|
||||
this.tokens = new ArrayList<>();
|
||||
}
|
||||
|
||||
char current() {
|
||||
return input.charAt(pos);
|
||||
}
|
||||
|
||||
char peek() {
|
||||
if (pos + 1 >= input.length()) {
|
||||
return '\0';
|
||||
}
|
||||
return input.charAt(pos + 1);
|
||||
}
|
||||
|
||||
void advance() {
|
||||
pos++;
|
||||
column++;
|
||||
}
|
||||
|
||||
boolean isEof() {
|
||||
return pos >= input.length();
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
/** Represents a token in the tokenizer. */
|
||||
public record Token(TokenType type, String value, int line, int column) {}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class TokenClassifier {
|
||||
public static List<Token> classify(List<RawToken> rawTokens) {
|
||||
validateFirstToken(rawTokens);
|
||||
|
||||
List<Token> tokens = new ArrayList<>();
|
||||
|
||||
for (int i = 0; i < rawTokens.size(); i++) {
|
||||
RawToken raw = rawTokens.get(i);
|
||||
|
||||
switch (raw.type()) {
|
||||
case SEPARATOR -> {
|
||||
validateSeparator(rawTokens, i);
|
||||
tokens.add(
|
||||
new Token(TokenType.SEPARATOR, raw.value(), raw.line(), raw.column()));
|
||||
}
|
||||
case WORD -> {
|
||||
TokenType type = resolveWordType(rawTokens, i);
|
||||
tokens.add(new Token(type, raw.value(), raw.line(), raw.column()));
|
||||
}
|
||||
case STRING -> {
|
||||
validateString(rawTokens, i);
|
||||
tokens.add(new Token(TokenType.VALUE, raw.value(), raw.line(), raw.column()));
|
||||
}
|
||||
|
||||
case NEWLINE -> {
|
||||
throw new TokenizerException(
|
||||
"Unexpected newline in request", raw.line(), raw.column());
|
||||
}
|
||||
|
||||
case EOF -> {
|
||||
tokens.add(new Token(TokenType.EOF, raw.value(), raw.line(), raw.column()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
private static void validateFirstToken(List<RawToken> rawTokens) {
|
||||
if (rawTokens.isEmpty() || rawTokens.get(0).type() != RawTokenType.WORD) {
|
||||
throw new TokenizerException("Expected COMMAND as first token", 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateSeparator(List<RawToken> rawTokens, int index) {
|
||||
boolean missingKey = index == 0 || rawTokens.get(index - 1).type() != RawTokenType.WORD;
|
||||
|
||||
boolean missingValue =
|
||||
index + 1 >= rawTokens.size()
|
||||
|| (rawTokens.get(index + 1).type() != RawTokenType.WORD
|
||||
&& rawTokens.get(index + 1).type() != RawTokenType.STRING);
|
||||
|
||||
boolean nextWordIsKey =
|
||||
!missingValue
|
||||
&& rawTokens.get(index + 1).type() == RawTokenType.WORD
|
||||
&& index + 2 < rawTokens.size()
|
||||
&& rawTokens.get(index + 2).type() == RawTokenType.SEPARATOR;
|
||||
|
||||
RawToken separator = rawTokens.get(index);
|
||||
|
||||
if (missingKey) {
|
||||
throw new TokenizerException(
|
||||
"Expected KEY before '='", separator.line(), separator.column());
|
||||
}
|
||||
|
||||
if (missingValue || nextWordIsKey) {
|
||||
throw new TokenizerException(
|
||||
"Expected VALUE after '='", separator.line(), separator.column());
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateString(List<RawToken> rawTokens, int index) {
|
||||
boolean afterSeparator =
|
||||
index > 0 && rawTokens.get(index - 1).type() == RawTokenType.SEPARATOR;
|
||||
|
||||
if (!afterSeparator) {
|
||||
RawToken token = rawTokens.get(index);
|
||||
throw new TokenizerException("Unexpected string literal", token.line(), token.column());
|
||||
}
|
||||
}
|
||||
|
||||
private static TokenType resolveWordType(List<RawToken> rawTokens, int index) {
|
||||
boolean isFirst = index == 0;
|
||||
boolean afterNewline = index > 0 && rawTokens.get(index - 1).type() == RawTokenType.NEWLINE;
|
||||
boolean afterSeparator =
|
||||
index > 0 && rawTokens.get(index - 1).type() == RawTokenType.SEPARATOR;
|
||||
|
||||
if (isFirst || afterNewline) {
|
||||
return TokenType.COMMAND;
|
||||
}
|
||||
|
||||
if (afterSeparator) {
|
||||
return TokenType.VALUE;
|
||||
}
|
||||
|
||||
return TokenType.KEY;
|
||||
}
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
/** Enumeration of token types used in the tokenizer. */
|
||||
public enum TokenType {
|
||||
COMMAND,
|
||||
KEY,
|
||||
VALUE,
|
||||
SEPARATOR,
|
||||
EOF
|
||||
}
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A static utility class for tokenizing input strings into a sequence of tokens.
|
||||
*
|
||||
* <p>Strings can be enclosed in single quotes and may contain escaped single quotes using a
|
||||
* backslash.
|
||||
*/
|
||||
public class Tokenizer {
|
||||
/**
|
||||
* Tokenizes the given input string into a list of tokens.
|
||||
*
|
||||
* @param input the string to tokenize
|
||||
* @return a list of tokens representing the input
|
||||
* @throws TokenizerException if the input contains unexpected characters, unterminated strings,
|
||||
* or other syntax errors
|
||||
*/
|
||||
public static List<RawToken> tokenize(String input) {
|
||||
State state = new State(input);
|
||||
|
||||
while (!state.isEof()) {
|
||||
char c = state.current();
|
||||
|
||||
if (c == ' ' || c == '\t') {
|
||||
state.advance();
|
||||
|
||||
} else if (c == '\n') {
|
||||
readNewline(state);
|
||||
|
||||
} else if (c == '=') {
|
||||
readSeparator(state);
|
||||
|
||||
} else if (c == '\'') {
|
||||
readString(state);
|
||||
|
||||
} else if (Character.isLetterOrDigit(c) || c == '_') {
|
||||
readWord(state);
|
||||
|
||||
} else {
|
||||
throw new TokenizerException(
|
||||
"Unexpected character '" + c + "'", state.line, state.column);
|
||||
}
|
||||
}
|
||||
|
||||
state.tokens.add(new RawToken(RawTokenType.EOF, null, state.line, state.column));
|
||||
return state.tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a newline character and creates a NEWLINE token.
|
||||
*
|
||||
* <p>Updates the line counter and resets the column to 1.
|
||||
*
|
||||
* @param state the current parsing state
|
||||
*/
|
||||
private static void readNewline(State state) {
|
||||
state.tokens.add(new RawToken(RawTokenType.NEWLINE, "", state.line, state.column));
|
||||
state.advance();
|
||||
state.line++;
|
||||
state.column = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a separator character ('=') and creates a SEPARATOR token.
|
||||
*
|
||||
* @param state the current parsing state
|
||||
*/
|
||||
private static void readSeparator(State state) {
|
||||
state.tokens.add(new RawToken(RawTokenType.SEPARATOR, "=", state.line, state.column));
|
||||
state.advance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a word (sequence of alphanumeric characters and underscores) and creates an appropriate
|
||||
* token (COMMAND, KEY, or VALUE) based on the parsing context.
|
||||
*
|
||||
* <p>The token type is determined by the {@link #resolveWordType(State)} method.
|
||||
*
|
||||
* @param state the current parsing state
|
||||
*/
|
||||
private static void readWord(State state) {
|
||||
int startColumn = state.column;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
while (!state.isEof()
|
||||
&& (Character.isLetterOrDigit(state.current()) || state.current() == '_')) {
|
||||
sb.append(state.current());
|
||||
state.advance();
|
||||
}
|
||||
|
||||
state.tokens.add(new RawToken(RawTokenType.WORD, sb.toString(), state.line, startColumn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a string literal enclosed in single quotes and creates a VALUE token.
|
||||
*
|
||||
* <p>Supports escaped single quotes using backslash notation (\'). Newlines within strings are
|
||||
* properly tracked for line counting.
|
||||
*
|
||||
* @param state the current parsing state
|
||||
* @throws TokenizerException if the string literal is not terminated before end of input
|
||||
*/
|
||||
private static void readString(State state) {
|
||||
int startColumn = state.column;
|
||||
state.advance();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
while (true) {
|
||||
if (state.isEof()) {
|
||||
throw new TokenizerException(
|
||||
"Unterminated string literal", state.line, startColumn);
|
||||
}
|
||||
|
||||
char c = state.current();
|
||||
|
||||
if (c == '\\' && state.peek() == '\'') {
|
||||
sb.append('\'');
|
||||
state.advance();
|
||||
state.advance();
|
||||
|
||||
} else if (c == '\'') {
|
||||
state.advance();
|
||||
break;
|
||||
|
||||
} else {
|
||||
if (c == '\n') {
|
||||
state.line++;
|
||||
state.column = 1;
|
||||
}
|
||||
sb.append(c);
|
||||
state.advance();
|
||||
}
|
||||
}
|
||||
|
||||
state.tokens.add(new RawToken(RawTokenType.STRING, sb.toString(), state.line, startColumn));
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
|
||||
|
||||
/** Exception thrown during tokenization. */
|
||||
public class TokenizerException extends RuntimeException {
|
||||
private final int line;
|
||||
private final int column;
|
||||
|
||||
public TokenizerException(String message, int line, int column) {
|
||||
super(message);
|
||||
this.line = line;
|
||||
this.column = column;
|
||||
}
|
||||
|
||||
public int getLine() {
|
||||
return line;
|
||||
}
|
||||
|
||||
public int getColumn() {
|
||||
return column;
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
|
||||
import java.util.List;
|
||||
|
||||
/** Created by the ProtocolParser to allow easy access to the request contents */
|
||||
public record PrimitiveRequest(
|
||||
RequestContext context, String command, List<RequestParameter> parameters) {}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
|
||||
import java.util.List;
|
||||
|
||||
public record RawRequest(String command, List<RequestParameter> parameters) {}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/** Request, produced by the CommandParser */
|
||||
public abstract class Request {
|
||||
protected final RequestContext context;
|
||||
|
||||
public Request(RequestContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
public RequestContext getContext() {
|
||||
return context;
|
||||
}
|
||||
|
||||
public SessionId getSessionId() {
|
||||
return context.sessionId();
|
||||
}
|
||||
|
||||
public int getRequestId() {
|
||||
return context.requestId();
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/**
|
||||
* Immutable context for a network request.
|
||||
*
|
||||
* <p>Contains the originating session's identifier and the request's id. Later used to create
|
||||
* response.
|
||||
*
|
||||
* @param sessionId the identifier of the session that initiated the request
|
||||
* @param requestId the request's numeric id within the session
|
||||
*/
|
||||
public record RequestContext(SessionId sessionId, int requestId) {}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
|
||||
|
||||
/** Exception thrown when a required parameter key is not found. */
|
||||
public class MissingParameterException extends RuntimeException {
|
||||
private final String parameterKey;
|
||||
|
||||
/**
|
||||
* Creates a new exception for a missing required parameter.
|
||||
*
|
||||
* @param message human-readable description of the missing parameter
|
||||
* @param parameterKey key of the parameter that could not be found
|
||||
*/
|
||||
public MissingParameterException(String message, String parameterKey) {
|
||||
super(message);
|
||||
this.parameterKey = parameterKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the missing parameter key.
|
||||
*
|
||||
* @return key of the parameter that could not be found
|
||||
*/
|
||||
public String getParameterKey() {
|
||||
return parameterKey;
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
|
||||
|
||||
/** Exception thrown when a parameter value cannot be converted to the requested type. */
|
||||
public class ParameterParseException extends RuntimeException {
|
||||
private final String parameterKey;
|
||||
|
||||
/**
|
||||
* Creates a new parse exception with a root cause.
|
||||
*
|
||||
* @param message human-readable description of the parsing failure
|
||||
* @param parameterKey key for whose value the error occured
|
||||
* @param cause original exception thrown during parsing
|
||||
*/
|
||||
public ParameterParseException(String message, String parameterKey, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.parameterKey = parameterKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the missing parameter key.
|
||||
*
|
||||
* @return key for whose value the error occured
|
||||
*/
|
||||
public String getParameterKey() {
|
||||
return parameterKey;
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Provides typed access to a request's parameters by indexing them by key.
|
||||
*
|
||||
* <p>Supports required and optional lookups, with optional conversion from {@link String} values to
|
||||
* domain-specific types via parser functions.
|
||||
*/
|
||||
public class RequestParameterAccessor {
|
||||
private final Map<String, String> index;
|
||||
|
||||
/**
|
||||
* Creates an accessor
|
||||
*
|
||||
* @param parameters to use
|
||||
*/
|
||||
public RequestParameterAccessor(List<RequestParameter> parameters) {
|
||||
this.index =
|
||||
parameters.stream()
|
||||
.collect(
|
||||
Collectors.toUnmodifiableMap(
|
||||
RequestParameter::key, RequestParameter::value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw value for a required parameter key.
|
||||
*
|
||||
* @param key parameter key to look up
|
||||
* @return raw parameter value
|
||||
* @throws MissingParameterException if no parameter with the given key exists
|
||||
*/
|
||||
public String require(String key) throws MissingParameterException {
|
||||
String value = index.get(key);
|
||||
if (value == null) {
|
||||
throw new MissingParameterException(
|
||||
"Required parameter with key '" + key + "' is missing.", key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parsed value for a required parameter key.
|
||||
*
|
||||
* @param key parameter key to look up
|
||||
* @param parser parser used to convert the raw value
|
||||
* @param <T> target type returned by the parser
|
||||
* @return parsed parameter value
|
||||
* @throws MissingParameterException if no parameter with the given key exists
|
||||
* @throws ParameterParseException if parsing the raw value fails
|
||||
*/
|
||||
public <T> T require(String key, ThrowingParser<T> parser)
|
||||
throws MissingParameterException, ParameterParseException {
|
||||
String value = require(key);
|
||||
try {
|
||||
return parser.parse(value);
|
||||
} catch (Exception e) {
|
||||
throw new ParameterParseException(
|
||||
"Error while parsing '" + key + "' with specified parser", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the raw value for a parameter key or the provided default value if missing.
|
||||
*
|
||||
* @param key parameter key to look up
|
||||
* @param defaultValue value returned when the key does not exist
|
||||
* @return found parameter value or {@code defaultValue} if absent
|
||||
*/
|
||||
public String optional(String key, String defaultValue) {
|
||||
String value = index.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a parsed value for a parameter key or the provided default value if missing.
|
||||
*
|
||||
* @param key parameter key to look up
|
||||
* @param defaultValue value returned when the key does not exist
|
||||
* @param parser parser used to convert the raw value
|
||||
* @param <T> target type returned by the parser
|
||||
* @return parsed parameter value or {@code defaultValue} if absent
|
||||
* @throws ParameterParseException if parsing the raw value fails
|
||||
*/
|
||||
public <T> T optional(String key, T defaultValue, ThrowingParser<T> parser)
|
||||
throws ParameterParseException {
|
||||
String value = index.get(key);
|
||||
if (value == null) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
try {
|
||||
return parser.parse(value);
|
||||
} catch (Exception e) {
|
||||
throw new ParameterParseException(
|
||||
"Error while parsing '" + key + "' with specified parser", key, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a parameter with the given key exists.
|
||||
*
|
||||
* @param key parameter key to check
|
||||
* @return {@code true} if the key exists, otherwise {@code false}
|
||||
*/
|
||||
public boolean containsKey(String key) {
|
||||
return index.containsKey(key);
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
|
||||
|
||||
/**
|
||||
* Functional parser interface used to convert a raw string parameter into a target type.
|
||||
*
|
||||
* @param <T> target type produced by the parser
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface ThrowingParser<T> {
|
||||
/**
|
||||
* Parses the provided raw parameter value.
|
||||
*
|
||||
* @param value raw parameter value
|
||||
* @return parsed value
|
||||
* @throws Exception if the value cannot be parsed
|
||||
*/
|
||||
T parse(String value) throws Exception;
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
|
||||
|
||||
/** Response representing an error outcome for a client's request. */
|
||||
public class ErrorResponse extends Response {
|
||||
/**
|
||||
* Construct an error response with a code and message.
|
||||
*
|
||||
* @param context the RequestContext of the request
|
||||
* @param errorCode a short error code identifying the failure
|
||||
* @param errorMessage a human readable error message
|
||||
*/
|
||||
public ErrorResponse(RequestContext context, String errorCode, String errorMessage) {
|
||||
super(
|
||||
context,
|
||||
ResponseBody.builder().param("CODE", errorCode).param("MSG", errorMessage).build());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>This implementation returns the fixed {@code -ERR} prefix.
|
||||
*
|
||||
* @return the {@code -ERR} prefix
|
||||
*/
|
||||
@Override
|
||||
public String prefix() {
|
||||
return "-ERR";
|
||||
}
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
|
||||
|
||||
/**
|
||||
* A simple success response with an empty body.
|
||||
*
|
||||
* <p>Use this to acknowledge successful requests that do not carry additional payload data.
|
||||
*/
|
||||
public class OkResponse extends SuccessResponse {
|
||||
/**
|
||||
* Create a minimal successful response (no body content).
|
||||
*
|
||||
* @param context the RequestContext of the request
|
||||
*/
|
||||
public OkResponse(RequestContext context) {
|
||||
super(context, ResponseBody.builder().build());
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/**
|
||||
* Immutable transport record representing an encoded response ready for delivery to a session.
|
||||
*
|
||||
* @param sessionId the target session id
|
||||
* @param requestId the originating request id
|
||||
* @param payload the serialized response payload
|
||||
*/
|
||||
public record PrimitiveResponse(SessionId sessionId, int requestId, String payload) {}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId;
|
||||
|
||||
/** Abstract base class for all server responses sent to clients. */
|
||||
public abstract class Response {
|
||||
private final RequestContext context;
|
||||
private final ResponseBody body;
|
||||
|
||||
/**
|
||||
* Create a new {@code Response}.
|
||||
*
|
||||
* @param context the RequestContext of the request
|
||||
* @param body the structured response body
|
||||
*/
|
||||
protected Response(RequestContext context, ResponseBody body) {
|
||||
this.context = context;
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the protocol prefix for this response (for example {@code "+OK"} or {@code "-ERR"}).
|
||||
*
|
||||
* @return the response prefix string used by the encoder
|
||||
*/
|
||||
public abstract String prefix();
|
||||
|
||||
/**
|
||||
* Returns the session id of the session that should receive this response.
|
||||
*
|
||||
* @return the target {@link SessionId}
|
||||
*/
|
||||
public SessionId getSessionId() {
|
||||
return context.sessionId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the request identifier associated with this response.
|
||||
*
|
||||
* @return the numeric request id
|
||||
*/
|
||||
public int getRequestId() {
|
||||
return context.requestId();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the immutable {@link ResponseBody} that carries the structured payload for this
|
||||
* response.
|
||||
*
|
||||
* @return the response body
|
||||
*/
|
||||
public ResponseBody getBody() {
|
||||
return body;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
|
||||
|
||||
/**
|
||||
* Abstract {@link Response} specialization indicating a successful outcome.
|
||||
*
|
||||
* <p>Implementations of this class use the {@code +OK} prefix. It provides a protected constructor
|
||||
* so subclasses can supply the response body content.
|
||||
*/
|
||||
public abstract class SuccessResponse extends Response {
|
||||
/**
|
||||
* Create a successful response with the provided body.
|
||||
*
|
||||
* @param context the RequestContext of the request
|
||||
* @param body the response body
|
||||
*/
|
||||
protected SuccessResponse(RequestContext context, ResponseBody body) {
|
||||
super(context, body);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* <p>This implementation returns the fixed {@code +OK} prefix.
|
||||
*
|
||||
* @return the {@code +OK} prefix
|
||||
*/
|
||||
@Override
|
||||
public final String prefix() {
|
||||
return "+OK";
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A block node that contains a tag and a list of child {@link ResponseNode} elements. Blocks can be
|
||||
* nested to build hierarchical response bodies.
|
||||
*
|
||||
* @param tag the block tag
|
||||
* @param children the child nodes contained in this block
|
||||
*/
|
||||
public record ResponseBlock(String tag, List<ResponseNode> children) implements ResponseNode {}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.Response;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable container for the structured content of a {@link Response}.
|
||||
*
|
||||
* <p>A {@code ResponseBody} holds an ordered list of {@link ResponseNode} items (parameters and
|
||||
* blocks). Use {@link #builder()} to construct instances.
|
||||
*/
|
||||
public class ResponseBody {
|
||||
private final List<ResponseNode> nodes;
|
||||
|
||||
/**
|
||||
* Package-private constructor used by {@link ResponseBodyBuilder}.
|
||||
*
|
||||
* @param nodes the list of response nodes to include in this body
|
||||
*/
|
||||
ResponseBody(List<ResponseNode> nodes) {
|
||||
this.nodes = List.copyOf(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new {@link ResponseBodyBuilder} for assembling a response body.
|
||||
*
|
||||
* @return a fresh builder instance
|
||||
*/
|
||||
public static ResponseBodyBuilder builder() {
|
||||
return new ResponseBodyBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ordered list of {@link ResponseNode} elements contained in this body.
|
||||
*
|
||||
* @return an immutable list of nodes
|
||||
*/
|
||||
public List<ResponseNode> nodes() {
|
||||
return nodes;
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Builder for {@link ResponseBody} instances.
|
||||
*
|
||||
* <p>Provides methods to append parameter nodes and nested blocks and to produce an immutable
|
||||
* {@link ResponseBody} via {@link #build()}.
|
||||
*/
|
||||
public class ResponseBodyBuilder {
|
||||
private final List<ResponseNode> nodes = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Add a key/value parameter to the response body under construction.
|
||||
*
|
||||
* @param key the parameter name
|
||||
* @param value the parameter value (will be converted to string when encoded)
|
||||
* @return this builder for fluent chaining
|
||||
*/
|
||||
public ResponseBodyBuilder param(String key, Object value) {
|
||||
nodes.add(new ResponseParameter(key, value));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a nested block with the given tag. The provided consumer receives a child builder to
|
||||
* populate the block content.
|
||||
*
|
||||
* @param tag the block tag
|
||||
* @param content consumer that appends child nodes to the block
|
||||
* @return this builder for fluent chaining
|
||||
*/
|
||||
public ResponseBodyBuilder block(String tag, Consumer<ResponseBodyBuilder> content) {
|
||||
ResponseBodyBuilder childBuilder = new ResponseBodyBuilder();
|
||||
content.accept(childBuilder);
|
||||
nodes.add(new ResponseBlock(tag, childBuilder.build().nodes()));
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an immutable {@link ResponseBody} from the accumulated nodes.
|
||||
*
|
||||
* @return a new {@link ResponseBody}
|
||||
*/
|
||||
public ResponseBody build() {
|
||||
return new ResponseBody(nodes);
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
|
||||
|
||||
/**
|
||||
* Marker interface for elements that may appear in a {@link ResponseBody}.
|
||||
*
|
||||
* <p>Implementations include {@link ResponseParameter} and {@link ResponseBlock}.
|
||||
*/
|
||||
public interface ResponseNode {}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder;
|
||||
|
||||
/**
|
||||
* A parameter node stored in a {@link ResponseBody}.
|
||||
*
|
||||
* <p>Represents a simple key/value pair. Callers can use {@link #rawValue()} to obtain the string
|
||||
* representation of the stored value.
|
||||
*
|
||||
* @param key the parameter name
|
||||
* @param value the parameter value
|
||||
*/
|
||||
public record ResponseParameter(String key, Object value) implements ResponseNode {
|
||||
/**
|
||||
* Returns the raw string representation of the stored value. This is a convenience wrapper
|
||||
* around {@code Object#toString()} and may throw {@link NullPointerException} if the stored
|
||||
* value is {@code null}.
|
||||
*
|
||||
* @return the value as string
|
||||
*/
|
||||
public String rawValue() {
|
||||
return value.toString();
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher;
|
||||
|
||||
public class ResponseDispatchException extends RuntimeException {
|
||||
public ResponseDispatchException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.Response;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
|
||||
|
||||
/**
|
||||
* Helper that dispatches {@link Response} instances to the corresponding {@link Session} by
|
||||
* encoding them and enqueuing the resulting {@link PrimitiveResponse} into the session's response
|
||||
* queue.
|
||||
*/
|
||||
public class ResponseDispatcher {
|
||||
private final SessionManager sessionManager;
|
||||
|
||||
/**
|
||||
* Create a dispatcher bound to a {@link SessionManager}.
|
||||
*
|
||||
* @param sessionManager manager used to resolve sessions
|
||||
*/
|
||||
public ResponseDispatcher(SessionManager sessionManager) {
|
||||
this.sessionManager = sessionManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode the given {@link Response} and enqueue the resulting {@link PrimitiveResponse} into
|
||||
* the target session's response queue.
|
||||
*
|
||||
* @param response the response to dispatch
|
||||
* @throws ResponseDispatchException wraps any exceptions that occur during dispatching, such as
|
||||
* the {@link InterruptedException}
|
||||
*/
|
||||
public void dispatch(Response response) {
|
||||
PrimitiveResponse primitiveResponse = ResponseEncoder.encode(response);
|
||||
Session session = sessionManager.getSessionById(response.getSessionId());
|
||||
try {
|
||||
session.getResponseQueue().put(primitiveResponse);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ResponseDispatchException("Interrupted while dispatching response", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.Response;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBlock;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseNode;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseParameter;
|
||||
|
||||
/**
|
||||
* Utility responsible for encoding a {@link Response} into a protocol payload string and wrapping
|
||||
* it into a {@link PrimitiveResponse} suitable for transmission.
|
||||
*/
|
||||
public class ResponseEncoder {
|
||||
private static final String INDENT = "\t";
|
||||
private static final String NEWLINE = "\n";
|
||||
|
||||
/**
|
||||
* Encode a {@link Response} into a {@link PrimitiveResponse} containing the serialized payload
|
||||
* string.
|
||||
*
|
||||
* @param response the response to encode
|
||||
* @return a {@link PrimitiveResponse} with encoded payload
|
||||
*/
|
||||
public static PrimitiveResponse encode(Response response) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append(response.prefix());
|
||||
|
||||
for (ResponseNode node : response.getBody().nodes()) {
|
||||
sb.append(NEWLINE);
|
||||
encodeNode(node, sb, 1);
|
||||
}
|
||||
|
||||
sb.append(NEWLINE).append("END");
|
||||
|
||||
return new PrimitiveResponse(
|
||||
response.getSessionId(), response.getRequestId(), sb.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper to encode any {@link ResponseNode}.
|
||||
*
|
||||
* @param node node to encode
|
||||
* @param sb string builder to append to
|
||||
* @param depth current indentation depth
|
||||
*/
|
||||
private static void encodeNode(ResponseNode node, StringBuilder sb, int depth) {
|
||||
if (node instanceof ResponseParameter param) {
|
||||
encodeParameter(param, sb, depth);
|
||||
} else if (node instanceof ResponseBlock block) {
|
||||
encodeBlock(block, sb, depth);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a {@link ResponseParameter} into the string builder.
|
||||
*
|
||||
* @param param the parameter to encode
|
||||
* @param sb the output builder
|
||||
* @param depth indentation depth
|
||||
*/
|
||||
private static void encodeParameter(ResponseParameter param, StringBuilder sb, int depth) {
|
||||
sb.append(INDENT.repeat(depth));
|
||||
sb.append(param.key());
|
||||
sb.append("=");
|
||||
sb.append(maskIfNeeded(param.value().toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a {@link ResponseBlock}, including its children and terminating with an {@code END}
|
||||
* marker.
|
||||
*
|
||||
* @param block the block to encode
|
||||
* @param sb the output builder
|
||||
* @param depth current indentation depth
|
||||
*/
|
||||
private static void encodeBlock(ResponseBlock block, StringBuilder sb, int depth) {
|
||||
sb.append(INDENT.repeat(depth));
|
||||
sb.append(block.tag());
|
||||
|
||||
for (ResponseNode child : block.children()) {
|
||||
sb.append(NEWLINE);
|
||||
encodeNode(child, sb, depth + 1);
|
||||
}
|
||||
|
||||
sb.append(NEWLINE);
|
||||
sb.append(INDENT.repeat(depth));
|
||||
sb.append("END");
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote or escape the provided value if it contains whitespace or single quotes so the encoded
|
||||
* payload remains parseable.
|
||||
*
|
||||
* @param value the raw string value
|
||||
* @return quoted/escaped value
|
||||
*/
|
||||
private static String maskIfNeeded(String value) {
|
||||
if (value.contains(" ") || value.contains("'")) {
|
||||
String escaped = value.replace("'", "\\'");
|
||||
return "'" + escaped + "'";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TransportLayer;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.ArrayBlockingQueue;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
|
||||
/** Represents a client session in the network server. */
|
||||
public class Session {
|
||||
private final SessionId id;
|
||||
private Instant lastActivity;
|
||||
private final TransportLayer transport;
|
||||
private final BlockingQueue<PrimitiveResponse> responseQueue;
|
||||
private final CommandParserDispatcher dispatcher;
|
||||
private final CommandRouter router;
|
||||
private static final int RESPOND_QUEUE_SIZE = 32;
|
||||
|
||||
/**
|
||||
* Creates a new Session with the given transport and event bus.
|
||||
*
|
||||
* @param transport the transport layer for communication
|
||||
* @param eventBus the event bus for publishing events
|
||||
*/
|
||||
public Session(
|
||||
TransportLayer transport,
|
||||
EventBus eventBus,
|
||||
CommandParserDispatcher dispatcher,
|
||||
CommandRouter router) {
|
||||
this.id = new SessionId();
|
||||
this.lastActivity = Instant.now();
|
||||
this.transport = transport;
|
||||
this.dispatcher = dispatcher;
|
||||
this.router = router;
|
||||
this.responseQueue = new ArrayBlockingQueue<>(RESPOND_QUEUE_SIZE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ID of this session.
|
||||
*
|
||||
* @return the session ID
|
||||
*/
|
||||
public SessionId getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the timestamp of the last inbound activity for this session.
|
||||
*
|
||||
* @return an {@link Instant} representing the time of the last inbound activity
|
||||
*/
|
||||
public Instant getLastInboundActivity() {
|
||||
return lastActivity;
|
||||
}
|
||||
|
||||
/** Updates the timestamp of the last inbound activity for this session. */
|
||||
public void updateLastInboundActivity() {
|
||||
this.lastActivity = Instant.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the TransportLayer of this session
|
||||
*
|
||||
* @return the transport layer of the session
|
||||
*/
|
||||
public TransportLayer getTransport() {
|
||||
return transport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the BlockingQueue of this session
|
||||
*
|
||||
* @return the queue containing outgoing responses
|
||||
*/
|
||||
public BlockingQueue<PrimitiveResponse> getResponseQueue() {
|
||||
return responseQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the CommandParserDispatcher of this session
|
||||
*
|
||||
* @return the dispatcher to dispatch PrimitiveRequests to for parsing
|
||||
*/
|
||||
public CommandParserDispatcher getDispatcher() {
|
||||
return dispatcher;
|
||||
}
|
||||
|
||||
public CommandRouter getRouter() {
|
||||
return router;
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public class SessionDisconnectJob implements Runnable {
|
||||
private final Logger logger;
|
||||
private final SessionManager sessionManager;
|
||||
private final EventBus eventBus;
|
||||
private final Duration timeoutThreshold;
|
||||
|
||||
public SessionDisconnectJob(
|
||||
SessionManager sessionManager, EventBus eventBus, Duration timeoutThreshold) {
|
||||
this.logger = LogManager.getLogger(SessionDisconnectJob.class);
|
||||
this.sessionManager = sessionManager;
|
||||
this.eventBus = eventBus;
|
||||
this.timeoutThreshold = timeoutThreshold;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
logger.debug("Job started.");
|
||||
Instant threshold = Instant.now().minus(timeoutThreshold);
|
||||
|
||||
for (Session session : sessionManager.getAllSessions()) {
|
||||
if (session.getLastInboundActivity().isBefore(threshold)) {
|
||||
eventBus.publish(new DisconnectEvent(session.getId()));
|
||||
logger.info(
|
||||
"Initiated disconnect of {}, as it hasn't been active since a while",
|
||||
session.getId());
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Job finished.");
|
||||
}
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
/** The SessionHandle stores the session and the two worker threads associated with the session */
|
||||
record SessionHandle(Session session, Thread reader, Thread writer) {}
|
||||
@@ -0,0 +1,31 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** Represents a unique identifier for a session. */
|
||||
public class SessionId {
|
||||
private final UUID value;
|
||||
|
||||
/** Creates a new SessionId with a randomly generated UUID. */
|
||||
public SessionId() {
|
||||
this.value = UUID.randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new SessionId with the specified UUID.
|
||||
*
|
||||
* @param value UUID to use for this SessionId
|
||||
*/
|
||||
public SessionId(UUID value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the UUID value of this SessionId.
|
||||
*
|
||||
* @return the UUID value
|
||||
*/
|
||||
public UUID value() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TransportLayer;
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
/** Manages active sessions in the server. */
|
||||
public class SessionManager {
|
||||
private Map<SessionId, SessionHandle> sessions;
|
||||
private final EventBus eventBus;
|
||||
private final Logger logger;
|
||||
private final CommandParserDispatcher dispatcher;
|
||||
private final CommandRouter router;
|
||||
|
||||
/** Constructs a new SessionManager. */
|
||||
public SessionManager(
|
||||
EventBus eventBus, CommandParserDispatcher dispatcher, CommandRouter router) {
|
||||
this.sessions = new ConcurrentHashMap<>();
|
||||
this.eventBus = eventBus;
|
||||
this.logger = LogManager.getLogger(SessionManager.class);
|
||||
this.dispatcher = dispatcher;
|
||||
this.router = router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new Session from provided transport.
|
||||
*
|
||||
* <p>Will create both worker threads and start them.
|
||||
*
|
||||
* @param transport to create session from
|
||||
* @return newly created session
|
||||
*/
|
||||
public Session create(TransportLayer transport) {
|
||||
Session session = new Session(transport, eventBus, dispatcher, router);
|
||||
SessionReader reader = new SessionReader(session, eventBus);
|
||||
SessionWriter writer = new SessionWriter(session);
|
||||
|
||||
Thread readerThread =
|
||||
Thread.ofVirtual()
|
||||
.name("session-" + session.getId().value() + "-reader")
|
||||
.unstarted(reader);
|
||||
Thread writerThread =
|
||||
Thread.ofVirtual()
|
||||
.name("session-" + session.getId().value() + "-writer")
|
||||
.unstarted(writer);
|
||||
|
||||
sessions.put(session.getId(), new SessionHandle(session, readerThread, writerThread));
|
||||
readerThread.start();
|
||||
writerThread.start();
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect specified client
|
||||
*
|
||||
* <p>WARNING: Client will be uninformed about disconnect. Use with caution.
|
||||
*
|
||||
* @param id of the client to disconnect
|
||||
*/
|
||||
public void disconnect(SessionId id) {
|
||||
SessionHandle handle = sessions.remove(id);
|
||||
if (handle == null) {
|
||||
logger.warn(
|
||||
"Requested to disconnect client with id {}. Failed as client is not found",
|
||||
id.value());
|
||||
return;
|
||||
}
|
||||
logger.debug("Disconnecting session {}", id.value());
|
||||
|
||||
handle.reader().interrupt();
|
||||
handle.writer().interrupt();
|
||||
try {
|
||||
handle.session().getTransport().close();
|
||||
} catch (IOException e) {
|
||||
logger.error("Unexpected exception while closing transport", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for the DisconnectEvent
|
||||
*
|
||||
* @param event the DisconnectEvent to handle
|
||||
*/
|
||||
public void onDisconnect(DisconnectEvent event) {
|
||||
logger.debug("Recieved DisconnectEvent event for session {}", event.sessionId().value());
|
||||
|
||||
disconnect(event.sessionId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a session by its ID.
|
||||
*
|
||||
* @param id the ID of the session to retrieve
|
||||
* @return the session with the specified ID, or null if not found
|
||||
*/
|
||||
public Session getSessionById(SessionId id) {
|
||||
SessionHandle handle = sessions.get(id);
|
||||
|
||||
if (handle == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return handle.session();
|
||||
}
|
||||
|
||||
public Collection<Session> getAllSessions() {
|
||||
return sessions.values().stream().map(SessionHandle::session).collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandRouter;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.UnknownCommandException;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.ProtocolParser;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.ProtocolParserException;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer.TokenizerException;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.PrimitiveRequest;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RawRequest;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.Request;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.ErrorResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatchException;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseEncoder;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TransportLayer;
|
||||
import java.io.EOFException;
|
||||
import java.io.IOException;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public class SessionReader implements Runnable {
|
||||
private final Session session;
|
||||
private final TransportLayer transport;
|
||||
private final EventBus eventBus;
|
||||
private final CommandParserDispatcher dispatcher;
|
||||
private final CommandRouter router;
|
||||
private final Logger logger;
|
||||
|
||||
public SessionReader(Session session, EventBus eventBus) {
|
||||
this.session = session;
|
||||
this.transport = session.getTransport();
|
||||
this.eventBus = eventBus;
|
||||
this.dispatcher = session.getDispatcher();
|
||||
this.router = session.getRouter();
|
||||
this.logger =
|
||||
LogManager.getLogger(
|
||||
SessionReader.class.toString() + "-" + session.getId().value());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
RawPacket rawPacket = null;
|
||||
RawRequest rawRequest = null;
|
||||
RequestContext requestContext = null;
|
||||
try {
|
||||
// Step 1: Read from transport
|
||||
rawPacket = transport.read();
|
||||
session.updateLastInboundActivity();
|
||||
logger.debug("Recieved: {}", rawPacket);
|
||||
requestContext = new RequestContext(session.getId(), rawPacket.requestId());
|
||||
|
||||
// Step 2: Syntax validation and conversion into transport object
|
||||
rawRequest = ProtocolParser.parse(rawPacket.payload());
|
||||
logger.debug("Parsed request to {}", rawRequest);
|
||||
|
||||
PrimitiveRequest primitiveRequest =
|
||||
new PrimitiveRequest(
|
||||
requestContext, rawRequest.command(), rawRequest.parameters());
|
||||
logger.debug("Converted to {}", primitiveRequest);
|
||||
|
||||
// Step 3: Parse into Request and execute Request
|
||||
Request request = dispatcher.parse(primitiveRequest);
|
||||
router.execute(request);
|
||||
} catch (EOFException e) {
|
||||
logger.info("Client disconnected");
|
||||
eventBus.publish(new DisconnectEvent(session.getId()));
|
||||
break;
|
||||
|
||||
} catch (TokenizerException | ProtocolParserException e) {
|
||||
logger.error("Error occured while parsing request. RawPacket: {}", rawPacket, e);
|
||||
|
||||
sendErrorResponse(
|
||||
new ErrorResponse(
|
||||
requestContext,
|
||||
"PARSING_ERROR",
|
||||
"Error occured during parsing. Likely due to malformed payload."));
|
||||
|
||||
} catch (UnknownCommandException e) {
|
||||
logger.error("Recieved unknown command '{}' from client", rawRequest.command(), e);
|
||||
sendErrorResponse(
|
||||
new ErrorResponse(
|
||||
requestContext,
|
||||
"UNKNOWN_COMMAND",
|
||||
"This command is unknown to the server."));
|
||||
|
||||
} catch (ResponseDispatchException e) {
|
||||
logger.error(
|
||||
"Unexpected ResponseDispatchException exception while dispatching request",
|
||||
e);
|
||||
|
||||
} catch (IOException e) {
|
||||
logger.error("Unexpected IO exception while reading from transport", e);
|
||||
|
||||
} catch (RuntimeException e) {
|
||||
logger.error("Unexpected RuntimeException occured", e);
|
||||
sendErrorResponse(
|
||||
new ErrorResponse(
|
||||
requestContext,
|
||||
"INTERNAL_ERROR",
|
||||
"Unexpected internal server error occured."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helperfunction to send ErrorResponse to client
|
||||
*
|
||||
* @param response to send to the client
|
||||
*/
|
||||
private void sendErrorResponse(ErrorResponse response) {
|
||||
PrimitiveResponse primitiveResponse = ResponseEncoder.encode(response);
|
||||
try {
|
||||
session.getResponseQueue().put(primitiveResponse);
|
||||
} catch (InterruptedException e) {
|
||||
logger.error("Got interrupted while sending ErrorResponse to client.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
package ch.unibas.dmi.dbis.cs108.casono.server.network.sessions;
|
||||
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.PrimitiveResponse;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.RawPacket;
|
||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.transport.TransportLayer;
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
public class SessionWriter implements Runnable {
|
||||
private final TransportLayer transport;
|
||||
private final BlockingQueue<PrimitiveResponse> queue;
|
||||
private final Logger logger;
|
||||
|
||||
public SessionWriter(Session session) {
|
||||
this.transport = session.getTransport();
|
||||
this.queue = session.getResponseQueue();
|
||||
this.logger =
|
||||
LogManager.getLogger(
|
||||
SessionReader.class.toString() + "-" + session.getId().value());
|
||||
}
|
||||
|
||||
public void run() {
|
||||
while (!Thread.currentThread().isInterrupted()) {
|
||||
RawPacket packet = null;
|
||||
try {
|
||||
PrimitiveResponse response = queue.take();
|
||||
packet = new RawPacket(response.requestId(), response.payload());
|
||||
transport.write(packet);
|
||||
} catch (IOException e) {
|
||||
logger.error(
|
||||
"Unexpected exception while writing to transport. RawPacket: {}",
|
||||
packet,
|
||||
e);
|
||||
} catch (InterruptedException e) {
|
||||
logger.warn("Thread got interrupted", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user