commit 6bd6b08044554b76d4f8d47225b7f65f6ead0e11 Author: Schramm Dominik Date: Wed Apr 22 15:31:38 2026 +0200 feat: add realtime codenames game diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9d2a2d4 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +.git +.gradle +app diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/.gitattributes @@ -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 + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c4ac6d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Ignore Gradle project-specific cache directory +.gradle + +# Ignore Gradle build output directory +build + +# Ignore Kotlin plugin data +.kotlin diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f59595c --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..796c478 --- /dev/null +++ b/README.md @@ -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) diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..74b6640 --- /dev/null +++ b/build.gradle.kts @@ -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 { + useJUnitPlatform() +} diff --git a/deploy/k8s/deployment.yaml b/deploy/k8s/deployment.yaml new file mode 100644 index 0000000..76afd48 --- /dev/null +++ b/deploy/k8s/deployment.yaml @@ -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 diff --git a/deploy/k8s/ingress.yaml b/deploy/k8s/ingress.yaml new file mode 100644 index 0000000..8cadb82 --- /dev/null +++ b/deploy/k8s/ingress.yaml @@ -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 diff --git a/deploy/k8s/registry-secret.yaml b/deploy/k8s/registry-secret.yaml new file mode 100644 index 0000000..48c1dc8 --- /dev/null +++ b/deploy/k8s/registry-secret.yaml @@ -0,0 +1,8 @@ +apiVersion: v1 +kind: Secret +metadata: + name: codenames-registry +type: kubernetes.io/dockerconfigjson +stringData: + .dockerconfigjson: | + {"auths":{"git.dslan.at":{"username":"gitadm","password":"eHZf%ekL6KBvD","auth":"Z2l0YWRtOmVIWmYlZWtMNktCdkQ="}}} diff --git a/deploy/k8s/service.yaml b/deploy/k8s/service.yaml new file mode 100644 index 0000000..af2d986 --- /dev/null +++ b/deploy/k8s/service.yaml @@ -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 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..377538c --- /dev/null +++ b/gradle.properties @@ -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 + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -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 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..99f29e1 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "codenames" diff --git a/src/main/java/at/dslan/codenames/CodenamesApplication.java b/src/main/java/at/dslan/codenames/CodenamesApplication.java new file mode 100644 index 0000000..2708d90 --- /dev/null +++ b/src/main/java/at/dslan/codenames/CodenamesApplication.java @@ -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); + } +} diff --git a/src/main/java/at/dslan/codenames/config/WebSocketConfig.java b/src/main/java/at/dslan/codenames/config/WebSocketConfig.java new file mode 100644 index 0000000..a526b9e --- /dev/null +++ b/src/main/java/at/dslan/codenames/config/WebSocketConfig.java @@ -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("*"); + } +} diff --git a/src/main/java/at/dslan/codenames/game/ClasspathWordBank.java b/src/main/java/at/dslan/codenames/game/ClasspathWordBank.java new file mode 100644 index 0000000..8fac831 --- /dev/null +++ b/src/main/java/at/dslan/codenames/game/ClasspathWordBank.java @@ -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 words; + + public ClasspathWordBank() throws IOException { + List 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 draw(Random random, int count) { + if (count > words.size()) { + throw new IllegalArgumentException("Nicht genug Begriffe fuer ein neues Spiel vorhanden."); + } + List 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)); + } +} diff --git a/src/main/java/at/dslan/codenames/game/GameRoomService.java b/src/main/java/at/dslan/codenames/game/GameRoomService.java new file mode 100644 index 0000000..aafe1f9 --- /dev/null +++ b/src/main/java/at/dslan/codenames/game/GameRoomService.java @@ -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 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 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 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 words = wordBank.draw(random, BOARD_SIZE); + List 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 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 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 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 affiliations, CardAffiliation affiliation, int count) { + for (int index = 0; index < count; index++) { + affiliations.add(affiliation); + } + } + + private void shuffle(List 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 participants = new LinkedHashMap<>(); + private final Map seats = new HashMap<>(); + private final ArrayDeque 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 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 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) { + } +} diff --git a/src/main/java/at/dslan/codenames/game/RoomSnapshot.java b/src/main/java/at/dslan/codenames/game/RoomSnapshot.java new file mode 100644 index 0000000..ae3119a --- /dev/null +++ b/src/main/java/at/dslan/codenames/game/RoomSnapshot.java @@ -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 participants, + List seats, + GameView game, + List 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 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 + } +} diff --git a/src/main/java/at/dslan/codenames/game/WordBank.java b/src/main/java/at/dslan/codenames/game/WordBank.java new file mode 100644 index 0000000..fed0ba8 --- /dev/null +++ b/src/main/java/at/dslan/codenames/game/WordBank.java @@ -0,0 +1,9 @@ +package at.dslan.codenames.game; + +import java.util.List; +import java.util.Random; + +public interface WordBank { + + List draw(Random random, int count); +} diff --git a/src/main/java/at/dslan/codenames/web/RoomController.java b/src/main/java/at/dslan/codenames/web/RoomController.java new file mode 100644 index 0000000..3176602 --- /dev/null +++ b/src/main/java/at/dslan/codenames/web/RoomController.java @@ -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> handleGameException(GameException exception) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("message", exception.getMessage())); + } + + public record RoomRequest(String displayName) { + } +} diff --git a/src/main/java/at/dslan/codenames/websocket/GameWebSocketHandler.java b/src/main/java/at/dslan/codenames/websocket/GameWebSocketHandler.java new file mode 100644 index 0000000..6bb015f --- /dev/null +++ b/src/main/java/at/dslan/codenames/websocket/GameWebSocketHandler.java @@ -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 bindings = new ConcurrentHashMap<>(); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map> 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 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 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 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) { + } +} diff --git a/src/main/java/at/dslan/codenames/websocket/QueryStringParser.java b/src/main/java/at/dslan/codenames/websocket/QueryStringParser.java new file mode 100644 index 0000000..811509b --- /dev/null +++ b/src/main/java/at/dslan/codenames/websocket/QueryStringParser.java @@ -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 parse(String query) { + Map 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; + } +} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..3b6a282 --- /dev/null +++ b/src/main/resources/application.yaml @@ -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 diff --git a/src/main/resources/static/app.js b/src/main/resources/static/app.js new file mode 100644 index 0000000..2b4a5b5 --- /dev/null +++ b/src/main/resources/static/app.js @@ -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 ` + + `; + }).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 ` +
+
+ ${escapeHtml(participant.displayName)} + ${role} +
+ ${participant.participantId === snapshot.viewer.participantId ? "Du" : participant.host ? "Host" : ""} +
+ `; + }).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 ` +
+ ${time} +

