feat: add realtime codenames game
This commit is contained in:
3
.dockerignore
Normal file
3
.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
.gradle
|
||||
app
|
||||
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
#
|
||||
# https://help.github.com/articles/dealing-with-line-endings/
|
||||
#
|
||||
# Linux start script should use lf
|
||||
/gradlew text eol=lf
|
||||
|
||||
# These are Windows script files and should use crlf
|
||||
*.bat text eol=crlf
|
||||
|
||||
# Binary files should be left untouched
|
||||
*.jar binary
|
||||
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# Ignore Gradle project-specific cache directory
|
||||
.gradle
|
||||
|
||||
# Ignore Gradle build output directory
|
||||
build
|
||||
|
||||
# Ignore Kotlin plugin data
|
||||
.kotlin
|
||||
8
Dockerfile
Normal file
8
Dockerfile
Normal file
@@ -0,0 +1,8 @@
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
|
||||
COPY build/libs/*.jar app.jar
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
|
||||
38
README.md
Normal file
38
README.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Codenames
|
||||
|
||||
Realtime-Codenames fuer Browser mit Java Spring Boot, nativen WebSockets und einem statischen HTML/JS-Frontend.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Raum erstellen und mit kurzer ID teilen
|
||||
- Spieler und Zuschauer koennen demselben Raum beitreten
|
||||
- Vier Rollen: Rot/Blau jeweils Hinweisgeber und Ermittler
|
||||
- Vollstaendiger Rundenablauf mit Hinweis, Ratephase, Zugende und Siegbedingung
|
||||
- Personalisierte Board-Sicht: nur Hinweisgeber sehen verdeckte Teamzuordnungen
|
||||
- Kubernetes-Deployment unter `deploy/k8s`
|
||||
|
||||
## Lokal starten
|
||||
|
||||
```bash
|
||||
./gradlew bootRun
|
||||
```
|
||||
|
||||
Danach ist die App unter [http://localhost:8080](http://localhost:8080) erreichbar.
|
||||
|
||||
## Tests und Build
|
||||
|
||||
```bash
|
||||
./gradlew test bootJar
|
||||
docker build -t git.dslan.at/zeugs/codenames:master .
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Die Kubernetes-Manifeste liegen unter [deploy/k8s](/Users/dschramm/git.dslan.at/codenames/deploy/k8s) und werden in `argo-cd-apps` als eigene ArgoCD-Application eingebunden.
|
||||
|
||||
## Wortbasis
|
||||
|
||||
Die deutsche Begriffsliste wurde fuer dieses Projekt aus einer im Web frei herunterladbaren deutschen Nomenliste kuratiert, insbesondere auf Basis der Sketch-Engine-Wortlisten:
|
||||
|
||||
- [Sketch Engine Word Lists](https://www.sketchengine.eu/word-lists/)
|
||||
- [German noun frequency list (PDF)](https://www.sketchengine.eu/wp-content/uploads/word-list/german/german-word-list-nouns.pdf)
|
||||
42
build.gradle.kts
Normal file
42
build.gradle.kts
Normal file
@@ -0,0 +1,42 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("org.springframework.boot:spring-boot-gradle-plugin:4.0.5")
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
java
|
||||
}
|
||||
|
||||
apply(plugin = "org.springframework.boot")
|
||||
|
||||
group = "at.dslan"
|
||||
version = "0.1.0"
|
||||
|
||||
java {
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("org.springframework.boot:spring-boot-starter-actuator:4.0.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-json:4.0.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-validation:4.0.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-web:4.0.5")
|
||||
implementation("org.springframework.boot:spring-boot-starter-websocket:4.0.5")
|
||||
|
||||
testImplementation("org.springframework.boot:spring-boot-starter-test:4.0.5")
|
||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
44
deploy/k8s/deployment.yaml
Normal file
44
deploy/k8s/deployment.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: codenames
|
||||
labels:
|
||||
app: codenames
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: codenames
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: codenames
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: codenames-registry
|
||||
containers:
|
||||
- name: codenames
|
||||
image: git.dslan.at/zeugs/codenames:master
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 192Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: http
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 15
|
||||
23
deploy/k8s/ingress.yaml
Normal file
23
deploy/k8s/ingress.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: codenames
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: lets-encrypt
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: codenames.dslan.at
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: codenames
|
||||
port:
|
||||
number: 80
|
||||
tls:
|
||||
- secretName: codenames-tls
|
||||
hosts:
|
||||
- codenames.dslan.at
|
||||
8
deploy/k8s/registry-secret.yaml
Normal file
8
deploy/k8s/registry-secret.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: codenames-registry
|
||||
type: kubernetes.io/dockerconfigjson
|
||||
stringData:
|
||||
.dockerconfigjson: |
|
||||
{"auths":{"git.dslan.at":{"username":"gitadm","password":"eHZf%ekL6KBvD","auth":"Z2l0YWRtOmVIWmYlZWtMNktCdkQ="}}}
|
||||
13
deploy/k8s/service.yaml
Normal file
13
deploy/k8s/service.yaml
Normal file
@@ -0,0 +1,13 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: codenames
|
||||
labels:
|
||||
app: codenames
|
||||
spec:
|
||||
selector:
|
||||
app: codenames
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: http
|
||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
# This file was generated by the Gradle 'init' task.
|
||||
# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties
|
||||
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed 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
|
||||
#
|
||||
# https://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.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
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
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
8
settings.gradle.kts
Normal file
8
settings.gradle.kts
Normal file
@@ -0,0 +1,8 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "codenames"
|
||||
12
src/main/java/at/dslan/codenames/CodenamesApplication.java
Normal file
12
src/main/java/at/dslan/codenames/CodenamesApplication.java
Normal file
@@ -0,0 +1,12 @@
|
||||
package at.dslan.codenames;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class CodenamesApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(CodenamesApplication.class, args);
|
||||
}
|
||||
}
|
||||
24
src/main/java/at/dslan/codenames/config/WebSocketConfig.java
Normal file
24
src/main/java/at/dslan/codenames/config/WebSocketConfig.java
Normal file
@@ -0,0 +1,24 @@
|
||||
package at.dslan.codenames.config;
|
||||
|
||||
import at.dslan.codenames.websocket.GameWebSocketHandler;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.socket.config.annotation.EnableWebSocket;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
|
||||
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
|
||||
|
||||
@Configuration
|
||||
@EnableWebSocket
|
||||
public class WebSocketConfig implements WebSocketConfigurer {
|
||||
|
||||
private final GameWebSocketHandler gameWebSocketHandler;
|
||||
|
||||
public WebSocketConfig(GameWebSocketHandler gameWebSocketHandler) {
|
||||
this.gameWebSocketHandler = gameWebSocketHandler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
|
||||
registry.addHandler(gameWebSocketHandler, "/ws")
|
||||
.setAllowedOriginPatterns("*");
|
||||
}
|
||||
}
|
||||
49
src/main/java/at/dslan/codenames/game/ClasspathWordBank.java
Normal file
49
src/main/java/at/dslan/codenames/game/ClasspathWordBank.java
Normal file
@@ -0,0 +1,49 @@
|
||||
package at.dslan.codenames.game;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class ClasspathWordBank implements WordBank {
|
||||
|
||||
private final List<String> words;
|
||||
|
||||
public ClasspathWordBank() throws IOException {
|
||||
List<String> loadedWords = new ArrayList<>();
|
||||
ClassPathResource resource = new ClassPathResource("words/de-terms.txt");
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
|
||||
reader.lines()
|
||||
.map(String::trim)
|
||||
.filter(line -> !line.isBlank())
|
||||
.distinct()
|
||||
.forEach(loadedWords::add);
|
||||
}
|
||||
if (loadedWords.size() < 25) {
|
||||
throw new IllegalStateException("Die Wortliste muss mindestens 25 Begriffe enthalten.");
|
||||
}
|
||||
this.words = List.copyOf(loadedWords);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> draw(Random random, int count) {
|
||||
if (count > words.size()) {
|
||||
throw new IllegalArgumentException("Nicht genug Begriffe fuer ein neues Spiel vorhanden.");
|
||||
}
|
||||
List<String> copy = new ArrayList<>(words);
|
||||
for (int index = copy.size() - 1; index > 0; index--) {
|
||||
int swapIndex = random.nextInt(index + 1);
|
||||
String current = copy.get(index);
|
||||
copy.set(index, copy.get(swapIndex));
|
||||
copy.set(swapIndex, current);
|
||||
}
|
||||
return List.copyOf(copy.subList(0, count));
|
||||
}
|
||||
}
|
||||
601
src/main/java/at/dslan/codenames/game/GameRoomService.java
Normal file
601
src/main/java/at/dslan/codenames/game/GameRoomService.java
Normal file
@@ -0,0 +1,601 @@
|
||||
package at.dslan.codenames.game;
|
||||
|
||||
import at.dslan.codenames.game.RoomSnapshot.ActivityEntry;
|
||||
import at.dslan.codenames.game.RoomSnapshot.CardAffiliation;
|
||||
import at.dslan.codenames.game.RoomSnapshot.CardView;
|
||||
import at.dslan.codenames.game.RoomSnapshot.ClueView;
|
||||
import at.dslan.codenames.game.RoomSnapshot.GamePhase;
|
||||
import at.dslan.codenames.game.RoomSnapshot.GameStatus;
|
||||
import at.dslan.codenames.game.RoomSnapshot.GameView;
|
||||
import at.dslan.codenames.game.RoomSnapshot.ParticipantView;
|
||||
import at.dslan.codenames.game.RoomSnapshot.SeatRole;
|
||||
import at.dslan.codenames.game.RoomSnapshot.SeatView;
|
||||
import at.dslan.codenames.game.RoomSnapshot.Team;
|
||||
import at.dslan.codenames.game.RoomSnapshot.ViewerView;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Random;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class GameRoomService {
|
||||
|
||||
private static final int BOARD_SIZE = 25;
|
||||
private static final int MAX_ACTIVITY_ITEMS = 30;
|
||||
private static final String ROOM_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
private static final List<SeatKey> SEAT_ORDER = List.of(
|
||||
new SeatKey(Team.RED, SeatRole.SPYMASTER),
|
||||
new SeatKey(Team.RED, SeatRole.OPERATIVE),
|
||||
new SeatKey(Team.BLUE, SeatRole.SPYMASTER),
|
||||
new SeatKey(Team.BLUE, SeatRole.OPERATIVE)
|
||||
);
|
||||
|
||||
private final WordBank wordBank;
|
||||
private final Random random;
|
||||
private final Map<String, Room> rooms = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public GameRoomService(WordBank wordBank) {
|
||||
this(wordBank, new SecureRandom());
|
||||
}
|
||||
|
||||
GameRoomService(WordBank wordBank, Random random) {
|
||||
this.wordBank = wordBank;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
public synchronized RoomSession createRoom(String requestedDisplayName) {
|
||||
String roomId = generateRoomId();
|
||||
Room room = new Room(roomId);
|
||||
Participant participant = new Participant(generateParticipantId(), room.createDisplayName(requestedDisplayName));
|
||||
room.hostId = participant.id;
|
||||
room.participants.put(participant.id, participant);
|
||||
addActivity(room, participant.displayName + " hat den Raum erstellt.");
|
||||
rooms.put(roomId, room);
|
||||
return new RoomSession(roomId, participant.id, snapshot(room, participant.id));
|
||||
}
|
||||
|
||||
public synchronized RoomSession joinRoom(String requestedRoomId, String requestedDisplayName) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = new Participant(generateParticipantId(), room.createDisplayName(requestedDisplayName));
|
||||
room.participants.put(participant.id, participant);
|
||||
addActivity(room, participant.displayName + " ist dem Raum beigetreten.");
|
||||
return new RoomSession(room.id, participant.id, snapshot(room, participant.id));
|
||||
}
|
||||
|
||||
public synchronized RoomSnapshot getSnapshot(String requestedRoomId, String participantId) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
requireParticipant(room, participantId);
|
||||
return snapshot(room, participantId);
|
||||
}
|
||||
|
||||
public synchronized void renameParticipant(String requestedRoomId, String participantId, String requestedDisplayName) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = requireParticipant(room, participantId);
|
||||
String oldName = participant.displayName;
|
||||
participant.displayName = room.createDisplayName(requestedDisplayName);
|
||||
if (!Objects.equals(oldName, participant.displayName)) {
|
||||
addActivity(room, oldName + " heisst jetzt " + participant.displayName + ".");
|
||||
}
|
||||
}
|
||||
|
||||
public synchronized void takeSeat(String requestedRoomId, String participantId, Team team, SeatRole role) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = requireParticipant(room, participantId);
|
||||
ensureLobby(room);
|
||||
|
||||
SeatKey targetSeat = new SeatKey(team, role);
|
||||
String occupantId = room.seats.get(targetSeat);
|
||||
if (occupantId != null && !occupantId.equals(participantId)) {
|
||||
throw new GameException("Dieser Platz ist bereits besetzt.");
|
||||
}
|
||||
|
||||
room.clearSeat(participantId);
|
||||
room.seats.put(targetSeat, participantId);
|
||||
addActivity(room, participant.displayName + " sitzt jetzt als " + seatLabel(targetSeat) + " bereit.");
|
||||
}
|
||||
|
||||
public synchronized void leaveSeat(String requestedRoomId, String participantId) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = requireParticipant(room, participantId);
|
||||
ensureLobby(room);
|
||||
|
||||
SeatKey seat = room.findSeat(participantId);
|
||||
if (seat == null) {
|
||||
throw new GameException("Du sitzt aktuell auf keinem Platz.");
|
||||
}
|
||||
room.seats.remove(seat);
|
||||
addActivity(room, participant.displayName + " hat den Platz als " + seatLabel(seat) + " wieder freigegeben.");
|
||||
}
|
||||
|
||||
public synchronized void startGame(String requestedRoomId, String participantId) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
requireParticipant(room, participantId);
|
||||
ensureLobby(room);
|
||||
if (!participantId.equals(room.hostId)) {
|
||||
throw new GameException("Nur der Host kann das Spiel starten.");
|
||||
}
|
||||
if (!room.allSeatsFilled()) {
|
||||
throw new GameException("Zum Start werden vier besetzte Rollen benoetigt.");
|
||||
}
|
||||
|
||||
Team startingTeam = random.nextBoolean() ? Team.RED : Team.BLUE;
|
||||
Team otherTeam = oppositeOf(startingTeam);
|
||||
List<CardAffiliation> affiliations = new ArrayList<>(BOARD_SIZE);
|
||||
appendAffiliations(affiliations, startingTeam == Team.RED ? CardAffiliation.RED : CardAffiliation.BLUE, 9);
|
||||
appendAffiliations(affiliations, otherTeam == Team.RED ? CardAffiliation.RED : CardAffiliation.BLUE, 8);
|
||||
appendAffiliations(affiliations, CardAffiliation.NEUTRAL, 7);
|
||||
appendAffiliations(affiliations, CardAffiliation.ASSASSIN, 1);
|
||||
shuffle(affiliations);
|
||||
|
||||
List<String> words = wordBank.draw(random, BOARD_SIZE);
|
||||
List<Card> cards = new ArrayList<>(BOARD_SIZE);
|
||||
for (int index = 0; index < BOARD_SIZE; index++) {
|
||||
cards.add(new Card(index, words.get(index), affiliations.get(index)));
|
||||
}
|
||||
|
||||
room.game = new Game(cards, startingTeam);
|
||||
addActivity(room, "Neues Spiel gestartet. " + teamLabel(startingTeam) + " beginnt mit dem ersten Hinweis.");
|
||||
}
|
||||
|
||||
public synchronized void submitClue(String requestedRoomId, String participantId, String clueWord, int count) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = requireParticipant(room, participantId);
|
||||
Game game = requirePlayableGame(room);
|
||||
SeatKey seat = requireSeat(room, participantId);
|
||||
|
||||
if (seat.role != SeatRole.SPYMASTER || seat.team != game.currentTeam || game.phase != GamePhase.CLUE) {
|
||||
throw new GameException("Gerade darfst du keinen Hinweis geben.");
|
||||
}
|
||||
|
||||
String normalizedClue = normalizeClue(clueWord);
|
||||
if (count < 1 || count > 9) {
|
||||
throw new GameException("Die Hinweiszahl muss zwischen 1 und 9 liegen.");
|
||||
}
|
||||
|
||||
game.phase = GamePhase.GUESSING;
|
||||
game.clue = new ClueState(normalizedClue, count);
|
||||
game.remainingGuesses = count + 1;
|
||||
addActivity(room, participant.displayName + " gibt den Hinweis \"" + normalizedClue + "\" fuer " + count + " Karte(n).");
|
||||
}
|
||||
|
||||
public synchronized void guessCard(String requestedRoomId, String participantId, int cardIndex) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
Participant participant = requireParticipant(room, participantId);
|
||||
Game game = requirePlayableGame(room);
|
||||
SeatKey seat = requireSeat(room, participantId);
|
||||
|
||||
if (seat.role != SeatRole.OPERATIVE || seat.team != game.currentTeam || game.phase != GamePhase.GUESSING) {
|
||||
throw new GameException("Gerade darfst du keine Karte aufdecken.");
|
||||
}
|
||||
if (cardIndex < 0 || cardIndex >= game.cards.size()) {
|
||||
throw new GameException("Diese Karte existiert nicht.");
|
||||
}
|
||||
|
||||
Card card = game.cards.get(cardIndex);
|
||||
if (card.revealed) {
|
||||
throw new GameException("Diese Karte ist bereits aufgedeckt.");
|
||||
}
|
||||
|
||||
card.revealed = true;
|
||||
if (card.affiliation == CardAffiliation.ASSASSIN) {
|
||||
finishGame(game, oppositeOf(game.currentTeam));
|
||||
addActivity(room, participant.displayName + " hat den Attentaeter erwischt. " + teamLabel(game.winner) + " gewinnt.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (card.affiliation == CardAffiliation.NEUTRAL) {
|
||||
addActivity(room, participant.displayName + " deckt \"" + card.word + "\" auf. Das ist ein Unbeteiligter.");
|
||||
passTurn(room, game);
|
||||
return;
|
||||
}
|
||||
|
||||
Team revealedTeam = card.affiliation == CardAffiliation.RED ? Team.RED : Team.BLUE;
|
||||
if (remainingAgents(game, revealedTeam) == 0) {
|
||||
finishGame(game, revealedTeam);
|
||||
addActivity(room, participant.displayName + " deckt die letzte " + teamLabel(revealedTeam).toLowerCase(Locale.ROOT)
|
||||
+ " Agentenkarte auf. " + teamLabel(revealedTeam) + " gewinnt.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (revealedTeam == game.currentTeam) {
|
||||
game.remainingGuesses = game.remainingGuesses == null ? null : game.remainingGuesses - 1;
|
||||
addActivity(room, participant.displayName + " trifft mit \"" + card.word + "\" richtig fuer " + teamLabel(revealedTeam) + ".");
|
||||
if (game.remainingGuesses != null && game.remainingGuesses <= 0) {
|
||||
passTurn(room, game);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
addActivity(room, participant.displayName + " deckt mit \"" + card.word + "\" eine Karte von " + teamLabel(revealedTeam) + " auf.");
|
||||
passTurn(room, game);
|
||||
}
|
||||
|
||||
public synchronized void endTurn(String requestedRoomId, String participantId) {
|
||||
Room room = requireRoom(requestedRoomId);
|
||||
requireParticipant(room, participantId);
|
||||
Game game = requirePlayableGame(room);
|
||||
SeatKey seat = requireSeat(room, participantId);
|
||||
|
||||
if (seat.role != SeatRole.OPERATIVE || seat.team != game.currentTeam || game.phase != GamePhase.GUESSING) {
|
||||
throw new GameException("Gerade kannst du den Zug nicht beenden.");
|
||||
}
|
||||
|
||||
addActivity(room, teamLabel(game.currentTeam) + " beendet den aktuellen Zug freiwillig.");
|
||||
passTurn(room, game);
|
||||
}
|
||||
|
||||
private RoomSnapshot snapshot(Room room, String viewerId) {
|
||||
Participant viewer = requireParticipant(room, viewerId);
|
||||
SeatKey viewerSeat = room.findSeat(viewerId);
|
||||
GameView gameView = room.game == null ? lobbyView() : gameView(room.game, viewerSeat);
|
||||
|
||||
ViewerView viewerView = new ViewerView(
|
||||
viewer.id,
|
||||
viewer.displayName,
|
||||
viewer.id.equals(room.hostId),
|
||||
viewerSeat == null ? null : viewerSeat.team,
|
||||
viewerSeat == null ? null : viewerSeat.role,
|
||||
(room.game == null || room.game.status == GameStatus.FINISHED)
|
||||
&& viewer.id.equals(room.hostId)
|
||||
&& room.allSeatsFilled(),
|
||||
canGiveClue(room.game, viewerSeat),
|
||||
canGuess(room.game, viewerSeat),
|
||||
canEndTurn(room.game, viewerSeat)
|
||||
);
|
||||
|
||||
List<ParticipantView> participantViews = room.participants.values().stream()
|
||||
.map(participant -> {
|
||||
SeatKey seat = room.findSeat(participant.id);
|
||||
return new ParticipantView(
|
||||
participant.id,
|
||||
participant.displayName,
|
||||
participant.id.equals(room.hostId),
|
||||
seat == null ? null : seat.team,
|
||||
seat == null ? null : seat.role
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
List<SeatView> seatViews = SEAT_ORDER.stream()
|
||||
.map(seat -> {
|
||||
String occupantId = room.seats.get(seat);
|
||||
Participant occupant = occupantId == null ? null : room.participants.get(occupantId);
|
||||
return new SeatView(
|
||||
seat.team,
|
||||
seat.role,
|
||||
occupant == null ? null : occupant.id,
|
||||
occupant == null ? null : occupant.displayName,
|
||||
viewerId.equals(occupantId)
|
||||
);
|
||||
})
|
||||
.toList();
|
||||
|
||||
return new RoomSnapshot(
|
||||
room.id,
|
||||
viewerView,
|
||||
participantViews,
|
||||
seatViews,
|
||||
gameView,
|
||||
List.copyOf(room.activity)
|
||||
);
|
||||
}
|
||||
|
||||
private GameView lobbyView() {
|
||||
return new GameView(
|
||||
GameStatus.LOBBY,
|
||||
null,
|
||||
null,
|
||||
GamePhase.LOBBY,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private GameView gameView(Game game, SeatKey viewerSeat) {
|
||||
boolean revealHiddenAffiliations = viewerSeat != null && viewerSeat.role == SeatRole.SPYMASTER
|
||||
|| game.status == GameStatus.FINISHED;
|
||||
|
||||
List<CardView> cards = game.cards.stream()
|
||||
.map(card -> new CardView(
|
||||
card.index,
|
||||
card.word,
|
||||
card.revealed,
|
||||
card.revealed || revealHiddenAffiliations ? card.affiliation : null,
|
||||
canGuess(game, viewerSeat) && !card.revealed
|
||||
))
|
||||
.toList();
|
||||
|
||||
return new GameView(
|
||||
game.status,
|
||||
game.startingTeam,
|
||||
game.currentTeam,
|
||||
game.phase,
|
||||
game.clue == null ? null : new ClueView(game.clue.word, game.clue.count),
|
||||
game.remainingGuesses,
|
||||
game.winner,
|
||||
remainingAgents(game, Team.RED),
|
||||
remainingAgents(game, Team.BLUE),
|
||||
cards
|
||||
);
|
||||
}
|
||||
|
||||
private boolean canGiveClue(Game game, SeatKey viewerSeat) {
|
||||
return game != null
|
||||
&& game.status == GameStatus.IN_PROGRESS
|
||||
&& game.phase == GamePhase.CLUE
|
||||
&& viewerSeat != null
|
||||
&& viewerSeat.role == SeatRole.SPYMASTER
|
||||
&& viewerSeat.team == game.currentTeam;
|
||||
}
|
||||
|
||||
private boolean canGuess(Game game, SeatKey viewerSeat) {
|
||||
return game != null
|
||||
&& game.status == GameStatus.IN_PROGRESS
|
||||
&& game.phase == GamePhase.GUESSING
|
||||
&& viewerSeat != null
|
||||
&& viewerSeat.role == SeatRole.OPERATIVE
|
||||
&& viewerSeat.team == game.currentTeam;
|
||||
}
|
||||
|
||||
private boolean canEndTurn(Game game, SeatKey viewerSeat) {
|
||||
return canGuess(game, viewerSeat) && game.clue != null;
|
||||
}
|
||||
|
||||
private Game requirePlayableGame(Room room) {
|
||||
if (room.game == null || room.game.status != GameStatus.IN_PROGRESS) {
|
||||
throw new GameException("Aktuell laeuft kein aktives Spiel.");
|
||||
}
|
||||
return room.game;
|
||||
}
|
||||
|
||||
private void ensureLobby(Room room) {
|
||||
if (room.game != null && room.game.status == GameStatus.IN_PROGRESS) {
|
||||
throw new GameException("Waehle Rollen nur in der Lobby, nicht waehrend einer laufenden Runde.");
|
||||
}
|
||||
}
|
||||
|
||||
private Participant requireParticipant(Room room, String participantId) {
|
||||
Participant participant = room.participants.get(participantId);
|
||||
if (participant == null) {
|
||||
throw new GameException("Diese Spielersitzung existiert nicht mehr.");
|
||||
}
|
||||
return participant;
|
||||
}
|
||||
|
||||
private SeatKey requireSeat(Room room, String participantId) {
|
||||
SeatKey seat = room.findSeat(participantId);
|
||||
if (seat == null) {
|
||||
throw new GameException("Du musst zuerst eine Rolle waehlen.");
|
||||
}
|
||||
return seat;
|
||||
}
|
||||
|
||||
private Room requireRoom(String requestedRoomId) {
|
||||
String normalizedRoomId = normalizeRoomId(requestedRoomId);
|
||||
Room room = rooms.get(normalizedRoomId);
|
||||
if (room == null) {
|
||||
throw new GameException("Raum " + normalizedRoomId + " wurde nicht gefunden.");
|
||||
}
|
||||
return room;
|
||||
}
|
||||
|
||||
private void addActivity(Room room, String message) {
|
||||
room.activity.addFirst(new ActivityEntry(Instant.now(), message));
|
||||
while (room.activity.size() > MAX_ACTIVITY_ITEMS) {
|
||||
room.activity.removeLast();
|
||||
}
|
||||
}
|
||||
|
||||
private String generateRoomId() {
|
||||
String candidate;
|
||||
do {
|
||||
StringBuilder roomId = new StringBuilder(6);
|
||||
for (int index = 0; index < 6; index++) {
|
||||
roomId.append(ROOM_ALPHABET.charAt(random.nextInt(ROOM_ALPHABET.length())));
|
||||
}
|
||||
candidate = roomId.toString();
|
||||
} while (rooms.containsKey(candidate));
|
||||
return candidate;
|
||||
}
|
||||
|
||||
private String generateParticipantId() {
|
||||
return UUID.randomUUID().toString();
|
||||
}
|
||||
|
||||
private String normalizeRoomId(String roomId) {
|
||||
if (roomId == null || roomId.isBlank()) {
|
||||
throw new GameException("Bitte gib eine gueltige Raum-ID an.");
|
||||
}
|
||||
return roomId.trim().toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private String normalizeClue(String clueWord) {
|
||||
if (clueWord == null || clueWord.isBlank()) {
|
||||
throw new GameException("Der Hinweis darf nicht leer sein.");
|
||||
}
|
||||
String normalized = clueWord.trim();
|
||||
if (normalized.contains(" ")) {
|
||||
throw new GameException("Hinweise muessen aus genau einem Wort bestehen.");
|
||||
}
|
||||
if (!normalized.matches("[\\p{L}\\-]{2,24}")) {
|
||||
throw new GameException("Bitte verwende ein Wort mit 2 bis 24 Buchstaben.");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private int remainingAgents(Game game, Team team) {
|
||||
CardAffiliation affiliation = team == Team.RED ? CardAffiliation.RED : CardAffiliation.BLUE;
|
||||
return (int) game.cards.stream()
|
||||
.filter(card -> card.affiliation == affiliation && !card.revealed)
|
||||
.count();
|
||||
}
|
||||
|
||||
private void finishGame(Game game, Team winner) {
|
||||
game.status = GameStatus.FINISHED;
|
||||
game.phase = GamePhase.FINISHED;
|
||||
game.winner = winner;
|
||||
game.remainingGuesses = null;
|
||||
}
|
||||
|
||||
private void passTurn(Room room, Game game) {
|
||||
game.currentTeam = oppositeOf(game.currentTeam);
|
||||
game.phase = GamePhase.CLUE;
|
||||
game.clue = null;
|
||||
game.remainingGuesses = null;
|
||||
addActivity(room, "Jetzt ist " + teamLabel(game.currentTeam) + " mit dem naechsten Hinweis am Zug.");
|
||||
}
|
||||
|
||||
private void appendAffiliations(List<CardAffiliation> affiliations, CardAffiliation affiliation, int count) {
|
||||
for (int index = 0; index < count; index++) {
|
||||
affiliations.add(affiliation);
|
||||
}
|
||||
}
|
||||
|
||||
private void shuffle(List<CardAffiliation> values) {
|
||||
for (int index = values.size() - 1; index > 0; index--) {
|
||||
int swapIndex = random.nextInt(index + 1);
|
||||
CardAffiliation current = values.get(index);
|
||||
values.set(index, values.get(swapIndex));
|
||||
values.set(swapIndex, current);
|
||||
}
|
||||
}
|
||||
|
||||
private Team oppositeOf(Team team) {
|
||||
return team == Team.RED ? Team.BLUE : Team.RED;
|
||||
}
|
||||
|
||||
private String teamLabel(Team team) {
|
||||
return team == Team.RED ? "Rot" : "Blau";
|
||||
}
|
||||
|
||||
private String seatLabel(SeatKey seat) {
|
||||
String prefix = teamLabel(seat.team);
|
||||
String role = seat.role == SeatRole.SPYMASTER ? "Hinweisgeber" : "Ermittler";
|
||||
return prefix + " " + role;
|
||||
}
|
||||
|
||||
public record RoomSession(String roomId, String participantId, RoomSnapshot snapshot) {
|
||||
}
|
||||
|
||||
public static final class GameException extends RuntimeException {
|
||||
public GameException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Room {
|
||||
private final String id;
|
||||
private final LinkedHashMap<String, Participant> participants = new LinkedHashMap<>();
|
||||
private final Map<SeatKey, String> seats = new HashMap<>();
|
||||
private final ArrayDeque<ActivityEntry> activity = new ArrayDeque<>();
|
||||
private int guestCounter = 1;
|
||||
private String hostId;
|
||||
private Game game;
|
||||
|
||||
private Room(String id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
private String createDisplayName(String requestedDisplayName) {
|
||||
String normalized = requestedDisplayName == null ? "" : requestedDisplayName.trim();
|
||||
if (normalized.isBlank()) {
|
||||
normalized = "Gast " + guestCounter;
|
||||
guestCounter++;
|
||||
}
|
||||
if (normalized.length() > 24) {
|
||||
normalized = normalized.substring(0, 24).trim();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private void clearSeat(String participantId) {
|
||||
SeatKey currentSeat = findSeat(participantId);
|
||||
if (currentSeat != null) {
|
||||
seats.remove(currentSeat);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean allSeatsFilled() {
|
||||
return seats.size() == SEAT_ORDER.size();
|
||||
}
|
||||
|
||||
private SeatKey findSeat(String participantId) {
|
||||
return seats.entrySet().stream()
|
||||
.filter(entry -> entry.getValue().equals(participantId))
|
||||
.map(Map.Entry::getKey)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Participant {
|
||||
private final String id;
|
||||
private String displayName;
|
||||
|
||||
private Participant(String id, String displayName) {
|
||||
this.id = id;
|
||||
this.displayName = displayName;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Game {
|
||||
private final List<Card> cards;
|
||||
private final Team startingTeam;
|
||||
private Team currentTeam;
|
||||
private GameStatus status;
|
||||
private GamePhase phase;
|
||||
private ClueState clue;
|
||||
private Integer remainingGuesses;
|
||||
private Team winner;
|
||||
|
||||
private Game(List<Card> cards, Team startingTeam) {
|
||||
this.cards = cards;
|
||||
this.startingTeam = startingTeam;
|
||||
this.currentTeam = startingTeam;
|
||||
this.status = GameStatus.IN_PROGRESS;
|
||||
this.phase = GamePhase.CLUE;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class Card {
|
||||
private final int index;
|
||||
private final String word;
|
||||
private final CardAffiliation affiliation;
|
||||
private boolean revealed;
|
||||
|
||||
private Card(int index, String word, CardAffiliation affiliation) {
|
||||
this.index = index;
|
||||
this.word = word;
|
||||
this.affiliation = affiliation;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ClueState {
|
||||
private final String word;
|
||||
private final int count;
|
||||
|
||||
private ClueState(String word, int count) {
|
||||
this.word = word;
|
||||
this.count = count;
|
||||
}
|
||||
}
|
||||
|
||||
private record SeatKey(Team team, SeatRole role) {
|
||||
}
|
||||
}
|
||||
104
src/main/java/at/dslan/codenames/game/RoomSnapshot.java
Normal file
104
src/main/java/at/dslan/codenames/game/RoomSnapshot.java
Normal file
@@ -0,0 +1,104 @@
|
||||
package at.dslan.codenames.game;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record RoomSnapshot(
|
||||
String roomId,
|
||||
ViewerView viewer,
|
||||
List<ParticipantView> participants,
|
||||
List<SeatView> seats,
|
||||
GameView game,
|
||||
List<ActivityEntry> activity
|
||||
) {
|
||||
|
||||
public record ViewerView(
|
||||
String participantId,
|
||||
String displayName,
|
||||
boolean host,
|
||||
Team team,
|
||||
SeatRole role,
|
||||
boolean canStartGame,
|
||||
boolean canGiveClue,
|
||||
boolean canGuess,
|
||||
boolean canEndTurn
|
||||
) {
|
||||
}
|
||||
|
||||
public record ParticipantView(
|
||||
String participantId,
|
||||
String displayName,
|
||||
boolean host,
|
||||
Team team,
|
||||
SeatRole role
|
||||
) {
|
||||
}
|
||||
|
||||
public record SeatView(
|
||||
Team team,
|
||||
SeatRole role,
|
||||
String participantId,
|
||||
String participantName,
|
||||
boolean yours
|
||||
) {
|
||||
}
|
||||
|
||||
public record GameView(
|
||||
GameStatus status,
|
||||
Team startingTeam,
|
||||
Team currentTeam,
|
||||
GamePhase phase,
|
||||
ClueView clue,
|
||||
Integer remainingGuesses,
|
||||
Team winner,
|
||||
int redRemaining,
|
||||
int blueRemaining,
|
||||
List<CardView> cards
|
||||
) {
|
||||
}
|
||||
|
||||
public record ClueView(String word, int count) {
|
||||
}
|
||||
|
||||
public record CardView(
|
||||
int index,
|
||||
String word,
|
||||
boolean revealed,
|
||||
CardAffiliation visibleAffiliation,
|
||||
boolean clickable
|
||||
) {
|
||||
}
|
||||
|
||||
public record ActivityEntry(Instant timestamp, String message) {
|
||||
}
|
||||
|
||||
public enum Team {
|
||||
RED,
|
||||
BLUE
|
||||
}
|
||||
|
||||
public enum SeatRole {
|
||||
SPYMASTER,
|
||||
OPERATIVE
|
||||
}
|
||||
|
||||
public enum GameStatus {
|
||||
LOBBY,
|
||||
IN_PROGRESS,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
public enum GamePhase {
|
||||
LOBBY,
|
||||
CLUE,
|
||||
GUESSING,
|
||||
FINISHED
|
||||
}
|
||||
|
||||
public enum CardAffiliation {
|
||||
RED,
|
||||
BLUE,
|
||||
NEUTRAL,
|
||||
ASSASSIN
|
||||
}
|
||||
}
|
||||
9
src/main/java/at/dslan/codenames/game/WordBank.java
Normal file
9
src/main/java/at/dslan/codenames/game/WordBank.java
Normal file
@@ -0,0 +1,9 @@
|
||||
package at.dslan.codenames.game;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
public interface WordBank {
|
||||
|
||||
List<String> draw(Random random, int count);
|
||||
}
|
||||
53
src/main/java/at/dslan/codenames/web/RoomController.java
Normal file
53
src/main/java/at/dslan/codenames/web/RoomController.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package at.dslan.codenames.web;
|
||||
|
||||
import at.dslan.codenames.game.GameRoomService;
|
||||
import at.dslan.codenames.game.GameRoomService.GameException;
|
||||
import at.dslan.codenames.game.RoomSnapshot;
|
||||
import java.util.Map;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/rooms")
|
||||
public class RoomController {
|
||||
|
||||
private final GameRoomService gameRoomService;
|
||||
|
||||
public RoomController(GameRoomService gameRoomService) {
|
||||
this.gameRoomService = gameRoomService;
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public GameRoomService.RoomSession createRoom(@RequestBody(required = false) RoomRequest request) {
|
||||
String displayName = request == null ? null : request.displayName();
|
||||
return gameRoomService.createRoom(displayName);
|
||||
}
|
||||
|
||||
@PostMapping("/{roomId}/join")
|
||||
public GameRoomService.RoomSession joinRoom(@PathVariable String roomId, @RequestBody(required = false) RoomRequest request) {
|
||||
String displayName = request == null ? null : request.displayName();
|
||||
return gameRoomService.joinRoom(roomId, displayName);
|
||||
}
|
||||
|
||||
@GetMapping("/{roomId}")
|
||||
public RoomSnapshot getRoom(@PathVariable String roomId, @RequestParam String participantId) {
|
||||
return gameRoomService.getSnapshot(roomId, participantId);
|
||||
}
|
||||
|
||||
@ExceptionHandler(GameException.class)
|
||||
public ResponseEntity<Map<String, String>> handleGameException(GameException exception) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(Map.of("message", exception.getMessage()));
|
||||
}
|
||||
|
||||
public record RoomRequest(String displayName) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package at.dslan.codenames.websocket;
|
||||
|
||||
import at.dslan.codenames.game.GameRoomService;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.socket.CloseStatus;
|
||||
import org.springframework.web.socket.TextMessage;
|
||||
import org.springframework.web.socket.WebSocketSession;
|
||||
import org.springframework.web.socket.handler.TextWebSocketHandler;
|
||||
import tools.jackson.databind.JsonNode;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
@Component
|
||||
public class GameWebSocketHandler extends TextWebSocketHandler {
|
||||
|
||||
private final GameRoomService gameRoomService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Map<String, SessionBinding> bindings = new ConcurrentHashMap<>();
|
||||
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();
|
||||
private final Map<String, Set<String>> roomSessions = new ConcurrentHashMap<>();
|
||||
|
||||
public GameWebSocketHandler(GameRoomService gameRoomService, ObjectMapper objectMapper) {
|
||||
this.gameRoomService = gameRoomService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
|
||||
SessionBinding binding = bindingFrom(session.getUri());
|
||||
gameRoomService.getSnapshot(binding.roomId(), binding.participantId());
|
||||
|
||||
bindings.put(session.getId(), binding);
|
||||
sessions.put(session.getId(), session);
|
||||
roomSessions.computeIfAbsent(binding.roomId(), ignored -> ConcurrentHashMap.newKeySet()).add(session.getId());
|
||||
sendSnapshot(session, binding.roomId(), binding.participantId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
|
||||
SessionBinding binding = bindings.get(session.getId());
|
||||
if (binding == null) {
|
||||
closeQuietly(session, CloseStatus.POLICY_VIOLATION);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
JsonNode payload = objectMapper.readTree(message.getPayload());
|
||||
handleAction(binding, payload);
|
||||
broadcast(binding.roomId());
|
||||
} catch (GameRoomService.GameException exception) {
|
||||
sendError(session, exception.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) {
|
||||
SessionBinding binding = bindings.remove(session.getId());
|
||||
sessions.remove(session.getId());
|
||||
if (binding != null) {
|
||||
Set<String> sessionIds = roomSessions.get(binding.roomId());
|
||||
if (sessionIds != null) {
|
||||
sessionIds.remove(session.getId());
|
||||
if (sessionIds.isEmpty()) {
|
||||
roomSessions.remove(binding.roomId());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void handleAction(SessionBinding binding, JsonNode payload) {
|
||||
String type = requiredText(payload, "type");
|
||||
switch (type) {
|
||||
case "rename" -> gameRoomService.renameParticipant(
|
||||
binding.roomId(),
|
||||
binding.participantId(),
|
||||
requiredText(payload, "displayName")
|
||||
);
|
||||
case "take-seat" -> gameRoomService.takeSeat(
|
||||
binding.roomId(),
|
||||
binding.participantId(),
|
||||
Enum.valueOf(at.dslan.codenames.game.RoomSnapshot.Team.class, requiredText(payload, "team")),
|
||||
Enum.valueOf(at.dslan.codenames.game.RoomSnapshot.SeatRole.class, requiredText(payload, "role"))
|
||||
);
|
||||
case "leave-seat" -> gameRoomService.leaveSeat(binding.roomId(), binding.participantId());
|
||||
case "start-game" -> gameRoomService.startGame(binding.roomId(), binding.participantId());
|
||||
case "submit-clue" -> gameRoomService.submitClue(
|
||||
binding.roomId(),
|
||||
binding.participantId(),
|
||||
requiredText(payload, "word"),
|
||||
requiredInt(payload, "count")
|
||||
);
|
||||
case "guess-card" -> gameRoomService.guessCard(
|
||||
binding.roomId(),
|
||||
binding.participantId(),
|
||||
requiredInt(payload, "cardIndex")
|
||||
);
|
||||
case "end-turn" -> gameRoomService.endTurn(binding.roomId(), binding.participantId());
|
||||
default -> throw new GameRoomService.GameException("Unbekannter WebSocket-Befehl: " + type);
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcast(String roomId) throws IOException {
|
||||
Set<String> sessionIds = roomSessions.get(roomId);
|
||||
if (sessionIds == null) {
|
||||
return;
|
||||
}
|
||||
for (String sessionId : sessionIds) {
|
||||
WebSocketSession session = sessions.get(sessionId);
|
||||
SessionBinding binding = bindings.get(sessionId);
|
||||
if (session == null || binding == null || !session.isOpen()) {
|
||||
continue;
|
||||
}
|
||||
sendSnapshot(session, binding.roomId(), binding.participantId());
|
||||
}
|
||||
}
|
||||
|
||||
private void sendSnapshot(WebSocketSession session, String roomId, String participantId) throws IOException {
|
||||
SnapshotMessage message = new SnapshotMessage(
|
||||
"snapshot",
|
||||
gameRoomService.getSnapshot(roomId, participantId)
|
||||
);
|
||||
synchronized (session) {
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
|
||||
}
|
||||
}
|
||||
|
||||
private void sendError(WebSocketSession session, String errorMessage) throws IOException {
|
||||
ErrorMessage message = new ErrorMessage("error", errorMessage);
|
||||
synchronized (session) {
|
||||
session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message)));
|
||||
}
|
||||
}
|
||||
|
||||
private SessionBinding bindingFrom(URI uri) {
|
||||
if (uri == null || uri.getQuery() == null) {
|
||||
throw new GameRoomService.GameException("WebSocket-Verbindung ohne Sitzungsdaten.");
|
||||
}
|
||||
Map<String, String> params = QueryStringParser.parse(uri.getQuery());
|
||||
String roomId = params.get("roomId");
|
||||
String participantId = params.get("participantId");
|
||||
if (roomId == null || participantId == null) {
|
||||
throw new GameRoomService.GameException("Raum-ID oder Teilnehmer-ID fehlt.");
|
||||
}
|
||||
return new SessionBinding(roomId, participantId);
|
||||
}
|
||||
|
||||
private String requiredText(JsonNode payload, String fieldName) {
|
||||
JsonNode value = payload.get(fieldName);
|
||||
if (value == null || value.asText().isBlank()) {
|
||||
throw new GameRoomService.GameException("Feld " + fieldName + " fehlt.");
|
||||
}
|
||||
return value.asText();
|
||||
}
|
||||
|
||||
private int requiredInt(JsonNode payload, String fieldName) {
|
||||
JsonNode value = payload.get(fieldName);
|
||||
if (value == null || !value.isInt()) {
|
||||
throw new GameRoomService.GameException("Feld " + fieldName + " fehlt oder ist ungueltig.");
|
||||
}
|
||||
return value.asInt();
|
||||
}
|
||||
|
||||
private void closeQuietly(WebSocketSession session, CloseStatus status) {
|
||||
try {
|
||||
session.close(status);
|
||||
} catch (IOException ignored) {
|
||||
// connection is already gone
|
||||
}
|
||||
}
|
||||
|
||||
private record SessionBinding(String roomId, String participantId) {
|
||||
}
|
||||
|
||||
private record SnapshotMessage(String type, Object snapshot) {
|
||||
}
|
||||
|
||||
private record ErrorMessage(String type, String message) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package at.dslan.codenames.websocket;
|
||||
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
final class QueryStringParser {
|
||||
|
||||
private QueryStringParser() {
|
||||
}
|
||||
|
||||
static Map<String, String> parse(String query) {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
for (String pair : query.split("&")) {
|
||||
String[] parts = pair.split("=", 2);
|
||||
String key = URLDecoder.decode(parts[0], StandardCharsets.UTF_8);
|
||||
String value = parts.length > 1 ? URLDecoder.decode(parts[1], StandardCharsets.UTF_8) : "";
|
||||
params.put(key, value);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
}
|
||||
16
src/main/resources/application.yaml
Normal file
16
src/main/resources/application.yaml
Normal file
@@ -0,0 +1,16 @@
|
||||
server:
|
||||
shutdown: graceful
|
||||
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 20s
|
||||
|
||||
management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
422
src/main/resources/static/app.js
Normal file
422
src/main/resources/static/app.js
Normal file
@@ -0,0 +1,422 @@
|
||||
const state = {
|
||||
roomId: null,
|
||||
participantId: null,
|
||||
snapshot: null,
|
||||
socket: null,
|
||||
reconnectTimer: null,
|
||||
};
|
||||
|
||||
const dom = {};
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
bindDom();
|
||||
bindEvents();
|
||||
restoreSession();
|
||||
});
|
||||
|
||||
function bindDom() {
|
||||
dom.flash = document.getElementById("flash");
|
||||
dom.createRoomForm = document.getElementById("create-room-form");
|
||||
dom.joinRoomForm = document.getElementById("join-room-form");
|
||||
dom.joinRoomId = document.getElementById("join-room-id");
|
||||
dom.roomView = document.getElementById("room-view");
|
||||
dom.roomCode = document.getElementById("room-code");
|
||||
dom.shareLink = document.getElementById("share-link");
|
||||
dom.copyLinkButton = document.getElementById("copy-link-button");
|
||||
dom.renameForm = document.getElementById("rename-form");
|
||||
dom.renameInput = document.getElementById("rename-input");
|
||||
dom.viewerRole = document.getElementById("viewer-role");
|
||||
dom.connectionState = document.getElementById("connection-state");
|
||||
dom.startGameButton = document.getElementById("start-game-button");
|
||||
dom.leaveSeatButton = document.getElementById("leave-seat-button");
|
||||
dom.seatGrid = document.getElementById("seat-grid");
|
||||
dom.participantList = document.getElementById("participant-list");
|
||||
dom.activityList = document.getElementById("activity-list");
|
||||
dom.turnTitle = document.getElementById("turn-title");
|
||||
dom.redRemaining = document.getElementById("red-remaining");
|
||||
dom.blueRemaining = document.getElementById("blue-remaining");
|
||||
dom.clueHelp = document.getElementById("clue-help");
|
||||
dom.clueForm = document.getElementById("clue-form");
|
||||
dom.clueWord = document.getElementById("clue-word");
|
||||
dom.clueCount = document.getElementById("clue-count");
|
||||
dom.activeClue = document.getElementById("active-clue");
|
||||
dom.endTurnButton = document.getElementById("end-turn-button");
|
||||
dom.boardGrid = document.getElementById("board-grid");
|
||||
dom.boardEmpty = document.getElementById("board-empty");
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
dom.createRoomForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(dom.createRoomForm);
|
||||
const payload = await api("/api/rooms", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ displayName: formData.get("displayName") }),
|
||||
});
|
||||
enterRoom(payload);
|
||||
});
|
||||
|
||||
dom.joinRoomForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(dom.joinRoomForm);
|
||||
const roomId = String(formData.get("roomId") || "").trim().toUpperCase();
|
||||
const payload = await api(`/api/rooms/${encodeURIComponent(roomId)}/join`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ displayName: formData.get("displayName") }),
|
||||
});
|
||||
enterRoom(payload);
|
||||
});
|
||||
|
||||
dom.renameForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
sendAction("rename", { displayName: dom.renameInput.value.trim() });
|
||||
});
|
||||
|
||||
dom.copyLinkButton.addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(dom.shareLink.value);
|
||||
flash("Join-Link in die Zwischenablage kopiert.");
|
||||
});
|
||||
|
||||
dom.startGameButton.addEventListener("click", () => sendAction("start-game"));
|
||||
dom.leaveSeatButton.addEventListener("click", () => sendAction("leave-seat"));
|
||||
dom.endTurnButton.addEventListener("click", () => sendAction("end-turn"));
|
||||
|
||||
dom.clueForm.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
sendAction("submit-clue", {
|
||||
word: dom.clueWord.value.trim(),
|
||||
count: Number(dom.clueCount.value),
|
||||
});
|
||||
dom.clueWord.value = "";
|
||||
});
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const button = event.target.closest("[data-action]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
const action = button.dataset.action;
|
||||
if (action === "take-seat") {
|
||||
sendAction("take-seat", {
|
||||
team: button.dataset.team,
|
||||
role: button.dataset.role,
|
||||
});
|
||||
}
|
||||
if (action === "guess-card") {
|
||||
sendAction("guess-card", {
|
||||
cardIndex: Number(button.dataset.cardIndex),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function restoreSession() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const roomId = (params.get("room") || "").trim().toUpperCase();
|
||||
const participantId = params.get("player") || "";
|
||||
|
||||
if (roomId) {
|
||||
dom.joinRoomId.value = roomId;
|
||||
}
|
||||
|
||||
if (!roomId || !participantId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const snapshot = await api(`/api/rooms/${encodeURIComponent(roomId)}?participantId=${encodeURIComponent(participantId)}`);
|
||||
state.roomId = roomId;
|
||||
state.participantId = participantId;
|
||||
state.snapshot = snapshot;
|
||||
render();
|
||||
connectSocket();
|
||||
} catch (error) {
|
||||
flash(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function enterRoom(payload) {
|
||||
state.roomId = payload.roomId;
|
||||
state.participantId = payload.participantId;
|
||||
state.snapshot = payload.snapshot;
|
||||
const params = new URLSearchParams({ room: state.roomId, player: state.participantId });
|
||||
window.history.replaceState({}, "", `${window.location.pathname}?${params.toString()}`);
|
||||
render();
|
||||
connectSocket(true);
|
||||
}
|
||||
|
||||
function connectSocket(forceReconnect = false) {
|
||||
if (forceReconnect && state.socket) {
|
||||
state.socket.close();
|
||||
}
|
||||
if (!state.roomId || !state.participantId) {
|
||||
return;
|
||||
}
|
||||
if (state.socket && state.socket.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
const socketUrl = `${protocol}://${window.location.host}/ws?roomId=${encodeURIComponent(state.roomId)}&participantId=${encodeURIComponent(state.participantId)}`;
|
||||
state.socket = new WebSocket(socketUrl);
|
||||
|
||||
setConnectionState("verbinde ...");
|
||||
|
||||
state.socket.addEventListener("open", () => {
|
||||
setConnectionState("verbunden");
|
||||
});
|
||||
|
||||
state.socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "snapshot") {
|
||||
state.snapshot = message.snapshot;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
if (message.type === "error") {
|
||||
flash(message.message);
|
||||
}
|
||||
});
|
||||
|
||||
state.socket.addEventListener("close", () => {
|
||||
setConnectionState("getrennt");
|
||||
if (state.reconnectTimer) {
|
||||
window.clearTimeout(state.reconnectTimer);
|
||||
}
|
||||
state.reconnectTimer = window.setTimeout(() => connectSocket(), 1500);
|
||||
});
|
||||
}
|
||||
|
||||
function sendAction(type, payload = {}) {
|
||||
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) {
|
||||
flash("Die WebSocket-Verbindung ist noch nicht bereit.");
|
||||
return;
|
||||
}
|
||||
state.socket.send(JSON.stringify({ type, ...payload }));
|
||||
}
|
||||
|
||||
function render() {
|
||||
const snapshot = state.snapshot;
|
||||
const inRoom = Boolean(snapshot);
|
||||
dom.roomView.classList.toggle("hidden", !inRoom);
|
||||
if (!inRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.title = `Codenames ${snapshot.roomId}`;
|
||||
dom.roomCode.textContent = snapshot.roomId;
|
||||
dom.shareLink.value = `${window.location.origin}${window.location.pathname}?room=${snapshot.roomId}`;
|
||||
dom.renameInput.value = snapshot.viewer.displayName;
|
||||
dom.viewerRole.textContent = formatViewerRole(snapshot.viewer);
|
||||
|
||||
renderSeats(snapshot);
|
||||
renderParticipants(snapshot);
|
||||
renderActivity(snapshot);
|
||||
renderGame(snapshot);
|
||||
renderControls(snapshot);
|
||||
}
|
||||
|
||||
function renderSeats(snapshot) {
|
||||
const canChangeSeats = snapshot.game.status !== "IN_PROGRESS";
|
||||
dom.seatGrid.innerHTML = snapshot.seats.map((seat) => {
|
||||
const occupied = Boolean(seat.participantId);
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="seat-card ${seat.team.toLowerCase()} ${occupied ? "occupied" : "vacant"} ${seat.yours ? "yours" : ""}"
|
||||
data-action="${occupied || !canChangeSeats ? "" : "take-seat"}"
|
||||
data-team="${seat.team}"
|
||||
data-role="${seat.role}"
|
||||
>
|
||||
<span class="seat-team">${labelForTeam(seat.team)}</span>
|
||||
<strong>${labelForRole(seat.role)}</strong>
|
||||
<span>${occupied ? seat.participantName : "Frei"}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
const hasSeat = snapshot.viewer.team && snapshot.viewer.role;
|
||||
dom.leaveSeatButton.classList.toggle("hidden", !(hasSeat && canChangeSeats));
|
||||
}
|
||||
|
||||
function renderParticipants(snapshot) {
|
||||
dom.participantList.innerHTML = snapshot.participants.map((participant) => {
|
||||
const role = participant.team && participant.role
|
||||
? `${labelForTeam(participant.team)} ${labelForRole(participant.role)}`
|
||||
: "Zuschauer";
|
||||
return `
|
||||
<article class="participant-card ${participant.host ? "host" : ""}">
|
||||
<div>
|
||||
<strong>${escapeHtml(participant.displayName)}</strong>
|
||||
<span>${role}</span>
|
||||
</div>
|
||||
${participant.participantId === snapshot.viewer.participantId ? "<em>Du</em>" : participant.host ? "<em>Host</em>" : ""}
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderActivity(snapshot) {
|
||||
dom.activityList.innerHTML = snapshot.activity.map((item) => {
|
||||
const time = new Date(item.timestamp).toLocaleTimeString("de-AT", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
return `
|
||||
<article class="activity-item">
|
||||
<span>${time}</span>
|
||||
<p>${escapeHtml(item.message)}</p>
|
||||
</article>
|
||||
`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function renderGame(snapshot) {
|
||||
const game = snapshot.game;
|
||||
dom.redRemaining.textContent = String(game.redRemaining);
|
||||
dom.blueRemaining.textContent = String(game.blueRemaining);
|
||||
|
||||
if (game.status === "LOBBY") {
|
||||
dom.turnTitle.textContent = "Lobby bereit machen";
|
||||
dom.boardGrid.innerHTML = "";
|
||||
dom.boardEmpty.classList.remove("hidden");
|
||||
dom.boardEmpty.textContent = "Sobald der Host startet, werden hier 25 Begriffe ausgespielt.";
|
||||
dom.activeClue.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
dom.turnTitle.textContent = describeTurn(game);
|
||||
dom.boardEmpty.classList.add("hidden");
|
||||
dom.boardGrid.innerHTML = game.cards.map((card) => {
|
||||
const classes = [
|
||||
"board-card",
|
||||
card.revealed ? "revealed" : "hidden-card",
|
||||
card.visibleAffiliation ? card.visibleAffiliation.toLowerCase() : "unknown",
|
||||
card.clickable ? "clickable" : "",
|
||||
].join(" ");
|
||||
|
||||
return `
|
||||
<button
|
||||
type="button"
|
||||
class="${classes}"
|
||||
${card.clickable ? `data-action="guess-card" data-card-index="${card.index}"` : "disabled"}
|
||||
>
|
||||
<span class="word">${escapeHtml(card.word)}</span>
|
||||
<span class="affiliation">${card.visibleAffiliation ? labelForAffiliation(card.visibleAffiliation) : "verdeckt"}</span>
|
||||
</button>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
if (game.clue) {
|
||||
const guesses = game.remainingGuesses == null ? "?" : String(game.remainingGuesses);
|
||||
dom.activeClue.classList.remove("hidden");
|
||||
dom.activeClue.innerHTML = `
|
||||
<span>Aktueller Hinweis</span>
|
||||
<strong>${escapeHtml(game.clue.word)}</strong>
|
||||
<p>${game.clue.count} Karten, noch ${guesses} Versuche.</p>
|
||||
`;
|
||||
} else {
|
||||
dom.activeClue.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function renderControls(snapshot) {
|
||||
const viewer = snapshot.viewer;
|
||||
const game = snapshot.game;
|
||||
|
||||
dom.startGameButton.classList.toggle("hidden", !viewer.canStartGame);
|
||||
dom.clueForm.classList.toggle("hidden", !viewer.canGiveClue);
|
||||
dom.endTurnButton.classList.toggle("hidden", !viewer.canEndTurn);
|
||||
|
||||
if (game.status === "FINISHED") {
|
||||
dom.clueHelp.textContent = `${labelForTeam(game.winner)} gewinnt diese Runde. Rollen koennen in derselben Lobby neu verteilt werden.`;
|
||||
return;
|
||||
}
|
||||
if (viewer.canGiveClue) {
|
||||
dom.clueHelp.textContent = "Du bist Hinweisgeber. Gib jetzt ein einziges Wort plus Anzahl weiter.";
|
||||
return;
|
||||
}
|
||||
if (viewer.canGuess) {
|
||||
dom.clueHelp.textContent = "Du bist Ermittler. Klickt die passenden Karten oder beendet den Zug freiwillig.";
|
||||
return;
|
||||
}
|
||||
if (game.status === "LOBBY") {
|
||||
dom.clueHelp.textContent = "Vier Rollen besetzen, dann kann der Host das Match starten.";
|
||||
return;
|
||||
}
|
||||
if (game.phase === "CLUE") {
|
||||
dom.clueHelp.textContent = `${labelForTeam(game.currentTeam)} wartet gerade auf den naechsten Hinweis.`;
|
||||
return;
|
||||
}
|
||||
dom.clueHelp.textContent = "Die Runde laeuft. Beobachte den aktuellen Hinweis und das Board.";
|
||||
}
|
||||
|
||||
function describeTurn(game) {
|
||||
if (game.status === "FINISHED") {
|
||||
return `${labelForTeam(game.winner)} gewinnt`;
|
||||
}
|
||||
if (game.phase === "CLUE") {
|
||||
return `${labelForTeam(game.currentTeam)} gibt den Hinweis`;
|
||||
}
|
||||
return `${labelForTeam(game.currentTeam)} raet`;
|
||||
}
|
||||
|
||||
function formatViewerRole(viewer) {
|
||||
if (!viewer.team || !viewer.role) {
|
||||
return viewer.host ? "Host und Zuschauer" : "Zuschauer";
|
||||
}
|
||||
return `${labelForTeam(viewer.team)} ${labelForRole(viewer.role)}${viewer.host ? " und Host" : ""}`;
|
||||
}
|
||||
|
||||
function setConnectionState(text) {
|
||||
dom.connectionState.textContent = text;
|
||||
}
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(body.message || "Die Anfrage konnte nicht verarbeitet werden.");
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function flash(message) {
|
||||
dom.flash.textContent = message;
|
||||
dom.flash.classList.remove("hidden");
|
||||
window.setTimeout(() => dom.flash.classList.add("hidden"), 3200);
|
||||
}
|
||||
|
||||
function labelForTeam(team) {
|
||||
return team === "RED" ? "Rot" : "Blau";
|
||||
}
|
||||
|
||||
function labelForRole(role) {
|
||||
return role === "SPYMASTER" ? "Hinweisgeber" : "Ermittler";
|
||||
}
|
||||
|
||||
function labelForAffiliation(affiliation) {
|
||||
if (affiliation === "ASSASSIN") {
|
||||
return "Attentaeter";
|
||||
}
|
||||
if (affiliation === "NEUTRAL") {
|
||||
return "Neutral";
|
||||
}
|
||||
return labelForTeam(affiliation);
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
157
src/main/resources/static/index.html
Normal file
157
src/main/resources/static/index.html
Normal file
@@ -0,0 +1,157 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Codenames Lobby</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script defer src="/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="ambient ambient-left"></div>
|
||||
<div class="ambient ambient-right"></div>
|
||||
<main class="page-shell">
|
||||
<section class="hero-card">
|
||||
<p class="eyebrow">Realtime Multiplayer</p>
|
||||
<h1>Codenames fuer euren Browser.</h1>
|
||||
<p class="hero-copy">
|
||||
Raum anlegen, ID teilen, Rollen besetzen und Hinweise per WebSocket in Echtzeit spielen.
|
||||
</p>
|
||||
<div id="flash" class="flash hidden"></div>
|
||||
<div class="hero-grid">
|
||||
<form id="create-room-form" class="panel stack-form">
|
||||
<div class="panel-head">
|
||||
<h2>Neuen Raum starten</h2>
|
||||
<p>Du wirst automatisch Host und kannst das erste Match anwerfen.</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Anzeigename</span>
|
||||
<input id="create-name" name="displayName" maxlength="24" placeholder="z. B. Daniela">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Raum erstellen</button>
|
||||
</form>
|
||||
|
||||
<form id="join-room-form" class="panel stack-form">
|
||||
<div class="panel-head">
|
||||
<h2>Raum beitreten</h2>
|
||||
<p>Mit Raum-ID als Spieler oder Zuschauer direkt einsteigen.</p>
|
||||
</div>
|
||||
<label class="field">
|
||||
<span>Raum-ID</span>
|
||||
<input id="join-room-id" name="roomId" maxlength="6" placeholder="ABC123" required>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzeigename</span>
|
||||
<input id="join-name" name="displayName" maxlength="24" placeholder="z. B. Lukas">
|
||||
</label>
|
||||
<button type="submit" class="secondary-button">Raum beitreten</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="room-view" class="room-layout hidden">
|
||||
<aside class="side-column">
|
||||
<section class="panel room-summary">
|
||||
<div class="room-head">
|
||||
<div>
|
||||
<p class="eyebrow">Raum</p>
|
||||
<h2 id="room-code">------</h2>
|
||||
</div>
|
||||
<button id="copy-link-button" class="ghost-button" type="button">Link kopieren</button>
|
||||
</div>
|
||||
<label class="field compact">
|
||||
<span>Join-Link</span>
|
||||
<input id="share-link" readonly>
|
||||
</label>
|
||||
<form id="rename-form" class="rename-row">
|
||||
<input id="rename-input" maxlength="24" placeholder="Dein Anzeigename">
|
||||
<button type="submit" class="ghost-button">Umbenennen</button>
|
||||
</form>
|
||||
<div class="meta-grid">
|
||||
<div>
|
||||
<span class="meta-label">Du spielst als</span>
|
||||
<strong id="viewer-role">Zuschauer</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span class="meta-label">Status</span>
|
||||
<strong id="connection-state">verbunden</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button id="start-game-button" class="primary-button wide hidden" type="button">
|
||||
Spiel starten
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Rollenverteilung</h3>
|
||||
<p>Ein Klick auf einen freien Platz setzt dich direkt in die Rolle.</p>
|
||||
</div>
|
||||
<div id="seat-grid" class="seat-grid"></div>
|
||||
<button id="leave-seat-button" class="ghost-button wide hidden" type="button">
|
||||
Rolle verlassen
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Teilnehmer</h3>
|
||||
<p>Wer mitspielt, spymastert oder nur zuschaut.</p>
|
||||
</div>
|
||||
<div id="participant-list" class="participant-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h3>Spiel-Feed</h3>
|
||||
<p>Die letzten Aktionen im Raum.</p>
|
||||
</div>
|
||||
<div id="activity-list" class="activity-list"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="board-column">
|
||||
<section class="panel status-strip">
|
||||
<div>
|
||||
<p class="eyebrow">Aktueller Zug</p>
|
||||
<h2 id="turn-title">Lobby</h2>
|
||||
</div>
|
||||
<div class="score-row">
|
||||
<span class="score-pill red">Rot <strong id="red-remaining">0</strong></span>
|
||||
<span class="score-pill blue">Blau <strong id="blue-remaining">0</strong></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel action-panel">
|
||||
<div class="panel-head">
|
||||
<h3>Hinweise und Tipps</h3>
|
||||
<p id="clue-help">Besetzt zuerst alle vier Rollen und startet danach das Match.</p>
|
||||
</div>
|
||||
|
||||
<form id="clue-form" class="clue-form hidden">
|
||||
<label class="field">
|
||||
<span>Hinweiswort</span>
|
||||
<input id="clue-word" maxlength="24" placeholder="z. B. Sternbild">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Anzahl</span>
|
||||
<input id="clue-count" type="number" min="1" max="9" value="2">
|
||||
</label>
|
||||
<button type="submit" class="primary-button">Hinweis senden</button>
|
||||
</form>
|
||||
|
||||
<div id="active-clue" class="active-clue hidden"></div>
|
||||
<button id="end-turn-button" class="secondary-button hidden" type="button">Zug beenden</button>
|
||||
</section>
|
||||
|
||||
<section class="panel board-panel">
|
||||
<div id="board-grid" class="board-grid"></div>
|
||||
<div id="board-empty" class="board-empty">
|
||||
Das Board erscheint, sobald der Host die Runde startet.
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
439
src/main/resources/static/styles.css
Normal file
439
src/main/resources/static/styles.css
Normal file
@@ -0,0 +1,439 @@
|
||||
:root {
|
||||
--paper: #f7f2e7;
|
||||
--paper-strong: #fffaf1;
|
||||
--ink: #1f2330;
|
||||
--muted: #6d7687;
|
||||
--red: #c94b4b;
|
||||
--red-strong: #942d2d;
|
||||
--blue: #296ca8;
|
||||
--blue-strong: #194a75;
|
||||
--gold: #d6a43a;
|
||||
--shadow: 0 18px 45px rgba(31, 35, 48, 0.14);
|
||||
--radius: 24px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: "Avenir Next", "Trebuchet MS", "Segoe UI", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(214, 164, 58, 0.22), transparent 28%),
|
||||
radial-gradient(circle at top right, rgba(41, 108, 168, 0.18), transparent 26%),
|
||||
linear-gradient(180deg, #efe2c4 0%, #f7f2e7 44%, #e8eef4 100%);
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.ambient {
|
||||
position: fixed;
|
||||
width: 30rem;
|
||||
height: 30rem;
|
||||
border-radius: 999px;
|
||||
filter: blur(42px);
|
||||
opacity: 0.36;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ambient-left {
|
||||
left: -10rem;
|
||||
top: 10rem;
|
||||
background: rgba(201, 75, 75, 0.28);
|
||||
}
|
||||
|
||||
.ambient-right {
|
||||
right: -12rem;
|
||||
top: -8rem;
|
||||
background: rgba(41, 108, 168, 0.24);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(1380px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 1.5rem 0 3rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
background: rgba(255, 250, 241, 0.8);
|
||||
border: 1px solid rgba(109, 118, 135, 0.14);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
padding: 2rem;
|
||||
animation: rise 420ms ease-out both;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.6rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.76rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-family: "Baskerville", "Palatino Linotype", serif;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(2.3rem, 3vw, 4rem);
|
||||
line-height: 0.95;
|
||||
max-width: 12ch;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.panel-head p,
|
||||
.meta-label,
|
||||
.field span,
|
||||
.activity-item span {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-grid,
|
||||
.room-layout {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
margin-top: 1.75rem;
|
||||
}
|
||||
|
||||
.room-layout {
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.side-column,
|
||||
.board-column {
|
||||
display: grid;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stack-form,
|
||||
.field,
|
||||
.participant-card,
|
||||
.activity-item,
|
||||
.seat-card,
|
||||
.board-card,
|
||||
.active-clue {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.rename-row input {
|
||||
width: 100%;
|
||||
padding: 0.86rem 1rem;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(31, 35, 48, 0.14);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button,
|
||||
.ghost-button {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease, opacity 160ms ease;
|
||||
}
|
||||
|
||||
.primary-button,
|
||||
.secondary-button {
|
||||
padding: 0.92rem 1.1rem;
|
||||
border-radius: 16px;
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
background: linear-gradient(135deg, var(--blue-strong), var(--blue));
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
background: linear-gradient(135deg, var(--red-strong), var(--red));
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
padding: 0.72rem 0.92rem;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.74);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.primary-button:hover,
|
||||
.secondary-button:hover,
|
||||
.ghost-button:hover,
|
||||
.seat-card:hover,
|
||||
.board-card.clickable:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wide {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.room-head,
|
||||
.rename-row,
|
||||
.meta-grid,
|
||||
.status-strip,
|
||||
.score-row {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.meta-grid {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.meta-grid > div {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.seat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.9rem;
|
||||
}
|
||||
|
||||
.seat-card {
|
||||
text-align: left;
|
||||
padding: 1rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
min-height: 7.2rem;
|
||||
}
|
||||
|
||||
.seat-card.red {
|
||||
border-color: rgba(201, 75, 75, 0.28);
|
||||
}
|
||||
|
||||
.seat-card.blue {
|
||||
border-color: rgba(41, 108, 168, 0.28);
|
||||
}
|
||||
|
||||
.seat-card.yours {
|
||||
box-shadow: inset 0 0 0 2px var(--gold);
|
||||
}
|
||||
|
||||
.seat-team {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.participant-list,
|
||||
.activity-list {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.participant-card,
|
||||
.activity-item {
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
.participant-card em {
|
||||
color: var(--blue-strong);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.participant-card.host {
|
||||
border: 1px solid rgba(214, 164, 58, 0.42);
|
||||
}
|
||||
|
||||
.score-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.score-pill.red {
|
||||
background: rgba(201, 75, 75, 0.12);
|
||||
color: var(--red-strong);
|
||||
}
|
||||
|
||||
.score-pill.blue {
|
||||
background: rgba(41, 108, 168, 0.12);
|
||||
color: var(--blue-strong);
|
||||
}
|
||||
|
||||
.board-panel {
|
||||
min-height: 620px;
|
||||
}
|
||||
|
||||
.board-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 0.95rem;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
position: relative;
|
||||
min-height: 120px;
|
||||
padding: 0.95rem;
|
||||
border-radius: 18px;
|
||||
border: none;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
background: linear-gradient(180deg, var(--paper-strong), var(--paper));
|
||||
box-shadow: inset 0 0 0 1px rgba(31, 35, 48, 0.08);
|
||||
}
|
||||
|
||||
.board-card .word {
|
||||
font-weight: 800;
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.board-card .affiliation {
|
||||
align-self: end;
|
||||
font-size: 0.72rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.board-card.red,
|
||||
.board-card.blue,
|
||||
.board-card.neutral,
|
||||
.board-card.assassin {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.board-card.red {
|
||||
background: linear-gradient(180deg, #d26a6a, #9f3030);
|
||||
}
|
||||
|
||||
.board-card.blue {
|
||||
background: linear-gradient(180deg, #4c8fca, #205e94);
|
||||
}
|
||||
|
||||
.board-card.neutral {
|
||||
background: linear-gradient(180deg, #848c96, #59606a);
|
||||
}
|
||||
|
||||
.board-card.assassin {
|
||||
background: linear-gradient(180deg, #2b2f3d, #13151d);
|
||||
}
|
||||
|
||||
.board-card.hidden-card {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.board-card.hidden-card .affiliation {
|
||||
color: rgba(109, 118, 135, 0.6);
|
||||
}
|
||||
|
||||
.board-card.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.board-card:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.board-empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 520px;
|
||||
border-radius: 20px;
|
||||
border: 1px dashed rgba(31, 35, 48, 0.14);
|
||||
color: var(--muted);
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.active-clue {
|
||||
padding: 1rem;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(135deg, rgba(41, 108, 168, 0.12), rgba(214, 164, 58, 0.18));
|
||||
}
|
||||
|
||||
.flash {
|
||||
margin-top: 1rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border-radius: 16px;
|
||||
background: rgba(201, 75, 75, 0.12);
|
||||
color: var(--red-strong);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1040px) {
|
||||
.room-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page-shell {
|
||||
width: min(100% - 1rem, 100%);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.seat-grid,
|
||||
.board-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.room-head,
|
||||
.rename-row,
|
||||
.status-strip,
|
||||
.score-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.board-card {
|
||||
min-height: 96px;
|
||||
}
|
||||
}
|
||||
302
src/main/resources/words/de-terms.txt
Normal file
302
src/main/resources/words/de-terms.txt
Normal file
@@ -0,0 +1,302 @@
|
||||
Jahr
|
||||
Mensch
|
||||
Zeit
|
||||
Kind
|
||||
Tag
|
||||
Leben
|
||||
Thema
|
||||
Frage
|
||||
Information
|
||||
Unternehmen
|
||||
Frau
|
||||
Fall
|
||||
Moeglichkeit
|
||||
Arbeit
|
||||
Welt
|
||||
Bereich
|
||||
Kunde
|
||||
Weg
|
||||
Stadt
|
||||
Land
|
||||
Teil
|
||||
Bild
|
||||
Projekt
|
||||
Angebot
|
||||
Haus
|
||||
Beispiel
|
||||
Buch
|
||||
Ziel
|
||||
Kirche
|
||||
Datum
|
||||
Woche
|
||||
Art
|
||||
Ende
|
||||
Person
|
||||
Grund
|
||||
Problem
|
||||
Ort
|
||||
Inhalt
|
||||
Produkt
|
||||
Geschichte
|
||||
Entwicklung
|
||||
Familie
|
||||
Mitglied
|
||||
Monat
|
||||
Rahmen
|
||||
Form
|
||||
Name
|
||||
Erfahrung
|
||||
Schule
|
||||
Mann
|
||||
Artikel
|
||||
Preis
|
||||
Beitrag
|
||||
Programm
|
||||
Wort
|
||||
Gruppe
|
||||
Aufgabe
|
||||
Recht
|
||||
Ergebnis
|
||||
Platz
|
||||
Sinn
|
||||
Hilfe
|
||||
Veranstaltung
|
||||
Gesellschaft
|
||||
Mitarbeiter
|
||||
Gemeinde
|
||||
Text
|
||||
Geld
|
||||
Idee
|
||||
Raum
|
||||
Stunde
|
||||
Hand
|
||||
Stelle
|
||||
Spiel
|
||||
Wasser
|
||||
Regel
|
||||
Zukunft
|
||||
Leistung
|
||||
Eltern
|
||||
Foto
|
||||
Blick
|
||||
Kosten
|
||||
Verein
|
||||
Freund
|
||||
Region
|
||||
Erfolg
|
||||
Film
|
||||
Liebe
|
||||
Europa
|
||||
Hotel
|
||||
System
|
||||
Gast
|
||||
Kontakt
|
||||
Wert
|
||||
Schueler
|
||||
Anfang
|
||||
Bedeutung
|
||||
Minute
|
||||
Wunsch
|
||||
Punkt
|
||||
Jahrhundert
|
||||
Kraft
|
||||
Loesung
|
||||
Anspruch
|
||||
Koerper
|
||||
Qualitaet
|
||||
Herz
|
||||
Schritt
|
||||
Universitaet
|
||||
Million
|
||||
Teilnehmer
|
||||
Energie
|
||||
Besucher
|
||||
Partner
|
||||
Team
|
||||
Entscheidung
|
||||
Natur
|
||||
Musik
|
||||
Rolle
|
||||
Ausbildung
|
||||
Antwort
|
||||
Sache
|
||||
Hinweis
|
||||
Situation
|
||||
Zahl
|
||||
Vorteil
|
||||
Auge
|
||||
Firma
|
||||
Sprache
|
||||
Strasse
|
||||
Markt
|
||||
Folge
|
||||
Einrichtung
|
||||
Grundlage
|
||||
Gespraech
|
||||
Funktion
|
||||
Suche
|
||||
Staat
|
||||
Zusammenarbeit
|
||||
Organisation
|
||||
Autor
|
||||
Spass
|
||||
Hoehe
|
||||
Kultur
|
||||
Farbe
|
||||
Tier
|
||||
Werk
|
||||
Arzt
|
||||
Nachricht
|
||||
Verhaeltnis
|
||||
Bewegung
|
||||
Chance
|
||||
Forschung
|
||||
Technik
|
||||
Zentrum
|
||||
Verantwortung
|
||||
Computer
|
||||
Studium
|
||||
Wetter
|
||||
Werkzeug
|
||||
Essen
|
||||
Szene
|
||||
Sonne
|
||||
Mond
|
||||
Stern
|
||||
Garten
|
||||
Feuer
|
||||
Schnee
|
||||
Wind
|
||||
Berg
|
||||
Fluss
|
||||
Meer
|
||||
Insel
|
||||
Hafen
|
||||
Dorf
|
||||
Schloss
|
||||
Bruecke
|
||||
Zug
|
||||
Auto
|
||||
Bus
|
||||
Fahrrad
|
||||
Maschine
|
||||
Karte
|
||||
Fenster
|
||||
Tuer
|
||||
Tisch
|
||||
Stuhl
|
||||
Bett
|
||||
Brot
|
||||
Kaese
|
||||
Apfel
|
||||
Birne
|
||||
Banane
|
||||
Kaffee
|
||||
Tee
|
||||
Milch
|
||||
Zucker
|
||||
Salz
|
||||
Pfeffer
|
||||
Messer
|
||||
Gabel
|
||||
Loeffel
|
||||
Teller
|
||||
Tasse
|
||||
Jacke
|
||||
Hemd
|
||||
Hose
|
||||
Schuh
|
||||
Tasche
|
||||
Schluessel
|
||||
Ring
|
||||
Krone
|
||||
Lampe
|
||||
Spiegel
|
||||
Kissen
|
||||
Decke
|
||||
Buero
|
||||
Kueche
|
||||
Keller
|
||||
Dach
|
||||
Boden
|
||||
Wand
|
||||
Schatten
|
||||
Licht
|
||||
Nebel
|
||||
Regen
|
||||
Sturm
|
||||
Blitz
|
||||
Donner
|
||||
Vogel
|
||||
Katze
|
||||
Hund
|
||||
Pferd
|
||||
Loewe
|
||||
Tiger
|
||||
Baer
|
||||
Fuchs
|
||||
Wolf
|
||||
Schaf
|
||||
Ziege
|
||||
Kuh
|
||||
Maus
|
||||
Fisch
|
||||
Wal
|
||||
Hai
|
||||
Blume
|
||||
Rose
|
||||
Tulpe
|
||||
Baum
|
||||
Wurzel
|
||||
Blatt
|
||||
Wald
|
||||
Wiese
|
||||
Stein
|
||||
Gold
|
||||
Silber
|
||||
Kupfer
|
||||
Glas
|
||||
Stahl
|
||||
Papier
|
||||
Feder
|
||||
Brief
|
||||
Paket
|
||||
Telefon
|
||||
Stimme
|
||||
Lied
|
||||
Takt
|
||||
Ton
|
||||
Buehne
|
||||
Kino
|
||||
Theater
|
||||
Kamera
|
||||
Geige
|
||||
Trommel
|
||||
Gitarre
|
||||
Ball
|
||||
Pokal
|
||||
Trikot
|
||||
Stadion
|
||||
Park
|
||||
Strand
|
||||
Wolke
|
||||
Quelle
|
||||
Burg
|
||||
Turm
|
||||
Tempel
|
||||
Palast
|
||||
Pirat
|
||||
Ritter
|
||||
Ninja
|
||||
Roboter
|
||||
Rakete
|
||||
Planet
|
||||
Vulkan
|
||||
Wueste
|
||||
Oase
|
||||
Kompass
|
||||
Uhr
|
||||
Alarm
|
||||
Signal
|
||||
Code
|
||||
Maske
|
||||
126
src/test/java/at/dslan/codenames/game/GameRoomServiceTest.java
Normal file
126
src/test/java/at/dslan/codenames/game/GameRoomServiceTest.java
Normal file
@@ -0,0 +1,126 @@
|
||||
package at.dslan.codenames.game;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
import at.dslan.codenames.game.RoomSnapshot.GamePhase;
|
||||
import at.dslan.codenames.game.RoomSnapshot.GameStatus;
|
||||
import at.dslan.codenames.game.RoomSnapshot.SeatRole;
|
||||
import at.dslan.codenames.game.RoomSnapshot.Team;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class GameRoomServiceTest {
|
||||
|
||||
private GameRoomService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
WordBank wordBank = (random, count) -> List.of(
|
||||
"Jahr", "Mensch", "Zeit", "Kind", "Tag",
|
||||
"Leben", "Thema", "Frage", "Arbeit", "Welt",
|
||||
"Buch", "Haus", "Spiel", "Herz", "Wasser",
|
||||
"Berg", "Baum", "Fisch", "Lampe", "Karte",
|
||||
"Ring", "Kamera", "Planet", "Turm", "Kompass"
|
||||
).subList(0, count);
|
||||
service = new GameRoomService(wordBank, new Random(7));
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsGameWhenAllSeatsAreFilled() {
|
||||
GameRoomService.RoomSession room = service.createRoom("Host");
|
||||
GameRoomService.RoomSession redOperative = service.joinRoom(room.roomId(), "Roter Ermittler");
|
||||
GameRoomService.RoomSession blueSpymaster = service.joinRoom(room.roomId(), "Blauer Hinweis");
|
||||
GameRoomService.RoomSession blueOperative = service.joinRoom(room.roomId(), "Blauer Ermittler");
|
||||
|
||||
service.takeSeat(room.roomId(), room.participantId(), Team.RED, SeatRole.SPYMASTER);
|
||||
service.takeSeat(room.roomId(), redOperative.participantId(), Team.RED, SeatRole.OPERATIVE);
|
||||
service.takeSeat(room.roomId(), blueSpymaster.participantId(), Team.BLUE, SeatRole.SPYMASTER);
|
||||
service.takeSeat(room.roomId(), blueOperative.participantId(), Team.BLUE, SeatRole.OPERATIVE);
|
||||
|
||||
service.startGame(room.roomId(), room.participantId());
|
||||
|
||||
RoomSnapshot snapshot = service.getSnapshot(room.roomId(), room.participantId());
|
||||
assertThat(snapshot.game().status()).isEqualTo(GameStatus.IN_PROGRESS);
|
||||
assertThat(snapshot.game().phase()).isEqualTo(GamePhase.CLUE);
|
||||
assertThat(snapshot.game().cards()).hasSize(25);
|
||||
assertThat(snapshot.game().redRemaining() + snapshot.game().blueRemaining()).isEqualTo(17);
|
||||
}
|
||||
|
||||
@Test
|
||||
void correctGuessConsumesAttemptAndCanBeEndedManually() {
|
||||
ReadyRoom room = createReadyRoom();
|
||||
RoomSnapshot hostView = service.getSnapshot(room.roomId(), room.redSpymasterId());
|
||||
Team currentTeam = hostView.game().currentTeam();
|
||||
|
||||
String spymasterId = currentTeam == Team.RED ? room.redSpymasterId() : room.blueSpymasterId();
|
||||
String operativeId = currentTeam == Team.RED ? room.redOperativeId() : room.blueOperativeId();
|
||||
int matchingCardIndex = findHiddenCardFor(room.roomId(), spymasterId, currentTeam);
|
||||
|
||||
service.submitClue(room.roomId(), spymasterId, "Atlas", 1);
|
||||
service.guessCard(room.roomId(), operativeId, matchingCardIndex);
|
||||
|
||||
RoomSnapshot afterGuess = service.getSnapshot(room.roomId(), operativeId);
|
||||
assertThat(afterGuess.game().phase()).isEqualTo(GamePhase.GUESSING);
|
||||
assertThat(afterGuess.game().remainingGuesses()).isEqualTo(1);
|
||||
|
||||
service.endTurn(room.roomId(), operativeId);
|
||||
|
||||
RoomSnapshot afterTurn = service.getSnapshot(room.roomId(), operativeId);
|
||||
assertThat(afterTurn.game().phase()).isEqualTo(GamePhase.CLUE);
|
||||
assertThat(afterTurn.game().currentTeam()).isEqualTo(currentTeam == Team.RED ? Team.BLUE : Team.RED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsGameStartWithoutCompleteRoster() {
|
||||
GameRoomService.RoomSession room = service.createRoom("Host");
|
||||
service.takeSeat(room.roomId(), room.participantId(), Team.RED, SeatRole.SPYMASTER);
|
||||
|
||||
assertThatThrownBy(() -> service.startGame(room.roomId(), room.participantId()))
|
||||
.isInstanceOf(GameRoomService.GameException.class)
|
||||
.hasMessageContaining("vier besetzte Rollen");
|
||||
}
|
||||
|
||||
private ReadyRoom createReadyRoom() {
|
||||
GameRoomService.RoomSession redSpymaster = service.createRoom("Host");
|
||||
GameRoomService.RoomSession redOperative = service.joinRoom(redSpymaster.roomId(), "Rot");
|
||||
GameRoomService.RoomSession blueSpymaster = service.joinRoom(redSpymaster.roomId(), "Blau");
|
||||
GameRoomService.RoomSession blueOperative = service.joinRoom(redSpymaster.roomId(), "Blau 2");
|
||||
|
||||
service.takeSeat(redSpymaster.roomId(), redSpymaster.participantId(), Team.RED, SeatRole.SPYMASTER);
|
||||
service.takeSeat(redSpymaster.roomId(), redOperative.participantId(), Team.RED, SeatRole.OPERATIVE);
|
||||
service.takeSeat(redSpymaster.roomId(), blueSpymaster.participantId(), Team.BLUE, SeatRole.SPYMASTER);
|
||||
service.takeSeat(redSpymaster.roomId(), blueOperative.participantId(), Team.BLUE, SeatRole.OPERATIVE);
|
||||
service.startGame(redSpymaster.roomId(), redSpymaster.participantId());
|
||||
|
||||
return new ReadyRoom(
|
||||
redSpymaster.roomId(),
|
||||
redSpymaster.participantId(),
|
||||
redOperative.participantId(),
|
||||
blueSpymaster.participantId(),
|
||||
blueOperative.participantId()
|
||||
);
|
||||
}
|
||||
|
||||
private int findHiddenCardFor(String roomId, String viewerId, Team team) {
|
||||
return service.getSnapshot(roomId, viewerId).game().cards().stream()
|
||||
.filter(card -> !card.revealed())
|
||||
.filter(card -> card.visibleAffiliation() == (team == Team.RED
|
||||
? RoomSnapshot.CardAffiliation.RED
|
||||
: RoomSnapshot.CardAffiliation.BLUE))
|
||||
.findFirst()
|
||||
.orElseThrow()
|
||||
.index();
|
||||
}
|
||||
|
||||
private record ReadyRoom(
|
||||
String roomId,
|
||||
String redSpymasterId,
|
||||
String redOperativeId,
|
||||
String blueSpymasterId,
|
||||
String blueOperativeId
|
||||
) {
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user