Merge branch 'main' into feat/game-ui-optimation

This commit is contained in:
Julian Kropff
2026-04-26 17:43:37 +02:00
6 changed files with 527 additions and 35 deletions
@@ -63,6 +63,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.ping.PingRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageHandler;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageParser;
import ch.unibas.dmi.dbis.cs108.casono.server.app.commands.send_message.SendMessageRequest;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby.LobbyManager;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob;
import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry;
@@ -73,11 +74,7 @@ import ch.unibas.dmi.dbis.cs108.casono.server.network.command.execution.CommandR
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.CommandParserDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionDisconnectJob;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
import java.time.Duration;
@@ -141,37 +138,12 @@ public class ServerApp {
userRegistry,
new CommandContext(lobbyManager, sessionManager));
// Periodic cleanup: remove empty lobbies older than 30s and notify affected
// users
scheduler.scheduleAtFixedRate(
() -> {
try {
var expired =
lobbyManager.findEmptyLobbiesOlderThan(
Duration.ofSeconds(LOBBY_EXPIRY_SECONDS));
for (var lid : expired) {
// remove lobby from manager first
lobbyManager.removeLobby(lid);
// broadcast LOBBY_CLOSED event to all connected sessions
// (requestId=0)
for (Session s : sessionManager.getAllSessions()) {
RequestContext ctx = new RequestContext(s.getId(), 0);
SuccessResponse ev =
new SuccessResponse(
ctx,
ResponseBody.builder()
.param("EVENT", "LOBBY_CLOSED")
.param("LOBBY_ID", lid.value())
.build()) {};
responseDispatcher.dispatch(ev);
}
}
} catch (Exception e) {
logger.warn("Lobby expiry job failed", e);
}
},
new LobbyCleanupJob(
lobbyManager,
sessionManager,
responseDispatcher,
Duration.ofSeconds(LOBBY_EXPIRY_SECONDS)),
LOBBY_CLEANUP_INITIAL_DELAY_SECONDS,
LOBBY_CLEANUP_PERIOD_SECONDS,
TimeUnit.SECONDS);
@@ -0,0 +1,100 @@
package ch.unibas.dmi.dbis.cs108.casono.server.domain.lobby;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.RequestContext;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.SuccessResponse;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.builder.ResponseBody;
import ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.response.dispatcher.ResponseDispatcher;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.Session;
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
import java.time.Duration;
import java.util.List;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
/** Periodically removes expired empty lobbies and notifies connected sessions. */
public class LobbyCleanupJob implements Runnable {
private static final Logger LOGGER = LogManager.getLogger(LobbyCleanupJob.class);
private final LobbyManager lobbyManager;
private final SessionManager sessionManager;
private final ResponseDispatcher responseDispatcher;
private final Duration expiryThreshold;
/**
* Creates a new cleanup job for expired empty lobbies.
*
* @param lobbyManager manager used to find and remove expired lobbies
* @param sessionManager manager used to resolve connected sessions
* @param responseDispatcher dispatcher used to notify clients about closed lobbies
* @param expiryThreshold age threshold that marks an empty lobby as expired
*/
public LobbyCleanupJob(
LobbyManager lobbyManager,
SessionManager sessionManager,
ResponseDispatcher responseDispatcher,
Duration expiryThreshold) {
this.lobbyManager = lobbyManager;
this.sessionManager = sessionManager;
this.responseDispatcher = responseDispatcher;
this.expiryThreshold = expiryThreshold;
}
/**
* Runs one cleanup cycle.
*
* <p>The method first fetches all expired empty lobbies. Each lobby is then processed
* independently so that a failure for one lobby does not stop the remaining cleanups.
*/
@Override
public void run() {
LOGGER.debug("Job started.");
try {
List<LobbyId> expired;
try {
expired = lobbyManager.findEmptyLobbiesOlderThan(expiryThreshold);
} catch (Exception e) {
LOGGER.error("Lobby expiry job failed: could not fetch expired lobbies", e);
return;
}
for (LobbyId lobbyId : expired) {
try {
lobbyManager.removeLobby(lobbyId);
broadcastLobbyClosed(lobbyId);
} catch (RuntimeException e) {
LOGGER.warn("Failed to process expired lobby {}", lobbyId.value(), e);
}
}
} finally {
LOGGER.debug("Job finished.");
}
}
/**
* Broadcasts a lobby-closed event to all currently connected sessions.
*
* @param lobbyId id of the lobby that was closed
*/
private void broadcastLobbyClosed(LobbyId lobbyId) {
for (Session session : sessionManager.getAllSessions()) {
try {
RequestContext ctx = new RequestContext(session.getId(), 0);
SuccessResponse event =
new SuccessResponse(
ctx,
ResponseBody.builder()
.param("EVENT", "LOBBY_CLOSED")
.param("LOBBY_ID", lobbyId.value())
.build()) {};
responseDispatcher.dispatch(event);
} catch (RuntimeException e) {
LOGGER.warn(
"Failed to notify session {} about closed lobby {}",
session.getId().value(),
lobbyId.value(),
e);
}
}
}
}
@@ -48,7 +48,7 @@ public class TokenClassifier {
}
private static void validateSeparator(List<RawToken> rawTokens, int index) {
boolean missingKey = index == 0 || rawTokens.get(index - 1).type() != RawTokenType.WORD;
boolean missingKey = index <= 1 || rawTokens.get(index - 1).type() != RawTokenType.WORD;
boolean missingValue =
index + 1 >= rawTokens.size()
@@ -0,0 +1,94 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
import static org.junit.jupiter.api.Assertions.*;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
public class TokenClassifierTest {
@Test
void testClassifySimpleCommand() {
List<RawToken> raw = Tokenizer.tokenize("PING");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(2, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
assertEquals(TokenType.EOF, tokens.get(1).type());
}
@Test
void testCommandWithParameter() {
List<RawToken> raw = Tokenizer.tokenize("ANSWER VALUE=42");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(5, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals(TokenType.KEY, tokens.get(1).type());
assertEquals(TokenType.SEPARATOR, tokens.get(2).type());
assertEquals(TokenType.VALUE, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(TokenType.EOF, tokens.get(4).type());
}
@Test
void testStringValue() {
List<RawToken> raw = Tokenizer.tokenize("GREET MSG='Hello World'");
List<Token> tokens = TokenClassifier.classify(raw);
assertEquals(5, tokens.size());
assertEquals(TokenType.COMMAND, tokens.get(0).type());
assertEquals(TokenType.KEY, tokens.get(1).type());
assertEquals(TokenType.SEPARATOR, tokens.get(2).type());
assertEquals(TokenType.VALUE, tokens.get(3).type());
assertEquals("Hello World", tokens.get(3).value());
}
@Test
void testUnexpectedStringLiteralThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD 'oops'");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertTrue(ex.getMessage().contains("Unexpected string literal"));
}
@Test
void testMissingValueAfterSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD KEY=");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected VALUE after '='", ex.getMessage());
}
@Test
void testMissingKeyBeforeSeparatorThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD =VALUE");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected KEY before '='", ex.getMessage());
}
@Test
void testNextWordIsKeyThrows() {
List<RawToken> raw = Tokenizer.tokenize("CMD KEY1=KEY2=42");
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected VALUE after '='", ex.getMessage());
}
@Test
void testEmptyRawTokensThrows() {
List<RawToken> raw = new ArrayList<>();
TokenizerException ex =
assertThrows(TokenizerException.class, () -> TokenClassifier.classify(raw));
assertEquals("Expected COMMAND as first token", ex.getMessage());
assertEquals(1, ex.getLine());
assertEquals(1, ex.getColumn());
}
}
@@ -0,0 +1,211 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.parser.tokenizer;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
import org.junit.jupiter.api.Test;
class TokenizerTest {
@Test
void testEofAlwaysPresent() {
List<RawToken> tokens = Tokenizer.tokenize("");
assertEquals(1, tokens.size());
assertEquals(RawTokenType.EOF, tokens.get(0).type());
}
@Test
void testSimpleCommand() {
List<RawToken> tokens = Tokenizer.tokenize("PING");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
assertEquals(RawTokenType.EOF, tokens.get(1).type());
}
@Test
void testSimpleCommandWithUnderscore() {
List<RawToken> tokens = Tokenizer.tokenize("JOIN_GAME");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("JOIN_GAME", tokens.get(0).value());
assertEquals(RawTokenType.EOF, tokens.get(1).type());
}
@Test
void testCommandWithOneParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GET VALUE=42");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("VALUE", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.WORD, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithOneParameterWhitespacesBetweenSeperator() {
List<RawToken> tokens = Tokenizer.tokenize("GET VALUE = 42");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("VALUE", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.WORD, tokens.get(3).type());
assertEquals("42", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithOneParameterAndNewline() {
List<RawToken> tokens = Tokenizer.tokenize("GET\nVALUE=42");
assertEquals(6, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GET", tokens.get(0).value());
// Parameter
assertEquals(RawTokenType.WORD, tokens.get(2).type());
assertEquals("VALUE", tokens.get(2).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(3).type());
assertEquals("=", tokens.get(3).value());
assertEquals(RawTokenType.WORD, tokens.get(4).type());
assertEquals("42", tokens.get(4).value());
assertEquals(RawTokenType.EOF, tokens.get(5).type());
}
@Test
void testCommandWithStringParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GREET MSG='Hello World'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GREET", tokens.get(0).value());
// First parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("MSG", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("Hello World", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testCommandWithMultilineStringParameter() {
List<RawToken> tokens = Tokenizer.tokenize("GREET MSG='Hello\nWorld'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("GREET", tokens.get(0).value());
// First parameter
assertEquals(RawTokenType.WORD, tokens.get(1).type());
assertEquals("MSG", tokens.get(1).value());
assertEquals(RawTokenType.SEPARATOR, tokens.get(2).type());
assertEquals("=", tokens.get(2).value());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("Hello\nWorld", tokens.get(3).value());
assertEquals(RawTokenType.EOF, tokens.get(4).type());
}
@Test
void testWhitespace() {
List<RawToken> tokens = Tokenizer.tokenize(" \t PING");
assertEquals(2, tokens.size());
assertEquals(RawTokenType.WORD, tokens.get(0).type());
assertEquals("PING", tokens.get(0).value());
}
@Test
void testStringWithEscapedQuote() {
List<RawToken> tokens =
Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\\'s a wonderful day'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("it's a wonderful day", tokens.get(3).value());
}
@Test
void testStringWithWronglyEscapedQuote() {
TokenizerException ex =
assertThrows(
TokenizerException.class,
() -> Tokenizer.tokenize("WONDERFUL_GREETING MSG='it\'s a wonderful day'"));
assertEquals("Unterminated string literal", ex.getMessage());
}
@Test
void testStringWithEscapedBackslash() {
List<RawToken> tokens = Tokenizer.tokenize("EXECUTE_COMMAND CMD='\\whoami'");
assertEquals(5, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(3).type());
assertEquals("\\whoami", tokens.get(3).value());
}
@Test
void testColumnTracking() {
List<RawToken> tokens = Tokenizer.tokenize("GET VAL=1");
assertEquals(5, tokens.size());
assertEquals(1, tokens.get(0).column());
assertEquals(5, tokens.get(1).column());
assertEquals(8, tokens.get(2).column());
assertEquals(9, tokens.get(3).column());
}
@Test
void testUnterminatedStringThrows() {
TokenizerException ex =
assertThrows(
TokenizerException.class, () -> Tokenizer.tokenize("GREETING = 'unclosed"));
assertEquals(1, ex.getLine());
assertEquals(12, ex.getColumn());
assertTrue(ex.getMessage().contains("Unterminated string literal"));
}
@Test
void testUnexpectedCharacterThrows() {
TokenizerException ex =
assertThrows(TokenizerException.class, () -> Tokenizer.tokenize("CMD @ KEY=VALUE"));
assertTrue(ex.getMessage().contains("Unexpected character '@'"));
assertEquals(1, ex.getLine());
}
@Test
void testEmptyStringValue() {
List<RawToken> tokens = Tokenizer.tokenize("cmd = ''");
assertEquals(4, tokens.size());
assertEquals(RawTokenType.STRING, tokens.get(2).type());
assertEquals("", tokens.get(2).value());
}
}
@@ -0,0 +1,115 @@
package ch.unibas.dmi.dbis.cs108.casono.server.network.protocol.request.accessor;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import ch.unibas.dmi.dbis.cs108.casono.server.network.command.parsing.RequestParameter;
import java.util.List;
import org.junit.jupiter.api.Test;
public class RequestParameterAccessorTest {
@Test
void testRequiredParameterPresent() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("VAL1", accessor.require("ARG1"));
}
@Test
void testMissingRequiredParameterThrows() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
MissingParameterException ex =
assertThrows(MissingParameterException.class, () -> accessor.require("ARG1"));
assertEquals("Required parameter with key 'ARG1' is missing.", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testRequiredParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(42, (int) accessor.require("ARG1", Integer::valueOf));
}
@Test
void testRequiredParameterInvalidParsingThrows() {
List<RequestParameter> parameters =
List.of(new RequestParameter("ARG1", "The answer is: 42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
ParameterParseException ex =
assertThrows(
ParameterParseException.class,
() -> accessor.require("ARG1", Integer::valueOf));
assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testOptionalParameterPresent() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("VAL1", accessor.optional("ARG1", "DEFAULT"));
}
@Test
void testOptionalParameterMissing() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals("DEFAULT", accessor.optional("ARG1", "DEFAULT"));
}
@Test
void testOptionalParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(42, (int) accessor.optional("ARG1", 7411, Integer::valueOf));
}
@Test
void testMissingOptionalParameterSuccessfulParsing() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertEquals(7411, (int) accessor.optional("ARG1", 7411, Integer::valueOf));
}
@Test
void testOptionalParameterInvalidParsingThrows() {
List<RequestParameter> parameters =
List.of(new RequestParameter("ARG1", "The answer is: 42"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
ParameterParseException ex =
assertThrows(
ParameterParseException.class,
() -> accessor.optional("ARG1", 7411, Integer::valueOf));
assertEquals("Error while parsing 'ARG1' with specified parser", ex.getMessage());
assertEquals("ARG1", ex.getParameterKey());
}
@Test
void testContainsExistingKey() {
List<RequestParameter> parameters = List.of(new RequestParameter("ARG1", "VAL1"));
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertTrue(accessor.containsKey("ARG1"));
}
@Test
void testContainsMissingKey() {
List<RequestParameter> parameters = List.of();
RequestParameterAccessor accessor = new RequestParameterAccessor(parameters);
assertFalse(accessor.containsKey("ARG1"));
}
}