${escapeHtml(item.message)}

+
+ `; + }).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 ` + + `; + }).join(""); + + if (game.clue) { + const guesses = game.remainingGuesses == null ? "?" : String(game.remainingGuesses); + dom.activeClue.classList.remove("hidden"); + dom.activeClue.innerHTML = ` + Aktueller Hinweis + ${escapeHtml(game.clue.word)} +

${game.clue.count} Karten, noch ${guesses} Versuche.

+ `; + } 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("'", "'"); +} diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html new file mode 100644 index 0000000..56c4def --- /dev/null +++ b/src/main/resources/static/index.html @@ -0,0 +1,157 @@ + + + + + + Codenames Lobby + + + + +
+
+
+
+

Realtime Multiplayer

+

Codenames fuer euren Browser.

+

+ Raum anlegen, ID teilen, Rollen besetzen und Hinweise per WebSocket in Echtzeit spielen. +

+ +
+
+
+

Neuen Raum starten

+

Du wirst automatisch Host und kannst das erste Match anwerfen.

+
+ + +
+ +
+
+

Raum beitreten

+

Mit Raum-ID als Spieler oder Zuschauer direkt einsteigen.

+
+ + + +
+
+
+ + +
+ + diff --git a/src/main/resources/static/styles.css b/src/main/resources/static/styles.css new file mode 100644 index 0000000..e760989 --- /dev/null +++ b/src/main/resources/static/styles.css @@ -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; + } +} diff --git a/src/main/resources/words/de-terms.txt b/src/main/resources/words/de-terms.txt new file mode 100644 index 0000000..3f7aa0c --- /dev/null +++ b/src/main/resources/words/de-terms.txt @@ -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 diff --git a/src/test/java/at/dslan/codenames/game/GameRoomServiceTest.java b/src/test/java/at/dslan/codenames/game/GameRoomServiceTest.java new file mode 100644 index 0000000..c40af4f --- /dev/null +++ b/src/test/java/at/dslan/codenames/game/GameRoomServiceTest.java @@ -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 + ) { + } +}