transferredPlayerIds
-) {
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueUpdatedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueUpdatedEvent.java
deleted file mode 100644
index e7af91a..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueUpdatedEvent.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package net.mythicisland.queue.api.event.queue;
-
-import net.mythicisland.queue.api.queue.QueueStatus;
-
-import java.util.List;
-import java.util.UUID;
-
-/**
- * Event fired when any aspect of a queue changes (e.g., status or player list).
- *
- * This event provides both the "before" and "after" snapshots, allowing consumers to
- * determine exactly what changed by comparing the two states.
- *
- * @param queueId the unique ID of the queue
- * @param queueType the queue type name
- * @param beforeStatus the queue's status before the update
- * @param beforePlayerIds the player UUIDs before the update
- * @param afterStatus the queue's status after the update
- * @param afterPlayerIds the player UUIDs after the update
- */
-public record QueueUpdatedEvent(
- UUID queueId,
- String queueType,
- QueueStatus beforeStatus,
- List beforePlayerIds,
- QueueStatus afterStatus,
- List afterPlayerIds
-) {
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketCreatedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketCreatedEvent.java
new file mode 100644
index 0000000..0311e8f
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketCreatedEvent.java
@@ -0,0 +1,13 @@
+package net.mythicisland.queue.api.event.ticket;
+
+import net.mythicisland.queue.api.ticket.Ticket;
+
+/**
+ * Event fired when a player or party entered matchmaking.
+ *
+ * @param ticket the created ticket
+ */
+public record TicketCreatedEvent(
+ Ticket ticket
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketDeletedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketDeletedEvent.java
new file mode 100644
index 0000000..f0993ab
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketDeletedEvent.java
@@ -0,0 +1,19 @@
+package net.mythicisland.queue.api.event.ticket;
+
+import net.mythicisland.queue.api.ticket.Ticket;
+import net.mythicisland.queue.api.ticket.TicketDeleteReason;
+
+/**
+ * Event fired when a ticket leaves matchmaking.
+ *
+ * The reason tells apart a player that cancelled from a party that was
+ * successfully transferred to its game server.
+ *
+ * @param ticket the deleted ticket
+ * @param reason why the ticket was deleted
+ */
+public record TicketDeletedEvent(
+ Ticket ticket,
+ TicketDeleteReason reason
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketEventApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketEventApi.java
new file mode 100644
index 0000000..c13a98a
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketEventApi.java
@@ -0,0 +1,36 @@
+package net.mythicisland.queue.api.event.ticket;
+
+import net.mythicisland.queue.api.event.Subscription;
+
+import java.util.function.Consumer;
+
+/**
+ * API for subscribing to ticket lifecycle events.
+ */
+public interface TicketEventApi {
+
+ /**
+ * Subscribes to events triggered when a player or party entered matchmaking.
+ *
+ * @param handler a consumer that will process the ticket creation events
+ * @return a subscription handle to manage the listener lifecycle
+ */
+ Subscription onCreated(Consumer handler);
+
+ /**
+ * Subscribes to events triggered when a ticket moves to a new state.
+ *
+ * @param handler a consumer that will process the state change events
+ * @return a subscription handle to manage the listener lifecycle
+ */
+ Subscription onStateChanged(Consumer handler);
+
+ /**
+ * Subscribes to events triggered when a ticket leaves matchmaking.
+ *
+ * @param handler a consumer that will process the ticket deletion events
+ * @return a subscription handle to manage the listener lifecycle
+ */
+ Subscription onDeleted(Consumer handler);
+
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketStateChangedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketStateChangedEvent.java
new file mode 100644
index 0000000..9151ecf
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketStateChangedEvent.java
@@ -0,0 +1,16 @@
+package net.mythicisland.queue.api.event.ticket;
+
+import net.mythicisland.queue.api.ticket.Ticket;
+import net.mythicisland.queue.api.ticket.TicketState;
+
+/**
+ * Event fired when a ticket transitions from one state to another.
+ *
+ * @param ticket the ticket, already carrying its new state
+ * @param previousState the state the ticket was in before
+ */
+public record TicketStateChangedEvent(
+ Ticket ticket,
+ TicketState previousState
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/ProtoUtil.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/ProtoUtil.java
index 042ac66..88e278b 100644
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/ProtoUtil.java
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/ProtoUtil.java
@@ -3,9 +3,16 @@
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
-import net.mythicisland.queue.api.queue.QueueStatus;
+import com.google.protobuf.Timestamp;
+import net.mythicisland.queue.api.match.Assignment;
+import net.mythicisland.queue.api.match.Match;
+import net.mythicisland.queue.api.match.MatchState;
+import net.mythicisland.queue.api.ticket.Ticket;
+import net.mythicisland.queue.api.ticket.TicketDeleteReason;
+import net.mythicisland.queue.api.ticket.TicketState;
import org.jetbrains.annotations.NotNull;
+import java.time.Instant;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
@@ -34,47 +41,69 @@ public void onFailure(@NotNull Throwable t) {
return future;
}
- public static QueueStatus toApiStatus(build.buf.gen.mythicisland.queue.v1.QueueStatus status) {
- return switch (status) {
- case NOT_ENOUGH_PLAYERS -> QueueStatus.NOT_ENOUGH_PLAYERS;
- case WAITING_COUNTDOWN -> QueueStatus.WAITING_COUNTDOWN;
- case SEARCHING_SERVER -> QueueStatus.SEARCHING_SERVER;
- case WAITING_FOR_SERVER -> QueueStatus.WAITING_FOR_SERVER;
- case SERVER_READY -> QueueStatus.SERVER_READY;
- case COUNTDOWN -> QueueStatus.COUNTDOWN;
- case TELEPORTING -> QueueStatus.TELEPORTING;
- case FINISHED -> QueueStatus.FINISHED;
- default -> throw new IllegalArgumentException("Unknown QueueStatus: " + status);
- };
- }
-
public static List toUuidList(List ids) {
return ids.stream().map(UUID::fromString).toList();
}
- public static UUID toQueueId(build.buf.gen.mythicisland.queue.v1.Queue proto) {
- return UUID.fromString(proto.getUniqueId());
+ public static Ticket toTicket(build.buf.gen.mythicisland.queue.v2.Ticket proto) {
+ return new Ticket(
+ UUID.fromString(proto.getId()),
+ toUuidList(proto.getPlayerIdsList()),
+ List.copyOf(proto.getQueueTypesList()),
+ toTicketState(proto.getState()),
+ toInstant(proto.getCreatedAt()),
+ proto.getMatchId().isEmpty() ? null : UUID.fromString(proto.getMatchId()),
+ proto.hasAssignment() ? toAssignment(proto.getAssignment()) : null,
+ proto.hasCountdownEndTime() ? toInstant(proto.getCountdownEndTime()) : null
+ );
}
- /**
- * Unpacks the fields shared by every queue event and hands them to {@code factory}.
- */
- public static E fromQueue(build.buf.gen.mythicisland.queue.v1.Queue queue, QueueFactory factory) {
- return factory.create(
- toQueueId(queue),
- queue.getType(),
- toApiStatus(queue.getStatus()),
- toUuidList(queue.getPlayerIdsList())
+ public static Match toMatch(build.buf.gen.mythicisland.queue.v2.Match proto) {
+ return new Match(
+ UUID.fromString(proto.getId()),
+ proto.getQueueType(),
+ proto.getTicketsList().stream().map(ProtoUtil::toTicket).toList(),
+ toMatchState(proto.getState()),
+ toInstant(proto.getCreatedAt()),
+ proto.hasAssignment() ? toAssignment(proto.getAssignment()) : null,
+ proto.hasCountdownEndTime() ? toInstant(proto.getCountdownEndTime()) : null
);
}
- /**
- * Builds an event from the fields shared by every queue event.
- *
- * @param the API event type
- */
- @FunctionalInterface
- public interface QueueFactory {
- E create(UUID queueId, String queueType, QueueStatus queueStatus, List queuePlayerIds);
+ public static Assignment toAssignment(build.buf.gen.mythicisland.queue.v2.Assignment proto) {
+ return new Assignment(proto.getServerId(), proto.getServerName());
+ }
+
+ public static Instant toInstant(Timestamp timestamp) {
+ return Instant.ofEpochSecond(timestamp.getSeconds(), timestamp.getNanos());
+ }
+
+ public static TicketState toTicketState(build.buf.gen.mythicisland.queue.v2.TicketState state) {
+ return switch (state) {
+ case TICKET_STATE_SEARCHING -> TicketState.SEARCHING;
+ case TICKET_STATE_MATCHED -> TicketState.MATCHED;
+ case TICKET_STATE_ASSIGNED -> TicketState.ASSIGNED;
+ default -> throw new IllegalArgumentException("Unknown TicketState: " + state);
+ };
+ }
+
+ public static MatchState toMatchState(build.buf.gen.mythicisland.queue.v2.MatchState state) {
+ return switch (state) {
+ case MATCH_STATE_ALLOCATING -> MatchState.ALLOCATING;
+ case MATCH_STATE_COUNTDOWN -> MatchState.COUNTDOWN;
+ case MATCH_STATE_TRANSFERRING -> MatchState.TRANSFERRING;
+ case MATCH_STATE_COMPLETED -> MatchState.COMPLETED;
+ case MATCH_STATE_FAILED -> MatchState.FAILED;
+ default -> throw new IllegalArgumentException("Unknown MatchState: " + state);
+ };
+ }
+
+ public static TicketDeleteReason toDeleteReason(build.buf.gen.mythicisland.queue.v2.TicketDeleteReason reason) {
+ return switch (reason) {
+ case TICKET_DELETE_REASON_CANCELLED -> TicketDeleteReason.CANCELLED;
+ case TICKET_DELETE_REASON_TRANSFERRED -> TicketDeleteReason.TRANSFERRED;
+ case TICKET_DELETE_REASON_EXPIRED -> TicketDeleteReason.EXPIRED;
+ default -> throw new IllegalArgumentException("Unknown TicketDeleteReason: " + reason);
+ };
}
}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/QueueApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/QueueApiImpl.java
index 43e4183..5453a82 100644
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/QueueApiImpl.java
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/QueueApiImpl.java
@@ -1,7 +1,7 @@
package net.mythicisland.queue.api.internal;
-import build.buf.gen.mythicisland.queue.v1.QueueDataServiceGrpc;
-import build.buf.gen.mythicisland.queue.v1.QueueServiceGrpc;
+import build.buf.gen.mythicisland.queue.v2.QueueDataServiceGrpc;
+import build.buf.gen.mythicisland.queue.v2.TicketServiceGrpc;
import io.grpc.ManagedChannel;
import io.grpc.ManagedChannelBuilder;
import io.nats.client.Connection;
@@ -14,8 +14,8 @@
import net.mythicisland.queue.api.event.EventApi;
import net.mythicisland.queue.api.internal.data.QueueDataApiImpl;
import net.mythicisland.queue.api.internal.event.EventApiImpl;
-import net.mythicisland.queue.api.internal.player.QueuePlayerApiImpl;
-import net.mythicisland.queue.api.player.QueuePlayerApi;
+import net.mythicisland.queue.api.internal.ticket.TicketApiImpl;
+import net.mythicisland.queue.api.ticket.TicketApi;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
@@ -24,7 +24,7 @@ public final class QueueApiImpl implements QueueApi {
private final ManagedChannel channel;
private final Connection nc;
- private final QueuePlayerApi playerApi;
+ private final TicketApi ticketApi;
private final QueueDataApi dataApi;
private final EventApi eventApi;
@@ -51,9 +51,9 @@ public QueueApiImpl(QueueApiOptions options) {
AuthCredentials credentials = new AuthCredentials(options.token());
- QueueServiceGrpc.QueueServiceFutureStub stub = QueueServiceGrpc.newFutureStub(channel)
+ TicketServiceGrpc.TicketServiceFutureStub stub = TicketServiceGrpc.newFutureStub(channel)
.withCallCredentials(credentials);
- this.playerApi = new QueuePlayerApiImpl(stub);
+ this.ticketApi = new TicketApiImpl(stub);
QueueDataServiceGrpc.QueueDataServiceFutureStub dataStub = QueueDataServiceGrpc.newFutureStub(channel)
.withCallCredentials(credentials);
@@ -80,8 +80,8 @@ public void close() {
}
@Override
- public QueuePlayerApi player() {
- return playerApi;
+ public TicketApi ticket() {
+ return ticketApi;
}
@Override
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/data/QueueDataApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/data/QueueDataApiImpl.java
index c55c981..f5af6b2 100644
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/data/QueueDataApiImpl.java
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/data/QueueDataApiImpl.java
@@ -1,6 +1,6 @@
package net.mythicisland.queue.api.internal.data;
-import build.buf.gen.mythicisland.queue.v1.*;
+import build.buf.gen.mythicisland.queue.v2.*;
import net.mythicisland.queue.api.data.QueueDataApi;
import java.util.UUID;
@@ -17,44 +17,60 @@ public QueueDataApiImpl(QueueDataServiceGrpc.QueueDataServiceFutureStub stub) {
}
@Override
- public CompletableFuture getQueue(UUID queueId) {
- return toCompletableFuture(stub.getQueue(
- GetQueueRequest.newBuilder()
- .setQueueId(queueId.toString())
+ public CompletableFuture getTicket(UUID ticketId) {
+ return toCompletableFuture(stub.getTicket(
+ GetTicketRequest.newBuilder()
+ .setTicketId(ticketId.toString())
.build()
));
}
@Override
- public CompletableFuture getAllQueues() {
- return toCompletableFuture(stub.getAllQueues(
- GetAllQueuesRequest.getDefaultInstance()
+ public CompletableFuture getTicketByPlayer(UUID playerId) {
+ return toCompletableFuture(stub.getTicketByPlayer(
+ GetTicketByPlayerRequest.newBuilder()
+ .setPlayerId(playerId.toString())
+ .build()
+ ));
+ }
+
+ @Override
+ public CompletableFuture listTickets() {
+ return toCompletableFuture(stub.listTickets(
+ ListTicketsRequest.getDefaultInstance()
));
}
@Override
- public CompletableFuture getQueuesByType(String type) {
- return toCompletableFuture(stub.getQueuesByType(
- GetQueuesByTypeRequest.newBuilder()
- .setType(type)
+ public CompletableFuture listTickets(String queueType) {
+ return toCompletableFuture(stub.listTickets(
+ ListTicketsRequest.newBuilder()
+ .setQueueType(queueType)
.build()
));
}
@Override
- public CompletableFuture getQueueByPlayer(UUID playerId) {
- return toCompletableFuture(stub.getQueueByPlayer(
- GetQueueByPlayerRequest.newBuilder()
- .setPlayerId(playerId.toString())
+ public CompletableFuture getMatch(UUID matchId) {
+ return toCompletableFuture(stub.getMatch(
+ GetMatchRequest.newBuilder()
+ .setMatchId(matchId.toString())
.build()
));
}
@Override
- public CompletableFuture getPlayerPosition(UUID playerId) {
- return toCompletableFuture(stub.getPlayerPosition(
- GetPlayerPositionRequest.newBuilder()
- .setPlayerId(playerId.toString())
+ public CompletableFuture listMatches() {
+ return toCompletableFuture(stub.listMatches(
+ ListMatchesRequest.getDefaultInstance()
+ ));
+ }
+
+ @Override
+ public CompletableFuture listMatches(String queueType) {
+ return toCompletableFuture(stub.listMatches(
+ ListMatchesRequest.newBuilder()
+ .setQueueType(queueType)
.build()
));
}
@@ -69,9 +85,25 @@ public CompletableFuture getQueueType(String name) {
}
@Override
- public CompletableFuture getAllQueueTypes() {
- return toCompletableFuture(stub.getAllQueueTypes(
- GetAllQueueTypesRequest.getDefaultInstance()
+ public CompletableFuture listQueueTypes() {
+ return toCompletableFuture(stub.listQueueTypes(
+ ListQueueTypesRequest.getDefaultInstance()
+ ));
+ }
+
+ @Override
+ public CompletableFuture getQueueStats(String queueType) {
+ return toCompletableFuture(stub.getQueueStats(
+ GetQueueStatsRequest.newBuilder()
+ .setQueueType(queueType)
+ .build()
+ ));
+ }
+
+ @Override
+ public CompletableFuture listQueueStats() {
+ return toCompletableFuture(stub.listQueueStats(
+ ListQueueStatsRequest.getDefaultInstance()
));
}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/EventApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/EventApiImpl.java
index f048f6c..fb6a529 100644
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/EventApiImpl.java
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/EventApiImpl.java
@@ -2,28 +2,28 @@
import io.nats.client.Connection;
import net.mythicisland.queue.api.event.EventApi;
-import net.mythicisland.queue.api.event.player.QueuePlayerEventApi;
-import net.mythicisland.queue.api.event.queue.QueueEventApi;
-import net.mythicisland.queue.api.internal.event.player.QueuePlayerEventApiImpl;
-import net.mythicisland.queue.api.internal.event.queue.QueueEventApiImpl;
+import net.mythicisland.queue.api.event.match.MatchEventApi;
+import net.mythicisland.queue.api.event.ticket.TicketEventApi;
+import net.mythicisland.queue.api.internal.event.match.MatchEventApiImpl;
+import net.mythicisland.queue.api.internal.event.ticket.TicketEventApiImpl;
public final class EventApiImpl implements EventApi {
- private final QueueEventApi queueEventApi;
- private final QueuePlayerEventApi playerEventApi;
+ private final TicketEventApi ticketEventApi;
+ private final MatchEventApi matchEventApi;
public EventApiImpl(Connection connection) {
- this.queueEventApi = new QueueEventApiImpl(connection);
- this.playerEventApi = new QueuePlayerEventApiImpl(connection);
+ this.ticketEventApi = new TicketEventApiImpl(connection);
+ this.matchEventApi = new MatchEventApiImpl(connection);
}
@Override
- public QueueEventApi queue() {
- return queueEventApi;
+ public TicketEventApi ticket() {
+ return ticketEventApi;
}
@Override
- public QueuePlayerEventApi player() {
- return playerEventApi;
+ public MatchEventApi match() {
+ return matchEventApi;
}
}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/QueueEventSubjects.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/QueueEventSubjects.java
index 1896888..9e017e4 100644
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/QueueEventSubjects.java
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/QueueEventSubjects.java
@@ -5,17 +5,17 @@ public final class QueueEventSubjects {
private QueueEventSubjects() {
}
- private static final String PREFIX = "queue.event.";
+ private static final String PREFIX = "queue.";
- public static final String ENQUEUE = PREFIX + "enqueue";
- public static final String DEQUEUE = PREFIX + "dequeue";
+ private static final String TICKET_PREFIX = PREFIX + "ticket.";
- private static final String QUEUE_PREFIX = PREFIX + "queue.";
+ public static final String TICKET_CREATED = TICKET_PREFIX + "created";
+ public static final String TICKET_STATE_CHANGED = TICKET_PREFIX + "state.changed";
+ public static final String TICKET_DELETED = TICKET_PREFIX + "deleted";
- public static final String QUEUE_CREATED = QUEUE_PREFIX + "created";
- public static final String QUEUE_UPDATED = QUEUE_PREFIX + "updated";
- public static final String QUEUE_DELETED = QUEUE_PREFIX + "deleted";
- public static final String QUEUE_TRANSFER = QUEUE_PREFIX + "transfer";
- public static final String QUEUE_STATUS_UPDATED = QUEUE_PREFIX + "status.updated";
- public static final String QUEUE_SERVER_ASSIGNED = QUEUE_PREFIX + "server.assigned";
+ private static final String MATCH_PREFIX = PREFIX + "match.";
+
+ public static final String MATCH_CREATED = MATCH_PREFIX + "created";
+ public static final String MATCH_STATE_CHANGED = MATCH_PREFIX + "state.changed";
+ public static final String MATCH_TRANSFERRED = MATCH_PREFIX + "transferred";
}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/match/MatchEventApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/match/MatchEventApiImpl.java
new file mode 100644
index 0000000..5062dc0
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/match/MatchEventApiImpl.java
@@ -0,0 +1,50 @@
+package net.mythicisland.queue.api.internal.event.match;
+
+import io.nats.client.Connection;
+import net.mythicisland.queue.api.event.Subscription;
+import net.mythicisland.queue.api.event.match.MatchCreatedEvent;
+import net.mythicisland.queue.api.event.match.MatchEventApi;
+import net.mythicisland.queue.api.event.match.MatchStateChangedEvent;
+import net.mythicisland.queue.api.event.match.MatchTransferredEvent;
+import net.mythicisland.queue.api.internal.ProtoUtil;
+import net.mythicisland.queue.api.internal.event.NatsEventApi;
+import net.mythicisland.queue.api.internal.event.QueueEventSubjects;
+
+import java.util.function.Consumer;
+
+public final class MatchEventApiImpl extends NatsEventApi implements MatchEventApi {
+
+ public MatchEventApiImpl(Connection connection) {
+ super(connection);
+ }
+
+ @Override
+ public Subscription onCreated(Consumer handler) {
+ return subscribe(QueueEventSubjects.MATCH_CREATED,
+ build.buf.gen.mythicisland.queue.v2.MatchCreatedEvent.parser(),
+ proto -> new MatchCreatedEvent(ProtoUtil.toMatch(proto.getMatch())),
+ handler);
+ }
+
+ @Override
+ public Subscription onStateChanged(Consumer handler) {
+ return subscribe(QueueEventSubjects.MATCH_STATE_CHANGED,
+ build.buf.gen.mythicisland.queue.v2.MatchStateChangedEvent.parser(),
+ proto -> new MatchStateChangedEvent(
+ ProtoUtil.toMatch(proto.getMatch()),
+ ProtoUtil.toMatchState(proto.getPreviousState())
+ ),
+ handler);
+ }
+
+ @Override
+ public Subscription onTransferred(Consumer handler) {
+ return subscribe(QueueEventSubjects.MATCH_TRANSFERRED,
+ build.buf.gen.mythicisland.queue.v2.MatchTransferredEvent.parser(),
+ proto -> new MatchTransferredEvent(
+ ProtoUtil.toMatch(proto.getMatch()),
+ ProtoUtil.toUuidList(proto.getTransferredPlayerIdsList())
+ ),
+ handler);
+ }
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/player/QueuePlayerEventApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/player/QueuePlayerEventApiImpl.java
deleted file mode 100644
index 82ce0d1..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/player/QueuePlayerEventApiImpl.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package net.mythicisland.queue.api.internal.event.player;
-
-import io.nats.client.Connection;
-import net.mythicisland.queue.api.event.Subscription;
-import net.mythicisland.queue.api.event.player.DequeueEvent;
-import net.mythicisland.queue.api.event.player.EnqueueEvent;
-import net.mythicisland.queue.api.event.player.QueuePlayerEventApi;
-import net.mythicisland.queue.api.internal.ProtoUtil;
-import net.mythicisland.queue.api.internal.event.NatsEventApi;
-import net.mythicisland.queue.api.internal.event.QueueEventSubjects;
-
-import java.util.function.Consumer;
-
-public final class QueuePlayerEventApiImpl extends NatsEventApi implements QueuePlayerEventApi {
-
- public QueuePlayerEventApiImpl(Connection connection) {
- super(connection);
- }
-
- @Override
- public Subscription onEnqueued(Consumer handler) {
- return subscribe(QueueEventSubjects.ENQUEUE,
- build.buf.gen.mythicisland.queue.v1.EnqueueEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), (id, type, status, queuePlayerIds) ->
- new EnqueueEvent(id, type, status, queuePlayerIds,
- ProtoUtil.toUuidList(proto.getPlayerIdsList()))),
- handler);
- }
-
- @Override
- public Subscription onDequeued(Consumer handler) {
- return subscribe(QueueEventSubjects.DEQUEUE,
- build.buf.gen.mythicisland.queue.v1.DequeueEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), (id, type, status, queuePlayerIds) ->
- new DequeueEvent(id, type, status, queuePlayerIds,
- ProtoUtil.toUuidList(proto.getPlayerIdsList()))),
- handler);
- }
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/queue/QueueEventApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/queue/QueueEventApiImpl.java
deleted file mode 100644
index 7649221..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/queue/QueueEventApiImpl.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package net.mythicisland.queue.api.internal.event.queue;
-
-import io.nats.client.Connection;
-import net.mythicisland.queue.api.event.Subscription;
-import net.mythicisland.queue.api.event.queue.*;
-import net.mythicisland.queue.api.internal.ProtoUtil;
-import net.mythicisland.queue.api.internal.event.NatsEventApi;
-import net.mythicisland.queue.api.internal.event.QueueEventSubjects;
-
-import java.util.function.Consumer;
-
-public final class QueueEventApiImpl extends NatsEventApi implements QueueEventApi {
-
- public QueueEventApiImpl(Connection connection) {
- super(connection);
- }
-
- @Override
- public Subscription onCreated(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_CREATED,
- build.buf.gen.mythicisland.queue.v1.QueueCreatedEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), QueueCreatedEvent::new),
- handler);
- }
-
- @Override
- public Subscription onDeleted(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_DELETED,
- build.buf.gen.mythicisland.queue.v1.QueueDeletedEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), QueueDeletedEvent::new),
- handler);
- }
-
- @Override
- public Subscription onServerAssigned(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_SERVER_ASSIGNED,
- build.buf.gen.mythicisland.queue.v1.QueueServerAssignedEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), (id, type, status, playerIds) ->
- new QueueServerAssignedEvent(id, type, status, playerIds, proto.getServerId())),
- handler);
- }
-
- @Override
- public Subscription onStatusUpdated(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_STATUS_UPDATED,
- build.buf.gen.mythicisland.queue.v1.QueueStatusUpdatedEvent.parser(),
- proto -> new QueueStatusUpdatedEvent(
- ProtoUtil.toQueueId(proto.getQueue()),
- proto.getQueue().getType(),
- ProtoUtil.toUuidList(proto.getQueue().getPlayerIdsList()),
- ProtoUtil.toApiStatus(proto.getOldStatus()),
- ProtoUtil.toApiStatus(proto.getNewStatus())
- ),
- handler);
- }
-
- @Override
- public Subscription onTransfer(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_TRANSFER,
- build.buf.gen.mythicisland.queue.v1.QueueTransferEvent.parser(),
- proto -> ProtoUtil.fromQueue(proto.getQueue(), (id, type, status, playerIds) ->
- new QueueTransferEvent(id, type, status, playerIds, proto.getServerId(),
- ProtoUtil.toUuidList(proto.getPlayerIdsList()))),
- handler);
- }
-
- @Override
- public Subscription onUpdated(Consumer handler) {
- return subscribe(QueueEventSubjects.QUEUE_UPDATED,
- build.buf.gen.mythicisland.queue.v1.QueueUpdatedEvent.parser(),
- proto -> new QueueUpdatedEvent(
- ProtoUtil.toQueueId(proto.getAfter()),
- proto.getAfter().getType(),
- ProtoUtil.toApiStatus(proto.getBefore().getStatus()),
- ProtoUtil.toUuidList(proto.getBefore().getPlayerIdsList()),
- ProtoUtil.toApiStatus(proto.getAfter().getStatus()),
- ProtoUtil.toUuidList(proto.getAfter().getPlayerIdsList())
- ),
- handler);
- }
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/ticket/TicketEventApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/ticket/TicketEventApiImpl.java
new file mode 100644
index 0000000..88e6da2
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/event/ticket/TicketEventApiImpl.java
@@ -0,0 +1,50 @@
+package net.mythicisland.queue.api.internal.event.ticket;
+
+import io.nats.client.Connection;
+import net.mythicisland.queue.api.event.Subscription;
+import net.mythicisland.queue.api.event.ticket.TicketCreatedEvent;
+import net.mythicisland.queue.api.event.ticket.TicketDeletedEvent;
+import net.mythicisland.queue.api.event.ticket.TicketEventApi;
+import net.mythicisland.queue.api.event.ticket.TicketStateChangedEvent;
+import net.mythicisland.queue.api.internal.ProtoUtil;
+import net.mythicisland.queue.api.internal.event.NatsEventApi;
+import net.mythicisland.queue.api.internal.event.QueueEventSubjects;
+
+import java.util.function.Consumer;
+
+public final class TicketEventApiImpl extends NatsEventApi implements TicketEventApi {
+
+ public TicketEventApiImpl(Connection connection) {
+ super(connection);
+ }
+
+ @Override
+ public Subscription onCreated(Consumer handler) {
+ return subscribe(QueueEventSubjects.TICKET_CREATED,
+ build.buf.gen.mythicisland.queue.v2.TicketCreatedEvent.parser(),
+ proto -> new TicketCreatedEvent(ProtoUtil.toTicket(proto.getTicket())),
+ handler);
+ }
+
+ @Override
+ public Subscription onStateChanged(Consumer handler) {
+ return subscribe(QueueEventSubjects.TICKET_STATE_CHANGED,
+ build.buf.gen.mythicisland.queue.v2.TicketStateChangedEvent.parser(),
+ proto -> new TicketStateChangedEvent(
+ ProtoUtil.toTicket(proto.getTicket()),
+ ProtoUtil.toTicketState(proto.getPreviousState())
+ ),
+ handler);
+ }
+
+ @Override
+ public Subscription onDeleted(Consumer handler) {
+ return subscribe(QueueEventSubjects.TICKET_DELETED,
+ build.buf.gen.mythicisland.queue.v2.TicketDeletedEvent.parser(),
+ proto -> new TicketDeletedEvent(
+ ProtoUtil.toTicket(proto.getTicket()),
+ ProtoUtil.toDeleteReason(proto.getReason())
+ ),
+ handler);
+ }
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/player/QueuePlayerApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/player/QueuePlayerApiImpl.java
deleted file mode 100644
index b9d5b27..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/internal/player/QueuePlayerApiImpl.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package net.mythicisland.queue.api.internal.player;
-
-import build.buf.gen.mythicisland.queue.v1.*;
-import net.mythicisland.queue.api.player.QueuePlayerApi;
-
-import java.util.List;
-import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-
-import static net.mythicisland.queue.api.internal.ProtoUtil.toCompletableFuture;
-
-public class QueuePlayerApiImpl implements QueuePlayerApi {
-
- private final QueueServiceGrpc.QueueServiceFutureStub stub;
-
- public QueuePlayerApiImpl(QueueServiceGrpc.QueueServiceFutureStub stub) {
- this.stub = stub;
- }
-
- @Override
- public CompletableFuture enqueue(String type, List playerIds) {
- List ids = playerIds.stream().map(UUID::toString).toList();
-
- return toCompletableFuture(stub.enqueue(
- EnqueueRequest.newBuilder()
- .setType(type)
- .addAllPlayerIds(ids)
- .build()
- ));
- }
-
- @Override
- public CompletableFuture dequeue(List playerIds) {
- List ids = playerIds.stream().map(UUID::toString).toList();
-
- return toCompletableFuture(stub.dequeue(
- DequeueRequest.newBuilder()
- .addAllPlayerIds(ids)
- .build()
- ));
- }
-
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/internal/ticket/TicketApiImpl.java b/queue-api/src/main/java/net/mythicisland/queue/api/internal/ticket/TicketApiImpl.java
new file mode 100644
index 0000000..eb182db
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/internal/ticket/TicketApiImpl.java
@@ -0,0 +1,50 @@
+package net.mythicisland.queue.api.internal.ticket;
+
+import build.buf.gen.mythicisland.queue.v2.*;
+import net.mythicisland.queue.api.ticket.TicketApi;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+
+import static net.mythicisland.queue.api.internal.ProtoUtil.toCompletableFuture;
+
+public class TicketApiImpl implements TicketApi {
+
+ private final TicketServiceGrpc.TicketServiceFutureStub stub;
+
+ public TicketApiImpl(TicketServiceGrpc.TicketServiceFutureStub stub) {
+ this.stub = stub;
+ }
+
+ @Override
+ public CompletableFuture createTicket(List playerIds, List queueTypes) {
+ List ids = playerIds.stream().map(UUID::toString).toList();
+
+ return toCompletableFuture(stub.createTicket(
+ CreateTicketRequest.newBuilder()
+ .addAllPlayerIds(ids)
+ .addAllQueueTypes(queueTypes)
+ .build()
+ ));
+ }
+
+ @Override
+ public CompletableFuture deleteTicket(UUID ticketId) {
+ return toCompletableFuture(stub.deleteTicket(
+ DeleteTicketRequest.newBuilder()
+ .setTicketId(ticketId.toString())
+ .build()
+ ));
+ }
+
+ @Override
+ public CompletableFuture deleteTicketByPlayer(UUID playerId) {
+ return toCompletableFuture(stub.deleteTicket(
+ DeleteTicketRequest.newBuilder()
+ .setPlayerId(playerId.toString())
+ .build()
+ ));
+ }
+
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/match/Assignment.java b/queue-api/src/main/java/net/mythicisland/queue/api/match/Assignment.java
new file mode 100644
index 0000000..47ca831
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/match/Assignment.java
@@ -0,0 +1,13 @@
+package net.mythicisland.queue.api.match;
+
+/**
+ * The game server a match was allocated to.
+ *
+ * @param serverId the unique ID of the server
+ * @param serverName the name used to connect players, for example battle-1
+ */
+public record Assignment(
+ String serverId,
+ String serverName
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/match/Match.java b/queue-api/src/main/java/net/mythicisland/queue/api/match/Match.java
new file mode 100644
index 0000000..d9742f8
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/match/Match.java
@@ -0,0 +1,29 @@
+package net.mythicisland.queue.api.match;
+
+import net.mythicisland.queue.api.ticket.Ticket;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * A set of tickets that will play together on one game server.
+ *
+ * @param id the unique ID of the match
+ * @param queueType the queue type the match was created for
+ * @param tickets the tickets forming the match
+ * @param state the current state of the match
+ * @param createdAt when the match was formed
+ * @param assignment the allocated server, null while allocating
+ * @param countdownEndsAt when the players get transferred, null until a server was allocated
+ */
+public record Match(
+ UUID id,
+ String queueType,
+ List tickets,
+ MatchState state,
+ Instant createdAt,
+ Assignment assignment,
+ Instant countdownEndsAt
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/match/MatchState.java b/queue-api/src/main/java/net/mythicisland/queue/api/match/MatchState.java
new file mode 100644
index 0000000..bd4b98e
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/match/MatchState.java
@@ -0,0 +1,33 @@
+package net.mythicisland.queue.api.match;
+
+/**
+ * Represents the current state of a match.
+ */
+public enum MatchState {
+
+ /**
+ * A game server is being searched or started for this match.
+ */
+ ALLOCATING,
+
+ /**
+ * The server is ready and the match is counting down before the transfer.
+ */
+ COUNTDOWN,
+
+ /**
+ * The players are being transferred to the server.
+ */
+ TRANSFERRING,
+
+ /**
+ * Every player was transferred, the match is handed over to the game server.
+ */
+ COMPLETED,
+
+ /**
+ * No server could be allocated, the tickets went back to searching.
+ */
+ FAILED;
+
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/player/QueuePlayerApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/player/QueuePlayerApi.java
deleted file mode 100644
index e832e86..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/player/QueuePlayerApi.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package net.mythicisland.queue.api.player;
-
-import build.buf.gen.mythicisland.queue.v1.EnqueueResponse;
-import build.buf.gen.mythicisland.queue.v1.DequeueResponse;
-
-import java.util.List;
-import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-
-/**
- * API for performing player related queuing operations.
- */
-public interface QueuePlayerApi {
-
- /**
- * Enqueues a group of players into a queue of the specified type.
- *
- * @param type the name of the queue type to join
- * @param playerIds a list of UUIDs of the players to enqueue together
- * @return a future completing with the result of the enqueue operation
- */
- CompletableFuture enqueue(String type, List playerIds);
-
- /**
- * Enqueues a player into a queue of the specified type.
- *
- * @param type the name of the queue type to join
- * @param playerId the UUID of the player to enqueue
- * @return a future completing with the result of the enqueue operation
- */
- default CompletableFuture enqueue(String type, UUID playerId) {
- return enqueue(type, List.of(playerId));
- }
-
- /**
- * Dequeues a group of players from their current queues.
- *
- * @param playerIds a list of UUIDs of the players to remove from their queues
- * @return a future completing with the result of the dequeue operation
- */
- CompletableFuture dequeue(List playerIds);
-
- /**
- * Dequeues a single player from their current queue.
- *
- * @param playerId the UUID of the player to remove from their queue
- * @return a future completing with the result of the dequeue operation
- */
- default CompletableFuture dequeue(UUID playerId) {
- return dequeue(List.of(playerId));
- }
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/queue/QueueStatus.java b/queue-api/src/main/java/net/mythicisland/queue/api/queue/QueueStatus.java
deleted file mode 100644
index 7859cc7..0000000
--- a/queue-api/src/main/java/net/mythicisland/queue/api/queue/QueueStatus.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package net.mythicisland.queue.api.queue;
-
-/**
- * Represents the current status of a queue.
- */
-public enum QueueStatus {
-
- /**
- * The queue does not have enough players to start a game.
- */
- NOT_ENOUGH_PLAYERS,
-
- /**
- * Minimum player count has been reached, and the pre-game countdown has started.
- */
- WAITING_COUNTDOWN,
-
- /**
- * The system is looking for an available game server for this queue.
- */
- SEARCHING_SERVER,
-
- /**
- * A request for a server has been made, but no server has been assigned yet.
- */
- WAITING_FOR_SERVER,
-
- /**
- * A game server has been successfully assigned and is ready to accept players.
- */
- SERVER_READY,
-
- /**
- * The final countdown before players are teleported.
- */
- COUNTDOWN,
-
- /**
- * Players are currently being teleported to the assigned game server.
- */
- TELEPORTING,
-
- /**
- * The queue lifecycle has been completed successfully.
- */
- FINISHED;
-
-}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/ticket/Ticket.java b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/Ticket.java
new file mode 100644
index 0000000..ecc22da
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/Ticket.java
@@ -0,0 +1,33 @@
+package net.mythicisland.queue.api.ticket;
+
+import net.mythicisland.queue.api.match.Assignment;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * A single player or a party that wants to play.
+ *
+ * A ticket is the atomic unit of matchmaking, a party is never split up.
+ *
+ * @param id the unique ID of the ticket
+ * @param playerIds the players behind the ticket, one entry means solo
+ * @param queueTypes the queue types the ticket is searching in
+ * @param state the current state of the ticket
+ * @param createdAt when the ticket entered matchmaking
+ * @param matchId the match the ticket was put into, null while searching
+ * @param assignment the server to connect to, null until one was allocated
+ * @param countdownEndsAt when the players get transferred, null until a server was allocated
+ */
+public record Ticket(
+ UUID id,
+ List playerIds,
+ List queueTypes,
+ TicketState state,
+ Instant createdAt,
+ UUID matchId,
+ Assignment assignment,
+ Instant countdownEndsAt
+) {
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketApi.java
new file mode 100644
index 0000000..8916c2e
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketApi.java
@@ -0,0 +1,64 @@
+package net.mythicisland.queue.api.ticket;
+
+import build.buf.gen.mythicisland.queue.v2.CreateTicketResponse;
+import build.buf.gen.mythicisland.queue.v2.DeleteTicketResponse;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+
+/**
+ * API for putting players into matchmaking and taking them back out.
+ */
+public interface TicketApi {
+
+ /**
+ * Creates a ticket for a party, searching in one or more queue types.
+ *
+ * Passing several queue types means the party joins whichever match fills up first.
+ *
+ * @param playerIds the UUIDs of the players to enqueue together
+ * @param queueTypes the names of the queue types to search in
+ * @return a future completing with the created ticket
+ */
+ CompletableFuture createTicket(List playerIds, List queueTypes);
+
+ /**
+ * Creates a ticket for a single player, searching in one or more queue types.
+ *
+ * @param playerId the UUID of the player to enqueue
+ * @param queueTypes the names of the queue types to search in
+ * @return a future completing with the created ticket
+ */
+ default CompletableFuture createTicket(UUID playerId, List queueTypes) {
+ return createTicket(List.of(playerId), queueTypes);
+ }
+
+ /**
+ * Creates a ticket for a single player, searching in one queue type.
+ *
+ * @param playerId the UUID of the player to enqueue
+ * @param queueType the name of the queue type to search in
+ * @return a future completing with the created ticket
+ */
+ default CompletableFuture createTicket(UUID playerId, String queueType) {
+ return createTicket(List.of(playerId), List.of(queueType));
+ }
+
+ /**
+ * Removes a ticket from matchmaking.
+ *
+ * @param ticketId the unique ID of the ticket
+ * @return a future completing when the ticket was removed
+ */
+ CompletableFuture deleteTicket(UUID ticketId);
+
+ /**
+ * Removes the ticket a player belongs to, including the rest of their party.
+ *
+ * @param playerId the UUID of the player
+ * @return a future completing when the ticket was removed
+ */
+ CompletableFuture deleteTicketByPlayer(UUID playerId);
+
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketDeleteReason.java b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketDeleteReason.java
new file mode 100644
index 0000000..2fd2917
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketDeleteReason.java
@@ -0,0 +1,23 @@
+package net.mythicisland.queue.api.ticket;
+
+/**
+ * Represents why a ticket left matchmaking.
+ */
+public enum TicketDeleteReason {
+
+ /**
+ * The player or party left the queue on purpose.
+ */
+ CANCELLED,
+
+ /**
+ * The players were transferred to their game server, matchmaking is done.
+ */
+ TRANSFERRED,
+
+ /**
+ * The ticket was dropped, for example because the players went offline.
+ */
+ EXPIRED;
+
+}
diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketState.java b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketState.java
new file mode 100644
index 0000000..d583c5a
--- /dev/null
+++ b/queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketState.java
@@ -0,0 +1,23 @@
+package net.mythicisland.queue.api.ticket;
+
+/**
+ * Represents the current state of a ticket.
+ */
+public enum TicketState {
+
+ /**
+ * The ticket is waiting in one or more queue types and is not part of a match yet.
+ */
+ SEARCHING,
+
+ /**
+ * The ticket is part of a match that is waiting for a server or counting down.
+ */
+ MATCHED,
+
+ /**
+ * A server was allocated and the players are being transferred to it.
+ */
+ ASSIGNED;
+
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/QueueDsl.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/QueueDsl.kt
new file mode 100644
index 0000000..bf76c61
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/QueueDsl.kt
@@ -0,0 +1,11 @@
+package net.mythicisland.queue.api
+
+/**
+ * Marks the receivers of the queue DSL.
+ *
+ * Keeps a nested block from accidentally calling into the enclosing builder,
+ * so every call inside a block belongs to that block.
+ */
+@DslMarker
+@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
+annotation class QueueDsl
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/QueueApiOptionsBuilder.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/QueueApiOptionsBuilder.kt
new file mode 100644
index 0000000..cea8907
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/QueueApiOptionsBuilder.kt
@@ -0,0 +1,60 @@
+package net.mythicisland.queue.api.builders
+
+import net.mythicisland.queue.api.QueueApiOptions
+import net.mythicisland.queue.api.QueueDsl
+
+/**
+ * Builds [QueueApiOptions] from a Kotlin block.
+ *
+ * Every property starts out with the value from [QueueApiOptions.DEFAULT], so
+ * anything not set explicitly still comes from the environment variables.
+ *
+ * ```
+ * val api = queueApi {
+ * grpcHost = "queue.internal"
+ * token = System.getenv("QUEUE_TOKEN")
+ * }
+ * ```
+ *
+ * @param defaults the options to start from.
+ */
+@QueueDsl
+class QueueApiOptionsBuilder internal constructor(
+ defaults: QueueApiOptions = QueueApiOptions.DEFAULT,
+) {
+
+ /**
+ * The host the queue gRPC server runs on.
+ */
+ var grpcHost: String = defaults.grpcHost()
+
+ /**
+ * The port the queue gRPC server listens on.
+ */
+ var grpcPort: Int = defaults.grpcPort()
+
+ /**
+ * The URL of the NATS server the events are read from.
+ */
+ var natsUrl: String = defaults.natsUrl()
+
+ /**
+ * The user for the NATS connection.
+ */
+ var natsUser: String = defaults.natsUser()
+
+ /**
+ * The secret for the NATS connection.
+ */
+ var natsSecret: String = defaults.natsSecret()
+
+ /**
+ * The shared auth token sent with every gRPC call.
+ */
+ var token: String = defaults.token()
+
+ internal fun build(): QueueApiOptions {
+ return QueueApiOptions(grpcHost, grpcPort, natsUrl, natsUser, natsSecret, token)
+ }
+
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/TicketBuilder.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/TicketBuilder.kt
new file mode 100644
index 0000000..a183dac
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/TicketBuilder.kt
@@ -0,0 +1,83 @@
+package net.mythicisland.queue.api.builders
+
+import net.mythicisland.queue.api.QueueDsl
+import java.util.UUID
+
+/**
+ * Describes the ticket that should enter matchmaking.
+ *
+ * A ticket holds either a single player or a whole party, and searches in one
+ * or more queue types. Several queue types mean the party joins whichever match
+ * fills up first.
+ *
+ * ```
+ * api.ticket().create {
+ * party(leader, member)
+ * queues("battle", "skywars")
+ * }
+ * ```
+ */
+@QueueDsl
+class TicketBuilder internal constructor() {
+
+ private val playerIds = mutableListOf()
+ private val queueTypes = mutableListOf()
+
+ /**
+ * Adds a single player to the ticket.
+ *
+ * @param playerId the UUID of the player.
+ */
+ fun player(playerId: UUID) {
+ playerIds.add(playerId)
+ }
+
+ /**
+ * Adds a group of players that must stay together.
+ *
+ * @param playerIds the UUIDs of the party members.
+ */
+ fun party(vararg playerIds: UUID) {
+ party(playerIds.asList())
+ }
+
+ /**
+ * Adds a group of players that must stay together.
+ *
+ * @param playerIds the UUIDs of the party members.
+ */
+ fun party(playerIds: Collection) {
+ this.playerIds.addAll(playerIds)
+ }
+
+ /**
+ * Adds queue types to search in.
+ *
+ * @param queueTypes the names of the queue types.
+ */
+ fun queues(vararg queueTypes: String) {
+ queues(queueTypes.asList())
+ }
+
+ /**
+ * Adds queue types to search in.
+ *
+ * @param queueTypes the names of the queue types.
+ */
+ fun queues(queueTypes: Collection) {
+ this.queueTypes.addAll(queueTypes)
+ }
+
+ /**
+ * Fails early instead of letting the server reject an empty request.
+ */
+ internal fun validate() {
+ require(playerIds.isNotEmpty()) { "A ticket needs at least one player" }
+ require(queueTypes.isNotEmpty()) { "A ticket needs at least one queue type" }
+ }
+
+ internal fun playerIds(): List = playerIds.toList()
+
+ internal fun queueTypes(): List = queueTypes.toList()
+
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/ProtoExtensions.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/ProtoExtensions.kt
new file mode 100644
index 0000000..9d9038c
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/ProtoExtensions.kt
@@ -0,0 +1,18 @@
+package net.mythicisland.queue.api.extensions
+
+import net.mythicisland.queue.api.internal.ProtoUtil
+import net.mythicisland.queue.api.match.Match
+import net.mythicisland.queue.api.ticket.Ticket
+
+/**
+ * Maps the protobuf ticket to the API type.
+ *
+ * The gRPC services answer with protobuf messages, everything else in the API
+ * works with [Ticket] and [Match]. These two turn one into the other.
+ */
+fun build.buf.gen.mythicisland.queue.v2.Ticket.toApi(): Ticket = ProtoUtil.toTicket(this)
+
+/**
+ * Maps the protobuf match to the API type.
+ */
+fun build.buf.gen.mythicisland.queue.v2.Match.toApi(): Match = ProtoUtil.toMatch(this)
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueApiExtensions.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueApiExtensions.kt
new file mode 100644
index 0000000..391f794
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueApiExtensions.kt
@@ -0,0 +1,36 @@
+package net.mythicisland.queue.api.extensions
+
+import net.mythicisland.queue.api.QueueApi
+import net.mythicisland.queue.api.builders.QueueApiOptionsBuilder
+import net.mythicisland.queue.api.event.Subscription
+import net.mythicisland.queue.api.scopes.EventScope
+
+/**
+ * Creates a [QueueApi] from a Kotlin block.
+ *
+ * Without a block the options come straight from the environment variables,
+ * the same as [QueueApi.create].
+ *
+ * ```
+ * val api = queueApi {
+ * grpcHost = "queue.internal"
+ * grpcPort = 4564
+ * }
+ * ```
+ *
+ * @param block configures the options.
+ * @return the connected API, close it when you are done with it.
+ */
+fun queueApi(block: QueueApiOptionsBuilder.() -> Unit = {}): QueueApi {
+ return QueueApi.create(QueueApiOptionsBuilder().apply(block).build())
+}
+
+/**
+ * Registers several event handlers at once.
+ *
+ * @param block registers the handlers.
+ * @return one subscription that unsubscribes all of them.
+ */
+fun QueueApi.events(block: EventScope.() -> Unit): Subscription {
+ return EventScope(event()).apply(block).subscription()
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueDataApiExtensions.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueDataApiExtensions.kt
new file mode 100644
index 0000000..34dac44
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueDataApiExtensions.kt
@@ -0,0 +1,101 @@
+package net.mythicisland.queue.api.extensions
+
+import kotlinx.coroutines.future.await
+import net.mythicisland.queue.api.data.QueueDataApi
+import net.mythicisland.queue.api.match.Match
+import net.mythicisland.queue.api.ticket.Ticket
+import java.util.UUID
+
+/**
+ * Reads a single ticket.
+ *
+ * @param ticketId the unique ID of the ticket.
+ * @return the ticket.
+ */
+suspend fun QueueDataApi.ticket(ticketId: UUID): Ticket {
+ return getTicket(ticketId).await().ticket.toApi()
+}
+
+/**
+ * Reads the ticket a player belongs to.
+ *
+ * @param playerId the UUID of the player.
+ * @return the ticket the player is part of.
+ */
+suspend fun QueueDataApi.ticketOf(playerId: UUID): Ticket {
+ return getTicketByPlayer(playerId).await().ticket.toApi()
+}
+
+/**
+ * Reads every ticket in matchmaking.
+ *
+ * @param queueType only the tickets searching in this queue type, null for all of them.
+ * @return the matching tickets.
+ */
+suspend fun QueueDataApi.tickets(queueType: String? = null): List {
+ val response = if (queueType == null) listTickets() else listTickets(queueType)
+ return response.await().ticketsList.map { it.toApi() }
+}
+
+/**
+ * Reads a single match.
+ *
+ * @param matchId the unique ID of the match.
+ * @return the match.
+ */
+suspend fun QueueDataApi.match(matchId: UUID): Match {
+ return getMatch(matchId).await().match.toApi()
+}
+
+/**
+ * Reads every match that has not finished yet.
+ *
+ * @param queueType only the matches of this queue type, null for all of them.
+ * @return the matching matches.
+ */
+suspend fun QueueDataApi.matches(queueType: String? = null): List {
+ val response = if (queueType == null) listMatches() else listMatches(queueType)
+ return response.await().matchesList.map { it.toApi() }
+}
+
+/**
+ * Reads the configuration of a queue type.
+ *
+ * There is no API type for the configuration, so this hands back the protobuf
+ * message the runtime answered with.
+ *
+ * @param name the name of the queue type.
+ * @return the queue type configuration.
+ */
+suspend fun QueueDataApi.queueType(name: String): build.buf.gen.mythicisland.queue.v2.QueueType {
+ return getQueueType(name).await().queueType
+}
+
+/**
+ * Reads every registered queue type.
+ *
+ * @return the configuration of all queue types.
+ */
+suspend fun QueueDataApi.queueTypes(): List {
+ return listQueueTypes().await().queueTypesList
+}
+
+/**
+ * Reads the live numbers of a queue type, for example how many players are
+ * currently searching.
+ *
+ * @param queueType the name of the queue type.
+ * @return the statistics of that queue type.
+ */
+suspend fun QueueDataApi.stats(queueType: String): build.buf.gen.mythicisland.queue.v2.QueueStats {
+ return getQueueStats(queueType).await().stats
+}
+
+/**
+ * Reads the live numbers of every queue type.
+ *
+ * @return the statistics of all queue types.
+ */
+suspend fun QueueDataApi.stats(): List {
+ return listQueueStats().await().statsList
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/TicketApiExtensions.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/TicketApiExtensions.kt
new file mode 100644
index 0000000..cc33240
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/TicketApiExtensions.kt
@@ -0,0 +1,63 @@
+package net.mythicisland.queue.api.extensions
+
+import kotlinx.coroutines.future.await
+import net.mythicisland.queue.api.builders.TicketBuilder
+import net.mythicisland.queue.api.ticket.Ticket
+import net.mythicisland.queue.api.ticket.TicketApi
+import java.util.UUID
+
+/**
+ * Puts a player or party into matchmaking.
+ *
+ * Suspends until the runtime accepted the ticket and answers with the created
+ * [Ticket] instead of the raw protobuf response.
+ *
+ * ```
+ * val ticket = api.ticket().create {
+ * party(leader, member)
+ * queues("battle", "skywars")
+ * }
+ * ```
+ *
+ * @param block describes the ticket.
+ * @return the created ticket.
+ * @throws IllegalArgumentException if no player or no queue type was added.
+ */
+suspend fun TicketApi.create(block: TicketBuilder.() -> Unit): Ticket {
+ val builder = TicketBuilder().apply(block)
+ builder.validate()
+
+ return createTicket(builder.playerIds(), builder.queueTypes()).await().ticket.toApi()
+}
+
+/**
+ * Puts a single player into matchmaking.
+ *
+ * @param playerId the UUID of the player.
+ * @param queueTypes the queue types to search in.
+ * @return the created ticket.
+ */
+suspend fun TicketApi.create(playerId: UUID, vararg queueTypes: String): Ticket {
+ return create {
+ player(playerId)
+ queues(*queueTypes)
+ }
+}
+
+/**
+ * Removes a ticket from matchmaking.
+ *
+ * @param ticketId the unique ID of the ticket.
+ */
+suspend fun TicketApi.delete(ticketId: UUID) {
+ deleteTicket(ticketId).await()
+}
+
+/**
+ * Removes the ticket a player belongs to, including the rest of their party.
+ *
+ * @param playerId the UUID of the player.
+ */
+suspend fun TicketApi.deleteByPlayer(playerId: UUID) {
+ deleteTicketByPlayer(playerId).await()
+}
diff --git a/queue-api/src/main/kotlin/net/mythicisland/queue/api/scopes/EventScope.kt b/queue-api/src/main/kotlin/net/mythicisland/queue/api/scopes/EventScope.kt
new file mode 100644
index 0000000..ab69702
--- /dev/null
+++ b/queue-api/src/main/kotlin/net/mythicisland/queue/api/scopes/EventScope.kt
@@ -0,0 +1,110 @@
+package net.mythicisland.queue.api.scopes
+
+import net.mythicisland.queue.api.QueueDsl
+import net.mythicisland.queue.api.event.EventApi
+import net.mythicisland.queue.api.event.Subscription
+import net.mythicisland.queue.api.event.match.MatchCreatedEvent
+import net.mythicisland.queue.api.event.match.MatchStateChangedEvent
+import net.mythicisland.queue.api.event.match.MatchTransferredEvent
+import net.mythicisland.queue.api.event.ticket.TicketCreatedEvent
+import net.mythicisland.queue.api.event.ticket.TicketDeletedEvent
+import net.mythicisland.queue.api.event.ticket.TicketStateChangedEvent
+import net.mythicisland.queue.api.match.MatchState
+import net.mythicisland.queue.api.ticket.TicketState
+
+/**
+ * Collects event handlers and hands back a single [Subscription] for all of them.
+ *
+ * Registering one listener at a time means keeping one handle per listener
+ * around. In a plugin that usually ends in a forgotten unsubscribe on disable,
+ * so this scope bundles them.
+ *
+ * ```
+ * val subscription = api.events {
+ * onTicketStateChanged(to = TicketState.ASSIGNED) { event ->
+ * connect(event.ticket)
+ * }
+ * onMatchTransferred { event ->
+ * logger.info("${event.transferredPlayerIds.size} players moved")
+ * }
+ * }
+ *
+ * // on disable
+ * subscription.unsubscribe()
+ * ```
+ *
+ * @param events the event API to register on.
+ */
+@QueueDsl
+class EventScope internal constructor(
+ private val events: EventApi,
+) {
+
+ private val subscriptions = mutableListOf()
+
+ /**
+ * Called when a player or party entered matchmaking.
+ */
+ fun onTicketCreated(handler: (TicketCreatedEvent) -> Unit) {
+ subscriptions.add(events.ticket().onCreated(handler))
+ }
+
+ /**
+ * Called when a ticket moved to a new state.
+ *
+ * @param to only call the handler when the ticket reached this state, null for every change.
+ */
+ fun onTicketStateChanged(to: TicketState? = null, handler: (TicketStateChangedEvent) -> Unit) {
+ subscriptions.add(events.ticket().onStateChanged { event ->
+ if (to == null || event.ticket().state() == to) handler(event)
+ })
+ }
+
+ /**
+ * Called when a ticket left matchmaking, no matter for which reason.
+ */
+ fun onTicketDeleted(handler: (TicketDeletedEvent) -> Unit) {
+ subscriptions.add(events.ticket().onDeleted(handler))
+ }
+
+ /**
+ * Called when enough tickets were found to form a match.
+ */
+ fun onMatchCreated(handler: (MatchCreatedEvent) -> Unit) {
+ subscriptions.add(events.match().onCreated(handler))
+ }
+
+ /**
+ * Called when a match moved to a new state.
+ *
+ * @param to only call the handler when the match reached this state, null for every change.
+ */
+ fun onMatchStateChanged(to: MatchState? = null, handler: (MatchStateChangedEvent) -> Unit) {
+ subscriptions.add(events.match().onStateChanged { event ->
+ if (to == null || event.match().state() == to) handler(event)
+ })
+ }
+
+ /**
+ * Called after the players of a match were sent to their game server.
+ */
+ fun onMatchTransferred(handler: (MatchTransferredEvent) -> Unit) {
+ subscriptions.add(events.match().onTransferred(handler))
+ }
+
+ internal fun subscription(): Subscription = CompositeSubscription(subscriptions.toList())
+
+}
+
+/**
+ * Unsubscribes every listener that was registered in one [EventScope].
+ */
+private class CompositeSubscription(
+ private val subscriptions: List,
+) : Subscription {
+
+ override fun unsubscribe() {
+ subscriptions.forEach { it.unsubscribe() }
+ }
+
+}
diff --git a/queue-runtime/build.gradle.kts b/queue-runtime/build.gradle.kts
index 05a0d9d..92a2064 100644
--- a/queue-runtime/build.gradle.kts
+++ b/queue-runtime/build.gradle.kts
@@ -11,4 +11,12 @@ dependencies {
implementation(libs.clikt)
implementation(libs.jnats)
implementation(libs.moonrise.common)
+}
+
+tasks.named("distTar") {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+}
+
+tasks.named("distZip") {
+ duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
\ No newline at end of file
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/QueueRuntime.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/QueueRuntime.kt
index 6d3c814..eee18a5 100644
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/QueueRuntime.kt
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/QueueRuntime.kt
@@ -1,5 +1,6 @@
package net.mythicisland.queue.runtime
+import build.buf.gen.mythicisland.queue.v2.MatchState
import io.grpc.Server
import io.grpc.ServerBuilder
import kotlinx.coroutines.CoroutineScope
@@ -10,14 +11,17 @@ import kotlinx.coroutines.suspendCancellableCoroutine
import net.mythicisland.moonrise.common.Moonrise
import net.mythicisland.moonrise.common.auth.AuthInterceptor
import net.mythicisland.moonrise.common.auth.AuthSecret
-import net.mythicisland.queue.runtime.launcher.QueueStartCommand
import net.mythicisland.queue.runtime.event.EventPublisher
-import net.mythicisland.queue.runtime.reconciler.QueueReconciler
-import net.mythicisland.queue.runtime.repository.QueueRepository
+import net.mythicisland.queue.runtime.launcher.QueueStartCommand
+import net.mythicisland.queue.runtime.match.MatchReconciler
+import net.mythicisland.queue.runtime.match.Matchmaker
+import net.mythicisland.queue.runtime.repository.MatchRepository
import net.mythicisland.queue.runtime.repository.QueueTypeRepository
-import net.mythicisland.queue.runtime.server.ServerFinder
+import net.mythicisland.queue.runtime.server.ServerAllocator
import net.mythicisland.queue.runtime.service.QueueDataService
-import net.mythicisland.queue.runtime.service.QueueService
+import net.mythicisland.queue.runtime.service.TicketService
+import net.mythicisland.queue.runtime.ticket.TicketPool
+import net.mythicisland.queue.runtime.ticket.TicketStore
import org.apache.logging.log4j.LogManager
class QueueRuntime(
@@ -29,18 +33,24 @@ class QueueRuntime(
private val eventPublisher = EventPublisher(manager.connection())
private val queueTypeRepository = QueueTypeRepository(args.typesPath)
- private val queueRepository = QueueRepository(queueTypeRepository, eventPublisher)
+ private val matchRepository = MatchRepository()
+ private val ticketStore = TicketStore()
+ private val ticketPool = TicketPool(ticketStore)
suspend fun start() {
logger.info("Starting Queue Service...")
logger.info("Loading queue types...")
- queueTypeRepository.load()
+ val types = queueTypeRepository.load()
+ logger.info("Loaded {} queue types: {}", types.size, types.map { it.name })
val api = Moonrise.connectToController(args.networkId, args.networkSecret, args.controllerUrl, args.controllerNatsUrl)
- val finder = ServerFinder(api, queueTypeRepository)
- val reconciler = QueueReconciler(queueRepository, queueTypeRepository, api, finder, eventPublisher)
- queueRepository.setReconciler(reconciler)
+ val allocator = ServerAllocator(api)
+
+ val matchmaker = Matchmaker(ticketStore, ticketPool, matchRepository, queueTypeRepository, eventPublisher)
+ val reconciler = MatchReconciler(ticketStore, matchRepository, queueTypeRepository, allocator, api, eventPublisher)
+
+ matchmaker.start()
reconciler.start()
val server = createGrpcServer()
@@ -50,14 +60,13 @@ class QueueRuntime(
Runtime.getRuntime().addShutdownHook(Thread {
logger.info("Shutting down Queue...")
runBlocking {
- val queues = queueRepository.getAllQueues()
- if (queues.isNotEmpty()) {
- for (queue in queues) {
- queue.server?.let { server ->
- finder.freeServer(server)
- }
- }
- }
+ // Hand back the servers of matches that never started, they
+ // would stay ingame without anybody on them.
+ matchRepository.getAll()
+ .filter { it.state != MatchState.MATCH_STATE_COMPLETED }
+ .forEach { allocator.release(it) }
+
+ matchmaker.shutdown()
reconciler.shutdown()
queueTypeRepository.close()
manager.shutdown()
@@ -90,8 +99,8 @@ class QueueRuntime(
return ServerBuilder.forPort(args.grpcPort)
.intercept(AuthInterceptor(token))
- .addService(QueueService(queueRepository))
- .addService(QueueDataService(queueRepository, queueTypeRepository))
+ .addService(TicketService(ticketStore, matchRepository, queueTypeRepository, eventPublisher))
+ .addService(QueueDataService(ticketStore, ticketPool, matchRepository, queueTypeRepository))
.build()
}
-}
\ No newline at end of file
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/event/EventPublisher.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/event/EventPublisher.kt
index aabcf42..cafdab4 100644
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/event/EventPublisher.kt
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/event/EventPublisher.kt
@@ -1,140 +1,107 @@
package net.mythicisland.queue.runtime.event
-import build.buf.gen.mythicisland.queue.v1.*
+import build.buf.gen.mythicisland.queue.v2.*
import io.nats.client.Connection
import net.mythicisland.moonrise.common.nats.Publisher
-import net.mythicisland.queue.shared.event.Subjects
-import net.mythicisland.queue.shared.queue.Queue
+import net.mythicisland.queue.shared.match.Match
+import net.mythicisland.queue.shared.match.Ticket
+import net.mythicisland.queue.shared.nats.Subjects
import java.util.UUID
/**
- * Publishes queue lifecycle events to NATS.
+ * Publishes ticket and match events to NATS.
*/
class EventPublisher(
connection: Connection
) : Publisher(connection) {
/**
- * Publishes an [EnqueueEvent] when players join a queue.
+ * Publishes a [TicketCreatedEvent] when a player or party entered matchmaking.
*
- * @param queue The queue that players joined
- * @param playerIds The UUIDs of the players that were enqueued
+ * @param ticket the created ticket.
*/
- fun publishEnqueue(queue: Queue, playerIds: List) {
- val event = EnqueueEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .addAllPlayerIds(playerIds.map { it.toString() })
- .build()
+ fun publishTicketCreated(ticket: Ticket) {
+ val event = ticketCreatedEvent {
+ this.ticket = ticket.toDefinition()
+ }
- publish(Subjects.ENQUEUE, event)
+ publish(Subjects.TICKET_CREATED, event)
}
/**
- * Publishes a [DequeueEvent] when players leave a queue.
+ * Publishes a [TicketStateChangedEvent] when a ticket moved to a new state.
*
- * @param queue The queue that players left
- * @param playerIds The UUIDs of the players that were dequeued
+ * @param ticket the ticket in its new state.
+ * @param previousState the state the ticket was in before.
*/
- fun publishDequeue(queue: Queue, playerIds: List) {
- val event = DequeueEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .addAllPlayerIds(playerIds.map { it.toString() })
- .build()
+ fun publishTicketStateChanged(ticket: Ticket, previousState: TicketState) {
+ val event = ticketStateChangedEvent {
+ this.ticket = ticket.toDefinition()
+ this.previousState = previousState
+ }
- publish(Subjects.DEQUEUE, event)
+ publish(Subjects.TICKET_STATE_CHANGED, event)
}
/**
- * Publishes a [QueueCreatedEvent] when a new queue is created.
+ * Publishes a [TicketDeletedEvent] when a ticket left matchmaking.
*
- * @param queue The newly created queue
+ * @param ticket the deleted ticket.
+ * @param reason why the ticket was deleted.
*/
- fun publishQueueCreated(queue: Queue) {
- val event = QueueCreatedEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .build()
+ fun publishTicketDeleted(ticket: Ticket, reason: TicketDeleteReason) {
+ val event = ticketDeletedEvent {
+ this.ticket = ticket.toDefinition()
+ this.reason = reason
+ }
- publish(Subjects.QUEUE_CREATED, event)
+ publish(Subjects.TICKET_DELETED, event)
}
/**
- * Publishes a [QueueUpdatedEvent] when a queue's state changes.
+ * Publishes a [MatchCreatedEvent] when enough tickets were found for a match.
*
- * @param before The queue's protobuf snapshot before the change
- * @param after The queue's protobuf snapshot after the change
+ * @param match the created match.
+ * @param tickets the tickets forming the match.
*/
- fun publishQueueUpdated(
- before: build.buf.gen.mythicisland.queue.v1.Queue,
- after: build.buf.gen.mythicisland.queue.v1.Queue,
- ) {
- val event = QueueUpdatedEvent.newBuilder()
- .setBefore(before)
- .setAfter(after)
- .build()
+ fun publishMatchCreated(match: Match, tickets: List) {
+ val event = matchCreatedEvent {
+ this.match = match.toDefinition(tickets)
+ }
- publish(Subjects.QUEUE_UPDATED, event)
+ publish(Subjects.MATCH_CREATED, event)
}
/**
- * Publishes a [QueueStatusUpdatedEvent] when a queue transitions between statuses.
+ * Publishes a [MatchStateChangedEvent] when a match moved to a new state.
*
- * @param queue The queue whose status changed
- * @param oldStatus The previous status
- * @param newStatus The new status
+ * @param match the match in its new state.
+ * @param tickets the tickets forming the match.
+ * @param previousState the state the match was in before.
*/
- fun publishStatusUpdated(queue: Queue, oldStatus: QueueStatus, newStatus: QueueStatus) {
- val event = QueueStatusUpdatedEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .setOldStatus(oldStatus)
- .setNewStatus(newStatus)
- .build()
+ fun publishMatchStateChanged(match: Match, tickets: List, previousState: MatchState) {
+ val event = matchStateChangedEvent {
+ this.match = match.toDefinition(tickets)
+ this.previousState = previousState
+ }
- publish(Subjects.QUEUE_STATUS_UPDATED, event)
+ publish(Subjects.MATCH_STATE_CHANGED, event)
}
/**
- * Publishes a [QueueDeletedEvent] when a queue is removed.
+ * Publishes a [MatchTransferredEvent] after the players were sent to their server.
*
- * @param queue The queue that was deleted
+ * @param match the transferred match.
+ * @param tickets the tickets forming the match.
+ * @param transferredPlayerIds the players that actually made it onto the server.
*/
- fun publishQueueDeleted(queue: Queue) {
- val event = QueueDeletedEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .build()
+ fun publishMatchTransferred(match: Match, tickets: List, transferredPlayerIds: List) {
+ val event = matchTransferredEvent {
+ this.match = match.toDefinition(tickets)
+ this.transferredPlayerIds.addAll(transferredPlayerIds.map(UUID::toString))
+ }
- publish(Subjects.QUEUE_DELETED, event)
+ publish(Subjects.MATCH_TRANSFERRED, event)
}
- /**
- * Publishes a [QueueServerAssignedEvent] when a server is reserved for a queue.
- *
- * @param queue The queue that received a server
- * @param serverId The ID of the assigned server
- */
- fun publishServerAssigned(queue: Queue, serverId: String) {
- val event = QueueServerAssignedEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .setServerId(serverId)
- .build()
-
- publish(Subjects.QUEUE_SERVER_ASSIGNED, event)
- }
-
- /**
- * Publishes a [QueueTransferEvent] when players are teleported to a game server.
- *
- * @param queue The queue whose players were transferred
- * @param serverId The ID of the target server
- * @param playerIds The UUIDs of the transferred players
- */
- fun publishTransfer(queue: Queue, serverId: String, playerIds: List) {
- val event = QueueTransferEvent.newBuilder()
- .setQueue(queue.toDefinition())
- .setServerId(serverId)
- .addAllPlayerIds(playerIds.map { it.toString() })
- .build()
-
- publish(Subjects.QUEUE_TRANSFER, event)
- }
-
-}
\ No newline at end of file
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/MatchReconciler.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/MatchReconciler.kt
new file mode 100644
index 0000000..aa09518
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/MatchReconciler.kt
@@ -0,0 +1,242 @@
+package net.mythicisland.queue.runtime.match
+
+import app.simplecloud.api.CloudApi
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import build.buf.gen.mythicisland.queue.v2.TicketDeleteReason
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import kotlinx.coroutines.*
+import kotlinx.coroutines.future.await
+import net.mythicisland.queue.runtime.event.EventPublisher
+import net.mythicisland.queue.runtime.repository.MatchRepository
+import net.mythicisland.queue.runtime.repository.QueueTypeRepository
+import net.mythicisland.queue.runtime.server.ServerAllocator
+import net.mythicisland.queue.runtime.ticket.TicketStore
+import net.mythicisland.queue.shared.match.Assignment
+import net.mythicisland.queue.shared.match.Match
+import org.apache.logging.log4j.LogManager
+import java.time.Duration
+import java.time.Instant
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Drives a match from the moment it was formed until its players are on the game server.
+ */
+class MatchReconciler(
+ private val tickets: TicketStore,
+ private val matches: MatchRepository,
+ private val types: QueueTypeRepository,
+ private val allocator: ServerAllocator,
+ private val api: CloudApi,
+ private val publisher: EventPublisher,
+) {
+
+ private val logger = LogManager.getLogger(MatchReconciler::class.java)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ /**
+ * Starts the reconciliation loop.
+ */
+ fun start() {
+ logger.info("Starting up match reconciler")
+ scope.launch {
+ while (isActive) {
+ delay(500.milliseconds)
+ tick()
+ }
+ }
+ }
+
+ /**
+ * Stops the reconciliation loop.
+ */
+ fun shutdown() {
+ logger.info("Shutting down match reconciler...")
+ scope.cancel()
+ }
+
+ /**
+ * Reconciles every active match once.
+ */
+ suspend fun tick() {
+ matches.getAll().forEach { match ->
+ try {
+ reconcile(match)
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logger.error("Failed to reconcile match {}", match.id, e)
+ }
+ }
+ }
+
+ private suspend fun reconcile(match: Match) {
+ // Everyone left while the match was still being set up.
+ if (match.ticketIds.isEmpty() && match.state != MatchState.MATCH_STATE_COMPLETED) {
+ fail(match, "every ticket left the match")
+ return
+ }
+
+ when (match.state) {
+ MatchState.MATCH_STATE_ALLOCATING -> handleAllocating(match)
+ MatchState.MATCH_STATE_COUNTDOWN -> handleCountdown(match)
+ MatchState.MATCH_STATE_TRANSFERRING -> handleTransferring(match)
+ MatchState.MATCH_STATE_COMPLETED, MatchState.MATCH_STATE_FAILED -> cleanUp(match)
+ else -> logger.warn("Match {} has unhandled state {}, skipping", match.id, match.state)
+ }
+ }
+
+ /**
+ * Looks for a server. Once one is reserved the countdown starts and the
+ * tickets learn where they are going.
+ */
+ private suspend fun handleAllocating(match: Match) {
+ val type = types.find(match.queueType)
+ if (type == null) {
+ fail(match, "queue type '${match.queueType}' does not exist anymore")
+ return
+ }
+
+ val waited = Duration.between(match.createdAt, Instant.now()).seconds
+ if (waited >= 60L) {
+ fail(match, "no server became available within ${60L}s")
+ return
+ }
+
+ val assignment = allocator.allocate(match, type) ?: return
+
+ val countdownEndsAt = Instant.now().plusSeconds(type.countdownDurationSeconds)
+ logger.info(
+ "Match {} starts in {}s on server {}",
+ match.id, type.countdownDurationSeconds, assignment.serverName,
+ )
+
+ val ready = match.copy(assignment = assignment, countdownEndsAt = countdownEndsAt)
+ assignTickets(ready, assignment, countdownEndsAt)
+ transition(ready, MatchState.MATCH_STATE_COUNTDOWN)
+ }
+
+ /**
+ * Waits for the countdown to run out.
+ */
+ private suspend fun handleCountdown(match: Match) {
+ val countdownEndsAt = match.countdownEndsAt
+ if (countdownEndsAt == null) {
+ fail(match, "countdown state without a countdown")
+ return
+ }
+
+ if (Instant.now().isBefore(countdownEndsAt)) return
+
+ logger.info("Match {} countdown finished", match.id)
+ transition(match, MatchState.MATCH_STATE_TRANSFERRING)
+ }
+
+ /**
+ * Sends every player to the allocated server.
+ */
+ private suspend fun handleTransferring(match: Match) {
+ val assignment = match.assignment
+ if (assignment == null) {
+ fail(match, "transfer state without a server")
+ return
+ }
+
+ val matchTickets = tickets.getAll(match.ticketIds)
+ val playerIds = matchTickets.flatMap { it.playerIds }
+ logger.info("Match {} transferring {} players to {}", match.id, playerIds.size, assignment.serverName)
+
+ val transferred = coroutineScope {
+ playerIds.map { async { transfer(it, assignment) } }.awaitAll()
+ }.filterNotNull()
+
+ logger.info("Match {} transferred {}/{} players", match.id, transferred.size, playerIds.size)
+ publisher.publishMatchTransferred(match, matchTickets, transferred)
+ transition(match, MatchState.MATCH_STATE_COMPLETED)
+ }
+
+ /**
+ * Connects a single player.
+ *
+ * @return the player id if the transfer worked, null otherwise.
+ */
+ private suspend fun transfer(playerId: UUID, assignment: Assignment): UUID? {
+ try {
+ val player = api.player().get(playerId).await()
+ if (player == null) {
+ logger.warn("Player {} is offline, skipping transfer to {}", playerId, assignment.serverName)
+ return null
+ }
+
+ player.connect(assignment.serverName).await()
+ return playerId
+ } catch (e: Exception) {
+ logger.error("Failed to transfer player {} to server {}", playerId, assignment.serverName, e)
+ return null
+ }
+ }
+
+ /**
+ * Removes a finished match.
+ *
+ * Completed matches take their tickets with them, failed ones put the
+ * players back into matchmaking so nobody gets stuck.
+ */
+ private suspend fun cleanUp(match: Match) {
+ val matchTickets = tickets.getAll(match.ticketIds)
+
+ if (match.state == MatchState.MATCH_STATE_COMPLETED) {
+ matchTickets.forEach { ticket ->
+ tickets.remove(ticket.id)
+ publisher.publishTicketDeleted(ticket, TicketDeleteReason.TICKET_DELETE_REASON_TRANSFERRED)
+ }
+ } else {
+ allocator.release(match)
+ matchTickets.forEach { ticket ->
+ val searching = tickets.update(ticket.asSearching()) ?: return@forEach
+ logger.info("Ticket {} is searching again after match {} failed", ticket.id, match.id)
+ publisher.publishTicketStateChanged(searching, ticket.state)
+ }
+ }
+
+ matches.remove(match.id)
+ logger.info("Match {} cleaned up ({})", match.id, match.state)
+ }
+
+ /**
+ * Mirrors the server and the countdown onto the tickets of a match, so a
+ * consumer never has to load the match to show them.
+ */
+ private suspend fun assignTickets(match: Match, assignment: Assignment, countdownEndsAt: Instant) {
+ tickets.getAll(match.ticketIds).forEach { ticket ->
+ val assigned = ticket.copy(
+ state = TicketState.TICKET_STATE_ASSIGNED,
+ assignment = assignment,
+ countdownEndsAt = countdownEndsAt,
+ )
+
+ tickets.update(assigned) ?: return@forEach
+ publisher.publishTicketStateChanged(assigned, ticket.state)
+ }
+ }
+
+ /**
+ * Moves a match into a new state and tells everyone about it.
+ */
+ private suspend fun transition(match: Match, state: MatchState) {
+ val updated = matches.update(match.copy(state = state))
+ if (updated == null) {
+ logger.debug("Match {} vanished before it could move to {}", match.id, state)
+ return
+ }
+
+ logger.info("Match {} state: {} -> {}", match.id, match.state, state)
+ publisher.publishMatchStateChanged(updated, tickets.getAll(updated.ticketIds), match.state)
+ }
+
+ private suspend fun fail(match: Match, reason: String) {
+ logger.warn("Match {} failed: {}", match.id, reason)
+ transition(match, MatchState.MATCH_STATE_FAILED)
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt
new file mode 100644
index 0000000..beb962d
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt
@@ -0,0 +1,135 @@
+package net.mythicisland.queue.runtime.match
+
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import kotlinx.coroutines.*
+import net.mythicisland.queue.runtime.event.EventPublisher
+import net.mythicisland.queue.runtime.repository.MatchRepository
+import net.mythicisland.queue.runtime.repository.QueueTypeRepository
+import net.mythicisland.queue.runtime.ticket.TicketPool
+import net.mythicisland.queue.runtime.ticket.TicketStore
+import net.mythicisland.queue.shared.match.Match
+import net.mythicisland.queue.shared.match.Ticket
+import net.mythicisland.queue.shared.queue.QueueType
+import org.apache.logging.log4j.LogManager
+import java.time.Duration
+import java.time.Instant
+import java.util.UUID
+import kotlin.time.Duration.Companion.milliseconds
+
+/**
+ * Forms matches out of the tickets that are waiting.
+ */
+class Matchmaker(
+ private val tickets: TicketStore,
+ private val pool: TicketPool,
+ private val matches: MatchRepository,
+ private val types: QueueTypeRepository,
+ private val publisher: EventPublisher,
+) {
+
+ private val logger = LogManager.getLogger(Matchmaker::class.java)
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
+ /**
+ * Starts the matchmaking loop.
+ */
+ fun start() {
+ logger.info("Starting up matchmaker")
+ scope.launch {
+ while (isActive) {
+ delay(500.milliseconds)
+ try {
+ tick()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ logger.error("Matchmaking pass failed", e)
+ }
+ }
+ }
+ }
+
+ /**
+ * Stops the matchmaking loop.
+ */
+ fun shutdown() {
+ logger.info("Shutting down matchmaker...")
+ scope.cancel()
+ }
+
+ /**
+ * Runs one matchmaking pass over every queue type.
+ */
+ suspend fun tick() {
+ types.getAll().forEach { type ->
+ // A queue type can fill more than one match per pass when a lot of
+ // players are waiting, so keep going until nothing fits anymore.
+ while (createMatch(type) != null) {
+ continue
+ }
+ }
+ }
+
+ /**
+ * Tries to form a single match for a queue type.
+ *
+ * @return the created match, or null if the queue type cannot start one yet.
+ */
+ private suspend fun createMatch(type: QueueType): Match? {
+ val candidates = pool.searching(type.name)
+ val selected = select(candidates, type, Instant.now()) ?: return null
+
+ val match = Match(
+ id = UUID.randomUUID(),
+ queueType = type.name,
+ ticketIds = selected.map { it.id },
+ state = MatchState.MATCH_STATE_ALLOCATING,
+ createdAt = Instant.now(),
+ )
+
+ val matched = tickets.matched(match.ticketIds, match.id)
+ if (matched == null) {
+ logger.debug("Dropped match for '{}', one of its tickets is no longer searching", type.name)
+ return null
+ }
+
+ matches.add(match)
+ logger.info("Created match {} for '{}' with {} tickets / {} players", match.id, type.name, matched.size, matched.sumOf { it.playerIds.size },)
+ publisher.publishMatchCreated(match, matched)
+ matched.forEach { publisher.publishTicketStateChanged(it, TicketState.TICKET_STATE_SEARCHING) }
+ return match
+ }
+
+ /**
+ * Picks the tickets for the next match of a queue type.
+ *
+ * A match is formed when it is full, or when it holds at least the minimum
+ * amount of players and the oldest ticket waited long enough. The waiting
+ * time comes from the ticket itself, so there is no countdown to keep track
+ * of anywhere.
+ *
+ * @param candidates the searching tickets of the queue type, oldest first.
+ * @param type the queue type to form a match for.
+ * @param now the current time.
+ * @return the picked tickets, or null if no match can be formed yet.
+ */
+ fun select(candidates: List, type: QueueType, now: Instant): List? {
+ if (candidates.isEmpty()) return null
+
+ val selected = candidates.fold(emptyList()) { picked, ticket ->
+ val players = picked.sumOf { it.playerIds.size }
+ if (players + ticket.playerIds.size <= type.maxPlayers) picked + ticket else picked
+ }
+
+ val players = selected.sumOf { it.playerIds.size }
+ if (players < type.minPlayers) return null
+ if (players >= type.maxPlayers) return selected
+
+ val waited = Duration.between(candidates.first().createdAt, now).seconds
+ if (waited < type.waitingDurationSeconds) return null
+
+ return selected
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/reconciler/QueueReconciler.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/reconciler/QueueReconciler.kt
deleted file mode 100644
index 494940e..0000000
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/reconciler/QueueReconciler.kt
+++ /dev/null
@@ -1,416 +0,0 @@
-package net.mythicisland.queue.runtime.reconciler
-
-import app.simplecloud.api.CloudApi
-import app.simplecloud.api.group.GroupServerType
-import app.simplecloud.api.server.Server
-import app.simplecloud.api.server.ServerState
-import build.buf.gen.mythicisland.queue.v1.QueueStatus
-import kotlinx.coroutines.*
-import kotlinx.coroutines.future.await
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import net.mythicisland.queue.shared.queue.Queue
-import net.mythicisland.queue.runtime.event.EventPublisher
-import net.mythicisland.queue.runtime.repository.QueueRepository
-import net.mythicisland.queue.runtime.repository.QueueTypeRepository
-import net.mythicisland.queue.runtime.server.ServerFinder
-import org.apache.logging.log4j.LogManager
-import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
-import kotlin.time.Duration.Companion.milliseconds
-
-/**
- * Reconciles queue statuses based on queue updates or server registrations.
- */
-class QueueReconciler(
- private val queues: QueueRepository,
- private val types: QueueTypeRepository,
- private val api: CloudApi,
- private val finder: ServerFinder,
- private val publisher: EventPublisher,
-) {
-
- private val logger = LogManager.getLogger(QueueReconciler::class.java)
- private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
-
- private val mutex = ConcurrentHashMap()
-
- /**
- * Starts the Reconciler.
- */
- fun start() {
- logger.info("Starting up queue reconciler...")
- startPeriodicReconciliation()
- startCountdownReconciliation()
- startWaitingCountdownReconciliation()
- startServerRetryReconciliation()
- registerServerListener()
- }
-
- /**
- * Shutdowns the reconciler.
- */
- fun shutdown() {
- logger.info("Shutting down queue reconciler...")
- scope.cancel()
- }
-
- /**
- * Reconciles a queue's status based on its current state.
- *
- * @param queueId The ID of the queue to reconcile
- */
- suspend fun reconcile(queueId: UUID) {
- mutex.getOrPut(queueId) { Mutex() }.withLock {
- var previousStatus: QueueStatus
-
- do {
- val queue = queues.getQueue(queueId) ?: return
-
- if (queue.players.isEmpty() && queue.status != QueueStatus.FINISHED) {
- logger.info("Queue {} has no players remaining, finishing", queue.id)
- updateStatus(queue, QueueStatus.FINISHED)
- }
-
- previousStatus = queue.status
-
- when (queue.status) {
- QueueStatus.NOT_ENOUGH_PLAYERS -> handleNotEnoughPlayers(queue)
- QueueStatus.WAITING_COUNTDOWN -> handleWaitingForPlayersCountdown(queue)
- QueueStatus.SEARCHING_SERVER -> handleSearchingServer(queue)
- QueueStatus.WAITING_FOR_SERVER -> handleWaitingForServer(queue)
- QueueStatus.SERVER_READY -> handleServerReady(queue)
- QueueStatus.COUNTDOWN -> handleCountdown(queue)
- QueueStatus.TELEPORTING -> handleTeleporting(queue)
- QueueStatus.FINISHED -> handleFinished(queue)
- else -> {
- logger.warn("Queue {} has unhandled status {}, skipping reconciliation", queueId, queue.status)
- return
- }
- }
-
- queues.updateQueue(queue)
- } while (queue.status != previousStatus)
- }
- }
-
- /**
- * Updates the status of a queue and logs the transition.
- *
- * @param queue The queue to update
- * @param newStatus The new status
- */
- private fun updateStatus(queue: Queue, newStatus: QueueStatus) {
- val oldStatus = queue.status
- logger.info("Queue {} status: {} -> {}", queue.id, oldStatus, newStatus)
- queue.status = newStatus
- publisher.publishStatusUpdated(queue, oldStatus, newStatus)
- }
-
- /**
- * Handles a queue with NOT_ENOUGH_PLAYERS status.
- * Transitions to WAITING_COUNTDOWN when the minimum player capacity is reached.
- *
- * @param queue The Queue to handle
- */
- private fun handleNotEnoughPlayers(queue: Queue): Queue {
- val type = types.find(queue.type) ?: return queue
-
- if (queue.players.size >= type.minCapacity) {
- updateStatus(queue, QueueStatus.WAITING_COUNTDOWN)
- queue.waitingCountdownEndsAt = System.currentTimeMillis() + type.waitingCountdownSeconds * 1000
- logger.info("Queue {} waiting countdown started: {}s", queue.id, type.waitingCountdownSeconds)
- }
-
- return queue
- }
-
- /**
- * Handles a queue with WAITING_COUNTDOWN status.
- * Waits for more players while counting down. Transitions to SEARCHING_SERVER
- * when the countdown expires or the queue is full. Falls back to NOT_ENOUGH_PLAYERS
- * if players drop below minimum.
- *
- * @param queue The Queue to handle the waiting countdown
- */
- private fun handleWaitingForPlayersCountdown(queue: Queue): Queue {
- val type = types.find(queue.type) ?: return queue
-
- if (queue.players.size < type.minCapacity) {
- logger.info("Queue {} players dropped below minimum ({}/{}), resetting countdown", queue.id, queue.players.size, type.minCapacity)
- updateStatus(queue, QueueStatus.NOT_ENOUGH_PLAYERS)
- queue.waitingCountdownEndsAt = null
- return queue
- }
-
- if (queue.waitingCountdownRemaining <= 0 || queue.players.size >= type.maxCapacity) {
- val reason = if (queue.players.size >= type.maxCapacity) "queue full" else "countdown expired"
- logger.info("Queue {} waiting countdown finished ({}), searching server", queue.id, reason)
- updateStatus(queue, QueueStatus.SEARCHING_SERVER)
- queue.waitingCountdownEndsAt = null
- }
-
- return queue
- }
-
- /**
- * Handles a queue with SEARCHING_SERVER status.
- * Attempts to reserve an existing server or request a new one.
- * Transitions to SERVER_READY if a server is immediately available,
- * or WAITING_FOR_SERVER if a new server was requested.
- *
- * @param queue The Queue to handle server searching
- */
- private suspend fun handleSearchingServer(queue: Queue): Queue {
- logger.info("Queue {} searching for server (type: {})", queue.id, queue.type)
-
- try {
- val server = finder.reserveOrRequestServer(queue)
-
- if (server != null) {
- logger.info("Queue {} assigned server {}", queue.id, server.serverId)
- publisher.publishServerAssigned(queue, server.serverId)
- updateStatus(queue, QueueStatus.SERVER_READY)
- } else {
- logger.info("Queue {} requested new server", queue.id)
- updateStatus(queue, QueueStatus.WAITING_FOR_SERVER)
- }
- } catch (e: Exception) {
- logger.error("Queue {} failed to find/reserve server", queue.id, e)
- updateStatus(queue, QueueStatus.WAITING_FOR_SERVER)
- }
-
- return queue
- }
-
- /**
- * Handles a queue with WAITING_FOR_SERVER status.
- * Checks if a server has become available for the queue, either through
- * direct assignment or by searching for one.
- *
- * @param queue The Queue to handle waiting for a server
- */
- private suspend fun handleWaitingForServer(queue: Queue): Queue {
- if (queue.server != null) {
- logger.info("Queue {} server already assigned ({}), marking ready", queue.id, queue.server?.serverId)
- updateStatus(queue, QueueStatus.SERVER_READY)
- return queue
- }
-
- val server = finder.findServer(queue)
- if (server != null) {
- logger.info("Queue {} found available server {}", queue.id, server.serverId)
- queue.server = server
- publisher.publishServerAssigned(queue, server.serverId)
- updateStatus(queue, QueueStatus.SERVER_READY)
- }
-
- return queue
- }
-
- /**
- * Handles a queue with SERVER_READY status.
- * Initializes the game countdown and transitions to COUNTDOWN.
- *
- * @param queue The Queue to handle server ready
- */
- private fun handleServerReady(queue: Queue): Queue {
- val type = types.find(queue.type) ?: return queue
-
- updateStatus(queue, QueueStatus.COUNTDOWN)
- queue.countdownEndsAt = System.currentTimeMillis() + type.countdownSeconds * 1000
- logger.info("Queue {} game countdown started: {}s on server {}", queue.id, type.countdownSeconds, queue.server?.serverId)
-
- return queue
- }
-
- /**
- * Handles a queue with COUNTDOWN status.
- * Transitions to TELEPORTING when the countdown expires.
- *
- * @param queue The Queue to handle the countdown
- */
- private fun handleCountdown(queue: Queue): Queue {
- if (queue.countdownRemaining <= 0) {
- logger.info("Queue {} game countdown finished, teleporting players", queue.id)
- updateStatus(queue, QueueStatus.TELEPORTING)
- queue.countdownEndsAt = null
- }
-
- return queue
- }
-
- /**
- * Handles a queue with TELEPORTING status.
- * Transfers all players to the assigned server and transitions to FINISHED.
- *
- * @param queue The Queue to handle player teleporting
- */
- private suspend fun handleTeleporting(queue: Queue): Queue {
- val server = queue.server ?: run {
- logger.error("Queue {} in TELEPORTING but no server assigned, searching again", queue.id)
- updateStatus(queue, QueueStatus.SEARCHING_SERVER)
- return queue
- }
-
- val serverName = "${server.group.name}-${server.numericalId}"
- logger.info("Queue {} teleporting {} players to server {} ({})", queue.id, queue.players.size, serverName, server.serverId)
-
- val transferredPlayers = mutableListOf()
-
- queue.players.toList().forEach { playerId ->
- try {
- val player = api.player().get(playerId).await()
- if (player == null) {
- logger.warn("Queue {} player {} is offline, skipping teleport", queue.id, playerId)
- return@forEach
- }
-
- val result = player.connect(serverName).await()
- logger.info("Queue {} player {} connect result: {}", queue.id, playerId, result)
- transferredPlayers.add(playerId)
- } catch (e: Exception) {
- logger.error("Failed to teleport player {} to server {}", playerId, server.serverId, e)
- }
- }
-
- publisher.publishTransfer(queue, server.serverId, transferredPlayers)
- updateStatus(queue, QueueStatus.FINISHED)
- return queue
- }
-
- /**
- * Handles a queue with FINISHED status.
- * Frees the assigned server and deletes the queue.
- *
- * @param queue The Queue to finish
- */
- private suspend fun handleFinished(queue: Queue): Queue {
- logger.info("Queue {} finished, cleaning up (players={}, server={})", queue.id, queue.players.size, queue.server?.serverId)
-
- queue.server?.let { server ->
- logger.info("Queue {} freeing server {}", queue.id, server.serverId)
- finder.freeServer(server)
- }
-
- queues.deleteQueue(queue.id)
- return queue
- }
-
- /**
- * Reconciles all queues in the repository.
- */
- private suspend fun reconcileAll() {
- queues.getAllQueues().forEach { queue ->
- reconcile(queue.id)
- }
- }
-
- /**
- * Handles server registration events.
- * Checks if any WAITING_FOR_SERVER queues can use the new server.
- *
- * @param server The newly registered server
- */
- private suspend fun handleServerRegistration(server: Server) {
- logger.info("Server {} became available, checking waiting queues", server.serverId)
- val waitingQueues = queues.getAllQueues().filter { it.status == QueueStatus.WAITING_FOR_SERVER }
-
- if (waitingQueues.isEmpty()) {
- logger.debug("No queues waiting for a server, freeing server {}", server.serverId)
- finder.freeServer(server)
- return
- }
-
- for (queue in waitingQueues) {
- if (finder.reserveServer(queue, server)) {
- logger.info("Server {} assigned to waiting queue {} (type={})", server.serverId, queue.id, queue.type)
- reconcile(queue.id)
- return
- }
- }
-
- logger.debug("Server {} does not match any waiting queue, freeing", server.serverId)
- finder.freeServer(server)
- }
-
- /**
- * Registers a listener for server state changes.
- * When a server becomes AVAILABLE, checks if waiting queues can use it.
- */
- fun registerServerListener() {
- api.event().server().onStateChanged { event ->
- val server = event.server ?: return@onStateChanged
- if (server.serverBase?.type != GroupServerType.SERVER) return@onStateChanged
- if (event.newState == ServerState.AVAILABLE && event.oldState != ServerState.AVAILABLE) {
- scope.launch {
- handleServerRegistration(event.server)
- }
- }
- }
- }
-
- /**
- * Clears all reconciliation state for a queue.
- *
- * @param id The queue ID to clear state for
- */
- fun clear(id: UUID) {
- mutex.remove(id)
- }
-
- /**
- * Periodically ticks queues in WAITING_COUNTDOWN status every 500ms.
- */
- private fun startWaitingCountdownReconciliation() {
- scope.launch {
- while (isActive) {
- delay(500.milliseconds)
- queues.getAllQueues()
- .filter { it.status == QueueStatus.WAITING_COUNTDOWN }
- .forEach { reconcile(it.id) }
- }
- }
- }
-
- /**
- * Periodically ticks queues in COUNTDOWN status every 500ms.
- */
- private fun startCountdownReconciliation() {
- scope.launch {
- while (isActive) {
- delay(500.milliseconds)
- queues.getAllQueues()
- .filter { it.status == QueueStatus.COUNTDOWN }
- .forEach { reconcile(it.id) }
- }
- }
- }
-
- /**
- * Periodically retries server reservation for queues stuck in WAITING_FOR_SERVER every 5 seconds.
- */
- private fun startServerRetryReconciliation() {
- scope.launch {
- while (isActive) {
- delay(5000.milliseconds)
- queues.getAllQueues()
- .filter { it.status == QueueStatus.WAITING_FOR_SERVER }
- .forEach { reconcile(it.id) }
- }
- }
- }
-
- /**
- * Periodically reconciles all queues every 30 seconds.
- */
- private fun startPeriodicReconciliation() {
- scope.launch {
- while (isActive) {
- reconcileAll()
- delay(30000.milliseconds)
- }
- }
- }
-
-}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/MatchRepository.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/MatchRepository.kt
new file mode 100644
index 0000000..7c59817
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/MatchRepository.kt
@@ -0,0 +1,118 @@
+package net.mythicisland.queue.runtime.repository
+
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import net.mythicisland.queue.shared.match.Match
+import org.apache.logging.log4j.LogManager
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Holds every match that has not finished yet.
+ *
+ * Matches only live for a few seconds, from being formed until their players
+ * are on the game server.
+ */
+class MatchRepository {
+
+ private val logger = LogManager.getLogger(MatchRepository::class.java)
+
+ private val mutex = Mutex()
+ private val matches = ConcurrentHashMap()
+ private val ticketToMatch = ConcurrentHashMap()
+
+ /**
+ * Gets a match by its id.
+ */
+ fun get(id: UUID): Match? {
+ return matches[id]
+ }
+
+ /**
+ * Gets the match a ticket was put into.
+ */
+ fun getByTicket(ticketId: UUID): Match? {
+ return ticketToMatch[ticketId]?.let { matches[it] }
+ }
+
+ /**
+ * Gets every active match.
+ */
+ fun getAll(): List {
+ return matches.values.toList()
+ }
+
+ /**
+ * Gets every active match of a queue type.
+ */
+ fun getAllByType(queueType: String): List {
+ return matches.values.filter { it.queueType == queueType }
+ }
+
+ /**
+ * Gets every active match in a state.
+ */
+ fun getAllByState(state: MatchState): List {
+ return matches.values.filter { it.state == state }
+ }
+
+ /**
+ * Adds a newly formed match.
+ */
+ suspend fun add(match: Match) {
+ mutex.withLock {
+ matches[match.id] = match
+ match.ticketIds.forEach { ticketToMatch[it] = match.id }
+ }
+ }
+
+ /**
+ * Replaces a match with an updated copy.
+ *
+ * @return the stored match, or null if it was removed in the meantime.
+ */
+ suspend fun update(match: Match): Match? {
+ mutex.withLock {
+ if (!matches.containsKey(match.id)) {
+ logger.debug("Skipped update of match {}, it is no longer stored", match.id)
+ return null
+ }
+
+ matches[match.id] = match
+ return match
+ }
+ }
+
+ /**
+ * Drops a ticket out of its match, for example because the player left
+ * while the match was still waiting for a server.
+ *
+ * @return the match the ticket was dropped from, or null if it was in none.
+ */
+ suspend fun removeTicket(ticketId: UUID): Match? {
+ mutex.withLock {
+ val matchId = ticketToMatch.remove(ticketId) ?: return null
+ val match = matches[matchId] ?: return null
+
+ val updated = match.copy(ticketIds = match.ticketIds - ticketId)
+ matches[matchId] = updated
+ logger.info("Ticket {} dropped out of match {}, {} tickets left", ticketId, matchId, updated.ticketIds.size)
+ return updated
+ }
+ }
+
+ /**
+ * Removes a finished match.
+ *
+ * @return the removed match, or null if it was not stored.
+ */
+ suspend fun remove(id: UUID): Match? {
+ mutex.withLock {
+ val match = matches.remove(id) ?: return null
+ match.ticketIds.forEach { ticketToMatch.remove(it, id) }
+ return match
+ }
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueRepository.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueRepository.kt
deleted file mode 100644
index c82c6dd..0000000
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueRepository.kt
+++ /dev/null
@@ -1,229 +0,0 @@
-package net.mythicisland.queue.runtime.repository
-
-import build.buf.gen.mythicisland.queue.v1.QueueStatus
-import net.mythicisland.queue.shared.queue.Queue
-import net.mythicisland.queue.shared.queue.QueueType
-import net.mythicisland.queue.runtime.event.EventPublisher
-import net.mythicisland.queue.runtime.reconciler.QueueReconciler
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import org.apache.logging.log4j.LogManager
-import java.util.UUID
-import java.util.concurrent.ConcurrentHashMap
-
-/**
- * Repository for managing active queues.
- *
- * @param types the repository for queue types.
- * @param publisher the publisher to publish events to NATS.
- */
-class QueueRepository(
- private val types: QueueTypeRepository,
- private val publisher: EventPublisher
-) {
-
- private val logger = LogManager.getLogger(QueueRepository::class.java)
-
- private val playersToQueue = ConcurrentHashMap()
- private val queues = ConcurrentHashMap()
- private val snapshots = ConcurrentHashMap()
- private val typeMutex = ConcurrentHashMap()
-
- private var reconciler: QueueReconciler? = null
-
- /**
- * Sets the [QueueReconciler] to reconcile queues.
- *
- * @param reconciler the reconciler to set.
- */
- fun setReconciler(reconciler: QueueReconciler) {
- this.reconciler = reconciler
- }
-
- /**
- * Gets a queue by its id.
- *
- * @param queueId the UUID of the queue.
- * @return the queue, or null if no queue with that id exists.
- */
- fun getQueue(queueId: UUID): Queue? {
- return queues[queueId]
- }
-
- /**
- * Gets the queue a player is in.
- *
- * @param id the UUID of the player.
- * @return the queue the player is in, or null if the player is not queued.
- */
- fun getQueueByPlayer(id: UUID): Queue? {
- return playersToQueue[id]?.let { queues[it] }
- }
-
- /**
- * Gets all active queues.
- *
- * @return all active queues.
- */
- fun getAllQueues(): List {
- return queues.values.toList()
- }
-
- /**
- * Gets all active queues of a type.
- *
- * @param type the name of the queue type.
- * @return all active queues of that type.
- */
- fun getAllQueuesByType(type: String): List {
- return queues.values.filter { it.type == type }
- }
-
- /**
- * Finds a queue of a type that is still waiting and has room for more players.
- *
- * @param queueType the name of the queue type.
- * @param playerAmount the amount of players that want to join.
- * @return a matching queue, or null if none has enough room.
- */
- private fun findQueue(queueType: String, playerAmount: Int): Queue? {
- return queues.values.firstOrNull {
- it.type == queueType
- && (it.status == QueueStatus.NOT_ENOUGH_PLAYERS || it.status == QueueStatus.WAITING_COUNTDOWN)
- && playerAmount + it.players.size <= it.capacity
- }
- }
-
- /**
- * Enqueues a single player or a group of players.
- *
- * @param queueType the name of the queue type to enqueue into.
- * @param playerIds the UUIDs of the players to enqueue.
- * @return a result with the queue the players joined.
- */
- suspend fun enqueue(queueType: String, playerIds: List): Result {
- val type = types.find(queueType)
- ?: return Result.failure(NoSuchElementException("Queue type '$queueType' not found"))
-
- val mutex = typeMutex.getOrPut(queueType) { Mutex() }
- return mutex.withLock {
- if (playerIds.any { playersToQueue.containsKey(it) }) {
- return@withLock Result.failure(IllegalStateException("Some players are already in a queue"))
- }
-
- val existingQueue = findQueue(queueType, playerIds.size)
- val queue = existingQueue ?: createQueue(type)
-
- if (existingQueue != null) {
- logger.info("Players {} joining existing queue {} (type={}, players={})", playerIds, queue.id, queue.type, queue.players.size)
- } else {
- logger.info("Players {} created new queue {} (type={}, capacity={})", playerIds, queue.id, queue.type, queue.players.size)
- }
-
- queue.players.addAll(playerIds)
- queues[queue.id] = queue
- playerIds.forEach { playersToQueue[it] = queue.id }
-
- if (existingQueue == null) {
- publisher.publishQueueCreated(queue)
- }
- publisher.publishEnqueue(queue, playerIds)
-
- reconciler?.reconcile(queue.id)
- Result.success(queue)
- }
- }
-
- /**
- * Dequeues a single player or a group of players.
- *
- * @param playerIds the UUIDs of the players to dequeue.
- * @return true if every player was successfully dequeued.
- */
- suspend fun dequeue(playerIds: List): Boolean {
- return playerIds.all { dequeue(it) }
- }
-
- /**
- * Dequeues a player.
- *
- * @param playerId the UUID of the player to dequeue.
- * @return true if the player was successfully dequeued.
- */
- private suspend fun dequeue(playerId: UUID): Boolean {
- if (!playersToQueue.containsKey(playerId)) {
- logger.debug("Dequeue failed: player {} is not in any queue", playerId)
- return false
- }
-
- val queue = getQueueByPlayer(playerId) ?: return false
- if (!playersToQueue.remove(playerId, queue.id)) return false
- if (!queue.players.remove(playerId)) {
- playersToQueue[playerId] = queue.id
- return false
- }
-
- logger.info("Player {} left queue {} (type={}, remaining={})", playerId, queue.id, queue.type, queue.players.size)
- publisher.publishDequeue(queue, listOf(playerId))
- reconciler?.reconcile(queue.id)
- return true
- }
-
- /**
- * Creates a new queue of a type.
- *
- * @param type the queue type to create the queue for.
- * @return the created queue.
- */
- private fun createQueue(type: QueueType): Queue {
- val queue = Queue(
- id = UUID.randomUUID(),
- type = type.name,
- capacity = type.maxCapacity,
- players = mutableListOf(),
- status = QueueStatus.NOT_ENOUGH_PLAYERS,
- )
- queues[queue.id] = queue
- snapshots[queue.id] = queue.toDefinition()
- return queue
- }
-
- /**
- * Updates a queue and publishes an update event if anything changed.
- *
- * @param queue the queue to update.
- */
- fun updateQueue(queue: Queue) {
- if (!queues.containsKey(queue.id)) return
-
- val before = snapshots[queue.id]
- val after = queue.toDefinition()
- queues[queue.id] = queue
- snapshots[queue.id] = after
-
- if (before != null && before != after) {
- publisher.publishQueueUpdated(before, after)
- }
- }
-
- /**
- * Deletes a queue.
- *
- * @param queueId the UUID of the queue to delete.
- * @return true if the queue was successfully deleted.
- */
- fun deleteQueue(queueId: UUID): Boolean {
- val queue = queues[queueId] ?: return false
- queues.remove(queueId)
- snapshots.remove(queueId)
- var removedCount = 0
- playersToQueue.entries.removeAll { (_, id) ->
- (id == queueId).also { if (it) removedCount++ }
- }
- reconciler?.clear(queueId)
- publisher.publishQueueDeleted(queue)
- logger.info("Successfully deleted queue $queueId")
- return true
- }
-
-}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueTypeRepository.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueTypeRepository.kt
index 92d53df..377b502 100644
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueTypeRepository.kt
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueTypeRepository.kt
@@ -1,7 +1,7 @@
package net.mythicisland.queue.runtime.repository
-import net.mythicisland.queue.shared.queue.QueueType
import net.mythicisland.moonrise.common.repository.YamlDirectoryRepository
+import net.mythicisland.queue.shared.queue.QueueType
import java.nio.file.Path
/**
@@ -26,4 +26,4 @@ class QueueTypeRepository(
return "$identifier.yml"
}
-}
\ No newline at end of file
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt
new file mode 100644
index 0000000..6594103
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt
@@ -0,0 +1,112 @@
+package net.mythicisland.queue.runtime.server
+
+import app.simplecloud.api.CloudApi
+import app.simplecloud.api.server.ServerState
+import app.simplecloud.api.server.UpdateServerRequest
+import kotlinx.coroutines.future.await
+import net.mythicisland.queue.shared.match.Assignment
+import net.mythicisland.queue.shared.match.Match
+import net.mythicisland.queue.shared.queue.QueueType
+import org.apache.logging.log4j.LogManager
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Handle server allocation.
+ *
+ * @param api the simplecloud api.
+ */
+class ServerAllocator(
+ private val api: CloudApi,
+) {
+
+ private val logger = LogManager.getLogger(ServerAllocator::class.java)
+
+ private val requested = ConcurrentHashMap.newKeySet()
+
+ /**
+ * Tries to take a free server for a match.
+ *
+ * If nothing is free a new server is requested once and null is returned,
+ * the next reconciliation picks it up as soon as it is running.
+ *
+ * @param match the match that needs a server.
+ * @param type the queue type of the match.
+ * @return the assignment, or null if no server is ready yet.
+ */
+ suspend fun allocate(match: Match, type: QueueType): Assignment? {
+ val servers = api.server().getServersByGroup(type.group).await()
+ val free = servers.firstOrNull { it.state == ServerState.AVAILABLE }
+
+ if (free == null) {
+ requestServer(match, type)
+ return null
+ }
+
+ if (!updateState(free.serverId, ServerState.INGAME)) {
+ logger.error("Failed to take server {} for match {}", free.serverId, match.id)
+ return null
+ }
+
+ requested.remove(match.id)
+
+ val assignment = Assignment(free.serverId, "${free.group.name}-${free.numericalId}")
+ logger.info("Took server {} ({}) for match {}", assignment.serverName, assignment.serverId, match.id)
+ return assignment
+ }
+
+ /**
+ * Puts the server of a match that never made it to the transfer back to
+ * available, so it can be handed to the next match.
+ *
+ * @param match the failed match.
+ */
+ suspend fun release(match: Match) {
+ requested.remove(match.id)
+
+ val assignment = match.assignment
+ if (assignment == null) {
+ logger.debug("Match {} had no server to release", match.id)
+ return
+ }
+
+ if (updateState(assignment.serverId, ServerState.AVAILABLE)) {
+ logger.info("Released server {} of match {}", assignment.serverName, match.id)
+ } else {
+ logger.error("Failed to release server {} of match {}", assignment.serverName, match.id)
+ }
+ }
+
+ private suspend fun requestServer(match: Match, type: QueueType) {
+ if (!requested.add(match.id)) {
+ logger.debug("Match {} is still waiting for its requested server in group '{}'", match.id, type.group)
+ return
+ }
+
+ try {
+ val group = api.group().getGroupByName(type.group).await()
+ if (group == null) {
+ logger.error("Group '{}' of queue type '{}' does not exist", type.group, type.name)
+ return
+ }
+
+ api.group().requestServerStart(group).await()
+ logger.info("Requested a new server in group '{}' for match {}", type.group, match.id)
+ } catch (e: Exception) {
+ requested.remove(match.id)
+ logger.error("Failed to request a server in group '{}' for match {}", type.group, match.id, e)
+ }
+ }
+
+ private suspend fun updateState(serverId: String, state: ServerState): Boolean {
+ return try {
+ val request = UpdateServerRequest.builder().state(state).build()
+ api.server().updateServer(serverId, request).await()
+ true
+ } catch (e: Exception) {
+ logger.error("Failed to update server {} to state {}", serverId, state, e)
+ false
+ }
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerFinder.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerFinder.kt
deleted file mode 100644
index a3cbbc1..0000000
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerFinder.kt
+++ /dev/null
@@ -1,141 +0,0 @@
-package net.mythicisland.queue.runtime.server
-
-import app.simplecloud.api.CloudApi
-import app.simplecloud.api.server.Server
-import app.simplecloud.api.server.ServerState
-import kotlinx.coroutines.future.await
-import net.mythicisland.queue.shared.queue.Queue
-import net.mythicisland.queue.runtime.repository.QueueTypeRepository
-import org.apache.logging.log4j.LogManager
-
-/**
- * Handles server discovery and reservation for queues.
- */
-class ServerFinder(
- private val api: CloudApi,
- private val types: QueueTypeRepository,
-) {
-
- private val logger = LogManager.getLogger(ServerFinder::class.java)
-
- /**
- * Finds the server currently assigned to the given queue.
- *
- * @param queue The queue to find a server for
- * @return The assigned server, or null if none found or queue type doesn't exist
- */
- suspend fun findServer(queue: Queue): Server? {
- val type = types.find(queue.type) ?: return null
- val servers = api.server().getServersByGroup(type.group).await()
-
- return servers.firstOrNull {
- it.properties["queue-id"] == queue.id.toString() && it.state == ServerState.AVAILABLE
- }
- }
-
- /**
- * Frees a server by clearing its queue id.
- *
- * @param server The server to free
- * @return true if the property was successfully removed, false on error
- */
- suspend fun freeServer(server: Server): Boolean {
- try {
- api.server().updateServerProperties(server.serverId, mapOf("queue-id" to "")).await()
- return true
- } catch (e: Exception) {
- logger.error("Failed to remove the queue-id property from server ${server.serverId}", e)
- return false
- }
- }
-
- /**
- * Attempts to reserve an available server, or requests a new one if none available.
- *
- * @param queue The queue to reserve or request a server for
- * @return The reserved server if one was available, null if a new server was requested
- */
- suspend fun reserveOrRequestServer(queue: Queue): Server? {
- val reserved = reserveServer(queue)
- if (reserved != null) {
- return reserved
- }
-
- requestNewServer(queue)
- return null
- }
-
- /**
- * Attempts to reserve an available server for the given queue.
- *
- * @param queue The queue to reserve a server for
- * @return The reserved server, or null if none available or queue type doesn't exist
- */
- private suspend fun reserveServer(queue: Queue): Server? {
- val type = types.find(queue.type) ?: return null
- val servers = api.server().getServersByGroup(type.group).await()
- val server = servers.firstOrNull {
- canReserveServer(queue, it)
- } ?: return null
-
- api.server().updateServerProperties(server.serverId, mapOf("queue-id" to queue.id.toString())).await()
- queue.server = server
-
- return server
- }
-
- /**
- * Checks if a server can be reserved for the given queue.
- *
- * A server can be reserved if:
- * - It is in [ServerState.AVAILABLE] state
- * - It belongs to the same group as the queue type
- * - It has no queue-id property, OR the property is empty, OR it already belongs to this queue
- *
- * @param queue The queue requesting the server
- * @param server The server to check
- * @return true if the server can be reserved, false otherwise
- */
- private fun canReserveServer(queue: Queue, server: Server): Boolean {
- val type = types.find(queue.type) ?: return false
-
- if (server.state != ServerState.AVAILABLE) return false
- if (type.group != server.group.name) return false
-
- val queueId = server.properties["queue-id"] as? String
- return queueId.isNullOrEmpty() || queueId == queue.id.toString()
- }
-
- /**
- * Reserves a specific server for the given queue.
- *
- * @param queue The queue to reserve the server for
- * @param server The specific server to reserve
- * @return true if the server was successfully reserved, false if it cannot be reserved
- */
- suspend fun reserveServer(queue: Queue, server: Server): Boolean {
- if (!canReserveServer(queue, server)) return false
-
- api.server().updateServerProperties(server.serverId, mapOf("queue-id" to queue.id.toString())).await()
- queue.server = server
-
- return true
- }
-
- /**
- * Queues a server start for a queue.
- *
- * @param queue The queue that needs a new server
- * @throws IllegalStateException if the queue type or group cannot be resolved
- */
- private suspend fun requestNewServer(queue: Queue) {
- val type = types.find(queue.type)
- ?: throw IllegalStateException("Queue type '${queue.type}' not found")
-
- val group = api.group().getGroupByName(type.group).await()
- ?: throw IllegalStateException("Group '${type.group}' not found for queue type '${queue.type}'")
-
- api.group().requestServerStart(group).await()
- logger.info("Requested server start for queue {} (group={})", queue.id, type.group)
- }
-}
\ No newline at end of file
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueDataService.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueDataService.kt
index 0c7aaab..928dbe8 100644
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueDataService.kt
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueDataService.kt
@@ -1,69 +1,74 @@
package net.mythicisland.queue.runtime.service
-import build.buf.gen.mythicisland.queue.v1.*
+import build.buf.gen.mythicisland.queue.v2.*
import io.grpc.Status
import net.mythicisland.moonrise.common.extension.asUUID
-import net.mythicisland.queue.shared.queue.QueueType
-import net.mythicisland.queue.runtime.repository.QueueRepository
+import net.mythicisland.queue.runtime.repository.MatchRepository
import net.mythicisland.queue.runtime.repository.QueueTypeRepository
+import net.mythicisland.queue.runtime.ticket.TicketPool
+import net.mythicisland.queue.runtime.ticket.TicketStore
+import net.mythicisland.queue.shared.match.Match
+import net.mythicisland.queue.shared.queue.QueueStats
+import net.mythicisland.queue.shared.queue.QueueType
class QueueDataService(
- private val queues: QueueRepository,
+ private val tickets: TicketStore,
+ private val pool: TicketPool,
+ private val matches: MatchRepository,
private val types: QueueTypeRepository,
) : QueueDataServiceGrpcKt.QueueDataServiceCoroutineImplBase() {
- override suspend fun getQueue(request: GetQueueRequest): GetQueueResponse {
- val id = request.queueId.asUUID()
- val queue = queues.getQueue(id)
+ override suspend fun getTicket(request: GetTicketRequest): GetTicketResponse {
+ val id = request.ticketId.asUUID()
+ val ticket = tickets.get(id)
?: throw Status.NOT_FOUND
- .withDescription("Queue '$id' not found")
+ .withDescription("Ticket '$id' not found")
.asRuntimeException()
- return getQueueResponse { this.queue = queue.toDefinition() }
+ return getTicketResponse { this.ticket = ticket.toDefinition() }
}
- override suspend fun getAllQueues(request: GetAllQueuesRequest): GetAllQueuesResponse {
- val queues = queues.getAllQueues()
+ override suspend fun getTicketByPlayer(request: GetTicketByPlayerRequest): GetTicketByPlayerResponse {
+ val playerId = request.playerId.asUUID()
+ val ticket = tickets.getByPlayer(playerId)
+ ?: throw Status.NOT_FOUND
+ .withDescription("Player '$playerId' is not queued")
+ .asRuntimeException()
- return getAllQueuesResponse {
- this.queues.addAll(queues.map { queue ->
- queue.toDefinition()
- })
- }
+ return getTicketByPlayerResponse { this.ticket = ticket.toDefinition() }
}
- override suspend fun getQueuesByType(request: GetQueuesByTypeRequest): GetQueuesByTypeResponse {
- val queues = queues.getAllQueuesByType(request.type)
+ override suspend fun listTickets(request: ListTicketsRequest): ListTicketsResponse {
+ val found = if (request.queueType.isEmpty()) {
+ tickets.getAll()
+ } else {
+ tickets.getAll().filter { request.queueType in it.queueTypes }
+ }
- return getQueuesByTypeResponse {
- this.queues.addAll(queues.map { queue ->
- queue.toDefinition()
- })
+ return listTicketsResponse {
+ this.tickets.addAll(found.map { it.toDefinition() })
}
}
- override suspend fun getQueueByPlayer(request: GetQueueByPlayerRequest): GetQueueByPlayerResponse {
- val player = request.playerId.asUUID()
- val queue = queues.getQueueByPlayer(player)
+ override suspend fun getMatch(request: GetMatchRequest): GetMatchResponse {
+ val id = request.matchId.asUUID()
+ val match = matches.get(id)
?: throw Status.NOT_FOUND
- .withDescription("Player '$player' is not in any queue")
+ .withDescription("Match '$id' not found")
.asRuntimeException()
- return getQueueByPlayerResponse { this.queue = queue.toDefinition() }
+ return getMatchResponse { this.match = toDefinition(match) }
}
- override suspend fun getPlayerPosition(request: GetPlayerPositionRequest): GetPlayerPositionResponse {
- val player = request.playerId.asUUID()
- val queue = queues.getQueueByPlayer(player)
- ?: throw Status.NOT_FOUND
- .withDescription("Player '$player' is not in any queue")
- .asRuntimeException()
-
- val position = queue.players.indexOf(player) + 1
+ override suspend fun listMatches(request: ListMatchesRequest): ListMatchesResponse {
+ val found = if (request.queueType.isEmpty()) {
+ matches.getAll()
+ } else {
+ matches.getAllByType(request.queueType)
+ }
- return getPlayerPositionResponse {
- this.queue = queue.toDefinition()
- this.position = position
+ return listMatchesResponse {
+ this.matches.addAll(found.map { toDefinition(it) })
}
}
@@ -76,11 +81,33 @@ class QueueDataService(
return getQueueTypeResponse { this.queueType = type.toDefinition() }
}
- override suspend fun getAllQueueTypes(request: GetAllQueueTypesRequest): GetAllQueueTypesResponse {
- val types = types.getAll()
+ override suspend fun listQueueTypes(request: ListQueueTypesRequest): ListQueueTypesResponse {
+ return listQueueTypesResponse {
+ this.queueTypes.addAll(types.getAll().map(QueueType::toDefinition))
+ }
+ }
+
+ override suspend fun getQueueStats(request: GetQueueStatsRequest): GetQueueStatsResponse {
+ val type = types.find(request.queueType)
+ ?: throw Status.NOT_FOUND
+ .withDescription("Queue type '${request.queueType}' not found")
+ .asRuntimeException()
+
+ return getQueueStatsResponse { this.stats = statsOf(type).toDefinition() }
+ }
- return getAllQueueTypesResponse {
- this.queueTypes.addAll(types.map(QueueType::toDefinition))
+ override suspend fun listQueueStats(request: ListQueueStatsRequest): ListQueueStatsResponse {
+ return listQueueStatsResponse {
+ this.stats.addAll(types.getAll().map { statsOf(it).toDefinition() })
}
}
+
+ private fun toDefinition(match: Match): build.buf.gen.mythicisland.queue.v2.Match {
+ return match.toDefinition(tickets.getAll(match.ticketIds))
+ }
+
+ private fun statsOf(type: QueueType): QueueStats {
+ return pool.stats(type.name, matches.getAllByType(type.name).size)
+ }
+
}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueService.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueService.kt
deleted file mode 100644
index c28d052..0000000
--- a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueService.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-package net.mythicisland.queue.runtime.service
-
-import build.buf.gen.mythicisland.queue.v1.*
-import io.grpc.Status
-import net.mythicisland.moonrise.common.extension.asUUID
-import net.mythicisland.queue.runtime.repository.QueueRepository
-import org.apache.logging.log4j.LogManager
-
-class QueueService(
- private val queues: QueueRepository,
-) : QueueServiceGrpcKt.QueueServiceCoroutineImplBase() {
-
- private val logger = LogManager.getLogger(QueueService::class.java)
-
- override suspend fun enqueue(request: EnqueueRequest): EnqueueResponse {
- val players = request.playerIdsList.map { it.asUUID() }
- val result = queues.enqueue(request.type, players)
- val queue = result.getOrElse { error ->
- logger.warn("Enqueue failed for players {} in type '{}': {}", players, request.type, error.message)
- val status = when (error) {
- is NoSuchElementException -> Status.NOT_FOUND
- is IllegalStateException -> Status.FAILED_PRECONDITION
- else -> Status.INTERNAL
- }
- throw status.withDescription(error.message).asRuntimeException()
- }
-
- return enqueueResponse { this.queue = queue.toDefinition() }
- }
-
- override suspend fun dequeue(request: DequeueRequest): DequeueResponse {
- val playerIds = request.playerIdsList.map { it.asUUID() }
- val success = queues.dequeue(playerIds)
-
- if (!success) {
- logger.warn("Dequeue failed: some players {} could not be dequeued", playerIds)
- throw Status.NOT_FOUND
- .withDescription("Some players could not be dequeued")
- .asRuntimeException()
- }
-
- return dequeueResponse { }
- }
-
-}
\ No newline at end of file
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/TicketService.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/TicketService.kt
new file mode 100644
index 0000000..a760321
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/TicketService.kt
@@ -0,0 +1,100 @@
+package net.mythicisland.queue.runtime.service
+
+import build.buf.gen.mythicisland.queue.v2.*
+import io.grpc.Status
+import net.mythicisland.moonrise.common.extension.asUUID
+import net.mythicisland.queue.runtime.event.EventPublisher
+import net.mythicisland.queue.runtime.repository.MatchRepository
+import net.mythicisland.queue.runtime.repository.QueueTypeRepository
+import net.mythicisland.queue.runtime.ticket.TicketStore
+import net.mythicisland.queue.shared.match.Ticket
+import org.apache.logging.log4j.LogManager
+import java.time.Instant
+import java.util.UUID
+
+class TicketService(
+ private val tickets: TicketStore,
+ private val matches: MatchRepository,
+ private val types: QueueTypeRepository,
+ private val publisher: EventPublisher,
+) : TicketServiceGrpcKt.TicketServiceCoroutineImplBase() {
+
+ private val logger = LogManager.getLogger(TicketService::class.java)
+
+ override suspend fun createTicket(request: CreateTicketRequest): CreateTicketResponse {
+ val playerIds = request.playerIdsList.map { it.asUUID() }
+ val queueTypes = request.queueTypesList.toList()
+
+ if (playerIds.isEmpty()) {
+ throw Status.INVALID_ARGUMENT
+ .withDescription("A ticket needs at least one player")
+ .asRuntimeException()
+ }
+
+ if (queueTypes.isEmpty()) {
+ throw Status.INVALID_ARGUMENT
+ .withDescription("A ticket needs at least one queue type")
+ .asRuntimeException()
+ }
+
+ val unknown = queueTypes.filter { types.find(it) == null }
+ if (unknown.isNotEmpty()) {
+ logger.warn("Rejected ticket for players {}, unknown queue types {}", playerIds, unknown)
+ throw Status.NOT_FOUND
+ .withDescription("Unknown queue types: $unknown")
+ .asRuntimeException()
+ }
+
+ val tooBig = queueTypes.mapNotNull { types.find(it) }.filter { playerIds.size > it.maxPlayers }
+ if (tooBig.isNotEmpty()) {
+ logger.warn("Rejected ticket for {} players, too big for {}", playerIds.size, tooBig.map { it.name })
+ throw Status.FAILED_PRECONDITION
+ .withDescription("Party of ${playerIds.size} players is too big for: ${tooBig.map { it.name }}")
+ .asRuntimeException()
+ }
+
+ val ticket = Ticket(
+ id = UUID.randomUUID(),
+ playerIds = playerIds,
+ queueTypes = queueTypes,
+ state = TicketState.TICKET_STATE_SEARCHING,
+ createdAt = Instant.now(),
+ )
+
+ if (!tickets.add(ticket)) {
+ logger.warn("Rejected ticket for players {}, some of them are already queued", playerIds)
+ throw Status.FAILED_PRECONDITION
+ .withDescription("Some players are already queued")
+ .asRuntimeException()
+ }
+
+ logger.info("Created ticket {} for {} players in {}", ticket.id, playerIds.size, queueTypes)
+ publisher.publishTicketCreated(ticket)
+
+ return createTicketResponse { this.ticket = ticket.toDefinition() }
+ }
+
+ override suspend fun deleteTicket(request: DeleteTicketRequest): DeleteTicketResponse {
+ val ticket = when (request.targetCase) {
+ DeleteTicketRequest.TargetCase.TICKET_ID -> tickets.get(request.ticketId.asUUID())
+ DeleteTicketRequest.TargetCase.PLAYER_ID -> tickets.getByPlayer(request.playerId.asUUID())
+ else -> throw Status.INVALID_ARGUMENT
+ .withDescription("Either a ticket id or a player id is required")
+ .asRuntimeException()
+ } ?: throw Status.NOT_FOUND
+ .withDescription("No ticket found for ${request.targetCase}")
+ .asRuntimeException()
+
+ tickets.remove(ticket.id)
+
+ // The match keeps running with the players that are left, the reconciler
+ // fails it once nobody is in it anymore.
+ matches.removeTicket(ticket.id)
+
+ logger.info("Deleted ticket {} with {} players", ticket.id, ticket.playerIds.size)
+ publisher.publishTicketDeleted(ticket, TicketDeleteReason.TICKET_DELETE_REASON_CANCELLED)
+
+ return deleteTicketResponse { }
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketPool.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketPool.kt
new file mode 100644
index 0000000..f95dc63
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketPool.kt
@@ -0,0 +1,48 @@
+package net.mythicisland.queue.runtime.ticket
+
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import net.mythicisland.queue.shared.match.Ticket
+import net.mythicisland.queue.shared.queue.QueueStats
+
+/**
+ * The view the matchmaker works on: the tickets that are still searching,
+ * grouped by queue type.
+ *
+ * A ticket searching in several queue types shows up in every one of their
+ * pools until it lands in a match.
+ *
+ * @param tickets the store to read the tickets from.
+ */
+class TicketPool(
+ private val tickets: TicketStore,
+) {
+
+ /**
+ * All tickets searching in a queue type, oldest first.
+ *
+ * @param queueType the name of the queue type.
+ */
+ fun searching(queueType: String): List {
+ return tickets.getAll()
+ .filter { it.state == TicketState.TICKET_STATE_SEARCHING && queueType in it.queueTypes }
+ .sortedBy { it.createdAt }
+ }
+
+ /**
+ * Builds the live numbers of a queue type.
+ *
+ * @param queueType the name of the queue type.
+ * @param activeMatches the amount of matches of that type that did not finish yet.
+ */
+ fun stats(queueType: String, activeMatches: Int): QueueStats {
+ val searching = searching(queueType)
+
+ return QueueStats(
+ queueType = queueType,
+ searchingTickets = searching.size,
+ searchingPlayers = searching.sumOf { it.playerIds.size },
+ activeMatches = activeMatches,
+ )
+ }
+
+}
diff --git a/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketStore.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketStore.kt
new file mode 100644
index 0000000..ca43147
--- /dev/null
+++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketStore.kt
@@ -0,0 +1,119 @@
+package net.mythicisland.queue.runtime.ticket
+
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import net.mythicisland.queue.shared.match.Ticket
+import org.apache.logging.log4j.LogManager
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+/**
+ * Holds every ticket that is currently in matchmaking.
+ */
+class TicketStore {
+
+ private val logger = LogManager.getLogger(TicketStore::class.java)
+
+ private val mutex = Mutex()
+ private val tickets = ConcurrentHashMap()
+ private val playerToTicket = ConcurrentHashMap()
+
+ /**
+ * Gets a ticket by its id.
+ */
+ fun get(id: UUID): Ticket? {
+ return tickets[id]
+ }
+
+ /**
+ * Gets the ticket a player belongs to.
+ */
+ fun getByPlayer(playerId: UUID): Ticket? {
+ return playerToTicket[playerId]?.let { tickets[it] }
+ }
+
+ /**
+ * Gets every ticket currently in matchmaking.
+ */
+ fun getAll(): List {
+ return tickets.values.toList()
+ }
+
+ /**
+ * Gets the tickets for the given ids, skipping the ones that are gone.
+ */
+ fun getAll(ids: Collection): List {
+ return ids.mapNotNull { tickets[it] }
+ }
+
+ /**
+ * Adds a ticket, unless one of its players is queued already.
+ *
+ * @return true if the ticket was added.
+ */
+ suspend fun add(ticket: Ticket): Boolean {
+ mutex.withLock {
+ val queued = ticket.playerIds.filter { playerToTicket.containsKey(it) }
+ if (queued.isNotEmpty()) {
+ logger.debug("Rejected ticket {}, players {} are already queued", ticket.id, queued)
+ return false
+ }
+
+ tickets[ticket.id] = ticket
+ ticket.playerIds.forEach { playerToTicket[it] = ticket.id }
+ return true
+ }
+ }
+
+ /**
+ * Replaces a ticket with an updated copy.
+ *
+ * @return the stored ticket, or null if it was removed in the meantime.
+ */
+ suspend fun update(ticket: Ticket): Ticket? {
+ mutex.withLock {
+ if (!tickets.containsKey(ticket.id)) {
+ logger.debug("Skipped update of ticket {}, it is no longer stored", ticket.id)
+ return null
+ }
+
+ tickets[ticket.id] = ticket
+ return ticket
+ }
+ }
+
+ /**
+ * Removes a ticket and frees its players.
+ *
+ * @return the removed ticket, or null if it was not stored.
+ */
+ suspend fun remove(id: UUID): Ticket? {
+ mutex.withLock {
+ val ticket = tickets.remove(id) ?: return null
+ ticket.playerIds.forEach { playerToTicket.remove(it, id) }
+ return ticket
+ }
+ }
+
+ /**
+ * Moves the given tickets into a match, but only if every one of them is
+ * still searching. This is what keeps a ticket that is queued for several
+ * queue types from ending up in two matches at once.
+ *
+ * @return the updated tickets, or null if one of them was not searching anymore.
+ */
+ suspend fun matched(ids: List, matchId: UUID): List? {
+ mutex.withLock {
+ val found = ids.map { tickets[it] ?: return null }
+ if (found.any { it.state != TicketState.TICKET_STATE_SEARCHING }) return null
+
+ val updated = found.map {
+ it.copy(state = TicketState.TICKET_STATE_MATCHED, matchId = matchId)
+ }
+ updated.forEach { tickets[it.id] = it }
+ return updated
+ }
+ }
+
+}
diff --git a/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/TestData.kt b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/TestData.kt
new file mode 100644
index 0000000..2d368c5
--- /dev/null
+++ b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/TestData.kt
@@ -0,0 +1,43 @@
+package net.mythicisland.queue.runtime
+
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import net.mythicisland.queue.shared.match.Match
+import net.mythicisland.queue.shared.match.Ticket
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * A fixed point in time, so nothing in the tests depends on the clock.
+ */
+val NOW: Instant = Instant.parse("2026-01-01T00:00:00Z")
+
+/**
+ * Creates the given amount of player ids.
+ */
+fun players(amount: Int): List = List(amount) { UUID.randomUUID() }
+
+fun ticket(
+ playerIds: List = players(1),
+ queueTypes: List = listOf("battle"),
+ state: TicketState = TicketState.TICKET_STATE_SEARCHING,
+ createdAt: Instant = NOW,
+): Ticket = Ticket(
+ id = UUID.randomUUID(),
+ playerIds = playerIds,
+ queueTypes = queueTypes,
+ state = state,
+ createdAt = createdAt,
+)
+
+fun match(
+ ticketIds: List = listOf(UUID.randomUUID()),
+ queueType: String = "battle",
+ state: MatchState = MatchState.MATCH_STATE_ALLOCATING,
+): Match = Match(
+ id = UUID.randomUUID(),
+ queueType = queueType,
+ ticketIds = ticketIds,
+ state = state,
+ createdAt = NOW,
+)
diff --git a/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/repository/MatchRepositoryTest.kt b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/repository/MatchRepositoryTest.kt
new file mode 100644
index 0000000..b2ed625
--- /dev/null
+++ b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/repository/MatchRepositoryTest.kt
@@ -0,0 +1,108 @@
+package net.mythicisland.queue.runtime.repository
+
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import kotlinx.coroutines.runBlocking
+import net.mythicisland.queue.runtime.match
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+
+class MatchRepositoryTest {
+
+ @Test
+ fun `finds a match by any of its tickets`() {
+ runBlocking {
+ val matches = MatchRepository()
+ val ticketIds = listOf(UUID.randomUUID(), UUID.randomUUID())
+ val match = match(ticketIds = ticketIds)
+ matches.add(match)
+
+ assertEquals(match, matches.get(match.id))
+ ticketIds.forEach { assertEquals(match, matches.getByTicket(it)) }
+ }
+ }
+
+ @Test
+ fun `filters matches by queue type and state`() {
+ runBlocking {
+ val matches = MatchRepository()
+ val battle = match(queueType = "battle", state = MatchState.MATCH_STATE_COUNTDOWN)
+ val skywars = match(queueType = "skywars")
+ matches.add(battle)
+ matches.add(skywars)
+
+ assertEquals(listOf(battle), matches.getAllByType("battle"))
+ assertEquals(listOf(battle), matches.getAllByState(MatchState.MATCH_STATE_COUNTDOWN))
+ assertEquals(2, matches.getAll().size)
+ }
+ }
+
+ @Test
+ fun `dropping a ticket leaves the rest of the match alone`() {
+ runBlocking {
+ val matches = MatchRepository()
+ val leaving = UUID.randomUUID()
+ val staying = UUID.randomUUID()
+ matches.add(match(ticketIds = listOf(leaving, staying)))
+
+ val updated = matches.removeTicket(leaving)
+
+ assertNotNull(updated)
+ assertEquals(listOf(staying), updated.ticketIds)
+ assertNull(matches.getByTicket(leaving))
+ assertEquals(updated, matches.getByTicket(staying))
+ }
+ }
+
+ @Test
+ fun `dropping the last ticket leaves an empty match behind`() {
+ runBlocking {
+ val matches = MatchRepository()
+ val only = UUID.randomUUID()
+ val match = match(ticketIds = listOf(only))
+ matches.add(match)
+
+ val updated = matches.removeTicket(only)
+
+ // The reconciler is what fails an empty match, the repository keeps it.
+ assertNotNull(updated)
+ assertEquals(emptyList(), updated.ticketIds)
+ assertNotNull(matches.get(match.id))
+ }
+ }
+
+ @Test
+ fun `dropping a ticket of no match does nothing`() {
+ runBlocking {
+ val matches = MatchRepository()
+
+ assertNull(matches.removeTicket(UUID.randomUUID()))
+ }
+ }
+
+ @Test
+ fun `removing a match clears its ticket index`() {
+ runBlocking {
+ val matches = MatchRepository()
+ val ticketId = UUID.randomUUID()
+ val match = match(ticketIds = listOf(ticketId))
+ matches.add(match)
+
+ assertEquals(match, matches.remove(match.id))
+ assertNull(matches.get(match.id))
+ assertNull(matches.getByTicket(ticketId))
+ }
+ }
+
+ @Test
+ fun `ignores an update of a match that was removed`() {
+ runBlocking {
+ val matches = MatchRepository()
+
+ assertNull(matches.update(match()))
+ }
+ }
+
+}
diff --git a/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketPoolTest.kt b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketPoolTest.kt
new file mode 100644
index 0000000..819bbc7
--- /dev/null
+++ b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketPoolTest.kt
@@ -0,0 +1,84 @@
+package net.mythicisland.queue.runtime.ticket
+
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import kotlinx.coroutines.runBlocking
+import net.mythicisland.queue.runtime.NOW
+import net.mythicisland.queue.runtime.players
+import net.mythicisland.queue.runtime.ticket
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class TicketPoolTest {
+
+ @Test
+ fun `only returns tickets of the asked queue type`() {
+ runBlocking {
+ val store = TicketStore()
+ val pool = TicketPool(store)
+ val battle = ticket(queueTypes = listOf("battle"))
+ store.add(battle)
+ store.add(ticket(queueTypes = listOf("skywars")))
+
+ assertEquals(listOf(battle), pool.searching("battle"))
+ }
+ }
+
+ @Test
+ fun `a ticket queued for several types shows up in every pool`() {
+ runBlocking {
+ val store = TicketStore()
+ val pool = TicketPool(store)
+ val ticket = ticket(queueTypes = listOf("battle", "skywars"))
+ store.add(ticket)
+
+ assertEquals(listOf(ticket), pool.searching("battle"))
+ assertEquals(listOf(ticket), pool.searching("skywars"))
+ }
+ }
+
+ @Test
+ fun `leaves out tickets that are no longer searching`() {
+ runBlocking {
+ val store = TicketStore()
+ val pool = TicketPool(store)
+ store.add(ticket(state = TicketState.TICKET_STATE_MATCHED))
+ store.add(ticket(state = TicketState.TICKET_STATE_ASSIGNED))
+
+ assertEquals(emptyList(), pool.searching("battle"))
+ }
+ }
+
+ @Test
+ fun `returns the oldest ticket first`() {
+ runBlocking {
+ val store = TicketStore()
+ val pool = TicketPool(store)
+ val newest = ticket(createdAt = NOW.plusSeconds(20))
+ val oldest = ticket(createdAt = NOW)
+ val middle = ticket(createdAt = NOW.plusSeconds(10))
+ store.add(newest)
+ store.add(oldest)
+ store.add(middle)
+
+ assertEquals(listOf(oldest, middle, newest), pool.searching("battle"))
+ }
+ }
+
+ @Test
+ fun `counts every player of a party`() {
+ runBlocking {
+ val store = TicketStore()
+ val pool = TicketPool(store)
+ store.add(ticket(playerIds = players(3)))
+ store.add(ticket(playerIds = players(1)))
+
+ val stats = pool.stats("battle", activeMatches = 2)
+
+ assertEquals("battle", stats.queueType)
+ assertEquals(2, stats.searchingTickets)
+ assertEquals(4, stats.searchingPlayers)
+ assertEquals(2, stats.activeMatches)
+ }
+ }
+
+}
diff --git a/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketStoreTest.kt b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketStoreTest.kt
new file mode 100644
index 0000000..82ba097
--- /dev/null
+++ b/queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketStoreTest.kt
@@ -0,0 +1,119 @@
+package net.mythicisland.queue.runtime.ticket
+
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import kotlinx.coroutines.runBlocking
+import net.mythicisland.queue.runtime.players
+import net.mythicisland.queue.runtime.ticket
+import java.util.UUID
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+class TicketStoreTest {
+
+ @Test
+ fun `finds a ticket by every one of its players`() {
+ runBlocking {
+ val store = TicketStore()
+ val ticket = ticket(playerIds = players(3))
+
+ assertTrue(store.add(ticket))
+ assertEquals(ticket, store.get(ticket.id))
+ ticket.playerIds.forEach { assertEquals(ticket, store.getByPlayer(it)) }
+ }
+ }
+
+ @Test
+ fun `rejects a ticket when one of its players is already queued`() {
+ runBlocking {
+ val store = TicketStore()
+ val queued = players(1)
+ store.add(ticket(playerIds = queued))
+
+ val second = ticket(playerIds = queued + players(1))
+
+ assertFalse(store.add(second))
+ assertEquals(1, store.getAll().size)
+ assertNull(store.get(second.id))
+ }
+ }
+
+ @Test
+ fun `removing a ticket frees its players`() {
+ runBlocking {
+ val store = TicketStore()
+ val ticket = ticket(playerIds = players(2))
+ store.add(ticket)
+
+ assertEquals(ticket, store.remove(ticket.id))
+ assertNull(store.getByPlayer(ticket.playerIds.first()))
+
+ // The players can queue again right away.
+ assertTrue(store.add(ticket(playerIds = ticket.playerIds)))
+ }
+ }
+
+ @Test
+ fun `ignores an update of a ticket that was removed`() {
+ runBlocking {
+ val store = TicketStore()
+ val ticket = ticket()
+
+ assertNull(store.update(ticket))
+ }
+ }
+
+ @Test
+ fun `matched moves every ticket into the match`() {
+ runBlocking {
+ val store = TicketStore()
+ val first = ticket()
+ val second = ticket()
+ store.add(first)
+ store.add(second)
+
+ val matchId = UUID.randomUUID()
+ val matched = store.matched(listOf(first.id, second.id), matchId)
+
+ assertNotNull(matched)
+ assertEquals(2, matched.size)
+ assertTrue(matched.all { it.state == TicketState.TICKET_STATE_MATCHED && it.matchId == matchId })
+ assertEquals(TicketState.TICKET_STATE_MATCHED, store.get(first.id)?.state)
+ }
+ }
+
+ @Test
+ fun `matched fails when a ticket is already in another match`() {
+ runBlocking {
+ val store = TicketStore()
+ val taken = ticket()
+ val free = ticket()
+ store.add(taken)
+ store.add(free)
+ store.matched(listOf(taken.id), UUID.randomUUID())
+
+ // This is what keeps a ticket queued for several types out of two matches.
+ assertNull(store.matched(listOf(taken.id, free.id), UUID.randomUUID()))
+
+ // The other ticket must be untouched, it keeps searching.
+ assertEquals(TicketState.TICKET_STATE_SEARCHING, store.get(free.id)?.state)
+ assertNull(store.get(free.id)?.matchId)
+ }
+ }
+
+ @Test
+ fun `matched fails when a ticket is gone`() {
+ runBlocking {
+ val store = TicketStore()
+ val ticket = ticket()
+ store.add(ticket)
+
+ assertNull(store.matched(listOf(ticket.id, UUID.randomUUID()), UUID.randomUUID()))
+ assertEquals(TicketState.TICKET_STATE_SEARCHING, store.get(ticket.id)?.state)
+ }
+ }
+
+}
diff --git a/queue-shared/build.gradle.kts b/queue-shared/build.gradle.kts
index 2d2285c..ac6c5e1 100644
--- a/queue-shared/build.gradle.kts
+++ b/queue-shared/build.gradle.kts
@@ -3,6 +3,5 @@ dependencies {
api(libs.cloud.api)
api(libs.bundles.grpc)
api(libs.bundles.configurate)
- api(libs.bundles.adventure)
api(libs.bundles.logging)
}
\ No newline at end of file
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/event/Subjects.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/event/Subjects.kt
deleted file mode 100644
index 45781c4..0000000
--- a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/event/Subjects.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package net.mythicisland.queue.shared.event
-
-object Subjects {
-
- private const val PREFIX = "queue.event"
-
- const val ENQUEUE = "${PREFIX}.enqueue"
-
- const val DEQUEUE = "${PREFIX}.dequeue"
-
- private const val QUEUE_PREFIX = "${PREFIX}.queue"
-
- const val QUEUE_CREATED = "${QUEUE_PREFIX}.created"
-
- const val QUEUE_UPDATED = "${QUEUE_PREFIX}.updated"
-
- const val QUEUE_DELETED = "${QUEUE_PREFIX}.deleted"
-
- const val QUEUE_TRANSFER = "${QUEUE_PREFIX}.transfer"
-
- const val QUEUE_STATUS_UPDATED = "${QUEUE_PREFIX}.status.updated"
-
- const val QUEUE_SERVER_ASSIGNED = "${QUEUE_PREFIX}.server.assigned"
-
-}
\ No newline at end of file
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Assignment.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Assignment.kt
new file mode 100644
index 0000000..2bb25ea
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Assignment.kt
@@ -0,0 +1,23 @@
+package net.mythicisland.queue.shared.match
+
+import build.buf.gen.mythicisland.queue.v2.assignment
+
+/**
+ * The server a match was allocated to.
+ *
+ * @param serverId the unique id of the simplecloud server.
+ * @param serverName the name used to connect players, for example battle-1.
+ */
+data class Assignment(
+ val serverId: String,
+ val serverName: String,
+) {
+
+ fun toDefinition(): build.buf.gen.mythicisland.queue.v2.Assignment {
+ return assignment {
+ serverId = this@Assignment.serverId
+ serverName = this@Assignment.serverName
+ }
+ }
+
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Match.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Match.kt
new file mode 100644
index 0000000..ad1225d
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Match.kt
@@ -0,0 +1,42 @@
+package net.mythicisland.queue.shared.match
+
+import build.buf.gen.mythicisland.queue.v2.MatchState
+import build.buf.gen.mythicisland.queue.v2.match
+import net.mythicisland.queue.shared.protobuf.toTimestamp
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * A match is a set of tickets.
+ *
+ * @param id the unique id of this match.
+ * @param queueType the queue type this match was created for.
+ * @param ticketIds the tickets forming this match.
+ * @param state the current state of this match.
+ * @param createdAt when the match was formed.
+ * @param assignment the allocated server, null while allocating.
+ * @param countdownEndsAt when the players get transferred, null until a server is there.
+ */
+data class Match(
+ val id: UUID,
+ val queueType: String,
+ val ticketIds: List,
+ val state: MatchState,
+ val createdAt: Instant,
+ val assignment: Assignment? = null,
+ val countdownEndsAt: Instant? = null,
+) {
+
+ fun toDefinition(tickets: List): build.buf.gen.mythicisland.queue.v2.Match {
+ return match {
+ id = this@Match.id.toString()
+ queueType = this@Match.queueType
+ this.tickets.addAll(tickets.map { it.toDefinition() })
+ state = this@Match.state
+ createdAt = this@Match.createdAt.toTimestamp()
+ this@Match.assignment?.let { assignment = it.toDefinition() }
+ this@Match.countdownEndsAt?.let { countdownEndTime = it.toTimestamp() }
+ }
+ }
+
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt
new file mode 100644
index 0000000..f398f47
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt
@@ -0,0 +1,55 @@
+package net.mythicisland.queue.shared.match
+
+import build.buf.gen.mythicisland.queue.v2.TicketState
+import build.buf.gen.mythicisland.queue.v2.ticket
+import net.mythicisland.queue.shared.protobuf.toTimestamp
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * A ticket is a single player or a party that wants to play.
+ *
+ * @param id the unique id of this ticket.
+ * @param playerIds the players behind this ticket, one entry means solo.
+ * @param queueTypes the queue types this ticket is searching in.
+ * @param state the current state of this ticket.
+ * @param createdAt when the ticket entered matchmaking.
+ * @param matchId the match this ticket was put into, null while searching.
+ * @param assignment the server to connect to, null until one was allocated.
+ * @param countdownEndsAt when the players get transferred, mirrored from the match.
+ */
+data class Ticket(
+ val id: UUID,
+ val playerIds: List,
+ val queueTypes: List,
+ val state: TicketState,
+ val createdAt: Instant,
+ val matchId: UUID? = null,
+ val assignment: Assignment? = null,
+ val countdownEndsAt: Instant? = null,
+) {
+
+ fun toDefinition(): build.buf.gen.mythicisland.queue.v2.Ticket {
+ return ticket {
+ id = this@Ticket.id.toString()
+ playerIds.addAll(this@Ticket.playerIds.map(UUID::toString))
+ queueTypes.addAll(this@Ticket.queueTypes)
+ state = this@Ticket.state
+ createdAt = this@Ticket.createdAt.toTimestamp()
+
+ this@Ticket.matchId?.let { matchId = it.toString() }
+ this@Ticket.assignment?.let { assignment = it.toDefinition() }
+ this@Ticket.countdownEndsAt?.let { countdownEndTime = it.toTimestamp() }
+ }
+ }
+
+ fun asSearching(): Ticket {
+ return copy(
+ state = TicketState.TICKET_STATE_SEARCHING,
+ matchId = null,
+ assignment = null,
+ countdownEndsAt = null,
+ )
+ }
+
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/nats/Subjects.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/nats/Subjects.kt
new file mode 100644
index 0000000..7e32a65
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/nats/Subjects.kt
@@ -0,0 +1,26 @@
+package net.mythicisland.queue.shared.nats
+
+/**
+ * The NATS subjects queue publishes its events on.
+ */
+object Subjects {
+
+ private const val PREFIX = "queue"
+
+ private const val TICKET_PREFIX = "${PREFIX}.ticket"
+
+ const val TICKET_CREATED = "${TICKET_PREFIX}.created"
+
+ const val TICKET_STATE_CHANGED = "${TICKET_PREFIX}.state.changed"
+
+ const val TICKET_DELETED = "${TICKET_PREFIX}.deleted"
+
+ private const val MATCH_PREFIX = "${PREFIX}.match"
+
+ const val MATCH_CREATED = "${MATCH_PREFIX}.created"
+
+ const val MATCH_STATE_CHANGED = "${MATCH_PREFIX}.state.changed"
+
+ const val MATCH_TRANSFERRED = "${MATCH_PREFIX}.transferred"
+
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/protobuf/ProtoExtensions.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/protobuf/ProtoExtensions.kt
new file mode 100644
index 0000000..d37e277
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/protobuf/ProtoExtensions.kt
@@ -0,0 +1,31 @@
+package net.mythicisland.queue.shared.protobuf
+
+import com.google.protobuf.Duration
+import com.google.protobuf.Timestamp
+import java.time.Instant
+
+/**
+ * Converts an [Instant] into its protobuf representation.
+ */
+fun Instant.toTimestamp(): Timestamp {
+ return Timestamp.newBuilder()
+ .setSeconds(epochSecond)
+ .setNanos(nano)
+ .build()
+}
+
+/**
+ * Converts a protobuf [Timestamp] back into an [Instant].
+ */
+fun Timestamp.toInstant(): Instant {
+ return Instant.ofEpochSecond(seconds, nanos.toLong())
+}
+
+/**
+ * Converts an amount of seconds into a protobuf [Duration].
+ */
+fun Long.toProtoDuration(): Duration {
+ return Duration.newBuilder()
+ .setSeconds(this)
+ .build()
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/Queue.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/Queue.kt
deleted file mode 100644
index 02f419d..0000000
--- a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/Queue.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package net.mythicisland.queue.shared.queue
-
-import app.simplecloud.api.server.Server
-import build.buf.gen.mythicisland.queue.v1.QueueStatus
-import java.util.UUID
-
-data class Queue(
- val id: UUID,
- val type: String,
- var status: QueueStatus,
- val players: MutableList,
- val capacity: Long = 0,
- var server: Server? = null,
-) {
- var waitingCountdownEndsAt: Long? = null
- var countdownEndsAt: Long? = null
-
- val waitingCountdownRemaining: Long
- get() = waitingCountdownEndsAt?.let { (it - System.currentTimeMillis()).coerceAtLeast(0) } ?: 0
- val countdownRemaining: Long
- get() = countdownEndsAt?.let { (it - System.currentTimeMillis()).coerceAtLeast(0) } ?: 0
-
- fun toDefinition() : build.buf.gen.mythicisland.queue.v1.Queue {
- return build.buf.gen.mythicisland.queue.v1.Queue.newBuilder()
- .setUniqueId(id.toString())
- .setType(type)
- .setStatus(status)
- .addAllPlayerIds(players.map { it.toString() })
- .build()
- }
-
-}
\ No newline at end of file
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueStats.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueStats.kt
new file mode 100644
index 0000000..357809b
--- /dev/null
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueStats.kt
@@ -0,0 +1,29 @@
+package net.mythicisland.queue.shared.queue
+
+import build.buf.gen.mythicisland.queue.v2.queueStats
+
+/**
+ * Live numbers of one queue type, for example to show "12 players searching".
+ *
+ * @param queueType the queue type these numbers belong to.
+ * @param searchingTickets the tickets currently searching in this queue type.
+ * @param searchingPlayers the players behind those tickets, parties counted fully.
+ * @param activeMatches the matches of this queue type that did not finish yet.
+ */
+data class QueueStats(
+ val queueType: String,
+ val searchingTickets: Int,
+ val searchingPlayers: Int,
+ val activeMatches: Int,
+) {
+
+ fun toDefinition(): build.buf.gen.mythicisland.queue.v2.QueueStats {
+ return queueStats {
+ queueType = this@QueueStats.queueType
+ searchingTickets = this@QueueStats.searchingTickets
+ searchingPlayers = this@QueueStats.searchingPlayers
+ activeMatches = this@QueueStats.activeMatches
+ }
+ }
+
+}
diff --git a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueType.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueType.kt
index 85996c1..56c1ed3 100644
--- a/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueType.kt
+++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueType.kt
@@ -1,26 +1,38 @@
package net.mythicisland.queue.shared.queue
+import build.buf.gen.mythicisland.queue.v2.queueType
+import net.mythicisland.queue.shared.protobuf.toProtoDuration
import org.spongepowered.configurate.objectmapping.ConfigSerializable
+/**
+ * The configuration of one queue.
+ *
+ * @param name the name of this queue type, for example battle.
+ * @param group the simplecloud group used to start game servers.
+ * @param minPlayers the minimum amount of players needed before a match may start.
+ * @param maxPlayers the maximum amount of players a match can hold.
+ * @param waitingDurationSeconds how long to wait for more players after minPlayers was reached.
+ * @param countdownDurationSeconds how long to count down once the server is ready.
+ */
@ConfigSerializable
data class QueueType(
val name: String = "",
val group: String = "",
- val maxCapacity: Long = -1L,
- val minCapacity: Long = -1L,
- val waitingCountdownSeconds: Long = 30L,
- val countdownSeconds: Long = 10L,
+ val minPlayers: Int = -1,
+ val maxPlayers: Int = -1,
+ val waitingDurationSeconds: Long = 30L,
+ val countdownDurationSeconds: Long = 10L,
) {
- fun toDefinition() : build.buf.gen.mythicisland.queue.v1.QueueType {
- return build.buf.gen.mythicisland.queue.v1.QueueType.newBuilder()
- .setName(name)
- .setGroup(group)
- .setMaxCapacity(maxCapacity.toInt())
- .setMinCapacity(minCapacity.toInt())
- .setWaitingCountdownMillis(waitingCountdownSeconds * 1000)
- .setCountdownMillis(countdownSeconds * 1000)
- .build()
+ fun toDefinition(): build.buf.gen.mythicisland.queue.v2.QueueType {
+ return queueType {
+ name = this@QueueType.name
+ group = this@QueueType.group
+ minPlayers = this@QueueType.minPlayers
+ maxPlayers = this@QueueType.maxPlayers
+ waitingDuration = this@QueueType.waitingDurationSeconds.toProtoDuration()
+ countdownDuration = this@QueueType.countdownDurationSeconds.toProtoDuration()
+ }
}
-}
\ No newline at end of file
+}