feat: add realtime codenames game

This commit is contained in:
Schramm Dominik
2026-04-22 15:31:38 +02:00
commit 6bd6b08044
31 changed files with 3080 additions and 0 deletions

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