TD-18: Implement Queue System
All checks were successful
Quality Check / Validate OAS (push) Successful in 38s
Quality Check / Validate OAS (pull_request) Successful in 40s
Quality Check / Testing (push) Successful in 1m22s
Quality Check / Linting (push) Successful in 1m24s
Quality Check / Static Analysis (push) Successful in 1m32s
Quality Check / Linting (pull_request) Successful in 1m14s
Quality Check / Static Analysis (pull_request) Successful in 1m16s
Quality Check / Testing (pull_request) Successful in 48s

This commit is contained in:
Snoweuph 2025-02-26 13:26:58 +01:00
parent 41509b6242
commit 160dbff816
Signed by: snoweuph
GPG key ID: BEFC41DA223CEC55
17 changed files with 595 additions and 17 deletions

View file

@ -0,0 +1,13 @@
package de.towerdefence.server.match;
import de.towerdefence.server.player.Player;
import lombok.AllArgsConstructor;
import lombok.Getter;
@AllArgsConstructor
@Getter
public class Match {
private final String matchId;
private final Player playerOne;
private final Player playerTwo;
}

View file

@ -0,0 +1,8 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.player.Player;
@FunctionalInterface
public interface AbortCallback {
void call(Player player, String matchId);
}

View file

@ -0,0 +1,16 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.match.queue.FoundCallback;
import de.towerdefence.server.match.queue.QueuedCallback;
import lombok.AllArgsConstructor;
import lombok.Getter;
@Getter
@AllArgsConstructor
public class ConfirmationCallbacks {
private final FoundCallback foundCallback;
private final QueuedCallback queuedCallback;
private final AbortCallback abortCallback;
private final EstablishedCallback establishedCallback;
private final RequeueCallback requeueCallback;
}

View file

@ -0,0 +1,10 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.player.Player;
import java.io.IOException;
@FunctionalInterface
public interface EstablishedCallback {
void call(Player player, String matchId, Player opponent) throws IOException;
}

View file

@ -0,0 +1,111 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.player.Player;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
@Service
public class MatchConfirmationService {
private final Map<Player, UnconfirmedMatch> unconfirmedMatch = new HashMap<>();
public UnconfirmedMatch createMatch(Player player1,
Player player2,
ConfirmationCallbacks player1Callbacks,
ConfirmationCallbacks player2callbacks) {
UnconfirmedMatch match = new UnconfirmedMatch(
player1,
player2,
player1Callbacks,
player2callbacks);
unconfirmedMatch.put(player1, match);
unconfirmedMatch.put(player2, match);
return match;
}
public void accept(Player player, String matchId) {
setPlayerAcceptState(player, matchId, PlayerMatchConfirmState.CONFIRMED);
}
public void decline(Player player, String matchId) {
setPlayerAcceptState(player, matchId, PlayerMatchConfirmState.ABORTED);
}
private void setPlayerAcceptState(Player player, String matchId, PlayerMatchConfirmState state) {
Optional<UnconfirmedMatch> optionalMatch = getPlayerMatch(player, matchId);
if (optionalMatch.isEmpty()) {
return;
}
UnconfirmedMatch match = optionalMatch.get();
Optional<PlayerMatchConfirmState> optionalPlayerState = match.getPlayerState(player);
if (optionalPlayerState.isEmpty()) {
unconfirmedMatch.remove(player);
return;
}
if (optionalPlayerState.get() != PlayerMatchConfirmState.UNKNOWN) {
return;
}
Optional<UnconfirmedMatchState> matchState = match.setPlayerConfirmState(player, state);
if (matchState.isEmpty()) {
unconfirmedMatch.remove(player);
return;
}
handleMatchConfirmation(match, matchState.get());
}
private void handleMatchConfirmation(UnconfirmedMatch match, UnconfirmedMatchState state) {
if (state == UnconfirmedMatchState.WAITING) {
return;
}
unconfirmedMatch.remove(match.getPlayer1());
unconfirmedMatch.remove(match.getPlayer2());
ConfirmationCallbacks player1Callbacks = match.getPlayer1Callbacks();
ConfirmationCallbacks player2Callbacks = match.getPlayer2Callbacks();
switch (state) {
case ABORTED -> {
if (match.getPlayer1State() == PlayerMatchConfirmState.CONFIRMED) {
player1Callbacks.getRequeueCallback().call(
match.getPlayer1(),
match.getMatchId(),
player1Callbacks.getFoundCallback(),
player1Callbacks.getQueuedCallback(),
player1Callbacks.getAbortCallback(),
player1Callbacks.getEstablishedCallback());
}
if (match.getPlayer2State() == PlayerMatchConfirmState.CONFIRMED) {
player2Callbacks.getRequeueCallback().call(
match.getPlayer2(),
match.getMatchId(),
player2Callbacks.getFoundCallback(),
player2Callbacks.getQueuedCallback(),
player2Callbacks.getAbortCallback(),
player2Callbacks.getEstablishedCallback());
}
}
case CONFIRMED -> {
// TODO: Create Match and Send Players the info that the Match is created
}
}
}
private Optional<UnconfirmedMatch> getPlayerMatch(Player player, String matchId) {
UnconfirmedMatch match = unconfirmedMatch.get(player);
if (match == null) {
return Optional.empty();
}
if (!match.getMatchId().equals(matchId)) {
unconfirmedMatch.remove(player);
return Optional.empty();
}
return Optional.of(match);
}
}

