feat: add realtime codenames game

This commit is contained in:
Schramm Dominik
2026-04-22 15:31:38 +02:00
commit 991e44a293
30 changed files with 3072 additions and 0 deletions

3
.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
.git
.gradle
app

12
.gitattributes vendored Normal file
View 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
View 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
View 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
View 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
View 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()
}

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

Binary file not shown.

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

@@ -0,0 +1,8 @@
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
}
}
rootProject.name = "codenames"

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

View 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("*");
}
}

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

View 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) {
}
}

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

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

View 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) {
}
}

View File

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

View File

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

View 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

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("\"", "&quot;")
.replaceAll("'", "&#039;");
}

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

View 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;
}
}

View 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

View 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
) {
}
}