first commit

pull/24/head
Eric Paci 2025-08-03 17:10:48 +02:00
parent 2279e8376f
commit 2ffaafc425
39 changed files with 2395 additions and 0 deletions

2
.gitattributes vendored 100644
View File

@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

33
.gitignore vendored 100644
View File

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

View File

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

View File

@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>eric</groupId>
<artifactId>Roullette</artifactId>
<version>0</version>
<build>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<plugin>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
<configuration>
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>
<plugin>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer>
<mainClass>eric.Roullette.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.9.2</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>junit-jupiter-api</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-params</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
<exclusion>
<artifactId>junit-jupiter-engine</artifactId>
<groupId>org.junit.jupiter</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin-testtools</artifactId>
<version>6.7.0</version>
<scope>test</scope>
<exclusions>
<exclusion>
<artifactId>okhttp</artifactId>
<groupId>com.squareup.okhttp3</groupId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<junit.jupiter.version>5.9.2</junit.jupiter.version>
<jackson.version>2.16.0</jackson.version>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<javalin.version>6.7.0</javalin.version>
</properties>
</project>

259
mvnw vendored 100644
View File

@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -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 "$@"

149
mvnw.cmd vendored 100644
View File

@ -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-<version>,maven-mvnd-<version>-<platform>}/<hash>
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"

128
pom.xml 100644
View File

@ -0,0 +1,128 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>eric</groupId>
<artifactId>Roullette</artifactId>
<version>0</version>
<properties>
<!-- Java -->
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<!-- Versions -->
<javalin.version>6.7.0</javalin.version>
<junit.jupiter.version>5.9.2</junit.jupiter.version>
<jackson.version>2.16.0</jackson.version>
</properties>
<dependencies>
<!-- Spotify API -->
<dependency>
<groupId>se.michaelthelin.spotify</groupId>
<artifactId>spotify-web-api-java</artifactId>
<version>9.3.0</version>
</dependency>
<!-- Javalin -->
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin-bundle</artifactId>
<version>${javalin.version}</version>
</dependency>
<!-- Jackson for application -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.jupiter.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin-testtools</artifactId>
<version>${javalin.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Compile plugin -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
<configuration>
<source>${maven.compiler.source}</source>
<target>${maven.compiler.target}</target>
</configuration>
</plugin>
<!-- Surefire for running tests -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.0.0-M7</version>
<configuration>
<failIfNoTests>true</failIfNoTests>
</configuration>
</plugin>
<!-- Shade plugin for fat-jar -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>eric.Roullette.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@ -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);
}
}

View File

@ -0,0 +1,30 @@
package eric.Roullette;
import java.util.List;
public class User {
private String name;
private List<String> recentTracks;
public User(String name, List<String> recentTracks) {
this.name = name;
this.recentTracks = recentTracks;
}
public String getName() {
return name;
}
public List<String> getRecentTracks() {
return recentTracks;
}
public void setRecentTracks(List<String> recentTracks) {
this.recentTracks = recentTracks;
}
@Override
public String toString() {
return "User{" +
"name='" + name + '\'' +
", recentTracks=" + recentTracks +
'}';
}
}

View File

@ -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");
}

View File

@ -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<String, Object> 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<String, String> 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<String, String> 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()
));
}
}

View File

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

View File

@ -0,0 +1,8 @@
package eric.Roullette.dto;
import java.util.List;
public record PlayersMessage(
MessageType type,
List<String> players
) { public PlayersMessage(List<String> players) { this(MessageType.PLAYERS, players); } }

View File

@ -0,0 +1,5 @@
package eric.Roullette.dto;
public record ReloadMessage(MessageType type) {
public ReloadMessage() { this(MessageType.RELOAD); }
}

View File

@ -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<String, Set<WsContext>> sessions = new ConcurrentHashMap<>();
private final Map<String, Game> games = new ConcurrentHashMap<>();
public record Game(String id, List<String> players, Map<String,Integer> scores,String currentOwner,
String currentSong,List<String> 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<String> 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<WsContext> getSessions(String gameId) {
return sessions.getOrDefault(gameId, Collections.emptySet());
}
}

View File