View file

@ -0,0 +1,7 @@
package de.towerdefence.server.match.confirmation;
public enum PlayerMatchConfirmState {
UNKNOWN,
CONFIRMED,
ABORTED
}

View file

@ -0,0 +1,18 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.match.queue.FoundCallback;
import de.towerdefence.server.match.queue.QueuedCallback;
import de.towerdefence.server.player.Player;
@FunctionalInterface
public interface RequeueCallback {
void call(
Player player,
String matchId,
FoundCallback foundCallback,
QueuedCallback queuedCallback,
AbortCallback abortCallback,
EstablishedCallback establishedCallback
);
}

View file

@ -0,0 +1,85 @@
package de.towerdefence.server.match.confirmation;
import de.towerdefence.server.player.Player;
import lombok.Getter;
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
@Getter
public class UnconfirmedMatch {
private final String matchId;
private final long created;
public static final long TTL = 30 * 1000;
private final Player player1;
private final Player player2;
private final ConfirmationCallbacks player1Callbacks;
private final ConfirmationCallbacks player2Callbacks;
public UnconfirmedMatch(
Player player1,
Player player2,
ConfirmationCallbacks player1Callbacks,
ConfirmationCallbacks player2Callbacks) {
this.player1 = player1;
this.player2 = player2;
this.player1Callbacks = player1Callbacks;
this.player2Callbacks = player2Callbacks;
this.created = Instant.now().toEpochMilli();
this.matchId = UUID.randomUUID().toString();
}
private PlayerMatchConfirmState player1State = PlayerMatchConfirmState.UNKNOWN;
private PlayerMatchConfirmState player2State = PlayerMatchConfirmState.UNKNOWN;
public Optional<UnconfirmedMatchState> setPlayerConfirmState(Player player, PlayerMatchConfirmState state) {
Optional<MatchPlayer> matchPlayer = getMatchPlayer(player);
if(matchPlayer.isEmpty()){
return Optional.empty();
}
switch (matchPlayer.get()){
case ONE -> player1State = state;
case TWO -> player2State = state;
}
if (player1State == PlayerMatchConfirmState.ABORTED || player2State == PlayerMatchConfirmState.ABORTED) {
return Optional.of(UnconfirmedMatchState.ABORTED);
}
if (player1State == PlayerMatchConfirmState.UNKNOWN || player2State == PlayerMatchConfirmState.UNKNOWN) {
return Optional.of(UnconfirmedMatchState.WAITING);
}
return Optional.of(UnconfirmedMatchState.CONFIRMED);
}
public Optional<PlayerMatchConfirmState> getPlayerState(Player player){
Optional<MatchPlayer> matchPlayer = getMatchPlayer(player);
if(matchPlayer.isEmpty()){
return Optional.empty();
}
return switch (matchPlayer.get()){
case ONE -> Optional.of(player1State);
case TWO -> Optional.of(player2State);
};
}
private enum MatchPlayer{
ONE,
TWO
}
private Optional<MatchPlayer> getMatchPlayer(Player player){
boolean isPlayerOne = player.equals(player1);
boolean isPlayerTwo = player.equals(player2);
if (!isPlayerOne && !isPlayerTwo) {
return Optional.empty();
}
if (isPlayerOne){
return Optional.of(MatchPlayer.ONE);
}
return Optional.of(MatchPlayer.TWO);
}
}

View file

