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