@ -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<String, SpotifyApi> 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<String> 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<PlayHistory> 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<String> getRecentTracksLimit(SpotifyApi userApi, int limit) throws IOException, SpotifyWebApiException, ParseException {
GetCurrentUsersRecentlyPlayedTracksRequest request = userApi.getCurrentUsersRecentlyPlayedTracks()
.limit(limit)
.build();
PagingCursorbased<PlayHistory> history = request.execute();
List<String> 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);
}
}
}

View File

@ -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> T fromJson(String json, Class<T> clazz) {
try {
return MAPPER.readValue(json, clazz);
} catch (Exception e) {
throw new RuntimeException("JSON deserialization failed for " + clazz, e);
}
}
}

View File

@ -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<String, Map<String, String>> 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<String> 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<String> opts = game.players();
String songUri = game.currentSong();
List<String> 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<String,Integer> scores = game.scores();
Map<String,String> guesses = currentGuesses.remove(gameId);
String owner = game.currentOwner();
// Für jeden Tippenden Score anpassen
for (Map.Entry<String, String> 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<WsContext> sessions = service.getSessions(gameId);
if (sessions == null) return;
sessions.stream()
.filter(s -> s.session.isOpen())
.forEach(ctx -> {
try {
ctx.send(msg);
} catch (Exception ignore) {}
});
}
}

View File

@ -0,0 +1,6 @@
spring.application.name=Roullette
spotify:
client-id: 70c36cd6e2d54ad0ba2e60ef6334bbc8
client-secret: 116188574dd140eab1973e75c7e5ecfe
redirect-uri: https://www.davidmagkuchen.de/spotify/callback

View File