@ -0,0 +1,7 @@
package de.towerdefence.server.match.confirmation;
public enum UnconfirmedMatchState {
WAITING,
ABORTED,
CONFIRMED
}

View file

@ -0,0 +1,10 @@
package de.towerdefence.server.match.queue;
import de.towerdefence.server.player.Player;
import java.io.IOException;
@FunctionalInterface
public interface FoundCallback {
void call(Player player, String matchId, long created, long ttl) throws IOException;
}

View file

@ -0,0 +1,143 @@
package de.towerdefence.server.match.queue;
import de.towerdefence.server.match.confirmation.*;
import de.towerdefence.server.player.Player;
import lombok.AllArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@AllArgsConstructor
public class MatchQueueService {
private final static int REQUIRED_PLAYER_COUNT = 2;
@Autowired
private final MatchConfirmationService matchConfirmationService;
private final List<Player> queue = new ArrayList<>();
private final Map<Player, ConfirmationCallbacks> confirmationCallbacks = new HashMap<>();
public void queuePlayer(
Player player,
FoundCallback foundCallback,
QueuedCallback queuedCallback,
AbortCallback abortCallback,
EstablishedCallback establishedCallback) {
queue.add(player);
confirmationCallbacks.put(player, new ConfirmationCallbacks(
foundCallback,
queuedCallback,
abortCallback,
establishedCallback,
this::onRequeue));
tryMatching();
}
public void onRequeue(
Player player,
String matchId,
FoundCallback foundCallback,
QueuedCallback queuedCallback,
AbortCallback abortCallback,
EstablishedCallback establishedCallback) {
abortCallback.call(player, matchId);
try {
queuedCallback.call(player);
} catch (IOException ignored) {
return;
}
queuePlayer(player, foundCallback, queuedCallback, abortCallback, establishedCallback);
}
public void unQueuePlayer(Player player) {
queue.remove(player);
confirmationCallbacks.remove(player);
}
private void tryMatching() {
if (queue.size() < REQUIRED_PLAYER_COUNT) {
return;
}
List<Player> loopQueue = new ArrayList<>(queue);
for (int i = 0; i < loopQueue.size() / REQUIRED_PLAYER_COUNT; i++) {
Player player1 = loopQueue.get(REQUIRED_PLAYER_COUNT * i);
Player player2 = loopQueue.get(REQUIRED_PLAYER_COUNT * i + 1);
ConfirmationCallbacks player1Callbacks = confirmationCallbacks.get(player1);
ConfirmationCallbacks player2Callbacks = confirmationCallbacks.get(player2);
UnconfirmedMatch match = this.matchConfirmationService.createMatch(
player1,
player2,
player1Callbacks,
player2Callbacks);
sentMatchFound(
match,
player1,
player2,
player1Callbacks,
player2Callbacks);
}
if (queue.size() > REQUIRED_PLAYER_COUNT) {
tryMatching();
}
}
private void sentMatchFound(
UnconfirmedMatch match,
Player player1,
Player player2,
ConfirmationCallbacks player1Callbacks,
ConfirmationCallbacks player2Callbacks) {
boolean player1disconnected = setMatchFoundToPlayer(player1, player1Callbacks.getFoundCallback(), match);
boolean player2disconnected = setMatchFoundToPlayer(player2, player2Callbacks.getFoundCallback(), match);
queue.remove(player1);
queue.remove(player2);
if (!player1disconnected && !player2disconnected) {
return;
}
if (player1disconnected && match.getPlayer2State() != PlayerMatchConfirmState.ABORTED) {
player2Callbacks.getRequeueCallback().call(
player2,
match.getMatchId(),
player2Callbacks.getFoundCallback(),
player2Callbacks.getQueuedCallback(),
player2Callbacks.getAbortCallback(),
player2Callbacks.getEstablishedCallback());
}
if (player2disconnected && match.getPlayer1State() != PlayerMatchConfirmState.ABORTED) {
player1Callbacks.getRequeueCallback().call(
player1,
match.getMatchId(),
player1Callbacks.getFoundCallback(),
player1Callbacks.getQueuedCallback(),
player1Callbacks.getAbortCallback(),
player1Callbacks.getEstablishedCallback());
}
}
private boolean setMatchFoundToPlayer(Player player, FoundCallback callback, UnconfirmedMatch match) {
try {
callback.call(
player,
match.getMatchId(),
match.getCreated(),
UnconfirmedMatch.TTL);
} catch (IOException e) {
return true;
}
return false;
}
}

