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

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

+
    + +
    + +
    + + + +

    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?

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

        Neues 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:

        + +
        +

        Geladene Songs

        +
          +
          + +

          Teilnehmer

          +
            + +
            + +
            + + + +

            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?

                    + + + + + + 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 +// } +}