diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..3b41682
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..667aaef
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..12fbe1e
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,19 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+wrapperVersion=3.3.2
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
diff --git a/dependency-reduced-pom.xml b/dependency-reduced-pom.xml
new file mode 100644
index 0000000..bd4ff2e
--- /dev/null
+++ b/dependency-reduced-pom.xml
@@ -0,0 +1,97 @@
+
+
+ 4.0.0
+ eric
+ Roullette
+ 0
+
+
+
+ maven-compiler-plugin
+ 3.11.0
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+
+ maven-surefire-plugin
+ 3.0.0-M7
+
+ true
+
+
+
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+ eric.Roullette.App
+
+
+
+
+
+
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ 5.9.2
+ test
+
+
+ junit-jupiter-api
+ org.junit.jupiter
+
+
+ junit-jupiter-params
+ org.junit.jupiter
+
+
+ junit-jupiter-engine
+ org.junit.jupiter
+
+
+
+
+ io.javalin
+ javalin-testtools
+ 6.7.0
+ test
+
+
+ okhttp
+ com.squareup.okhttp3
+
+
+
+
+
+ 17
+ 5.9.2
+ 2.16.0
+ 17
+ UTF-8
+ 6.7.0
+
+
diff --git a/mvnw b/mvnw
new file mode 100644
index 0000000..19529dd
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,259 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.2
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..249bdf3
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..da4e0f5
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,128 @@
+
+
+
+ 4.0.0
+
+ eric
+ Roullette
+ 0
+
+
+
+ 17
+ 17
+ UTF-8
+
+
+ 6.7.0
+ 5.9.2
+ 2.16.0
+
+
+
+
+
+ se.michaelthelin.spotify
+ spotify-web-api-java
+ 9.3.0
+
+
+
+
+ io.javalin
+ javalin-bundle
+ ${javalin.version}
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ ${jackson.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ ${jackson.version}
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ ${jackson.version}
+
+
+
+
+ org.junit.jupiter
+ junit-jupiter
+ ${junit.jupiter.version}
+ test
+
+
+ io.javalin
+ javalin-testtools
+ ${javalin.version}
+ test
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.11.0
+
+ ${maven.compiler.source}
+ ${maven.compiler.target}
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.0.0-M7
+
+ true
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-shade-plugin
+ 3.5.0
+
+
+ package
+
+ shade
+
+
+
+
+ *:*
+
+ META-INF/*.SF
+ META-INF/*.DSA
+ META-INF/*.RSA
+
+
+
+
+
+ eric.Roullette.App
+
+
+
+
+
+
+
+
+
diff --git a/src/main/java/eric/Roullette/App.java b/src/main/java/eric/Roullette/App.java
new file mode 100644
index 0000000..723777b
--- /dev/null
+++ b/src/main/java/eric/Roullette/App.java
@@ -0,0 +1,69 @@
+package eric.Roullette;
+
+import eric.Roullette.config.AppConfig;
+import eric.Roullette.controller.GameController;
+import eric.Roullette.service.GameService;
+import eric.Roullette.service.SpotifyAuthService;
+import eric.Roullette.websocket.GameWebSocketHandler;
+import io.javalin.Javalin;
+import io.javalin.http.staticfiles.Location;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import java.net.URLEncoder;
+import java.util.Map;
+
+public class App {
+ private static final Logger log = LoggerFactory.getLogger(App.class);
+
+ public static void main(String[] args) {
+ AppConfig cfg = new AppConfig();
+ if (cfg.spotifyClientId == null || cfg.spotifyClientSecret == null || cfg.spotifyRedirectUri == null) {
+ throw new IllegalStateException("Spotify-Konfiguration fehlt: Bitte stelle sicher, dass ClientId, ClientSecret und RedirectUri gesetzt sind.");
+ }
+ GameService gs = new GameService();
+ SpotifyAuthService sas = new SpotifyAuthService(
+ cfg.spotifyClientId,
+ cfg.spotifyClientSecret,
+ cfg.spotifyRedirectUri
+ );
+
+ Javalin app = Javalin.create(config -> {
+ config.showJavalinBanner = false;
+ config.staticFiles.add("/public", Location.CLASSPATH);
+ }).start(cfg.port);
+
+ app.exception(Exception.class, (e, ctx) -> {
+ log.error("Unhandled error", e);
+ ctx.status(500).json(Map.of("error", e.getMessage()));
+ });
+
+ app.get("/", ctx -> ctx.redirect("/index.html"));
+
+ // OAuth
+ app.get("/login", ctx -> ctx.redirect(sas.getAuthorizationUri(ctx.queryParam("username")).toString()));
+ app.get("/spotify/callback", ctx -> {
+ String code = ctx.queryParam("code");
+ String user = ctx.queryParam("state"); // Benutzername aus dem 'state' Parameter holen
+ try {
+ sas.exchangeCode(code, user); // Benutzername an den Service übergeben
+ ctx.redirect("/lobby.html?username=" + URLEncoder.encode(user, UTF_8));
+ } catch (Exception e) {
+ log.error("Fehler beim Austausch des Spotify-Codes für Benutzer {}", user, e);
+ ctx.status(500).result("Fehler bei der Spotify-Authentifizierung.");
+ }
+ });
+
+ // WS-Handler
+ GameWebSocketHandler wsHandler = new GameWebSocketHandler(gs, sas);
+
+ // HTTP-Controller
+ new GameController(app, gs, sas, wsHandler);
+
+ app.ws("/ws/{gameId}", wsHandler::register);
+
+ log.info("Server läuft auf Port {}", cfg.port);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/User.java b/src/main/java/eric/Roullette/User.java
new file mode 100644
index 0000000..ffeb636
--- /dev/null
+++ b/src/main/java/eric/Roullette/User.java
@@ -0,0 +1,30 @@
+package eric.Roullette;
+
+import java.util.List;
+
+public class User {
+ private String name;
+ private List recentTracks;
+
+ public User(String name, List recentTracks) {
+ this.name = name;
+ this.recentTracks = recentTracks;
+ }
+ public String getName() {
+ return name;
+ }
+ public List getRecentTracks() {
+ return recentTracks;
+ }
+ public void setRecentTracks(List recentTracks) {
+ this.recentTracks = recentTracks;
+ }
+
+ @Override
+ public String toString() {
+ return "User{" +
+ "name='" + name + '\'' +
+ ", recentTracks=" + recentTracks +
+ '}';
+ }
+}
diff --git a/src/main/java/eric/Roullette/config/AppConfig.java b/src/main/java/eric/Roullette/config/AppConfig.java
new file mode 100644
index 0000000..97d1994
--- /dev/null
+++ b/src/main/java/eric/Roullette/config/AppConfig.java
@@ -0,0 +1,10 @@
+package eric.Roullette.config;
+
+ public class AppConfig {
+ public final int port = Integer.parseInt(
+ System.getenv().getOrDefault("PORT", "8080")
+ );
+ public final String spotifyClientId = System.getenv("SPOTIFY_CLIENT_ID");
+ public final String spotifyClientSecret = System.getenv("SPOTIFY_CLIENT_SECRET");
+ public final String spotifyRedirectUri = System.getenv("SPOTIFY_REDIRECT_URI");
+ }
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/controller/GameController.java b/src/main/java/eric/Roullette/controller/GameController.java
new file mode 100644
index 0000000..1ffed96
--- /dev/null
+++ b/src/main/java/eric/Roullette/controller/GameController.java
@@ -0,0 +1,103 @@
+package eric.Roullette.controller;
+
+ import com.fasterxml.jackson.core.type.TypeReference;
+ import io.javalin.Javalin;
+ import io.javalin.http.Context;
+ import eric.Roullette.service.GameService;
+ import eric.Roullette.service.SpotifyAuthService;
+ import eric.Roullette.websocket.GameWebSocketHandler;
+
+ import java.util.List;
+ import java.util.Map;
+ import java.util.Objects;
+ import java.util.UUID;
+ import java.util.concurrent.ThreadLocalRandom;
+
+ public class GameController {
+ private final GameService gameService;
+ private final SpotifyAuthService authService;
+ private final GameWebSocketHandler webSocketHandler;
+
+ public GameController(Javalin app, GameService gs, SpotifyAuthService sas, GameWebSocketHandler wsHandler) {
+ this.gameService = gs;
+ this.authService = sas;
+ this.webSocketHandler = wsHandler;
+ app.post("/api/create-game", this::createGame);
+ app.post("/api/join-game", this::joinGame);
+ app.get("/api/game/{gameId}/players", this::getPlayers);
+ app.post("/api/game/{gameId}/start-round", this::startRound);
+ app.post("/api/game/{gameId}/guess", this::guess);
+ }
+
+ private void createGame(Context ctx) {
+ Map body = ctx.bodyAsClass(Map.class);
+ String user = (String) body.get("username");
+ if (user == null || user.isBlank()) {
+ ctx.status(400).result("username fehlt");
+ return;
+ }
+ int limit = body.get("limit") != null ? (int) body.get("limit") : 10;
+
+ String gameId;
+ do {
+ gameId = String.format("%04d", ThreadLocalRandom.current().nextInt(0, 10000));
+ } while (gameService.gameExists(gameId));
+ gameService.createGame(gameId, limit);
+ gameService.addPlayer(gameId, user, limit);
+ gameService.broadcastPlayers(gameId);
+
+ ctx.json(Map.of("status", "ok", "gameId", gameId));
+ }
+
+ private void joinGame(Context ctx) {
+ Map body = ctx.bodyAsClass(Map.class);
+ String user = body.get("username");
+ String gameId = body.get("gameId");
+ int limit = body.get("limit") != null ? Integer.parseInt(body.get("limit")) : 10;
+ if (user == null || gameId == null) {
+ ctx.status(400).result("username oder gameId fehlt");
+ return;
+ }
+ gameService.addPlayer(gameId, user, limit);
+ gameService.broadcastPlayers(gameId);
+ ctx.json(Map.of("status", "ok"));
+ }
+
+ private void getPlayers(Context ctx) {
+ String gameId = ctx.pathParam("gameId");
+ int limit = Integer.parseInt(Objects.requireNonNull(ctx.queryParam("limit")));
+ var game = gameService.getOrCreateGame(gameId, limit);
+ ctx.json(Map.of(
+ "players", game.players(),
+ "limit", game.limit() // Limit mitgeben!
+ ));
+ }
+
+ private void startRound(Context ctx) {
+ String gameId = ctx.pathParam("gameId");
+ int limit = Integer.parseInt(Objects.requireNonNull(ctx.queryParam("limit")));
+ ctx.json(Map.of("status", "ok"));
+ webSocketHandler.broadcastRoundStart(gameId, limit);
+ }
+
+ private void guess(Context ctx) {
+ String gameId = ctx.pathParam("gameId");
+ int limit = Integer.parseInt(Objects.requireNonNull(ctx.queryParam("limit")));
+ Map body = ctx.bodyAsClass(Map.class);
+ String guess = body.get("guess");
+ String user = body.get("username");
+ GameService.Game game = gameService.getOrCreateGame(gameId, limit);
+ String owner = game.currentOwner();
+ if (owner == null || guess == null) {
+ ctx.status(400).result("ungültig");
+ return;
+ }
+ boolean correct = guess.equals(owner);
+ if (correct) game.scores().merge(user, 1, Integer::sum);
+ ctx.json(Map.of(
+ "correct", correct,
+ "owner", owner,
+ "scores", game.scores()
+ ));
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/dto/MessageType.java b/src/main/java/eric/Roullette/dto/MessageType.java
new file mode 100644
index 0000000..a9564d6
--- /dev/null
+++ b/src/main/java/eric/Roullette/dto/MessageType.java
@@ -0,0 +1,14 @@
+package eric.Roullette.dto;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * Wird von Jackson kleinbuchstabig serialisiert,
+ * damit frontend und backend denselben `type` haben.
+ */
+@JsonFormat(shape = JsonFormat.Shape.STRING)
+public enum MessageType {
+ @JsonProperty("players") PLAYERS,
+ @JsonProperty("reload") RELOAD
+}
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/dto/PlayersMessage.java b/src/main/java/eric/Roullette/dto/PlayersMessage.java
new file mode 100644
index 0000000..f75835f
--- /dev/null
+++ b/src/main/java/eric/Roullette/dto/PlayersMessage.java
@@ -0,0 +1,8 @@
+package eric.Roullette.dto;
+
+import java.util.List;
+public record PlayersMessage(
+ MessageType type,
+ List players
+) { public PlayersMessage(List players) { this(MessageType.PLAYERS, players); } }
+
diff --git a/src/main/java/eric/Roullette/dto/ReloadMessage.java b/src/main/java/eric/Roullette/dto/ReloadMessage.java
new file mode 100644
index 0000000..af55820
--- /dev/null
+++ b/src/main/java/eric/Roullette/dto/ReloadMessage.java
@@ -0,0 +1,5 @@
+package eric.Roullette.dto;
+
+public record ReloadMessage(MessageType type) {
+ public ReloadMessage() { this(MessageType.RELOAD); }
+}
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/service/GameService.java b/src/main/java/eric/Roullette/service/GameService.java
new file mode 100644
index 0000000..de9cbbb
--- /dev/null
+++ b/src/main/java/eric/Roullette/service/GameService.java
@@ -0,0 +1,78 @@
+package eric.Roullette.service;
+
+import eric.Roullette.dto.PlayersMessage;
+import eric.Roullette.util.JsonUtil;
+import io.javalin.websocket.WsContext;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import java.util.*;
+import java.util.concurrent.*;
+
+public class GameService {
+ private static final Logger log = LoggerFactory.getLogger(GameService.class);
+ private final Map> sessions = new ConcurrentHashMap<>();
+ private final Map games = new ConcurrentHashMap<>();
+
+ public record Game(String id, List players, Map scores,String currentOwner,
+ String currentSong,List allTracks, int limit) {
+ public static Game create(String id, int limit) {
+ return new Game(id, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), limit);
+ }
+
+ }
+
+ public Game getOrCreateGame(String gameId, int limit) {
+ return games.computeIfAbsent(gameId, id -> Game.create(id, limit));
+ }
+
+ public void addPlayer(String gameId, String user, int limit) {
+ Game g = getOrCreateGame(gameId, limit);
+ if (user != null && !g.players().contains(user)) {
+ g.players().add(user);
+ g.scores().putIfAbsent(user, 0);
+ }
+ }
+
+ public void registerSession(String gameId, WsContext ctx) {
+ sessions
+ .computeIfAbsent(gameId, id -> ConcurrentHashMap.newKeySet())
+ .add(ctx);
+ broadcastPlayers(gameId);
+ }
+
+ public void removeSession(String gameId, WsContext ctx) {
+ sessions.getOrDefault(gameId, Collections.emptySet()).remove(ctx);
+ broadcastPlayers(gameId);
+ }
+
+ public void broadcastPlayers(String gameId) {
+ Game game = games.get(gameId);
+ if (game == null) return;
+ PlayersMessage msg = new PlayersMessage(new ArrayList<>(game.players()));
+ sessions.getOrDefault(gameId, Collections.emptySet())
+ .forEach(ctx -> ctx.send(JsonUtil.toJson(msg)));
+ }
+
+ public void createGame(String gameId, int limit) {
+ Game game = new Game(gameId, new CopyOnWriteArrayList<>(), new ConcurrentHashMap<>(), null, null, new ArrayList<>(), limit);
+ games.put(gameId, game);
+ }
+
+ public boolean gameExists(String gameId) {
+ return games.containsKey(gameId);
+ }
+ public Game startRound(String gameId, List uris, int limit) {
+ Game g = getOrCreateGame(gameId, limit);
+ if (g.players().isEmpty()) throw new IllegalStateException("No players");
+ String owner = g.players().get(ThreadLocalRandom.current().nextInt(g.players().size()));
+ String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size()));
+ Game updated = new Game(gameId, g.players(), g.scores(), owner, song, uris, limit);
+ games.put(gameId, updated);
+ return updated;
+ }
+ // In GameService.java
+ public Set getSessions(String gameId) {
+ return sessions.getOrDefault(gameId, Collections.emptySet());
+ }
+
+}
diff --git a/src/main/java/eric/Roullette/service/SpotifyAuthService.java b/src/main/java/eric/Roullette/service/SpotifyAuthService.java
new file mode 100644
index 0000000..cac941c
--- /dev/null
+++ b/src/main/java/eric/Roullette/service/SpotifyAuthService.java
@@ -0,0 +1,109 @@
+package eric.Roullette.service;
+
+ import org.apache.hc.core5.http.ParseException;
+ import se.michaelthelin.spotify.SpotifyApi;
+ import se.michaelthelin.spotify.SpotifyHttpManager;
+ import se.michaelthelin.spotify.exceptions.SpotifyWebApiException;
+ import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials;
+ import se.michaelthelin.spotify.model_objects.specification.Paging;
+ import se.michaelthelin.spotify.model_objects.specification.PagingCursorbased;
+ import se.michaelthelin.spotify.model_objects.specification.PlayHistory;
+ import se.michaelthelin.spotify.requests.data.player.GetCurrentUsersRecentlyPlayedTracksRequest;
+
+ import java.io.IOException;
+ import java.net.URI;
+ import java.util.Arrays;
+ import java.util.Collections;
+ import java.util.List;
+ import java.util.Map;
+ import java.util.concurrent.ConcurrentHashMap;
+
+ public class SpotifyAuthService {
+ private final String clientId;
+ private final String clientSecret;
+ private final URI redirectUri;
+ // Speichert für jeden Benutzer eine eigene, authentifizierte SpotifyApi-Instanz
+ private final Map userApis = new ConcurrentHashMap<>();
+
+ public SpotifyAuthService(String clientId, String clientSecret, String redirectUri) {
+ this.clientId = clientId;
+ this.clientSecret = clientSecret;
+ this.redirectUri = SpotifyHttpManager.makeUri(redirectUri);
+ }
+
+ public URI getAuthorizationUri(String user) {
+ // Temporäre API-Instanz nur für die Erstellung der Auth-URL
+ SpotifyApi tempApi = new SpotifyApi.Builder()
+ .setClientId(clientId)
+ .setClientSecret(clientSecret)
+ .setRedirectUri(redirectUri)
+ .build();
+
+ return tempApi.authorizationCodeUri()
+ .scope("user-read-recently-played")
+ .state(user) // Der Benutzername wird im State mitgegeben
+ .build()
+ .execute();
+ }
+
+ public void exchangeCode(String code, String user) throws IOException, ParseException, SpotifyWebApiException {
+ // Erstellt eine neue, dedizierte API-Instanz für diesen Benutzer
+ SpotifyApi userApi = new SpotifyApi.Builder()
+ .setClientId(clientId)
+ .setClientSecret(clientSecret)
+ .setRedirectUri(redirectUri)
+ .build();
+
+ // Tauscht den Code gegen Tokens und konfiguriert die Instanz
+ AuthorizationCodeCredentials creds = userApi.authorizationCode(code).build().execute();
+ userApi.setAccessToken(creds.getAccessToken());
+ userApi.setRefreshToken(creds.getRefreshToken());
+
+ // Speichert die fertig konfigurierte API-Instanz für den Benutzer
+ userApis.put(user, userApi);
+ }
+
+ public List getRecentTracks(String user, int limit) {
+ SpotifyApi userApi = userApis.get(user);
+
+ if (userApi == null) {
+ System.err.println("Kein SpotifyApi-Client für Benutzer gefunden: " + user);
+ return Collections.emptyList();
+ }
+
+ try {
+ GetCurrentUsersRecentlyPlayedTracksRequest request = userApi.getCurrentUsersRecentlyPlayedTracks()
+ .limit(limit)
+ .build();
+ PagingCursorbased history = request.execute();
+ if (history == null || history.getItems() == null) {
+ return Collections.emptyList();
+ }
+ return Arrays.stream(history.getItems())
+ .map(item -> item.getTrack().getUri())
+ .distinct()
+ .toList();
+ } catch (IOException | SpotifyWebApiException | ParseException e) {
+ e.printStackTrace();
+ return Collections.emptyList();
+ }
+ }
+
+ private List getRecentTracksLimit(SpotifyApi userApi, int limit) throws IOException, SpotifyWebApiException, ParseException {
+ GetCurrentUsersRecentlyPlayedTracksRequest request = userApi.getCurrentUsersRecentlyPlayedTracks()
+ .limit(limit)
+ .build();
+ PagingCursorbased history = request.execute();
+
+ List uris = Arrays.stream(history.getItems())
+ .map(item -> item.getTrack().getUri())
+ .distinct()
+ .toList();
+ if (uris.size() < limit) {
+ int newLimit = limit + (limit - uris.size());
+ return getRecentTracksLimit(userApi, newLimit);
+
+ }
+
+ }
+ }
\ No newline at end of file
diff --git a/src/main/java/eric/Roullette/util/JsonUtil.java b/src/main/java/eric/Roullette/util/JsonUtil.java
new file mode 100644
index 0000000..0430713
--- /dev/null
+++ b/src/main/java/eric/Roullette/util/JsonUtil.java
@@ -0,0 +1,38 @@
+package eric.Roullette.util;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+
+/**
+ * Utility für JSON-Serialisierung/Deserialisierung via Jackson.
+ */
+public final class JsonUtil {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper()
+ // Optional: schönere Ausgabe, falls Du Debug-JSON brauchst
+ .configure(SerializationFeature.INDENT_OUTPUT, false);
+
+ private JsonUtil() { /* no instances */ }
+
+ /**
+ * Serialisiert ein Objekt in einen JSON-String.
+ */
+ public static String toJson(Object obj) {
+ try {
+ return MAPPER.writeValueAsString(obj);
+ } catch (Exception e) {
+ throw new RuntimeException("JSON serialization failed for " + obj, e);
+ }
+ }
+
+ /**
+ * Deserialisiert einen JSON-String in eine Instanz von {@code clazz}.
+ */
+ public static T fromJson(String json, Class clazz) {
+ try {
+ return MAPPER.readValue(json, clazz);
+ } catch (Exception e) {
+ throw new RuntimeException("JSON deserialization failed for " + clazz, e);
+ }
+ }
+}
diff --git a/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java b/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java
new file mode 100644
index 0000000..f7e4016
--- /dev/null
+++ b/src/main/java/eric/Roullette/websocket/GameWebSocketHandler.java
@@ -0,0 +1,178 @@
+package eric.Roullette.websocket;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import eric.Roullette.service.GameService;
+import eric.Roullette.service.SpotifyAuthService;
+import eric.Roullette.util.JsonUtil;
+import io.javalin.websocket.WsConfig;
+import io.javalin.websocket.WsContext;
+
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * WebSocket-Handler für Spiel-Sessions. Verwaltet Spieler-Sessions,
+ * broadcastet Spieler-Listen, Runden-Starts und Rundenergebnisse.
+ */
+public class GameWebSocketHandler {
+
+ private final GameService service;
+ private final SpotifyAuthService authService;
+
+ // Spiel-ID → (Username → deren Guess)
+ private final Map> currentGuesses = new ConcurrentHashMap<>();
+
+ public GameWebSocketHandler(GameService gameService, SpotifyAuthService authService) {
+ this.service = gameService;
+ this.authService = authService;
+ }
+
+ /**
+ * Registriert Connect/Close/Message-Handler für eine WebSocket-Route.
+ */
+ public void register(WsConfig ws) {
+
+ // Neue Connection
+ ws.onConnect(ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ String username = ctx.queryParam("username");
+ int limit = Integer.parseInt(Objects.requireNonNull(ctx.queryParam("limit")));
+ // Spiel- und Session-Registrierung
+ service.addPlayer(gameId, username, limit);
+ // Alle Clients über neue Spielerliste informieren
+ service.registerSession(gameId, ctx);
+ service.broadcastPlayers(gameId);
+ });
+
+ // Connection geschlossen
+ ws.onClose(ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ service.removeSession(gameId, ctx);
+ });
+
+ // Eingehende Nachrichten (Guesses & Player-Requests)
+ ws.onMessage(ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ int limit = Integer.parseInt(Objects.requireNonNull(ctx.queryParam("limit")));
+ JsonNode node = JsonUtil.fromJson(ctx.message(), JsonNode.class);
+ String type = node.get("type").asText();
+
+ switch (type) {
+ case "guess" -> {
+ String user = node.get("username").asText();
+ String guess = node.get("guess").asText();
+ // Guess speichern
+ currentGuesses
+ .computeIfAbsent(gameId, id -> new ConcurrentHashMap<>())
+ .put(user, guess);
+ // Wenn alle getippt haben, Ergebnis broadcasten
+ int numPlayers = service.getOrCreateGame(gameId, limit).players().size();
+ if (currentGuesses.get(gameId).size() == numPlayers) {
+ broadcastRoundResult(gameId, limit);
+ }
+ }
+ case "requestPlayers" -> service.broadcastPlayers(gameId);
+
+ case "start-round" -> {
+ var game = service.getOrCreateGame(gameId,limit);
+ if (game.players().isEmpty()) return;
+
+ // Songs von allen Spielern sammeln
+ List allTracks = new ArrayList<>();
+ for (String player : game.players()) {
+ allTracks.addAll(authService.getRecentTracks(player, game.limit()));
+ }
+ if (allTracks.isEmpty()) {
+ // TODO: Fehler an Client senden, dass keine Songs da sind
+ return;
+ }
+
+ // Runde im Service starten, um Song und Owner zu setzen
+ service.startRound(gameId, allTracks,limit);
+ // Jetzt Broadcast mit den aktuellen Daten
+ broadcastRoundStart(gameId, limit);
+ }
+ }
+ });
+ }
+
+ // ----- Broadcast-Methoden -----
+
+ /** Broadcastet den Runden-Start (Song + Optionen) an alle Clients. */
+ public void broadcastRoundStart(String gameId, int limit) {
+ var game = service.getOrCreateGame(gameId,limit);
+ List opts = game.players();
+ String songUri = game.currentSong();
+ List allTracks = game.allTracks();
+ String msg = JsonUtil.toJson(Map.of(
+ "type", "round-start",
+ "ownerOptions", opts,
+ "songUri", songUri,
+ "allTracks", allTracks
+ ));
+ broadcastToAll(gameId, msg);
+ }
+
+ /** Broadcastet das Rundenergebnis (Scores, wer richtig, wer getippt hat). */
+// Punkte für alle Guess-Teilnehmer anpassen
+private void broadcastRoundResult(String gameId, int limit) {
+ var game = service.getOrCreateGame(gameId, limit);
+ Map scores = game.scores();
+ Map guesses = currentGuesses.remove(gameId);
+ String owner = game.currentOwner();
+
+ // Für jeden Tippenden Score anpassen
+ for (Map.Entry entry : guesses.entrySet()) {
+ String guesser = entry.getKey();
+ boolean correct = owner.equals(entry.getValue());
+ scores.merge(guesser, correct ? 3 : -1, Integer::sum);
+ }
+
+ String msg = JsonUtil.toJson(Map.of(
+ "type", "round-result",
+ "scores", scores,
+ "guesses", guesses,
+ "owner", owner
+ ));
+ broadcastToAll(gameId, msg);
+
+ // Prüfe auf Gewinner
+ String winner = scores.entrySet().stream()
+ .filter(e -> e.getValue() >= 30)
+ .map(Map.Entry::getKey)
+ .findFirst()
+ .orElse(null);
+ if (winner != null) {
+ // Broadcast an alle, dass das Spiel vorbei ist
+ String winMsg = JsonUtil.toJson(Map.of(
+ "type", "game-end",
+ "winner", winner,
+ "scores", scores
+ ));
+ broadcastToAll(gameId, winMsg);
+ game.scores().replaceAll((user , pts) -> 0); // Reset Scores für alle Spieler
+
+ }else{
+ // nächste Runde starten
+ new Thread(() -> {
+ try { Thread.sleep(4000); } catch (InterruptedException ignored) {}
+ broadcastRoundStart(gameId, limit);
+ }).start();
+ }
+}
+
+ /** Hilfsmethode: Sendet eine Nachricht an alle WebSocket-Sessions eines Spiels. */
+ private void broadcastToAll(String gameId, String msg) {
+ // Holt alle WsContext, die der Service beim Connect registriert hat
+ Set sessions = service.getSessions(gameId);
+ if (sessions == null) return;
+ sessions.stream()
+ .filter(s -> s.session.isOpen())
+ .forEach(ctx -> {
+ try {
+ ctx.send(msg);
+ } catch (Exception ignore) {}
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..cb82fcd
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,6 @@
+spring.application.name=Roullette
+
+spotify:
+client-id: 70c36cd6e2d54ad0ba2e60ef6334bbc8
+client-secret: 116188574dd140eab1973e75c7e5ecfe
+redirect-uri: https://www.davidmagkuchen.de/spotify/callback
\ No newline at end of file
diff --git a/src/main/resources/archiv/App.java b/src/main/resources/archiv/App.java
new file mode 100644
index 0000000..30b53ac
--- /dev/null
+++ b/src/main/resources/archiv/App.java
@@ -0,0 +1,280 @@
+package eric.Roullette;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import io.javalin.Javalin;
+import io.javalin.http.staticfiles.Location;
+import io.javalin.websocket.WsContext;
+import se.michaelthelin.spotify.SpotifyApi;
+import se.michaelthelin.spotify.SpotifyHttpManager;
+import se.michaelthelin.spotify.model_objects.credentials.AuthorizationCodeCredentials;
+import se.michaelthelin.spotify.requests.authorization.authorization_code.AuthorizationCodeUriRequest;
+
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.ThreadLocalRandom;
+
+public class App {
+
+ // JSON-Mapper
+ private static final ObjectMapper objectMapper = new ObjectMapper();
+
+ // WebSocket-Sessions per gameId
+ private static final ConcurrentMap> gameSockets = new ConcurrentHashMap<>();
+
+ // In-memory stores
+ private static final ConcurrentMap games = new ConcurrentHashMap<>();
+ private static final ConcurrentMap> userRecentTracks = new ConcurrentHashMap<>();
+
+ // Spotify API credentials
+ private static final String clientId = "70c36cd6e2d54ad0ba2e60ef6334bbc8";
+ private static final String clientSecret = "116188574dd140eab1973e75c7e5ecfe";
+ private static final URI redirectUri = SpotifyHttpManager.makeUri(
+ "https://www.davidmagkuchen.de/spotify/callback"
+ );
+ private static final SpotifyApi spotifyApi = new SpotifyApi.Builder()
+ .setClientId(clientId)
+ .setClientSecret(clientSecret)
+ .setRedirectUri(redirectUri)
+ .build();
+
+ // Game record
+ record Game(
+ String id,
+ List players,
+ Map scores,
+ String currentOwner,
+ String currentSong
+ ) {
+ static Game create(String id) {
+ return new Game(id,
+ new ArrayList<>(),
+ new ConcurrentHashMap<>(),
+ null,
+ null
+ );
+ }
+ }
+
+ public static void main(String[] args) {
+ int limit = 20;
+
+ // Create Javalin
+ Javalin app = Javalin.create(config -> {
+ config.staticFiles.add(sf -> {
+ sf.directory = "/public";
+ sf.location = Location.CLASSPATH;
+ sf.hostedPath = "/";
+ });
+ });
+
+ // WebSocket-Endpoint
+ app.ws("/ws/{gameId}", ws -> {
+ ws.onConnect(ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ String username = ctx.queryParam("username");
+
+ // Session registrieren
+ Set sessions = gameSockets
+ .computeIfAbsent(gameId, id -> ConcurrentHashMap.newKeySet());
+ sessions.add(ctx);
+
+ // Game anlegen + Spieler hinzufügen
+ Game game = games.computeIfAbsent(gameId, Game::create);
+ if (username != null
+ && !username.isBlank()
+ && game.players().add(username)) {
+ game.scores().putIfAbsent(username, 0);
+ }
+
+ // Immer an alle pushen
+ broadcastPlayers(gameId);
+ });
+
+ ws.onClose(ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ gameSockets
+ .getOrDefault(gameId, Collections.emptySet())
+ .remove(ctx);
+ broadcastPlayers(gameId);
+ });
+ });
+
+ app.start(8080);
+
+ // Redirect root to index
+ app.get("/", ctx -> ctx.redirect("/index.html"));
+
+ // OAuth login
+ app.get("/login", ctx -> {
+ String user = ctx.queryParam("username");
+ if (user == null || user.isEmpty()) {
+ ctx.status(400).result("username fehlt");
+ return;
+ }
+ AuthorizationCodeUriRequest req = spotifyApi.authorizationCodeUri()
+ .scope("user-read-recently-played")
+ .state(user)
+ .build();
+ ctx.redirect(req.execute().toString());
+ });
+
+ // OAuth callback
+ app.get("/spotify/callback", ctx -> {
+ String code = ctx.queryParam("code");
+ String user = ctx.queryParam("state");
+ if (code == null || user == null) {
+ ctx.status(400).result("code oder state fehlt");
+ return;
+ }
+ AuthorizationCodeCredentials creds = spotifyApi
+ .authorizationCode(code).build().execute();
+ spotifyApi.setAccessToken(creds.getAccessToken());
+ spotifyApi.setRefreshToken(creds.getRefreshToken());
+
+ var history = spotifyApi.getCurrentUsersRecentlyPlayedTracks()
+ .limit(limit).build().execute().getItems();
+ List uris = Arrays.stream(history)
+ .map(it -> it.getTrack().getUri())
+ .toList();
+ userRecentTracks.put(user, uris);
+
+ String uenc = URLEncoder.encode(user, StandardCharsets.UTF_8);
+ ctx.redirect("/lobby.html?username=" + uenc);
+ });
+
+ // create game
+ app.post("/api/create-game", ctx -> {
+ Map body = ctx.bodyAsClass(Map.class);
+ String user = (String) body.get("username");
+ if (user == null || user.isEmpty()) {
+ ctx.status(400).result("username fehlt");
+ return;
+ }
+ String gameId;
+ do {
+ gameId = String.format("%04d", ThreadLocalRandom.current().nextInt(10000));
+ } while (games.containsKey(gameId));
+ Game game = Game.create(gameId);
+ game.players().add(user);
+ game.scores().put(user, 0);
+ games.put(gameId, game);
+ broadcastPlayers(gameId);
+ broadcastReload(gameId);
+ ctx.json(Map.of("status","ok","gameId",gameId));
+ });
+
+ // join game
+ app.post("/api/join-game", ctx -> {
+ Map body = ctx.bodyAsClass(Map.class);
+ String user = (String) body.get("username");
+ String gameId = (String) body.get("gameId");
+ if (user == null || gameId == null) {
+ ctx.status(400).result("username oder gameId fehlt");
+ return;
+ }
+ Game game = games.get(gameId);
+ if (game == null) {
+ ctx.status(404).result("Game nicht gefunden");
+ return;
+ }
+ if (game.players().add(user)) {
+ game.scores().putIfAbsent(user, 0);
+ }
+ broadcastPlayers(gameId);
+ broadcastReload(gameId);
+ ctx.json(Map.of("status","ok"));
+ });
+
+ // start round
+ app.post("/api/game/{gameId}/start-round", ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ Game game = games.get(gameId);
+ if (game == null || game.players().isEmpty()) {
+ ctx.status(400).result("Game ungültig");
+ return;
+ }
+ List pls = new ArrayList<>(game.players());
+ String owner = pls.get(ThreadLocalRandom.current().nextInt(pls.size()));
+ List uris = userRecentTracks.get(owner);
+ if (uris == null || uris.isEmpty()) {
+ ctx.status(400).result("keine Tracks");
+ return;
+ }
+ String song = uris.get(ThreadLocalRandom.current().nextInt(uris.size()));
+ game = new Game(game.id(), game.players(), game.scores(), owner, song);
+ games.put(gameId, game);
+ ctx.json(Map.of(
+ "ownerOptions", game.players(),
+ "songUri", song,
+ "scores", game.scores()
+ ));
+ });
+
+ // guess
+ app.post("/api/game/{gameId}/guess", ctx -> {
+ String gameId = ctx.pathParam("gameId");
+ Map body = ctx.bodyAsClass(Map.class);
+ String guess = (String) body.get("guess");
+ String user = (String) body.get("username");
+ Game game = games.get(gameId);
+ if (game == null || game.currentOwner() == null || guess == null) {
+ ctx.status(400).result("ungültig");
+ return;
+ }
+ boolean correct = guess.equals(game.currentOwner());
+ if (correct) game.scores().merge(user, 1, Integer::sum);
+ ctx.json(Map.of(
+ "correct", correct,
+ "owner", game.currentOwner(),
+ "scores", game.scores()
+ ));
+ });
+
+ // players list
+ app.get("/api/game/{gameId}/players", ctx -> {
+ Game game = games.get(ctx.pathParam("gameId"));
+ if (game == null) {
+ ctx.status(404).result("Game nicht gefunden");
+ } else {
+ ctx.json(Map.of("players", new ArrayList<>(game.players())));
+ }
+ });
+ }
+
+ // broadcast helper: sendet an alle Clients
+ private static void broadcastPlayers(String gameId) {
+ Game game = games.get(gameId);
+ Set sessions = gameSockets.get(gameId);
+ if (game == null || sessions == null) return;
+ Map payload = Map.of(
+ "type", "players",
+ "players", new ArrayList<>(game.players())
+ );
+ String msg;
+ try {
+ msg = objectMapper.writeValueAsString(payload);
+ } catch (JsonProcessingException e) {
+ e.printStackTrace();
+ return;
+ }
+ sessions.forEach(ctx -> {
+ try { ctx.send(msg); } catch (Exception ignore) {}
+ });
+ }
+
+ // broadcast reload: schickt allen Clients ein Reload-Event
+ private static void broadcastReload(String gameId) {
+ Set sessions = gameSockets.get(gameId);
+ if (sessions == null) return;
+ sessions.forEach(ctx -> {
+ try {
+ ctx.send("{\"type\":\"reload\"}");
+ } catch (Exception ignored) {}
+ });
+ }
+}
diff --git a/src/main/resources/archiv/create-game.html b/src/main/resources/archiv/create-game.html
new file mode 100644
index 0000000..8864dde
--- /dev/null
+++ b/src/main/resources/archiv/create-game.html
@@ -0,0 +1,32 @@
+
+
+Spiel erstellen
+
+Neues Spiel
+Spiel erstellen
+
+
+
diff --git a/src/main/resources/archiv/game.html b/src/main/resources/archiv/game.html
new file mode 100644
index 0000000..c35ff40
--- /dev/null
+++ b/src/main/resources/archiv/game.html
@@ -0,0 +1,164 @@
+
+
+
+
+
+ Spotify Roulette – Spiel
+
+
+
+Spotify Roulette
+Spiel-Code:
+
+Teilnehmer
+
+
+
+ Runde starten
+
+
+
+
Wer hat’s gehört?
+
+
+
+
+
+Scoreboard
+
+
+
+
+
diff --git a/src/main/resources/archiv/index.html b/src/main/resources/archiv/index.html
new file mode 100644
index 0000000..9820575
--- /dev/null
+++ b/src/main/resources/archiv/index.html
@@ -0,0 +1,26 @@
+
+
+
+
+ Zuletzt gespielt
+
+
+Spotify – Zuletzt gehörte Songs
+
+
+
+
+
+
diff --git a/src/main/resources/archiv/join-game.html b/src/main/resources/archiv/join-game.html
new file mode 100644
index 0000000..58024a9
--- /dev/null
+++ b/src/main/resources/archiv/join-game.html
@@ -0,0 +1,32 @@
+
+
+Spiel beitreten
+
+Spiel beitreten
+
+
+
+
+
\ No newline at end of file
diff --git a/src/main/resources/archiv/lobby.html b/src/main/resources/archiv/lobby.html
new file mode 100644
index 0000000..e9b2419
--- /dev/null
+++ b/src/main/resources/archiv/lobby.html
@@ -0,0 +1,34 @@
+
+
+
+
+ Spotify Roulette – Lobby
+
+
+Lobby
+Was möchtest du tun?
+Spiel erstellen
+Spiel beitreten
+
+
+
+
diff --git a/src/main/resources/notes b/src/main/resources/notes
new file mode 100644
index 0000000..a841e64
--- /dev/null
+++ b/src/main/resources/notes
@@ -0,0 +1,9 @@
+1. Limit manuell einstellbar
+2. songs die schon gespielt wurden nicht mehr spielen
+3. songs die schon gespielt wurden nicht mehr in der Liste anzeigen
+4. songs die schon gespielt wurden in einer extra Liste anzeigen
+mehrere auswählbar, wenn man alle richtig hat einen extra punkt kriegen
+verhindern dass 3 mal hinterander der gleicher owner bzw. das gleiche lied kommt
+songs die schon gespielt wurden in extra liste anzeigen um doppelte songs zu vermeiden
+(evtl. feste liste für spätere runden)
+Schauen ob andere auch einen song in der liste haben für mehrere auswahlen
\ No newline at end of file
diff --git a/src/main/resources/public/create-game.html b/src/main/resources/public/create-game.html
new file mode 100644
index 0000000..cc68022
--- /dev/null
+++ b/src/main/resources/public/create-game.html
@@ -0,0 +1,20 @@
+
+
+
+
+ Spiel erstellen – Spotify Roulette
+
+
+Wie viele kürzlich gehörte Songs sollen verwendet werden?
+
+ 10
+ 50
+ 100
+ 500
+
+Neues Spiel erstellen
+Spiel erstellen
+
+
+
+
diff --git a/src/main/resources/public/game.html b/src/main/resources/public/game.html
new file mode 100644
index 0000000..13dd250
--- /dev/null
+++ b/src/main/resources/public/game.html
@@ -0,0 +1,42 @@
+
+
+
+
+
+ Spotify Roulette – Spiel
+
+
+
+Spotify Roulette
+Spiel-Code:
+
+
+
+Teilnehmer
+
+
+
+ Runde starten
+
+
+
+
Wer hat’s gehört?
+
+
+
+
+
+Scoreboard
+
+
+
+
+
+
diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html
new file mode 100644
index 0000000..d2dcc5d
--- /dev/null
+++ b/src/main/resources/public/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+ Spotify – Zuletzt gehörte Songs
+
+
+Spotify – Zuletzt gehörte Songs
+
+
+
+
+
+
diff --git a/src/main/resources/public/join-game.html b/src/main/resources/public/join-game.html
new file mode 100644
index 0000000..96c9004
--- /dev/null
+++ b/src/main/resources/public/join-game.html
@@ -0,0 +1,16 @@
+
+
+
+
+ Spiel beitreten – Spotify Roulette
+
+
+Spiel beitreten
+
+
+
+
+
diff --git a/src/main/resources/public/js/create-game.js b/src/main/resources/public/js/create-game.js
new file mode 100644
index 0000000..b6b5dd0
--- /dev/null
+++ b/src/main/resources/public/js/create-game.js
@@ -0,0 +1,27 @@
+// public/js/create.js
+
+import { getParam, fetchJson } from "./utils.js";
+
+const username = getParam("username");
+if (!username) {
+ alert("Kein Username gefunden!");
+ window.location.href = "/lobby.html";
+ throw new Error("Missing username");
+}
+
+document.getElementById("createGame").addEventListener("click", async () => {
+ const limit = parseInt(document.getElementById("limit").value, 10);
+ try {
+ const { status, gameId } = await fetchJson("/api/create-game", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username, limit })
+ });
+ if (status === "ok") {
+ window.location.href = `/game.html?gameId=${gameId}&username=${encodeURIComponent(username)}&limit=${limit}`; } else {
+ alert("Fehler beim Erstellen des Spiels");
+ }
+ } catch (err) {
+ alert(`Fehler: ${err.message}`);
+ }
+});
diff --git a/src/main/resources/public/js/game.js b/src/main/resources/public/js/game.js
new file mode 100644
index 0000000..c2fa88a
--- /dev/null
+++ b/src/main/resources/public/js/game.js
@@ -0,0 +1,192 @@
+// public/js/game.js
+
+import { getParam, fetchJson, renderList } from "./utils.js";
+import { setupStartRound } from "./start-round.js";
+
+const gameId = getParam("gameId");
+const username = getParam("username");
+const limit = getParam("limit");
+
+if (!limit) {
+ alert("Limit fehlt!");
+ throw new Error("Missing limit");
+}
+
+// 1) Parameter prüfen
+if (!gameId || !username) {
+ alert("Ungültige oder fehlende URL-Parameter!");
+ throw new Error("Missing gameId or username");
+}
+
+// 2) Copy to clipboard (unverändert)
+(function copyCodeToClipboard(code) {
+ if (navigator.clipboard && window.isSecureContext) {
+ navigator.clipboard.writeText(code)
+ .then(() => console.log(`GameCode ${code} copied to clipboard`))
+ .catch(err => console.error("Clipboard write failed:", err));
+ } else {
+ const ta = document.createElement("textarea");
+ ta.value = code;
+ ta.style.position = "fixed";
+ ta.style.left = "-9999px";
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ try {
+ document.execCommand("copy");
+ console.log(`GameCode ${code} copied via execCommand`);
+ } catch (err) {
+ console.error("Fallback copy failed:", err);
+ }
+ document.body.removeChild(ta);
+ }
+})(gameId);
+
+// 3) Visuelles Feedback (unverändert)
+const notice = document.createElement("div");
+notice.textContent = `Spiel-Code ${gameId} in die Zwischenablage kopiert!`;
+Object.assign(notice.style, { /* … Styles … */ });
+document.body.append(notice);
+setTimeout(() => notice.remove(), 3000);
+
+// 4) Spiel-Code ins DOM schreiben
+document.getElementById("gameId").textContent = gameId;
+
+// 5) WebSocket einrichten und Spieler laden
+const protocol = location.protocol === "https:" ? "wss" : "ws";
+const socket = new WebSocket(
+ `${protocol}://${location.host}/ws/${gameId}?username=${encodeURIComponent(username)}&limit=${limit}`
+);
+
+socket.addEventListener("open", () => {
+ console.log("WebSocket connected. Requesting player list...");
+ // Fordere die Spielerliste vom Server an, sobald die Verbindung steht.
+ socket.send(JSON.stringify({ type: "requestPlayers" }));
+ // Binde den Handler für den "Runde starten"-Button
+ setupStartRound(socket);
+});
+
+// 6) WebSocket-Nachrichten verarbeiten
+socket.addEventListener("message", ({ data }) => {
+ console.log("WS-Rohdaten:", data);
+ const msg = JSON.parse(data);
+
+
+ switch (msg.type) {
+ case "players":
+ console.log("Empfangene Spieler:", msg.players);
+ renderList("#playersList", msg.players, username);
+ break;
+ case "reload":
+ window.location.reload();
+ break;
+ case "round-start":
+ handleRoundStart(msg);
+ break;
+ case "round-result":
+ handleRoundResult(msg);
+ break;
+ case "game-end":
+ handleGameEnd(msg);
+ break;
+ default:
+ console.warn("Unknown WS message type:", msg.type);
+ }
+});
+
+// 8) Funktion zum Anzeigen einer neuen Runde
+const startBtn = document.getElementById("startRound");
+const roundArea = document.getElementById("roundArea");
+const songEmbed = document.getElementById("songEmbed");
+const optionsDiv = document.getElementById("options");
+const resultP = document.getElementById("result");
+const scoreboard = document.getElementById("scoreboard");
+
+function handleRoundStart({ ownerOptions, songUri, allTracks }) {
+ // UI zurücksetzen
+ resultP.textContent = "";
+ optionsDiv.innerHTML = "";
+ songEmbed.innerHTML = "";
+
+ // Song einbetten
+ const trackId = songUri.split(":")[2];
+ songEmbed.innerHTML = `
+ `;
+
+ // Tipp-Buttons erzeugen
+ ownerOptions.forEach(user => {
+ const btn = document.createElement("button");
+ btn.textContent = user;
+ btn.addEventListener("click", () => {
+ socket.send(JSON.stringify({
+ type: "guess",
+ username: username,
+ guess: user
+ }));
+ // Nach Tipp alle Buttons deaktivieren
+ optionsDiv.querySelectorAll("button").forEach(b => b.disabled = true);
+ });
+ optionsDiv.append(btn);
+ });
+
+ // UI anzeigen
+ startBtn.hidden = true;
+ roundArea.hidden = false;
+ const songList = document.getElementById("songList");
+ songList.innerHTML = "";
+ if (Array.isArray(allTracks)) {
+ allTracks.forEach(uri => {
+ const li = document.createElement("li");
+ li.textContent = uri;
+ songList.appendChild(li);
+ });
+ }
+}
+
+// 9) Funktion zum Anzeigen des Ergebnisses
+function renderScoreboard(scores) {
+ scoreboard.innerHTML = "";
+ Object.entries(scores).forEach(([user, pts]) => {
+ const li = document.createElement("li");
+ li.textContent = `${user}: ${pts} Punkte`;
+ scoreboard.append(li);
+ });
+}
+
+
+function handleRoundResult({ scores, guesses, owner }) {
+ // Scoreboard updaten
+ renderScoreboard(scores);
+
+ // Ergebnis für alle Spieler anzeigen
+ resultP.innerHTML = ""; // Vorher leeren
+ Object.entries(guesses).forEach(([user, guess]) => {
+ const correct = guess === owner;
+ const icon = correct ? "✅" : "❌";
+ const msg = `${icon} ${user} hat auf ${guess} getippt${correct ? " (richtig!)" : " (falsch)"}`;
+ const p = document.createElement("p");
+ p.textContent = msg;
+ resultP.appendChild(p);
+ });
+
+ // Nach kurzer Pause für die nächste Runde vorbereiten
+ setTimeout(() => {
+ resultP.textContent = "";
+ startBtn.hidden = true;
+ startBtn.disabled = true;
+ roundArea.hidden = true;
+ }, 4000);
+}
+
+function handleGameEnd({winner}) {
+ resultP.textContent = `🎉 ${winner} hat gewonnen!`;
+ startBtn.hidden = false;
+ roundArea.hidden = true;
+ startBtn.disabled = false;
+ // scoreboard leeren
+ scoreboard.innerHTML = "";
+}
diff --git a/src/main/resources/public/js/join-game.js b/src/main/resources/public/js/join-game.js
new file mode 100644
index 0000000..8781e2a
--- /dev/null
+++ b/src/main/resources/public/js/join-game.js
@@ -0,0 +1,26 @@
+// public/js/join.js
+
+import { getParam, fetchJson } from "./utils.js";
+
+const username = getParam("username");
+
+document.getElementById("joinForm").addEventListener("submit", async e => {
+ e.preventDefault();
+ const gameId = document.getElementById("gameId").value;
+ if (!username) {
+ alert("Kein Username gefunden!");
+ return;
+ }
+ try {
+ const res = await fetchJson(`/api/game/${gameId}/players?limit=50`); // Dummy-Limit, Backend ignoriert es beim getOrCreateGame
+ const limit = res && res.players ? res.limit : 10; // Backend sollte das Limit mitliefern!
+ await fetchJson("/api/join-game", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ gameId, username })
+ });
+ window.location.href = `/game.html?gameId=${gameId}&username=${encodeURIComponent(username)}&limit=${limit}`;
+ } catch (err) {
+ alert(`Fehler: ${err.message}`);
+ }
+});
diff --git a/src/main/resources/public/js/lobby.js b/src/main/resources/public/js/lobby.js
new file mode 100644
index 0000000..99df629
--- /dev/null
+++ b/src/main/resources/public/js/lobby.js
@@ -0,0 +1,19 @@
+// public/js/lobby.js
+
+import { getParam } from "./utils.js";
+
+const username = getParam("username");
+
+document.getElementById("createGame").addEventListener("click", () => {
+ if (!username) {
+ alert("Kein Username gefunden!");
+ return;
+ }
+ window.location.href = `/create-game.html?username=${encodeURIComponent(username)}`;
+});
+
+document.getElementById("joinGame").addEventListener("click", () => {
+ window.location.href = username
+ ? `/join-game.html?username=${encodeURIComponent(username)}`
+ : "/join-game.html";
+});
diff --git a/src/main/resources/public/js/login.js b/src/main/resources/public/js/login.js
new file mode 100644
index 0000000..e42ab6b
--- /dev/null
+++ b/src/main/resources/public/js/login.js
@@ -0,0 +1,11 @@
+// public/js/login.js
+
+document.getElementById("usernameForm").addEventListener("submit", e => {
+ e.preventDefault();
+ const user = document.getElementById("username").value.trim();
+ if (!user) {
+ alert("Bitte gib einen Benutzernamen ein.");
+ return;
+ }
+ window.location.href = `/login?username=${encodeURIComponent(user)}`;
+});
diff --git a/src/main/resources/public/js/start-round.js b/src/main/resources/public/js/start-round.js
new file mode 100644
index 0000000..78d5a34
--- /dev/null
+++ b/src/main/resources/public/js/start-round.js
@@ -0,0 +1,25 @@
+// public/js/start-round.js
+
+import { getParam } from "./utils.js";
+
+/**
+ * Bindet den Klick-Handler an den "Runde starten"-Button,
+ * der per WebSocket an den Server das Start-Event feuert.
+ * @param {WebSocket} socket – die geöffnete WS-Verbindung
+ */
+export function setupStartRound(socket) {
+ const gameId = getParam("gameId");
+ if (!gameId || socket.readyState !== WebSocket.OPEN) return;
+
+ const startBtn = document.getElementById("startRound");
+ startBtn.addEventListener("click", () => {
+ // Button direkt deaktivieren, bis neue Runde kommt
+ startBtn.disabled = true;
+
+ // Sende das Start-Runden-Event an den Server
+ socket.send(JSON.stringify({
+ type: "start-round",
+ gameId: gameId
+ }));
+ });
+}
diff --git a/src/main/resources/public/js/utils.js b/src/main/resources/public/js/utils.js
new file mode 100644
index 0000000..dc32213
--- /dev/null
+++ b/src/main/resources/public/js/utils.js
@@ -0,0 +1,37 @@
+// public/js/utils.js
+
+/**
+ * Liest einen Query-Parameter aus der URL.
+ */
+export function getParam(name) {
+ return new URLSearchParams(window.location.search).get(name);
+}
+
+/**
+ * Führt einen Fetch aus und wirft bei HTTP-Fehlern eine Exception.
+ */
+export async function fetchJson(url, options) {
+ const res = await fetch(url, options);
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(`${res.status} ${res.statusText}: ${text}`);
+ }
+ return await res.json();
+}
+
+/**
+ * Rendert eine Liste von Strings in ein (oder ), hebt optional ein Item hervor.
+ */
+export function renderList(selector, items, highlight) {
+ const ul = document.querySelector(selector);
+ if (!ul) return;
+ ul.innerHTML = "";
+ items.forEach(item => {
+ const li = document.createElement("li");
+ li.textContent = item;
+ if (item === highlight) {
+ li.style.fontWeight = "bold";
+ }
+ ul.append(li);
+ });
+}
diff --git a/src/main/resources/public/lobby.html b/src/main/resources/public/lobby.html
new file mode 100644
index 0000000..63d7e23
--- /dev/null
+++ b/src/main/resources/public/lobby.html
@@ -0,0 +1,15 @@
+
+
+
+
+ Lobby – Spotify Roulette
+
+
+Lobby
+Was möchtest du tun?
+Spiel erstellen
+Spiel beitreten
+
+
+
+
diff --git a/src/test/java/eric/Roullette/service/GameServiceTest.java b/src/test/java/eric/Roullette/service/GameServiceTest.java
new file mode 100644
index 0000000..afeddc1
--- /dev/null
+++ b/src/test/java/eric/Roullette/service/GameServiceTest.java
@@ -0,0 +1,35 @@
+// src/test/java/eric/roulette/service/GameServiceTest.java
+package eric.Roullette.service;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class GameServiceTest {
+
+ @Test
+ void testGetOrCreateGame() {
+ GameService service = new GameService();
+ // Erstes Mal anlegen
+ GameService.Game g1 = service.getOrCreateGame("g1",0);
+ assertNotNull(g1);
+ assertEquals("g1", g1.id());
+ // Beim zweiten Aufruf dieselbe Instanz
+ GameService.Game g2 = service.getOrCreateGame("g1",0);
+ assertSame(g1, g2);
+ }
+
+// @Test
+// void testAddPlayerAndScores() {
+// GameService service = new GameService();
+// service.getOrCreateGame("g2"); // Spiel anlegen
+// service.addPlayer("g2", "Alice"); // Spieler hinzufügen
+// GameService.Game game = service.getOrCreateGame("g2");
+// // Spieler-Liste korrekt
+// assertTrue(game.players().contains("Alice"));
+// assertEquals(1, game.players().size());
+// // Score für neuen Spieler initial 0
+// assertEquals(0, game.scores().get("Alice").intValue());
+// // Duplikate vermeiden
+// }
+}