View file

@ -0,0 +1,10 @@
package de.towerdefence.server.match.queue;
import de.towerdefence.server.player.Player;
import java.io.IOException;
@FunctionalInterface
public interface QueuedCallback {
void call(Player player) throws IOException;
}

View file

@ -1,5 +1,7 @@
package de.towerdefence.server.server.channels; package de.towerdefence.server.server.channels;
import de.towerdefence.server.match.confirmation.MatchConfirmationService;
import de.towerdefence.server.match.queue.MatchQueueService;
import de.towerdefence.server.server.JsonWebsocketHandler; import de.towerdefence.server.server.JsonWebsocketHandler;
import de.towerdefence.server.server.channels.connection.ConnectionWebsocketHandler; import de.towerdefence.server.server.channels.connection.ConnectionWebsocketHandler;
import de.towerdefence.server.server.channels.matchmaking.MatchmakingWebsocketHandler; import de.towerdefence.server.server.channels.matchmaking.MatchmakingWebsocketHandler;
@ -19,10 +21,19 @@ public class WebsocketConfig implements WebSocketConfigurer {
private static final String CHANNEL_BASE_PATH = "/ws/"; private static final String CHANNEL_BASE_PATH = "/ws/";
@Autowired @Autowired
private final SessionsService sessionsService; private final SessionsService sessionsService;
@Autowired
private final MatchQueueService matchQueueService;
@Autowired
private final MatchConfirmationService matchConfirmationService;
@Override @Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registerJsonChannel(registry, new ConnectionWebsocketHandler(this.sessionsService)); registerJsonChannel(registry, new ConnectionWebsocketHandler(this.sessionsService));
registerJsonChannel(registry, new MatchmakingWebsocketHandler(this.sessionsService)); registerJsonChannel(registry, new MatchmakingWebsocketHandler(
this.sessionsService,
this.matchQueueService,
this.matchConfirmationService
));
registerJsonChannel(registry, new TimeWebsocketHandler(this.sessionsService)); registerJsonChannel(registry, new TimeWebsocketHandler(this.sessionsService));
} }

View file