@ -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<String, Set<WsContext>> gameSockets = new ConcurrentHashMap<>();
// In-memory stores
private static final ConcurrentMap<String, Game> games = new ConcurrentHashMap<>();
private static final ConcurrentMap<String, List<String>> 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<String> players,
Map<String,Integer> 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<WsContext> 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<String> 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<String,Object> 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<String,Object> 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<String> pls = new ArrayList<>(game.players());
String owner = pls.get(ThreadLocalRandom.current().nextInt(pls.size()));
List<String> 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<String,Object> 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<WsContext> sessions = gameSockets.get(gameId);
if (game == null || sessions == null) return;
Map<String,Object> 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<WsContext> sessions = gameSockets.get(gameId);
if (sessions == null) return;
sessions.forEach(ctx -> {
try {
ctx.send("{\"type\":\"reload\"}");
} catch (Exception ignored) {}
});
}
}

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>Spiel erstellen</title></head>
<body>
<h1>Neues Spiel</h1>
<button id="createGame">Spiel erstellen</button>
<script>
// Username aus URL holen
const params = new URLSearchParams(window.location.search);
const username = params.get('username');
if (!username) {
alert('Kein Username gefunden!');
window.location.href = '/lobby.html';
}
document.getElementById("createGame").addEventListener("click", async () => {
const res = await fetch("/api/create-game", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({ username })
});
const json = await res.json();
if (json.status === "ok") {
// Spiel-Code anzeigen oder in URL weiterleiten
alert("Dein Spiel-Code: " + json.gameId);
window.location.href = "/game.html?gameId=" + json.gameId + "&username=" + encodeURIComponent(username);
} else {
alert("Fehler beim Erstellen");
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spotify Roulette Spiel</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 1rem; }
#options button { margin: 0.5rem; }
#scoreboard { margin-top: 1rem; }
</style>
</head>
<body>
<h1>Spotify Roulette</h1>
<p>Spiel-Code: <strong id="gameId"></strong></p>
<h2>Teilnehmer</h2>
<ul id="playersList"></ul>
<div id="controls">
<button id="startRound">Runde starten</button>
</div>
<div id="roundArea" hidden>
<h2>Wer hats gehört?</h2>
<div id="songEmbed"></div>
<div id="options"></div>
<p id="result"></p>
</div>
<h2>Scoreboard</h2>
<ul id="scoreboard"></ul>
<script>
document.addEventListener("DOMContentLoaded", () => {
const params = new URLSearchParams(window.location.search);
const gameId = params.get('gameId');
const username = params.get('username');
if (!gameId) {
alert('Ungültige oder fehlende gameId in URL!');
return;
}
document.getElementById('gameId').textContent = gameId;
// Teilnehmerliste initial laden
async function loadPlayers() {
const res = await fetch(`/api/game/${gameId}/players`);
if (res.ok) {
const { players = [] } = await res.json();
const ul = document.getElementById("playersList");
ul.innerHTML = "";
players.forEach(user => {
const li = document.createElement("li");
li.textContent = user;
if (user === username) li.style.fontWeight = 'bold';
ul.append(li);
});
}
}
loadPlayers();
// WebSocket-Verbindung
const protocol = location.protocol === "https:" ? "wss" : "ws";
const socket = new WebSocket(`${protocol}://${location.host}/ws/${gameId}?username=${encodeURIComponent(username)}`);
socket.onopen = () => {
// Liste frisch laden, falls das erste Broadcast verpasst wurde
loadPlayers();
};
socket.onmessage = event => {
const data = JSON.parse(event.data);
if (data.type === "players") {
const ul = document.getElementById("playersList");
ul.innerHTML = "";
(data.players || []).forEach(user => {
const li = document.createElement("li");
li.textContent = user;
if (user === username) li.style.fontWeight = 'bold';
ul.append(li);
});
}
if (data.type === "reload") {
window.location.reload();
}
};
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 renderScoreboard(scores) {
scoreboard.innerHTML = "";
for (const [user, pts] of Object.entries(scores)) {
const li = document.createElement('li');
li.textContent = `${user}: ${pts} Punkte`;
scoreboard.append(li);
}
}
startBtn.addEventListener('click', async () => {
resultP.textContent = "";
optionsDiv.innerHTML = "";
songEmbed.innerHTML = "";
try {
const res = await fetch(`/api/game/${gameId}/start-round`, { method: 'POST' });
if (!res.ok) {
alert(`Fehler beim Starten der Runde: ${await res.text()}`);
return;
}
const { ownerOptions, songUri, scores } = await res.json();
renderScoreboard(scores);
const trackId = songUri.split(':')[2];
songEmbed.innerHTML = `
<iframe
src="https://open.spotify.com/embed/track/${trackId}"
width="100%" height="80" frameborder="0"
allow="encrypted-media">
</iframe>`;
ownerOptions.forEach(user => {
const btn = document.createElement('button');
btn.textContent = user;
btn.onclick = () => makeGuess(user);
optionsDiv.append(btn);
});
startBtn.hidden = true;
roundArea.hidden = false;
} catch (err) {
alert(`Netzwerkfehler: ${err.message}`);
}
});
async function makeGuess(guess) {
try {
const res = await fetch(`/api/game/${gameId}/guess`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ guess, username })
});
if (!res.ok) {
alert(`Fehler beim Tippen: ${await res.text()}`);
return;
}
const { correct, owner, scores } = await res.json();
resultP.textContent = correct
? '✅ Richtig!'
: `❌ Falsch. Es war: ${owner}`;
renderScoreboard(scores);
startBtn.hidden = false;
} catch (err) {
alert(`Netzwerkfehler: ${err.message}`);
}
}
});
</script>
</body>
</html>

View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Zuletzt gespielt</title>
</head>
<body>
<h1>Spotify Zuletzt gehörte Songs</h1>
<form id="usernameForm">
<label for="username">Benutzername:</label>
<input type="text" id="username" name="username" required>
<button type="submit">Weiter mit Spotify</button>
</form>
<ul id="tracks"></ul>
<script>
// document, nicht document
document.getElementById('usernameForm').addEventListener('submit', e => {
e.preventDefault();
const u = document.getElementById('username').value;
// direkt zum OAuth-Login, username im state-Param
window.location = `/login?username=${encodeURIComponent(u)}`;
});
</script>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="de">
<head><meta charset="UTF-8"><title>Spiel beitreten</title></head>
<body>
<h1>Spiel beitreten</h1>
<form id="joinForm">
<input id="gameId" name="gameId" placeholder="4-stelliger Code" required pattern="\d{4}"/>
<button type="submit">Beitreten</button>
</form>
<script>
// Username aus URL holen
const params = new URLSearchParams(window.location.search);
const username = params.get('username');
document.getElementById("joinForm").addEventListener("submit", async e => {
e.preventDefault();
const id = document.getElementById("gameId").value;
if (!username) {
alert('Kein Username gefunden!');
return;
}
const res = await fetch("/api/join-game", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({gameId: id, username})
});
if (res.ok) window.location.href = "/game.html?gameId=" + id + "&username=" + encodeURIComponent(username);
else alert("Spiel nicht gefunden");
});
</script>
</body>
</html>
<!-- This HTML file allows users to join an existing game by entering the game ID. -->