@ -1,11 +1,112 @@
package de.towerdefence.server.server.channels.matchmaking; package de.towerdefence.server.server.channels.matchmaking;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.towerdefence.server.match.confirmation.MatchConfirmationService;
import de.towerdefence.server.match.queue.MatchQueueService;
import de.towerdefence.server.player.Player;
import de.towerdefence.server.server.JsonWebsocketHandler; import de.towerdefence.server.server.JsonWebsocketHandler;
import de.towerdefence.server.server.channels.matchmaking.bi.MatchSetSearchStateMessage;
import de.towerdefence.server.server.channels.matchmaking.in.MatchAcceptedMessage;
import de.towerdefence.server.server.channels.matchmaking.out.MatchAbortedMessage;
import de.towerdefence.server.server.channels.matchmaking.out.MatchEstablishedMessage;
import de.towerdefence.server.server.channels.matchmaking.out.MatchFoundMessage;
import de.towerdefence.server.session.Channel; import de.towerdefence.server.session.Channel;
import de.towerdefence.server.session.SessionsService; import de.towerdefence.server.session.SessionsService;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class MatchmakingWebsocketHandler extends JsonWebsocketHandler { public class MatchmakingWebsocketHandler extends JsonWebsocketHandler {
public MatchmakingWebsocketHandler(SessionsService sessionsService) { protected final Map<Player, WebSocketSession> playerSessions = new ConcurrentHashMap<>();
private final ObjectMapper objectMapper = new ObjectMapper();
private final MatchQueueService matchQueueService;
private final MatchConfirmationService matchConfirmationService;
public MatchmakingWebsocketHandler(
SessionsService sessionsService,
MatchQueueService matchQueueService,
MatchConfirmationService matchConfirmationService
) {
super(Channel.MATCHMAKING, sessionsService); super(Channel.MATCHMAKING, sessionsService);
this.matchQueueService = matchQueueService;
this.matchConfirmationService = matchConfirmationService;
}
@Override
public void afterConnectionEstablished(WebSocketSession session) {
super.afterConnectionEstablished(session);
playerSessions.put(sessionPlayers.get(session), session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) {
try {
String payload = message.getPayload();
switch (objectMapper.readTree(payload).get("$id").asText()) {
case MatchSetSearchStateMessage.MESSAGE_ID -> handleMatchSetSearchStateMessage(session, payload);
case MatchAcceptedMessage.MESSAGE_ID -> handleMatchAcceptedMessage(session, payload);
default -> this.closeSession(session, CloseStatus.BAD_DATA);
}
} catch (Exception ignored) {
this.closeSession(session, CloseStatus.BAD_DATA);
}
}
private void handleMatchSetSearchStateMessage(WebSocketSession session, String payload) throws Exception {
MatchSetSearchStateMessage msg = objectMapper.readValue(payload, MatchSetSearchStateMessage.class);
Player player = sessionPlayers.get(session);
if (!msg.isSearching()) {
this.matchQueueService.unQueuePlayer(player);
return;
}
this.matchQueueService.queuePlayer(
player,
this::onFound,
this::onQueued,
this::onAbort,
this::onEstablished
);
}
private void onFound(Player player, String matchId, long created, long ttl) throws IOException {
WebSocketSession session = playerSessions.get(player);
MatchFoundMessage msg = new MatchFoundMessage(matchId, created, ttl);
msg.send(session);
}
private void onQueued(Player player) throws IOException{
WebSocketSession session = playerSessions.get(player);
MatchSetSearchStateMessage msg = new MatchSetSearchStateMessage(true);
msg.send(session);
}
private void onAbort(Player player, String matchId) {
WebSocketSession session = playerSessions.get(player);
MatchAbortedMessage msg = new MatchAbortedMessage(matchId);
try {
msg.send(session);
} catch (IOException ignored) {
}
}
private void onEstablished(Player player, String matchId, Player opponent) throws IOException {
WebSocketSession session = playerSessions.get(player);
MatchEstablishedMessage msg = new MatchEstablishedMessage(matchId, opponent.getUsername());
msg.send(session);
}
private void handleMatchAcceptedMessage(WebSocketSession session, String payload) throws Exception {
MatchAcceptedMessage msg = objectMapper.readValue(payload, MatchAcceptedMessage.class);
Player player = sessionPlayers.get(session);
if (msg.isAccepted()) {
this.matchConfirmationService.accept(player, msg.getMatchId());
} else {
this.matchConfirmationService.decline(player, msg.getMatchId());
}
} }
} }

View file

@ -0,0 +1,35 @@
package de.towerdefence.server.server.channels.matchmaking.bi;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import de.towerdefence.server.server.JsonMessage;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Map;
@Getter
@NotNull
@AllArgsConstructor
public class MatchSetSearchStateMessage extends JsonMessage {
public static final String MESSAGE_ID = "MatchSetSearchState";
@Getter
@JsonProperty("$id")
private String messageId;
private boolean searching;
public MatchSetSearchStateMessage(boolean searching) {
this(MESSAGE_ID, searching);
}
@Override
protected Map<String, JsonNode> getData(JsonNodeFactory factory) {
return Map.of(
"searching", factory.booleanNode(this.searching)
);
}
}

View file

@ -1,14 +0,0 @@
package de.towerdefence.server.server.channels.matchmaking.in;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
@NotNull
public class MatchSetSearchStateMessage {
public static final String MESSAGE_ID = "MatchSetSearchState";
@JsonProperty("$id")
private String messageId;
private boolean searching;
}

View file

@ -5,7 +5,7 @@ info:
description: | description: |
This is the Websocket Specification for the Tower Defence Game. <br> This is the Websocket Specification for the Tower Defence Game. <br>
Because of the limitations of Async API, we expect that the actual json, Because of the limitations of Async API, we expect that the actual json,
which is send as payload to always contain a field called `$id` with which is send as payload to always contain a field called `$id` with
the corresponding `messageId`. <br> the corresponding `messageId`. <br>
The `messageId` should be handled case sensitive. The `messageId` should be handled case sensitive.
defaultContentType: application/json defaultContentType: application/json
@ -194,6 +194,13 @@ operations:
$ref: "#/channels/matchmaking" $ref: "#/channels/matchmaking"
messages: messages:
- $ref: "#/channels/matchmaking/messages/MatchSetSearchState" - $ref: "#/channels/matchmaking/messages/MatchSetSearchState"
setPlayerMatchSearching:
title: SetPlayerMatchSearching
action: receive
channel:
$ref: "#/channels/matchmaking"
messages:
- $ref: "#/channels/matchmaking/messages/MatchSetSearchState"
foundMatch: foundMatch:
title: FoundGame title: FoundGame
action: receive action: receive