View File

@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spotify Roulette Lobby</title>
</head>
<body>
<h1>Lobby</h1>
<p>Was möchtest du tun?</p>
<button id="createGame">Spiel erstellen</button>
<button id="joinGame">Spiel beitreten</button>
<script>
// Username aus URL holen
const params = new URLSearchParams(window.location.search);
const username = params.get('username');
document.getElementById("createGame").addEventListener("click", () => {
// Username an create-game.html anhängen
if (username) {
window.location.href = "/create-game.html?username=" + encodeURIComponent(username);
} else {
alert('Kein Username gefunden!');
}
});
document.getElementById("joinGame").addEventListener("click", () => {
if (username) {
window.location.href = "/join-game.html?username=" + encodeURIComponent(username);
} else {
window.location.href = "/join-game.html";
}
});
</script>
</body>
</html>

View File

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

View File

@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spiel erstellen Spotify Roulette</title>
</head>
<body>
<label for="limit">Wie viele kürzlich gehörte Songs sollen verwendet werden?</label>
<select id="limit">
<option value="10">10</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="500">500</option>
</select>
<h1>Neues Spiel erstellen</h1>
<button id="createGame">Spiel erstellen</button>
<script type="module" src="/js/create-game.js"></script>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Spotify Roulette Spiel</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: auto; padding: 1rem; }
#options button { margin: 0.5rem; }
#scoreboard { margin-top: 1rem; }
</style>
</head>
<body>
<h1>Spotify Roulette</h1>
<p>Spiel-Code: <strong id="gameId"></strong></p>
<div id="songListArea" style="position:fixed; right:0; top:0; width:200px; height:100vh; overflow-y:auto; background:#f8f8f8; border-left:1px solid #ccc; padding:1rem; z-index:10;">
<h3>Geladene Songs</h3>
<ul id="songList"></ul>
</div>
<h2>Teilnehmer</h2>
<ul id="playersList"></ul>
<div id="controls">
<button id="startRound">Runde starten</button>
</div>
<div id="roundArea" hidden>
<h2>Wer hats gehört?</h2>
<div id="songEmbed"></div>
<div id="options"></div>
<p id="result"></p>
</div>
<h2>Scoreboard</h2>
<ul id="scoreboard"></ul>
<script type="module" src="/js/game.js"></script>
</body>
</html>

View File

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spotify Zuletzt gehörte Songs</title>
</head>
<body>
<h1>Spotify Zuletzt gehörte Songs</h1>
<form id="usernameForm">
<label for="username">Benutzername:</label>
<input type="text" id="username" name="username" required>
<button type="submit">Weiter mit Spotify</button>
</form>
<ul id="tracks"></ul>
<script type="module" src="/js/login.js"></script>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Spiel beitreten Spotify Roulette</title>
</head>
<body>
<h1>Spiel beitreten</h1>
<form id="joinForm">
<input id="gameId" name="gameId" placeholder="4-stelliger Code" required pattern="\d{4}">
<button type="submit">Beitreten</button>
</form>
<script type="module" src="/js/join-game.js"></script>
</body>
</html>

View File

@ -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}`);
}
});

View File

@ -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 = `
<iframe
src="https://open.spotify.com/embed/track/${trackId}"
width="100%" height="80" frameborder="0"
allow="encrypted-media">
</iframe>`;
// 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 = "";
}

View File

@ -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}`);
}
});

View File

@ -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";
});

View File

@ -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)}`;
});

View File

@ -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
}));
});
}

View File

@ -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 <ul> (oder <ol>), 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);
});
}

View File

@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Lobby Spotify Roulette</title>
</head>
<body>
<h1>Lobby</h1>
<p>Was möchtest du tun?</p>
<button id="createGame">Spiel erstellen</button>
<button id="joinGame">Spiel beitreten</button>
<script type="module" src="/js/lobby.js"></script>
</body>
</html>

View File

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