From ab5b52aed2f84ae104a88edc6c8e837382a97592 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 01:24:27 +0200 Subject: [PATCH 01/13] feat: proto specs --- README.md | 58 +-------- build.gradle.kts | 2 +- proto/mythicisland/queue/v2/api.proto | 122 ++++++++++++++++++ proto/mythicisland/queue/v2/events.proto | 54 ++++++++ proto/mythicisland/queue/v2/types.proto | 110 ++++++++++++++++ .../queue/api/event/player/DequeueEvent.java | 9 +- 6 files changed, 294 insertions(+), 61 deletions(-) create mode 100644 proto/mythicisland/queue/v2/api.proto create mode 100644 proto/mythicisland/queue/v2/events.proto create mode 100644 proto/mythicisland/queue/v2/types.proto diff --git a/README.md b/README.md index 1912255..61e0a46 100644 --- a/README.md +++ b/README.md @@ -1,56 +1,10 @@ -# Queue +# Queue v2 A microservice for queuing players into minigames. -## Architecture - -### Queue Lifecycle - -```mermaid -flowchart TD - NEP[NOT_ENOUGH_PLAYERS] -->|min players reached| WC[WAITING_COUNTDOWN] - WC -.->|players drop below min| NEP - WC -->|countdown expired or full| SS[SEARCHING_SERVER] - SS -->|server available| SR[SERVER_READY] - SS -->|no server free| WFS[WAITING_FOR_SERVER] - WFS -->|server becomes available| SR - SR --> CD[COUNTDOWN] - CD --> TP[TELEPORTING] - TP --> FIN[FINISHED] -``` - -| Status | Description | -|----------------------|---------------------------------------------------------------| -| `NOT_ENOUGH_PLAYERS` | Waiting for the minimum player count | -| `WAITING_COUNTDOWN` | Minimum reached, counting down while waiting for more players | -| `SEARCHING_SERVER` | Reserving an available game server | -| `WAITING_FOR_SERVER` | No server available yet, waiting for one | -| `SERVER_READY` | Server reserved, starting the game countdown | -| `COUNTDOWN` | Final countdown before teleport | -| `TELEPORTING` | Transferring players to the game server | -| `FINISHED` | Cleanup: free server, delete queue | - -### Queue Type Configuration - -Queue types are defined as YAML files in the types directory: - -```yml -name: bedwars -group: bedwars -max-capacity: 6 -min-capacity: 12 -waiting-countdown-seconds: 30 -countdown-seconds: 10 -``` - -| Field | Description | -|-----------------------------|----------------------------------------------------------------| -| `name` | Unique identifier for this queue type | -| `group` | SimpleCloud server group to use for game servers | -| `min-capacity` | Minimum players required to start the waiting countdown | -| `max-capacity` | Maximum players per queue (starts immediately when full) | -| `waiting-countdown-seconds` | Seconds to wait for more players after minimum is reached | -| `countdown-seconds` | Seconds to count down before teleporting after server is ready | - ## TODO -- [ x ] **Multi Queue**: Queue players in multiplie queues \ No newline at end of file +- [ ] **Proto Specs**: Write the Protocol Buffers for v2 +- [ ] **Runtime Implementation**: Make the microservice work +- [ ] **API Implementation**: Write the Java and Kotlin API for v2 +- [ ] **Multi Queue**: After v2 ist working good implement multi-queue for players +- [ ] **Queue Rating**: Maybe implement ratings for queues depend on the player count that player in the last weeks idk things like: Good Okay Dead etc. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 20db586..1cd7997 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ plugins { allprojects { group = "net.mythicisland.queue" - version = "1.0.0" + version = "2.0.0-beta.1" repositories { mavenCentral() diff --git a/proto/mythicisland/queue/v2/api.proto b/proto/mythicisland/queue/v2/api.proto new file mode 100644 index 0000000..d15cb61 --- /dev/null +++ b/proto/mythicisland/queue/v2/api.proto @@ -0,0 +1,122 @@ +syntax = "proto3"; + +package mythicisland.queue.v2; + +import "types.proto"; + +option java_package = "net.mythicisland.queue.v2"; +option java_multiple_files = true; + +service TicketService { + // Creates a ticket for a single player or a party. + rpc CreateTicket(CreateTicketRequest) returns (CreateTicketResponse); + // Removes a ticket from matchmaking. Always removes the whole party. + rpc DeleteTicket(DeleteTicketRequest) returns (DeleteTicketResponse); +} + +message CreateTicketRequest { + // The players to enqueue. One entry = solo, multiple = party. + repeated string player_ids = 1; + // The queue types to search in. At least one entry is required. + repeated string queue_types = 2; +} + +message CreateTicketResponse { + Ticket ticket = 1; +} + +message DeleteTicketRequest { + oneof target { + // Deletes this ticket. + string ticket_id = 1; + // Deletes the ticket this player belongs to, including their party. + string player_id = 2; + } +} + +message DeleteTicketResponse {} + +service QueueDataService { + rpc GetTicket(GetTicketRequest) returns (GetTicketResponse); + rpc GetTicketByPlayer(GetTicketByPlayerRequest) returns (GetTicketByPlayerResponse); + rpc ListTickets(ListTicketsRequest) returns (ListTicketsResponse); + + rpc GetMatch(GetMatchRequest) returns (GetMatchResponse); + rpc ListMatches(ListMatchesRequest) returns (ListMatchesResponse); + + rpc GetQueueType(GetQueueTypeRequest) returns (GetQueueTypeResponse); + rpc ListQueueTypes(ListQueueTypesRequest) returns (ListQueueTypesResponse); + + rpc GetQueueStats(GetQueueStatsRequest) returns (GetQueueStatsResponse); + rpc ListQueueStats(ListQueueStatsRequest) returns (ListQueueStatsResponse); +} + +message GetTicketRequest { + string ticket_id = 1; +} + +message GetTicketResponse { + Ticket ticket = 1; +} + +message GetTicketByPlayerRequest { + string player_id = 1; +} + +message GetTicketByPlayerResponse { + Ticket ticket = 1; +} + +message ListTicketsRequest { + // Only return tickets searching in this queue type. Empty returns all. + string queue_type = 1; +} + +message ListTicketsResponse { + repeated Ticket tickets = 1; +} + +message GetMatchRequest { + string match_id = 1; +} + +message GetMatchResponse { + Match match = 1; +} + +message ListMatchesRequest { + // Only return matches of this queue type. Empty returns all. + string queue_type = 1; +} + +message ListMatchesResponse { + repeated Match matches = 1; +} + +message GetQueueTypeRequest { + string name = 1; +} + +message GetQueueTypeResponse { + QueueType queue_type = 1; +} + +message ListQueueTypesRequest {} + +message ListQueueTypesResponse { + repeated QueueType queue_types = 1; +} + +message GetQueueStatsRequest { + string queue_type = 1; +} + +message GetQueueStatsResponse { + QueueStats stats = 1; +} + +message ListQueueStatsRequest {} + +message ListQueueStatsResponse { + repeated QueueStats stats = 1; +} diff --git a/proto/mythicisland/queue/v2/events.proto b/proto/mythicisland/queue/v2/events.proto new file mode 100644 index 0000000..2538b74 --- /dev/null +++ b/proto/mythicisland/queue/v2/events.proto @@ -0,0 +1,54 @@ +syntax = "proto3"; + +package mythicisland.queue.v2; + +import "types.proto"; + +option java_package = "net.mythicisland.queue.v2"; +option java_multiple_files = true; + +// Published when a player or party entered matchmaking. +message TicketCreatedEvent { + Ticket ticket = 1; +} + +// Published on every state transition of a ticket. The ticket already carries +// the new state, so only the old one is extra. +message TicketStateChangedEvent { + Ticket ticket = 1; + TicketState previous_state = 2; +} + +// Published when a ticket leaves matchmaking, for whatever reason. +message TicketDeletedEvent { + Ticket ticket = 1; + TicketDeleteReason reason = 2; +} + +enum TicketDeleteReason { + TICKET_DELETE_REASON_UNSPECIFIED = 0; + // The player or party left the queue on purpose. + TICKET_DELETE_REASON_CANCELLED = 1; + // The players were transferred to their game server, matchmaking is done. + TICKET_DELETE_REASON_TRANSFERRED = 2; + // The ticket was dropped, for example because the players went offline. + TICKET_DELETE_REASON_EXPIRED = 3; +} + +// Published when enough tickets were found to form a match. +message MatchCreatedEvent { + Match match = 1; +} + +// Published on every state transition of a match. +message MatchStateChangedEvent { + Match match = 1; + MatchState previous_state = 2; +} + +// Published after the transfer attempt. Players that were offline or failed to +// connect are missing from transferred_player_ids. +message MatchTransferredEvent { + Match match = 1; + repeated string transferred_player_ids = 2; +} diff --git a/proto/mythicisland/queue/v2/types.proto b/proto/mythicisland/queue/v2/types.proto new file mode 100644 index 0000000..0e7f31b --- /dev/null +++ b/proto/mythicisland/queue/v2/types.proto @@ -0,0 +1,110 @@ +syntax = "proto3"; + +package mythicisland.queue.v2; + +import "google/protobuf/duration.proto"; +import "google/protobuf/timestamp.proto"; + +option java_package = "net.mythicisland.queue.v2"; +option java_multiple_files = true; + +// A Ticket represents a single player or a group of players. +message Ticket { + // The unique identifier of this ticket. + string id = 1; + // The players this ticket belongs to. + repeated string player_ids = 2; + // The queue types this ticket is searching in. + repeated string queue_types = 3; + // The current state of this ticket. + TicketState state = 4; + // The match this ticket was put into. Empty while still searching. + string match_id = 5; + // The server to connect to. Only set once the match got a server. + Assignment assignment = 6; + // When the countdown ends and the players get transferred. Mirrored from the + // match so consumers can show the countdown without loading the match. + google.protobuf.Timestamp countdown_end_time = 7; + // When the ticket entered matchmaking. Used to decide when a match with at + // least min_players may start instead of waiting for a full lobby. + google.protobuf.Timestamp created_at = 8; +} + +enum TicketState { + TICKET_STATE_UNSPECIFIED = 0; + // Waiting in one or more queue types. + TICKET_STATE_SEARCHING = 1; + // Part of a match that is waiting for a server or counting down. + TICKET_STATE_MATCHED = 2; + // A server was allocated, the players are being transferred. + TICKET_STATE_ASSIGNED = 3; +} + +// A Match is a set of tickets that will play together on one server. +message Match { + // The unique identifier of this match. + string id = 1; + // The queue type this match was created for. + string queue_type = 2; + // The tickets forming this match. They stay grouped. + repeated Ticket tickets = 3; + // The current state of this match. + MatchState state = 4; + // The server allocated for this match. Empty while allocating. + Assignment assignment = 5; + // When the countdown ends and the players get transferred. + google.protobuf.Timestamp countdown_end_time = 6; + // When the match was formed. + google.protobuf.Timestamp created_at = 7; +} + +enum MatchState { + MATCH_STATE_UNSPECIFIED = 0; + // A server is being searched or started for this match. + MATCH_STATE_ALLOCATING = 1; + // The server is ready, counting down before the transfer. + MATCH_STATE_COUNTDOWN = 2; + // The players are being transferred to the server. + MATCH_STATE_TRANSFERRING = 3; + // Every player was transferred, the match is handed over to the game server. + MATCH_STATE_COMPLETED = 4; + // No server could be allocated, the tickets went back to searching. + MATCH_STATE_FAILED = 5; +} + +// The result of matchmaking: the server the players have to connect to. +message Assignment { + // The unique identifier of the simplecloud server. + string server_id = 1; + // The name of the server used to connect players, for example: battle-1 + string server_name = 2; +} + +// The configuration of one queue. +message QueueType { + // The name of this queue type, for example: battle + string name = 1; + // The simplecloud group used to start game servers, for example: battle + string group = 2; + // The minimum amount of players needed before a match may start. + uint32 min_players = 3; + // The maximum amount of players a match can hold. Reaching it starts the + // match immediately, without waiting. + uint32 max_players = 4; + // How long to wait for more players after min_players was reached. + google.protobuf.Duration waiting_duration = 5; + // How long to count down after the server is ready, before transferring. + google.protobuf.Duration countdown_duration = 6; +} + +// Live numbers for one queue type, for example to show "12 players searching". +message QueueStats { + // The queue type these numbers belong to. + string queue_type = 1; + // The tickets currently searching in this queue type. + uint32 searching_tickets = 2; + // The players behind those tickets, parties counted fully. + uint32 searching_players = 3; + // The matches of this queue type that are allocating or counting down. + uint32 active_matches = 4; +} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java index cd2797c..7c084f5 100644 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java @@ -14,11 +14,4 @@ * @param queuePlayerIds all player UUIDs remaining in the queue * @param playerIds the UUIDs of the players that were dequeued */ -public record DequeueEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds, - List playerIds -) { -} +public record DequeueEvent(UUID queueId, String queueType, QueueStatus queueStatus, List queuePlayerIds, List playerIds) { } From 2e17e979f487fdb7007cb8076b535cc32dd7103d Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 02:27:00 +0200 Subject: [PATCH 02/13] feat: basic runtime implementation --- README.md | 2 +- c.md | 20 + gradle/libs.versions.toml | 2 +- proto/buf.yaml | 2 +- .../queue/runtime/QueueRuntime.kt | 47 +- .../queue/runtime/event/EventPublisher.kt | 154 +++---- .../queue/runtime/match/MatchReconciler.kt | 258 +++++++++++ .../queue/runtime/match/Matchmaker.kt | 146 ++++++ .../runtime/reconciler/QueueReconciler.kt | 416 ------------------ .../runtime/repository/MatchRepository.kt | 116 +++++ .../runtime/repository/QueueRepository.kt | 229 ---------- .../runtime/repository/QueueTypeRepository.kt | 4 +- .../queue/runtime/server/ServerAllocator.kt | 156 +++++++ .../queue/runtime/server/ServerFinder.kt | 141 ------ .../queue/runtime/service/QueueDataService.kt | 117 +++-- .../queue/runtime/service/QueueService.kt | 45 -- .../queue/runtime/service/TicketService.kt | 108 +++++ .../queue/runtime/ticket/TicketPool.kt | 48 ++ .../queue/runtime/ticket/TicketStore.kt | 120 +++++ .../queue/shared/event/Subjects.kt | 25 -- .../queue/shared/match/Assignment.kt | 23 + .../mythicisland/queue/shared/match/Match.kt | 51 +++ .../mythicisland/queue/shared/match/Ticket.kt | 68 +++ .../queue/shared/nats/Subjects.kt | 29 ++ .../queue/shared/protobuf/ProtoExtensions.kt | 31 ++ .../mythicisland/queue/shared/queue/Queue.kt | 32 -- .../queue/shared/queue/QueueStats.kt | 29 ++ .../queue/shared/queue/QueueType.kt | 40 +- 28 files changed, 1397 insertions(+), 1062 deletions(-) create mode 100644 c.md create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/MatchReconciler.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt delete mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/reconciler/QueueReconciler.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/MatchRepository.kt delete mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/QueueRepository.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt delete mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerFinder.kt delete mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/QueueService.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/TicketService.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketPool.kt create mode 100644 queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketStore.kt delete mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/event/Subjects.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Assignment.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Match.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/nats/Subjects.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/protobuf/ProtoExtensions.kt delete mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/Queue.kt create mode 100644 queue-shared/src/main/kotlin/net/mythicisland/queue/shared/queue/QueueStats.kt diff --git a/README.md b/README.md index 61e0a46..e1cb20a 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A microservice for queuing players into minigames. ## TODO -- [ ] **Proto Specs**: Write the Protocol Buffers for v2 +- [x] **Proto Specs**: Write the Protocol Buffers for v2 - [ ] **Runtime Implementation**: Make the microservice work - [ ] **API Implementation**: Write the Java and Kotlin API for v2 - [ ] **Multi Queue**: After v2 ist working good implement multi-queue for players diff --git a/c.md b/c.md new file mode 100644 index 0000000..d63341c --- /dev/null +++ b/c.md @@ -0,0 +1,20 @@ +Structure + +net.mythicisland.queue.shared.match.Match +net.mythicisland.queue.shared.match.Ticket +net.mythicisland.queue.shared.match.Assignment +net.mythicisland.queue.shared.queue.QueueType +net.mythicisland.queue.shared.queue.QueueStats +net.mythicisland.queue.shared.nats.Subjects + +net.mythicisland.queue.runtime.QueueRuntime +net.mythicisland.queue.runtime.launcher.Launcher +net.mythicisland.queue.runtime.launcher.QueueStartCommand +net.mythicisland.queue.runtime.service.TicketService +net.mythicisland.queue.runtime.service.QueueDataService +net.mythicisland.queue.runtime.ticket.TicketStore +net.mythicisland.queue.runtime.ticket.TicketPool +net.mythicisland.queue.runtime.repository.QueueTypeRepository +net.mythicisland.queue.runtime.repository.MatchRepository +net.mythicisland.queue.runtime.match.Matchmaker +net.mythicisland.queue.runtime.match.MatchReconciler \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 81c23bc..9e96827 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,7 +13,7 @@ jnats = "2.26.0" adventure = "5.2.0" configurate = "4.2.0" cloud-api = "0.1.0-platform.45" -queue-proto = "1.5.0.4.20260710134554.c3cd3d34e56f" +queue-proto = "1.5.0.4.20260803235217.273a6fa06034" moonrise = "1.2.1" protobuf = "4.35.1" diff --git a/proto/buf.yaml b/proto/buf.yaml index 4d99f70..566a535 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -1,6 +1,6 @@ version: v2 modules: - - path: mythicisland/queue/v1 + - path: mythicisland/queue/v2 name: buf.build/mythicisland/queue lint: use: 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..2ae0248 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 @@ -10,14 +10,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 +32,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 +59,10 @@ 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) - } - } - } + // Free the servers of matches that never made it to a transfer. + matchRepository.getAll().forEach { allocator.release(it) } + + matchmaker.shutdown() reconciler.shutdown() queueTypeRepository.close() manager.shutdown() @@ -90,8 +95,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..0028576 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,110 @@ 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. + * + * Matches only store ticket ids, so every match event takes the resolved + * tickets as well instead of looking them up itself. */ 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..e770152 --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/MatchReconciler.kt @@ -0,0 +1,258 @@ +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. + * + * ``` + * ALLOCATING -> COUNTDOWN -> TRANSFERRING -> COMPLETED + * | | + * +--------------> FAILED <----------------+ + * ``` + * + * Everything runs in a single loop, so a match is never reconciled twice at + * the same time and the states cannot interleave. + */ +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 companion object { + val INTERVAL = 500.milliseconds + + /** How long a match may look for a server before it is given up on. */ + const val ALLOCATION_TIMEOUT_SECONDS = 60L + } + + 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 (interval={})", INTERVAL) + scope.launch { + while (isActive) { + delay(INTERVAL) + 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: 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 >= ALLOCATION_TIMEOUT_SECONDS) { + fail(match, "no server became available within ${ALLOCATION_TIMEOUT_SECONDS}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) { + allocator.forget(match) + 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 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 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 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..5511502 --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt @@ -0,0 +1,146 @@ +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. + * + * The matchmaker runs a pass over every queue type on a fixed interval. All it + * does is pick tickets and hand the result to the [MatchRepository], driving + * the match afterwards is the job of the [MatchReconciler]. + */ +class Matchmaker( + private val tickets: TicketStore, + private val pool: TicketPool, + private val matches: MatchRepository, + private val types: QueueTypeRepository, + private val publisher: EventPublisher, +) { + + private companion object { + val INTERVAL = 500.milliseconds + } + + private val logger = LogManager.getLogger(Matchmaker::class.java) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + + /** + * Starts the matchmaking loop. + */ + fun start() { + logger.info("Starting up matchmaker (interval={})", INTERVAL) + scope.launch { + while (isActive) { + delay(INTERVAL) + runCatching { tick() } + .onFailure { logger.error("Matchmaking pass failed", it) } + } + } + } + + /** + * Stops the matchmaking loop. + */ + fun shutdown() { + logger.info("Shutting down matchmaker...") + scope.cancel() + } + + /** + * Runs one matchmaking pass over every queue type. + */ + 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 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(), + ) + + // Fails when one of the tickets was matched or cancelled in between, + // the next pass simply tries again with what is left. + 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.playerCount }, + ) + + 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 -> + // Parties that do not fit into the remaining slots are skipped, the + // smaller tickets behind them can still get in. + val players = picked.sumOf { it.playerCount } + if (players + ticket.playerCount <= type.maxPlayers) picked + ticket else picked + } + + val players = selected.sumOf { it.playerCount } + 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..77195cf --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/repository/MatchRepository.kt @@ -0,0 +1,116 @@ +package net.mythicisland.queue.runtime.repository + +import build.buf.gen.mythicisland.queue.v2.MatchState +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 lock = Any() + 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. + */ + fun add(match: Match) { + synchronized(lock) { + 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. + */ + fun update(match: Match): Match? { + synchronized(lock) { + 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. + */ + fun removeTicket(ticketId: UUID): Match? { + synchronized(lock) { + 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. + */ + fun remove(id: UUID): Match? { + synchronized(lock) { + 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..ec39ca3 --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt @@ -0,0 +1,156 @@ +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.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 + +/** + * Hands out simplecloud servers to matches. + * + * The reservations live here and not in a server property, so two matches can + * never grab the same server. The property is only written as a marker, it + * makes it easy to see on the cloud side which match a server belongs to. + * + * @param api the simplecloud api. + */ +class ServerAllocator( + private val api: CloudApi, +) { + + private companion object { + const val MATCH_PROPERTY = "match-id" + } + + private val logger = LogManager.getLogger(ServerAllocator::class.java) + + private val reservations = ConcurrentHashMap() + private val requested = ConcurrentHashMap.newKeySet() + + /** + * Tries to reserve 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 { isFree(it) } + + if (free == null) { + requestServer(match, type) + return null + } + + // Reserving with putIfAbsent keeps two matches from taking the same + // server, even if they are allocated at the same time. + val owner = reservations.putIfAbsent(free.serverId, match.id) + if (owner != null && owner != match.id) { + logger.debug("Server {} was taken by match {}, match {} keeps waiting", free.serverId, owner, match.id) + return null + } + + requested.remove(match.id) + markServer(free, match) + + val assignment = Assignment(free.serverId, "${free.group.name}-${free.numericalId}") + logger.info("Reserved server {} ({}) for match {}", assignment.serverName, assignment.serverId, match.id) + return assignment + } + + /** + * Frees the server of a match that never made it to the transfer, so it + * can be handed to the next match. + * + * @param match the failed match. + */ + suspend fun release(match: Match) { + requested.remove(match.id) + + val serverId = reservations.entries.firstOrNull { it.value == match.id }?.key + if (serverId == null) { + logger.debug("Match {} had no server to release", match.id) + return + } + + reservations.remove(serverId, match.id) + clearServer(serverId) + logger.info("Released server {} of match {}", serverId, match.id) + } + + /** + * Forgets the reservation of a finished match. The server is busy running + * the game now, so the marker property stays for debugging. + * + * @param match the finished match. + */ + fun forget(match: Match) { + requested.remove(match.id) + reservations.entries.removeAll { it.value == match.id } + } + + /** + * A server can be taken when it is idle, not reserved by another match and + * not marked for one either. + */ + private fun isFree(server: Server): Boolean { + if (server.state != ServerState.AVAILABLE) return false + if (reservations.containsKey(server.serverId)) return false + + val marker = server.properties[MATCH_PROPERTY] as? String + return marker.isNullOrEmpty() + } + + /** + * Asks simplecloud for a new server, at most once per match. + */ + 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) { + // Allow another attempt on the next reconciliation. + requested.remove(match.id) + logger.error("Failed to request a server in group '{}' for match {}", type.group, match.id, e) + } + } + + private suspend fun markServer(server: Server, match: Match) { + try { + api.server().updateServerProperties(server.serverId, mapOf(MATCH_PROPERTY to match.id.toString())).await() + } catch (e: Exception) { + // Only a marker, the reservation above is what actually counts. + logger.warn("Failed to mark server {} with match {}", server.serverId, match.id, e) + } + } + + private suspend fun clearServer(serverId: String) { + try { + api.server().updateServerProperties(serverId, mapOf(MATCH_PROPERTY to "")).await() + } catch (e: Exception) { + logger.warn("Failed to clear the match marker of server {}", serverId, e) + } + } + +} 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..0365f43 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,77 @@ 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 +/** + * Read only access to tickets, matches and the queue configuration. + */ 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 +84,36 @@ 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() }) } } + + /** + * Builds the protobuf of a match, resolving its tickets from the store. + */ + 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..7f8e75a --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/service/TicketService.kt @@ -0,0 +1,108 @@ +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 + +/** + * Puts players into matchmaking and takes them back out. + * + * Everything that happens afterwards is published as an event, see + * [net.mythicisland.queue.shared.nats.Subjects]. + */ +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() + } + + // A party bigger than the match itself would never be picked, so say so + // instead of letting it search forever. + 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.playerCount) + 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..597e409 --- /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.playerCount }, + 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..6388710 --- /dev/null +++ b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/ticket/TicketStore.kt @@ -0,0 +1,120 @@ +package net.mythicisland.queue.runtime.ticket + +import build.buf.gen.mythicisland.queue.v2.TicketState +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. + * + * Reads go straight to the maps, writes that touch more than one map are + * guarded by a lock so a ticket and its player index can never drift apart. + */ +class TicketStore { + + private val logger = LogManager.getLogger(TicketStore::class.java) + + private val lock = Any() + 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. + */ + fun add(ticket: Ticket): Boolean { + synchronized(lock) { + 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. + */ + fun update(ticket: Ticket): Ticket? { + synchronized(lock) { + 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. + */ + fun remove(id: UUID): Ticket? { + synchronized(lock) { + 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. + */ + fun matched(ids: List, matchId: UUID): List? { + synchronized(lock) { + 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-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..bb3f62f --- /dev/null +++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Match.kt @@ -0,0 +1,51 @@ +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 that will play together on one server. + * + * Only the ticket ids are stored, the tickets themselves live in the ticket + * store. That way there is exactly one place holding the ticket state. + * + * @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, +) { + + /** + * Builds the protobuf representation of this match. + * + * @param tickets the tickets of this match, resolved from the ticket store. + */ + 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..27abab9 --- /dev/null +++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt @@ -0,0 +1,68 @@ +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. It is the atomic + * unit of matchmaking, a party is never split up. + * + * Tickets are immutable, every change creates a copy. That keeps them safe to + * hand out to other threads while the runtime keeps working with them. + * + * @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, +) { + + /** + * The amount of players this ticket brings into a match. + */ + val playerCount: Int + get() = playerIds.size + + 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() } + } + } + + /** + * Returns a copy that is searching again, without any match leftovers. + */ + 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..16e81b0 --- /dev/null +++ b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/nats/Subjects.kt @@ -0,0 +1,29 @@ +package net.mythicisland.queue.shared.nats + +/** + * The NATS subjects queue publishes its events on. + * + * The version is part of the subject so v1 and v2 can run side by side while + * consumers are migrated. + */ +object Subjects { + + private const val PREFIX = "queue.v2" + + 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..28fcff5 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, loaded from a yaml file. + * + * @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 +} From 36545eaefaa6cfbb87c768b7c310a01f6eae8cb6 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 02:47:19 +0200 Subject: [PATCH 03/13] feat: update server state to INGAME on allocation --- .../queue/runtime/QueueRuntime.kt | 8 +- .../queue/runtime/event/EventPublisher.kt | 3 - .../queue/runtime/match/MatchReconciler.kt | 10 ++- .../queue/runtime/match/Matchmaker.kt | 15 ++-- .../runtime/repository/MatchRepository.kt | 20 +++-- .../queue/runtime/server/ServerAllocator.kt | 90 +++++-------------- .../queue/runtime/service/QueueDataService.kt | 6 -- .../queue/runtime/service/TicketService.kt | 8 -- .../queue/runtime/ticket/TicketStore.kt | 23 +++-- .../mythicisland/queue/shared/match/Match.kt | 11 +-- .../mythicisland/queue/shared/match/Ticket.kt | 12 +-- .../queue/shared/nats/Subjects.kt | 5 +- .../queue/shared/queue/QueueType.kt | 2 +- 13 files changed, 71 insertions(+), 142 deletions(-) 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 2ae0248..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 @@ -59,8 +60,11 @@ class QueueRuntime( Runtime.getRuntime().addShutdownHook(Thread { logger.info("Shutting down Queue...") runBlocking { - // Free the servers of matches that never made it to a transfer. - matchRepository.getAll().forEach { allocator.release(it) } + // 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() 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 0028576..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 @@ -10,9 +10,6 @@ import java.util.UUID /** * Publishes ticket and match events to NATS. - * - * Matches only store ticket ids, so every match event takes the resolved - * tickets as well instead of looking them up itself. */ class EventPublisher( connection: Connection 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 index e770152..74b3127 100644 --- 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 @@ -79,6 +79,8 @@ class MatchReconciler( matches.getAll().forEach { match -> try { reconcile(match) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger.error("Failed to reconcile match {}", match.id, e) } @@ -201,7 +203,7 @@ class MatchReconciler( val matchTickets = tickets.getAll(match.ticketIds) if (match.state == MatchState.MATCH_STATE_COMPLETED) { - allocator.forget(match) + // The server stays ingame, it is running the match from now on. matchTickets.forEach { ticket -> tickets.remove(ticket.id) publisher.publishTicketDeleted(ticket, TicketDeleteReason.TICKET_DELETE_REASON_TRANSFERRED) @@ -223,7 +225,7 @@ class MatchReconciler( * 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 fun assignTickets(match: Match, assignment: Assignment, countdownEndsAt: Instant) { + 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, @@ -239,7 +241,7 @@ class MatchReconciler( /** * Moves a match into a new state and tells everyone about it. */ - private fun transition(match: Match, state: MatchState) { + 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) @@ -250,7 +252,7 @@ class MatchReconciler( publisher.publishMatchStateChanged(updated, tickets.getAll(updated.ticketIds), match.state) } - private fun fail(match: Match, reason: String) { + 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 index 5511502..fddb21a 100644 --- 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 @@ -37,7 +37,7 @@ class Matchmaker( } private val logger = LogManager.getLogger(Matchmaker::class.java) - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) /** * Starts the matchmaking loop. @@ -47,8 +47,13 @@ class Matchmaker( scope.launch { while (isActive) { delay(INTERVAL) - runCatching { tick() } - .onFailure { logger.error("Matchmaking pass failed", it) } + try { + tick() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Matchmaking pass failed", e) + } } } } @@ -64,7 +69,7 @@ class Matchmaker( /** * Runs one matchmaking pass over every queue type. */ - fun tick() { + 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. @@ -79,7 +84,7 @@ class Matchmaker( * * @return the created match, or null if the queue type cannot start one yet. */ - private fun createMatch(type: QueueType): Match? { + private suspend fun createMatch(type: QueueType): Match? { val candidates = pool.searching(type.name) val selected = select(candidates, type, Instant.now()) ?: return null 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 index 77195cf..7c59817 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -16,7 +18,7 @@ class MatchRepository { private val logger = LogManager.getLogger(MatchRepository::class.java) - private val lock = Any() + private val mutex = Mutex() private val matches = ConcurrentHashMap() private val ticketToMatch = ConcurrentHashMap() @@ -58,8 +60,8 @@ class MatchRepository { /** * Adds a newly formed match. */ - fun add(match: Match) { - synchronized(lock) { + suspend fun add(match: Match) { + mutex.withLock { matches[match.id] = match match.ticketIds.forEach { ticketToMatch[it] = match.id } } @@ -70,8 +72,8 @@ class MatchRepository { * * @return the stored match, or null if it was removed in the meantime. */ - fun update(match: Match): Match? { - synchronized(lock) { + 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 @@ -88,8 +90,8 @@ class MatchRepository { * * @return the match the ticket was dropped from, or null if it was in none. */ - fun removeTicket(ticketId: UUID): Match? { - synchronized(lock) { + suspend fun removeTicket(ticketId: UUID): Match? { + mutex.withLock { val matchId = ticketToMatch.remove(ticketId) ?: return null val match = matches[matchId] ?: return null @@ -105,8 +107,8 @@ class MatchRepository { * * @return the removed match, or null if it was not stored. */ - fun remove(id: UUID): Match? { - synchronized(lock) { + 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/server/ServerAllocator.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/server/ServerAllocator.kt index ec39ca3..6594103 100644 --- 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 @@ -1,8 +1,8 @@ package net.mythicisland.queue.runtime.server import app.simplecloud.api.CloudApi -import app.simplecloud.api.server.Server 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 @@ -12,11 +12,7 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** - * Hands out simplecloud servers to matches. - * - * The reservations live here and not in a server property, so two matches can - * never grab the same server. The property is only written as a marker, it - * makes it easy to see on the cloud side which match a server belongs to. + * Handle server allocation. * * @param api the simplecloud api. */ @@ -24,17 +20,12 @@ class ServerAllocator( private val api: CloudApi, ) { - private companion object { - const val MATCH_PROPERTY = "match-id" - } - private val logger = LogManager.getLogger(ServerAllocator::class.java) - private val reservations = ConcurrentHashMap() private val requested = ConcurrentHashMap.newKeySet() /** - * Tries to reserve a free server for a match. + * 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. @@ -45,75 +36,47 @@ class ServerAllocator( */ suspend fun allocate(match: Match, type: QueueType): Assignment? { val servers = api.server().getServersByGroup(type.group).await() - val free = servers.firstOrNull { isFree(it) } + val free = servers.firstOrNull { it.state == ServerState.AVAILABLE } if (free == null) { requestServer(match, type) return null } - // Reserving with putIfAbsent keeps two matches from taking the same - // server, even if they are allocated at the same time. - val owner = reservations.putIfAbsent(free.serverId, match.id) - if (owner != null && owner != match.id) { - logger.debug("Server {} was taken by match {}, match {} keeps waiting", free.serverId, owner, match.id) + if (!updateState(free.serverId, ServerState.INGAME)) { + logger.error("Failed to take server {} for match {}", free.serverId, match.id) return null } requested.remove(match.id) - markServer(free, match) val assignment = Assignment(free.serverId, "${free.group.name}-${free.numericalId}") - logger.info("Reserved server {} ({}) for match {}", assignment.serverName, assignment.serverId, match.id) + logger.info("Took server {} ({}) for match {}", assignment.serverName, assignment.serverId, match.id) return assignment } /** - * Frees the server of a match that never made it to the transfer, so it - * can be handed to the next match. + * 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 serverId = reservations.entries.firstOrNull { it.value == match.id }?.key - if (serverId == null) { + val assignment = match.assignment + if (assignment == null) { logger.debug("Match {} had no server to release", match.id) return } - reservations.remove(serverId, match.id) - clearServer(serverId) - logger.info("Released server {} of match {}", serverId, match.id) - } - - /** - * Forgets the reservation of a finished match. The server is busy running - * the game now, so the marker property stays for debugging. - * - * @param match the finished match. - */ - fun forget(match: Match) { - requested.remove(match.id) - reservations.entries.removeAll { it.value == match.id } - } - - /** - * A server can be taken when it is idle, not reserved by another match and - * not marked for one either. - */ - private fun isFree(server: Server): Boolean { - if (server.state != ServerState.AVAILABLE) return false - if (reservations.containsKey(server.serverId)) return false - - val marker = server.properties[MATCH_PROPERTY] as? String - return marker.isNullOrEmpty() + 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) + } } - /** - * Asks simplecloud for a new server, at most once per match. - */ 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) @@ -130,26 +93,19 @@ class ServerAllocator( api.group().requestServerStart(group).await() logger.info("Requested a new server in group '{}' for match {}", type.group, match.id) } catch (e: Exception) { - // Allow another attempt on the next reconciliation. requested.remove(match.id) logger.error("Failed to request a server in group '{}' for match {}", type.group, match.id, e) } } - private suspend fun markServer(server: Server, match: Match) { - try { - api.server().updateServerProperties(server.serverId, mapOf(MATCH_PROPERTY to match.id.toString())).await() - } catch (e: Exception) { - // Only a marker, the reservation above is what actually counts. - logger.warn("Failed to mark server {} with match {}", server.serverId, match.id, e) - } - } - - private suspend fun clearServer(serverId: String) { - try { - api.server().updateServerProperties(serverId, mapOf(MATCH_PROPERTY to "")).await() + 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.warn("Failed to clear the match marker of server {}", serverId, e) + logger.error("Failed to update server {} to state {}", serverId, state, e) + false } } 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 0365f43..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 @@ -11,9 +11,6 @@ import net.mythicisland.queue.shared.match.Match import net.mythicisland.queue.shared.queue.QueueStats import net.mythicisland.queue.shared.queue.QueueType -/** - * Read only access to tickets, matches and the queue configuration. - */ class QueueDataService( private val tickets: TicketStore, private val pool: TicketPool, @@ -105,9 +102,6 @@ class QueueDataService( } } - /** - * Builds the protobuf of a match, resolving its tickets from the store. - */ private fun toDefinition(match: Match): build.buf.gen.mythicisland.queue.v2.Match { return match.toDefinition(tickets.getAll(match.ticketIds)) } 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 index 7f8e75a..05e6fa9 100644 --- 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 @@ -12,12 +12,6 @@ import org.apache.logging.log4j.LogManager import java.time.Instant import java.util.UUID -/** - * Puts players into matchmaking and takes them back out. - * - * Everything that happens afterwards is published as an event, see - * [net.mythicisland.queue.shared.nats.Subjects]. - */ class TicketService( private val tickets: TicketStore, private val matches: MatchRepository, @@ -51,8 +45,6 @@ class TicketService( .asRuntimeException() } - // A party bigger than the match itself would never be picked, so say so - // instead of letting it search forever. 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 }) 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 index 6388710..ca43147 100644 --- 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 @@ -1,6 +1,8 @@ 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 @@ -8,15 +10,12 @@ import java.util.concurrent.ConcurrentHashMap /** * Holds every ticket that is currently in matchmaking. - * - * Reads go straight to the maps, writes that touch more than one map are - * guarded by a lock so a ticket and its player index can never drift apart. */ class TicketStore { private val logger = LogManager.getLogger(TicketStore::class.java) - private val lock = Any() + private val mutex = Mutex() private val tickets = ConcurrentHashMap() private val playerToTicket = ConcurrentHashMap() @@ -53,8 +52,8 @@ class TicketStore { * * @return true if the ticket was added. */ - fun add(ticket: Ticket): Boolean { - synchronized(lock) { + 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) @@ -72,8 +71,8 @@ class TicketStore { * * @return the stored ticket, or null if it was removed in the meantime. */ - fun update(ticket: Ticket): Ticket? { - synchronized(lock) { + 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 @@ -89,8 +88,8 @@ class TicketStore { * * @return the removed ticket, or null if it was not stored. */ - fun remove(id: UUID): Ticket? { - synchronized(lock) { + suspend fun remove(id: UUID): Ticket? { + mutex.withLock { val ticket = tickets.remove(id) ?: return null ticket.playerIds.forEach { playerToTicket.remove(it, id) } return ticket @@ -104,8 +103,8 @@ class TicketStore { * * @return the updated tickets, or null if one of them was not searching anymore. */ - fun matched(ids: List, matchId: UUID): List? { - synchronized(lock) { + 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 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 index bb3f62f..ad1225d 100644 --- 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 @@ -7,10 +7,7 @@ import java.time.Instant import java.util.UUID /** - * A match is a set of tickets that will play together on one server. - * - * Only the ticket ids are stored, the tickets themselves live in the ticket - * store. That way there is exactly one place holding the ticket state. + * 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. @@ -30,11 +27,6 @@ data class Match( val countdownEndsAt: Instant? = null, ) { - /** - * Builds the protobuf representation of this match. - * - * @param tickets the tickets of this match, resolved from the ticket store. - */ fun toDefinition(tickets: List): build.buf.gen.mythicisland.queue.v2.Match { return match { id = this@Match.id.toString() @@ -42,7 +34,6 @@ data class Match( 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 index 27abab9..4c513fe 100644 --- 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 @@ -7,11 +7,7 @@ import java.time.Instant import java.util.UUID /** - * A ticket is a single player or a party that wants to play. It is the atomic - * unit of matchmaking, a party is never split up. - * - * Tickets are immutable, every change creates a copy. That keeps them safe to - * hand out to other threads while the runtime keeps working with them. + * 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. @@ -33,9 +29,6 @@ data class Ticket( val countdownEndsAt: Instant? = null, ) { - /** - * The amount of players this ticket brings into a match. - */ val playerCount: Int get() = playerIds.size @@ -53,9 +46,6 @@ data class Ticket( } } - /** - * Returns a copy that is searching again, without any match leftovers. - */ fun asSearching(): Ticket { return copy( state = TicketState.TICKET_STATE_SEARCHING, 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 index 16e81b0..7e32a65 100644 --- 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 @@ -2,13 +2,10 @@ package net.mythicisland.queue.shared.nats /** * The NATS subjects queue publishes its events on. - * - * The version is part of the subject so v1 and v2 can run side by side while - * consumers are migrated. */ object Subjects { - private const val PREFIX = "queue.v2" + private const val PREFIX = "queue" private const val TICKET_PREFIX = "${PREFIX}.ticket" 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 28fcff5..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 @@ -5,7 +5,7 @@ import net.mythicisland.queue.shared.protobuf.toProtoDuration import org.spongepowered.configurate.objectmapping.ConfigSerializable /** - * The configuration of one queue, loaded from a yaml file. + * 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. From 6a6c20e8b478468f0df002e180e01b78abc40ef4 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 02:51:17 +0200 Subject: [PATCH 04/13] refactor: just some improvements --- README.md | 2 +- c.md | 20 ------------- .../queue/runtime/match/MatchReconciler.kt | 28 ++++--------------- .../queue/runtime/match/Matchmaker.kt | 22 ++------------- 4 files changed, 9 insertions(+), 63 deletions(-) delete mode 100644 c.md diff --git a/README.md b/README.md index e1cb20a..9237965 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A microservice for queuing players into minigames. ## TODO - [x] **Proto Specs**: Write the Protocol Buffers for v2 -- [ ] **Runtime Implementation**: Make the microservice work +- [x] **Runtime Implementation**: Make the microservice work - [ ] **API Implementation**: Write the Java and Kotlin API for v2 - [ ] **Multi Queue**: After v2 ist working good implement multi-queue for players - [ ] **Queue Rating**: Maybe implement ratings for queues depend on the player count that player in the last weeks idk things like: Good Okay Dead etc. \ No newline at end of file diff --git a/c.md b/c.md deleted file mode 100644 index d63341c..0000000 --- a/c.md +++ /dev/null @@ -1,20 +0,0 @@ -Structure - -net.mythicisland.queue.shared.match.Match -net.mythicisland.queue.shared.match.Ticket -net.mythicisland.queue.shared.match.Assignment -net.mythicisland.queue.shared.queue.QueueType -net.mythicisland.queue.shared.queue.QueueStats -net.mythicisland.queue.shared.nats.Subjects - -net.mythicisland.queue.runtime.QueueRuntime -net.mythicisland.queue.runtime.launcher.Launcher -net.mythicisland.queue.runtime.launcher.QueueStartCommand -net.mythicisland.queue.runtime.service.TicketService -net.mythicisland.queue.runtime.service.QueueDataService -net.mythicisland.queue.runtime.ticket.TicketStore -net.mythicisland.queue.runtime.ticket.TicketPool -net.mythicisland.queue.runtime.repository.QueueTypeRepository -net.mythicisland.queue.runtime.repository.MatchRepository -net.mythicisland.queue.runtime.match.Matchmaker -net.mythicisland.queue.runtime.match.MatchReconciler \ 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 index 74b3127..aa09518 100644 --- 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 @@ -20,17 +20,7 @@ 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. - * - * ``` - * ALLOCATING -> COUNTDOWN -> TRANSFERRING -> COMPLETED - * | | - * +--------------> FAILED <----------------+ - * ``` - * - * Everything runs in a single loop, so a match is never reconciled twice at - * the same time and the states cannot interleave. + * Drives a match from the moment it was formed until its players are on the game server. */ class MatchReconciler( private val tickets: TicketStore, @@ -41,13 +31,6 @@ class MatchReconciler( private val publisher: EventPublisher, ) { - private companion object { - val INTERVAL = 500.milliseconds - - /** How long a match may look for a server before it is given up on. */ - const val ALLOCATION_TIMEOUT_SECONDS = 60L - } - private val logger = LogManager.getLogger(MatchReconciler::class.java) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -55,10 +38,10 @@ class MatchReconciler( * Starts the reconciliation loop. */ fun start() { - logger.info("Starting up match reconciler (interval={})", INTERVAL) + logger.info("Starting up match reconciler") scope.launch { while (isActive) { - delay(INTERVAL) + delay(500.milliseconds) tick() } } @@ -115,8 +98,8 @@ class MatchReconciler( } val waited = Duration.between(match.createdAt, Instant.now()).seconds - if (waited >= ALLOCATION_TIMEOUT_SECONDS) { - fail(match, "no server became available within ${ALLOCATION_TIMEOUT_SECONDS}s") + if (waited >= 60L) { + fail(match, "no server became available within ${60L}s") return } @@ -203,7 +186,6 @@ class MatchReconciler( val matchTickets = tickets.getAll(match.ticketIds) if (match.state == MatchState.MATCH_STATE_COMPLETED) { - // The server stays ingame, it is running the match from now on. matchTickets.forEach { ticket -> tickets.remove(ticket.id) publisher.publishTicketDeleted(ticket, TicketDeleteReason.TICKET_DELETE_REASON_TRANSFERRED) 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 index fddb21a..a65c6dd 100644 --- 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 @@ -19,10 +19,6 @@ import kotlin.time.Duration.Companion.milliseconds /** * Forms matches out of the tickets that are waiting. - * - * The matchmaker runs a pass over every queue type on a fixed interval. All it - * does is pick tickets and hand the result to the [MatchRepository], driving - * the match afterwards is the job of the [MatchReconciler]. */ class Matchmaker( private val tickets: TicketStore, @@ -32,10 +28,6 @@ class Matchmaker( private val publisher: EventPublisher, ) { - private companion object { - val INTERVAL = 500.milliseconds - } - private val logger = LogManager.getLogger(Matchmaker::class.java) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -43,10 +35,10 @@ class Matchmaker( * Starts the matchmaking loop. */ fun start() { - logger.info("Starting up matchmaker (interval={})", INTERVAL) + logger.info("Starting up matchmaker") scope.launch { while (isActive) { - delay(INTERVAL) + delay(500.milliseconds) try { tick() } catch (e: CancellationException) { @@ -96,8 +88,6 @@ class Matchmaker( createdAt = Instant.now(), ) - // Fails when one of the tickets was matched or cancelled in between, - // the next pass simply tries again with what is left. 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) @@ -105,11 +95,7 @@ class Matchmaker( } matches.add(match) - logger.info( - "Created match {} for '{}' with {} tickets / {} players", - match.id, type.name, matched.size, matched.sumOf { it.playerCount }, - ) - + logger.info("Created match {} for '{}' with {} tickets / {} players", match.id, type.name, matched.size, matched.sumOf { it.playerCount },) publisher.publishMatchCreated(match, matched) matched.forEach { publisher.publishTicketStateChanged(it, TicketState.TICKET_STATE_SEARCHING) } return match @@ -132,8 +118,6 @@ class Matchmaker( if (candidates.isEmpty()) return null val selected = candidates.fold(emptyList()) { picked, ticket -> - // Parties that do not fit into the remaining slots are skipped, the - // smaller tickets behind them can still get in. val players = picked.sumOf { it.playerCount } if (players + ticket.playerCount <= type.maxPlayers) picked + ticket else picked } From d4e03ca4b0d28437af0861088f553aeeff7227e0 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 04:28:40 +0200 Subject: [PATCH 05/13] feat: Java and Kotlin API --- gradle/libs.versions.toml | 5 - .../net/mythicisland/queue/api/QueueApi.java | 10 +- .../queue/api/data/QueueDataApi.java | 80 ++++++++++----- .../queue/api/event/EventApi.java | 16 +-- .../api/event/match/MatchCreatedEvent.java | 13 +++ .../queue/api/event/match/MatchEventApi.java | 36 +++++++ .../event/match/MatchStateChangedEvent.java | 16 +++ .../event/match/MatchTransferredEvent.java | 21 ++++ .../queue/api/event/player/DequeueEvent.java | 17 ---- .../queue/api/event/player/EnqueueEvent.java | 24 ----- .../api/event/player/QueuePlayerEventApi.java | 28 ------ .../api/event/queue/QueueCreatedEvent.java | 22 ----- .../api/event/queue/QueueDeletedEvent.java | 22 ----- .../queue/api/event/queue/QueueEventApi.java | 60 ------------ .../event/queue/QueueServerAssignedEvent.java | 24 ----- .../event/queue/QueueStatusUpdatedEvent.java | 24 ----- .../api/event/queue/QueueTransferEvent.java | 26 ----- .../api/event/queue/QueueUpdatedEvent.java | 29 ------ .../api/event/ticket/TicketCreatedEvent.java | 13 +++ .../api/event/ticket/TicketDeletedEvent.java | 19 ++++ .../api/event/ticket/TicketEventApi.java | 36 +++++++ .../event/ticket/TicketStateChangedEvent.java | 16 +++ .../queue/api/internal/ProtoUtil.java | 97 ++++++++++++------- .../queue/api/internal/QueueApiImpl.java | 18 ++-- .../api/internal/data/QueueDataApiImpl.java | 78 ++++++++++----- .../api/internal/event/EventApiImpl.java | 24 ++--- .../internal/event/QueueEventSubjects.java | 20 ++-- .../event/match/MatchEventApiImpl.java | 50 ++++++++++ .../event/player/QueuePlayerEventApiImpl.java | 39 -------- .../event/queue/QueueEventApiImpl.java | 81 ---------------- .../event/ticket/TicketEventApiImpl.java | 50 ++++++++++ .../internal/player/QueuePlayerApiImpl.java | 43 -------- .../api/internal/ticket/TicketApiImpl.java | 50 ++++++++++ .../queue/api/match/Assignment.java | 13 +++ .../mythicisland/queue/api/match/Match.java | 29 ++++++ .../queue/api/match/MatchState.java | 33 +++++++ .../queue/api/player/QueuePlayerApi.java | 52 ---------- .../queue/api/queue/QueueStatus.java | 48 --------- .../mythicisland/queue/api/ticket/Ticket.java | 33 +++++++ .../queue/api/ticket/TicketApi.java | 64 ++++++++++++ .../queue/api/ticket/TicketDeleteReason.java | 23 +++++ .../queue/api/ticket/TicketState.java | 23 +++++ queue-runtime/build.gradle.kts | 8 ++ .../queue/runtime/match/Matchmaker.kt | 8 +- .../queue/runtime/service/TicketService.kt | 2 +- .../queue/runtime/ticket/TicketPool.kt | 2 +- queue-shared/build.gradle.kts | 1 - .../mythicisland/queue/shared/match/Ticket.kt | 3 - 48 files changed, 770 insertions(+), 679 deletions(-) create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchCreatedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchEventApi.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchStateChangedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchTransferredEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/player/EnqueueEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/player/QueuePlayerEventApi.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueCreatedEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueDeletedEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueEventApi.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueServerAssignedEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueStatusUpdatedEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueTransferEvent.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueUpdatedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketCreatedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketDeletedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketEventApi.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/event/ticket/TicketStateChangedEvent.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/event/match/MatchEventApiImpl.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/event/player/QueuePlayerEventApiImpl.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/event/queue/QueueEventApiImpl.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/event/ticket/TicketEventApiImpl.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/player/QueuePlayerApiImpl.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/internal/ticket/TicketApiImpl.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/match/Assignment.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/match/Match.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/match/MatchState.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/player/QueuePlayerApi.java delete mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/queue/QueueStatus.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/ticket/Ticket.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketApi.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketDeleteReason.java create mode 100644 queue-api/src/main/java/net/mythicisland/queue/api/ticket/TicketState.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e96827..1853b75 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,7 +10,6 @@ slf4j = "2.0.18" clikt = "5.1.0" jnats = "2.26.0" -adventure = "5.2.0" configurate = "4.2.0" cloud-api = "0.1.0-platform.45" queue-proto = "1.5.0.4.20260803235217.273a6fa06034" @@ -38,9 +37,6 @@ cloud-api = { module = "app.simplecloud.api:api", version.ref = "cloud-api" } queue-proto = { module = "build.buf.gen:mythicisland_queue_grpc_kotlin", version.ref = "queue-proto" } moonrise-common = { module = "net.mythicisland.moonrise:moonrise-common", version.ref = "moonrise" } -adventure-api = { module = "net.kyori:adventure-api", version.ref = "adventure" } -adventure-text-minimessage = { module = "net.kyori:adventure-text-minimessage", version.ref = "adventure" } - protobuf-kotlin = { module = "com.google.protobuf:protobuf-kotlin", version.ref = "protobuf" } grpc-stub = { module = "io.grpc:grpc-stub", version.ref = "grpc" } @@ -50,7 +46,6 @@ grpc-netty-shaded = { module = "io.grpc:grpc-netty-shaded", version.ref = "grpc" [bundles] logging = ["log4j-core", "log4j-api", "log4j-slf4j-impl", "slf4j-api"] -adventure = ["adventure-api", "adventure-text-minimessage"] configurate = ["configurate-yaml", "configurate-extra-kotlin"] grpc = ["grpc-stub", "grpc-netty-shaded", "grpc-kotlin-stub", "grpc-protobuf", "protobuf-kotlin"] diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/QueueApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/QueueApi.java index a4757f2..d9d1182 100644 --- a/queue-api/src/main/java/net/mythicisland/queue/api/QueueApi.java +++ b/queue-api/src/main/java/net/mythicisland/queue/api/QueueApi.java @@ -3,7 +3,7 @@ import net.mythicisland.queue.api.data.QueueDataApi; import net.mythicisland.queue.api.event.EventApi; import net.mythicisland.queue.api.internal.QueueApiImpl; -import net.mythicisland.queue.api.player.QueuePlayerApi; +import net.mythicisland.queue.api.ticket.TicketApi; /** * Main entrypoint to interacting with queue. @@ -30,14 +30,14 @@ static QueueApi create(QueueApiOptions options) { } /** - * Provides player related operations. + * Provides ticket related operations. * - * @return the queue player API + * @return the ticket API */ - QueuePlayerApi player(); + TicketApi ticket(); /** - * Provides access to data query operations for queues and queue types. + * Provides access to data query operations for tickets, matches and queue types. * * @return the data query API */ diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/data/QueueDataApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/data/QueueDataApi.java index 396ad45..8a7eba8 100644 --- a/queue-api/src/main/java/net/mythicisland/queue/api/data/QueueDataApi.java +++ b/queue-api/src/main/java/net/mythicisland/queue/api/data/QueueDataApi.java @@ -1,56 +1,71 @@ package net.mythicisland.queue.api.data; -import build.buf.gen.mythicisland.queue.v1.*; +import build.buf.gen.mythicisland.queue.v2.*; import java.util.UUID; import java.util.concurrent.CompletableFuture; /** - * API for querying queue data. + * API for querying tickets, matches and the queue configuration. */ public interface QueueDataApi { /** - * Retrieves information about a specific queue. + * Retrieves a specific ticket. * - * @param queueId the unique identifier of the queue - * @return a future completing with the queue details + * @param ticketId the unique identifier of the ticket + * @return a future completing with the ticket */ - CompletableFuture getQueue(UUID queueId); + CompletableFuture getTicket(UUID ticketId); /** - * Retrieves a list of all currently active queues across all types. + * Finds the ticket a player belongs to. * - * @return a future completing with a list of all active queues + * @param playerId the unique identifier of the player + * @return a future completing with the ticket containing the player */ - CompletableFuture getAllQueues(); + CompletableFuture getTicketByPlayer(UUID playerId); /** - * Retrieves all active queues of a specific type (e.g., "skyblock", "bedwars"). + * Retrieves every ticket currently in matchmaking. * - * @param type the name of the queue type - * @return a future completing with a list of matching queues + * @return a future completing with all tickets */ - CompletableFuture getQueuesByType(String type); + CompletableFuture listTickets(); /** - * Finds the queue that a specific player is currently a member of. + * Retrieves every ticket searching in a specific queue type. * - * @param playerId the unique identifier of the player - * @return a future completing with the queue containing the player, or an empty response if not in any queue + * @param queueType the name of the queue type + * @return a future completing with the matching tickets */ - CompletableFuture getQueueByPlayer(UUID playerId); + CompletableFuture listTickets(String queueType); /** - * Retrieves a player's current position and progress within their queue. + * Retrieves a specific match. * - * @param playerId the unique identifier of the player - * @return a future completing with the player's position information + * @param matchId the unique identifier of the match + * @return a future completing with the match + */ + CompletableFuture getMatch(UUID matchId); + + /** + * Retrieves every match that has not finished yet. + * + * @return a future completing with all active matches + */ + CompletableFuture listMatches(); + + /** + * Retrieves every active match of a specific queue type. + * + * @param queueType the name of the queue type + * @return a future completing with the matching matches */ - CompletableFuture getPlayerPosition(UUID playerId); + CompletableFuture listMatches(String queueType); /** - * Retrieves the configuration and metadata for a specific queue type. + * Retrieves the configuration of a specific queue type. * * @param name the name of the queue type * @return a future completing with the queue type configuration @@ -58,9 +73,26 @@ public interface QueueDataApi { CompletableFuture getQueueType(String name); /** - * Retrieves a list of all registered queue types and their configurations. + * Retrieves every registered queue type and its configuration. * * @return a future completing with all available queue types */ - CompletableFuture getAllQueueTypes(); + CompletableFuture listQueueTypes(); + + /** + * Retrieves the live numbers of a queue type, for example to show how many + * players are currently searching. + * + * @param queueType the name of the queue type + * @return a future completing with the queue statistics + */ + CompletableFuture getQueueStats(String queueType); + + /** + * Retrieves the live numbers of every queue type. + * + * @return a future completing with the statistics of all queue types + */ + CompletableFuture listQueueStats(); + } diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/EventApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/EventApi.java index 6fd5181..3fdbd57 100644 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/EventApi.java +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/EventApi.java @@ -1,7 +1,7 @@ package net.mythicisland.queue.api.event; -import net.mythicisland.queue.api.event.player.QueuePlayerEventApi; -import net.mythicisland.queue.api.event.queue.QueueEventApi; +import net.mythicisland.queue.api.event.match.MatchEventApi; +import net.mythicisland.queue.api.event.ticket.TicketEventApi; /** * API for subscribing to events. @@ -9,17 +9,17 @@ public interface EventApi { /** - * Provides access to queue lifecycle and status events. + * Provides access to ticket lifecycle events. * - * @return the queue event subscription API + * @return the ticket event subscription API */ - QueueEventApi queue(); + TicketEventApi ticket(); /** - * Provides access to events related to players. + * Provides access to match lifecycle events. * - * @return the player event subscription API + * @return the match event subscription API */ - QueuePlayerEventApi player(); + MatchEventApi match(); } diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchCreatedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchCreatedEvent.java new file mode 100644 index 0000000..2789102 --- /dev/null +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchCreatedEvent.java @@ -0,0 +1,13 @@ +package net.mythicisland.queue.api.event.match; + +import net.mythicisland.queue.api.match.Match; + +/** + * Event fired when enough tickets were found to form a match. + * + * @param match the created match + */ +public record MatchCreatedEvent( + Match match +) { +} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchEventApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchEventApi.java new file mode 100644 index 0000000..cb587a0 --- /dev/null +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchEventApi.java @@ -0,0 +1,36 @@ +package net.mythicisland.queue.api.event.match; + +import net.mythicisland.queue.api.event.Subscription; + +import java.util.function.Consumer; + +/** + * API for subscribing to match lifecycle events. + */ +public interface MatchEventApi { + + /** + * Subscribes to events triggered when enough tickets were found for a match. + * + * @param handler a consumer that will process the match creation events + * @return a subscription handle to manage the listener lifecycle + */ + Subscription onCreated(Consumer handler); + + /** + * Subscribes to events triggered when a match 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 after the players of a match were sent to their server. + * + * @param handler a consumer that will process the transfer events + * @return a subscription handle to manage the listener lifecycle + */ + Subscription onTransferred(Consumer handler); + +} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchStateChangedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchStateChangedEvent.java new file mode 100644 index 0000000..e12515f --- /dev/null +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchStateChangedEvent.java @@ -0,0 +1,16 @@ +package net.mythicisland.queue.api.event.match; + +import net.mythicisland.queue.api.match.Match; +import net.mythicisland.queue.api.match.MatchState; + +/** + * Event fired when a match transitions from one state to another. + * + * @param match the match, already carrying its new state + * @param previousState the state the match was in before + */ +public record MatchStateChangedEvent( + Match match, + MatchState previousState +) { +} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchTransferredEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchTransferredEvent.java new file mode 100644 index 0000000..7cf0728 --- /dev/null +++ b/queue-api/src/main/java/net/mythicisland/queue/api/event/match/MatchTransferredEvent.java @@ -0,0 +1,21 @@ +package net.mythicisland.queue.api.event.match; + +import net.mythicisland.queue.api.match.Match; + +import java.util.List; +import java.util.UUID; + +/** + * Event fired after the players of a match were sent to their game server. + * + *

Players that were offline or failed to connect are missing from + * {@code transferredPlayerIds}.

+ * + * @param match the transferred match + * @param transferredPlayerIds the UUIDs of the players that actually made it onto the server + */ +public record MatchTransferredEvent( + Match match, + List transferredPlayerIds +) { +} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java deleted file mode 100644 index 7c084f5..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/DequeueEvent.java +++ /dev/null @@ -1,17 +0,0 @@ -package net.mythicisland.queue.api.event.player; - -import net.mythicisland.queue.api.queue.QueueStatus; - -import java.util.List; -import java.util.UUID; - -/** - * Event fired when one or more players are removed from a queue. - * - * @param queueId the unique ID of the queue the players left - * @param queueType the queue type name - * @param queueStatus the queue's status after the dequeue - * @param queuePlayerIds all player UUIDs remaining in the queue - * @param playerIds the UUIDs of the players that were dequeued - */ -public record DequeueEvent(UUID queueId, String queueType, QueueStatus queueStatus, List queuePlayerIds, List playerIds) { } diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/EnqueueEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/player/EnqueueEvent.java deleted file mode 100644 index c130a26..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/EnqueueEvent.java +++ /dev/null @@ -1,24 +0,0 @@ -package net.mythicisland.queue.api.event.player; - -import net.mythicisland.queue.api.queue.QueueStatus; - -import java.util.List; -import java.util.UUID; - -/** - * Event fired when one or more players are added to a queue. - * - * @param queueId the unique ID of the queue the players joined - * @param queueType the queue type name - * @param queueStatus the queue's status after the enqueue - * @param queuePlayerIds all player UUIDs currently in the queue (including the new ones) - * @param playerIds the UUIDs of the players that were enqueued - */ -public record EnqueueEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds, - List playerIds -) { -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/QueuePlayerEventApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/player/QueuePlayerEventApi.java deleted file mode 100644 index e1b1662..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/player/QueuePlayerEventApi.java +++ /dev/null @@ -1,28 +0,0 @@ -package net.mythicisland.queue.api.event.player; - -import net.mythicisland.queue.api.event.Subscription; - -import java.util.function.Consumer; - -/** - * API for subscribing to player-specific queue events. - */ -public interface QueuePlayerEventApi { - - /** - * Subscribes to events triggered when players are added to a queue. - * - * @param handler a consumer that will process the enqueue events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onEnqueued(Consumer handler); - - /** - * Subscribes to events triggered when players are removed from a queue. - * - * @param handler a consumer that will process the dequeue events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onDequeued(Consumer handler); - -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueCreatedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueCreatedEvent.java deleted file mode 100644 index 9258ff6..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueCreatedEvent.java +++ /dev/null @@ -1,22 +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 a new queue instance is created. - * - * @param queueId the unique ID of the created queue - * @param queueType the queue type name (e.g., "skyblock") - * @param queueStatus the queue's initial status - * @param queuePlayerIds the player UUIDs in the queue at creation time - */ -public record QueueCreatedEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds -) { -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueDeletedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueDeletedEvent.java deleted file mode 100644 index 4b1e723..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueDeletedEvent.java +++ /dev/null @@ -1,22 +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 a queue instance is deleted, typically after it has finished its lifecycle. - * - * @param queueId the unique ID of the deleted queue - * @param queueType the queue type name - * @param queueStatus the queue's final status - * @param queuePlayerIds the player UUIDs that were in the queue at deletion time - */ -public record QueueDeletedEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds -) { -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueEventApi.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueEventApi.java deleted file mode 100644 index b92dae5..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueEventApi.java +++ /dev/null @@ -1,60 +0,0 @@ -package net.mythicisland.queue.api.event.queue; - -import net.mythicisland.queue.api.event.Subscription; - -import java.util.function.Consumer; - -/** - * API for subscribing to queue lifecycle and status events. - */ -public interface QueueEventApi { - - /** - * Subscribes to events triggered when a new queue is created. - * - * @param handler a consumer that will process the queue creation events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onCreated(Consumer handler); - - /** - * Subscribes to events triggered when a queue is deleted (usually after finishing). - * - * @param handler a consumer that will process the queue deletion events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onDeleted(Consumer handler); - - /** - * Subscribes to events triggered when a game server is assigned to a queue. - * - * @param handler a consumer that will process the server assignment events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onServerAssigned(Consumer handler); - - /** - * Subscribes to events triggered when a queue's status changes. - * - * @param handler a consumer that will process the status update events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onStatusUpdated(Consumer handler); - - /** - * Subscribes to events triggered when players are transferred between queues. - * - * @param handler a consumer that will process the transfer events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onTransfer(Consumer handler); - - /** - * Subscribes to general queue update events. - * - * @param handler a consumer that will process the queue update events - * @return a subscription handle to manage the listener lifecycle - */ - Subscription onUpdated(Consumer handler); - -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueServerAssignedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueServerAssignedEvent.java deleted file mode 100644 index c1e406a..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueServerAssignedEvent.java +++ /dev/null @@ -1,24 +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 a game server is reserved and assigned to a specific queue. - * - * @param queueId the unique ID of the queue - * @param queueType the queue type name - * @param queueStatus the queue's current status - * @param queuePlayerIds the player UUIDs in the queue - * @param serverId the ID of the assigned server - */ -public record QueueServerAssignedEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds, - String serverId -) { -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueStatusUpdatedEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueStatusUpdatedEvent.java deleted file mode 100644 index 9493e1a..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueStatusUpdatedEvent.java +++ /dev/null @@ -1,24 +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 a queue transitions from one lifecycle status to another. - * - * @param queueId the unique ID of the queue - * @param queueType the queue type name - * @param queuePlayerIds the player UUIDs in the queue - * @param oldStatus the status before the transition - * @param newStatus the status after the transition - */ -public record QueueStatusUpdatedEvent( - UUID queueId, - String queueType, - List queuePlayerIds, - QueueStatus oldStatus, - QueueStatus newStatus -) { -} diff --git a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueTransferEvent.java b/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueTransferEvent.java deleted file mode 100644 index a5ef089..0000000 --- a/queue-api/src/main/java/net/mythicisland/queue/api/event/queue/QueueTransferEvent.java +++ /dev/null @@ -1,26 +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 players are being transferred to a game server. - * - * @param queueId the unique ID of the queue - * @param queueType the queue type name - * @param queueStatus the queue's current status - * @param queuePlayerIds all player UUIDs in the queue - * @param serverId the ID of the target server - * @param transferredPlayerIds the UUIDs of the players that are being transferred - */ -public record QueueTransferEvent( - UUID queueId, - String queueType, - QueueStatus queueStatus, - List queuePlayerIds, - String serverId, - List 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-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/match/Matchmaker.kt b/queue-runtime/src/main/kotlin/net/mythicisland/queue/runtime/match/Matchmaker.kt index a65c6dd..beb962d 100644 --- 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 @@ -95,7 +95,7 @@ class Matchmaker( } matches.add(match) - logger.info("Created match {} for '{}' with {} tickets / {} players", match.id, type.name, matched.size, matched.sumOf { it.playerCount },) + 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 @@ -118,11 +118,11 @@ class Matchmaker( if (candidates.isEmpty()) return null val selected = candidates.fold(emptyList()) { picked, ticket -> - val players = picked.sumOf { it.playerCount } - if (players + ticket.playerCount <= type.maxPlayers) picked + ticket else picked + val players = picked.sumOf { it.playerIds.size } + if (players + ticket.playerIds.size <= type.maxPlayers) picked + ticket else picked } - val players = selected.sumOf { it.playerCount } + val players = selected.sumOf { it.playerIds.size } if (players < type.minPlayers) return null if (players >= type.maxPlayers) return selected 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 index 05e6fa9..a760321 100644 --- 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 @@ -91,7 +91,7 @@ class TicketService( // fails it once nobody is in it anymore. matches.removeTicket(ticket.id) - logger.info("Deleted ticket {} with {} players", ticket.id, ticket.playerCount) + 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 index 597e409..f95dc63 100644 --- 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 @@ -40,7 +40,7 @@ class TicketPool( return QueueStats( queueType = queueType, searchingTickets = searching.size, - searchingPlayers = searching.sumOf { it.playerCount }, + searchingPlayers = searching.sumOf { it.playerIds.size }, activeMatches = activeMatches, ) } 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/match/Ticket.kt b/queue-shared/src/main/kotlin/net/mythicisland/queue/shared/match/Ticket.kt index 4c513fe..f398f47 100644 --- 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 @@ -29,9 +29,6 @@ data class Ticket( val countdownEndsAt: Instant? = null, ) { - val playerCount: Int - get() = playerIds.size - fun toDefinition(): build.buf.gen.mythicisland.queue.v2.Ticket { return ticket { id = this@Ticket.id.toString() From 4f87b11c6f910f06022c92964a59c7ade062bb9f Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 04:46:58 +0200 Subject: [PATCH 06/13] feat: tests --- .../mythicisland/queue/runtime/TestData.kt | 43 +++++++ .../runtime/repository/MatchRepositoryTest.kt | 108 ++++++++++++++++ .../queue/runtime/ticket/TicketPoolTest.kt | 84 +++++++++++++ .../queue/runtime/ticket/TicketStoreTest.kt | 119 ++++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/TestData.kt create mode 100644 queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/repository/MatchRepositoryTest.kt create mode 100644 queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketPoolTest.kt create mode 100644 queue-runtime/src/test/kotlin/net/mythicisland/queue/runtime/ticket/TicketStoreTest.kt 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) + } + } + +} From 9312dab720b920ffe22d80dede55eedd1860618b Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 04:56:24 +0200 Subject: [PATCH 07/13] feat: update readme --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9237965..7867458 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ A microservice for queuing players into minigames. ## TODO - [x] **Proto Specs**: Write the Protocol Buffers for v2 - [x] **Runtime Implementation**: Make the microservice work -- [ ] **API Implementation**: Write the Java and Kotlin API for v2 -- [ ] **Multi Queue**: After v2 ist working good implement multi-queue for players -- [ ] **Queue Rating**: Maybe implement ratings for queues depend on the player count that player in the last weeks idk things like: Good Okay Dead etc. \ No newline at end of file +- [x] **API Implementation**: Write the Java and Kotlin API for v2 +- [x] **Multi Queue**: After v2 ist working good implement multi-queue for players +- [ ] **Queue Rating**: Maybe implement ratings for queues depend on the player count that player in the last weeks idk things like: Good Okay Dead etc. +- [ ] **Readme and Concepts**: Cool readme, concepts and api docs \ No newline at end of file From 74e9b1a029a41a231dea96467d72807a0618cd60 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 05:39:12 +0200 Subject: [PATCH 08/13] feat: workflows --- .github/workflows/build.yml | 29 ++++++++++++++ .github/workflows/release.yml | 74 +++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4c28d8a --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + pull_request: + +concurrency: + group: build-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout sources + uses: actions/checkout@v7 + with: + fetch-depth: '0' + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 25 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Build with Gradle + run: chmod +x ./gradlew && ./gradlew build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8e5cbae --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,74 @@ +name: Release + +on: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: release + cancel-in-progress: false + +jobs: + release: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - name: Checkout sources + uses: actions/checkout@v7 + with: + fetch-depth: '0' + + - name: Setup Java + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 25 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Read the gradle version + id: version + run: | + chmod +x ./gradlew + VERSION=$(./gradlew -q --console=plain :queue-api:properties | awk '/^version:/ { print $2 }') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check for an existing release + id: existing + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + if gh release view "${{ steps.version.outputs.tag }}" > /dev/null 2>&1; then + echo "Release ${{ steps.version.outputs.tag }} already exists, skipping" + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Publish the api + if: steps.existing.outputs.exists == 'false' + env: + MAVEN_USER: ${{ secrets.MAVEN_USER }} + MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} + run: ./gradlew :queue-api:publishAllPublicationsToPublicRepository + + - name: Create the release + if: steps.existing.outputs.exists == 'false' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PRERELEASE="" + if [[ "${{ steps.version.outputs.version }}" == *-* ]]; then + PRERELEASE="--prerelease" + fi + + gh release create "${{ steps.version.outputs.tag }}" \ + --title "${{ steps.version.outputs.tag }}" \ + --generate-notes \ + $PRERELEASE From bcb3af1668fcc26024de8e85f7228ca9cc86132b Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 11:23:15 +0200 Subject: [PATCH 09/13] feat: docker registry publish --- .dockerignore | 4 ++++ .github/workflows/release.yml | 43 +++++++++++++++++++++++++++++++++++ Dockerfile | 25 ++++++++++++++++++++ 3 files changed, 72 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c69008e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,4 @@ +# Only the shadow jar is copied into the image, everything else would just +# make the build context bigger. +* +!queue-runtime/build/libs/queue-runtime.jar diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8e5cbae..9a194a9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,11 @@ on: permissions: contents: write + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} concurrency: group: release @@ -58,6 +63,44 @@ jobs: MAVEN_TOKEN: ${{ secrets.MAVEN_TOKEN }} run: ./gradlew :queue-api:publishAllPublicationsToPublicRepository + - name: Build the runtime jar + if: steps.existing.outputs.exists == 'false' + run: ./gradlew :queue-runtime:shadowJar + + - name: Set up Docker Buildx + if: steps.existing.outputs.exists == 'false' + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + if: steps.existing.outputs.exists == 'false' + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + if: steps.existing.outputs.exists == 'false' + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest + type=raw,value=${{ steps.version.outputs.version }} + type=sha,format=short + + - name: Build and push the runtime image + if: steps.existing.outputs.exists == 'false' + uses: docker/build-push-action@v7 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Create the release if: steps.existing.outputs.exists == 'false' env: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..65e1b42 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# The jar is built by gradle before the image is built: +# ./gradlew :queue-runtime:shadowJar +FROM eclipse-temurin:25-jre + +ARG JAR=queue-runtime/build/libs/queue-runtime.jar + +WORKDIR /app + +# The directories the runtime uses by default. Where they actually live and +# which port it listens on stays configurable through env vars or a mounted +# queue.properties, see QueueStartCommand. +RUN useradd --system --uid 1000 queue \ + && mkdir -p types .secrets logs \ + && chown -R queue:queue /app + +COPY --chown=queue:queue ${JAR} queue-runtime.jar + +USER queue + +# Netty loads a native library and the protobuf shaded into the simplecloud api +# still uses sun.misc.Unsafe. Both only warn, the flags keep the log clean. +ENTRYPOINT ["java", \ + "--enable-native-access=ALL-UNNAMED", \ + "--sun-misc-unsafe-memory-access=allow", \ + "-jar", "queue-runtime.jar"] From 4930ae1d3836c9fc8af2abb75085567a674265c7 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 11:51:03 +0200 Subject: [PATCH 10/13] feat: kotlin dsl --- .dockerignore | 2 - Dockerfile | 12 +- .../net/mythicisland/queue/api/QueueDsl.kt | 11 ++ .../api/builders/QueueApiOptionsBuilder.kt | 60 ++++++++++ .../queue/api/builders/TicketBuilder.kt | 83 +++++++++++++ .../queue/api/extensions/ProtoExtensions.kt | 18 +++ .../api/extensions/QueueApiExtensions.kt | 36 ++++++ .../api/extensions/QueueDataApiExtensions.kt | 101 ++++++++++++++++ .../api/extensions/TicketApiExtensions.kt | 63 ++++++++++ .../queue/api/scopes/EventScope.kt | 110 ++++++++++++++++++ 10 files changed, 483 insertions(+), 13 deletions(-) create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/QueueDsl.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/QueueApiOptionsBuilder.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/builders/TicketBuilder.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/ProtoExtensions.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueApiExtensions.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/QueueDataApiExtensions.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/extensions/TicketApiExtensions.kt create mode 100644 queue-api/src/main/kotlin/net/mythicisland/queue/api/scopes/EventScope.kt diff --git a/.dockerignore b/.dockerignore index c69008e..1def55f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,2 @@ -# Only the shadow jar is copied into the image, everything else would just -# make the build context bigger. * !queue-runtime/build/libs/queue-runtime.jar diff --git a/Dockerfile b/Dockerfile index 65e1b42..1fad402 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,14 +1,9 @@ -# The jar is built by gradle before the image is built: -# ./gradlew :queue-runtime:shadowJar FROM eclipse-temurin:25-jre ARG JAR=queue-runtime/build/libs/queue-runtime.jar WORKDIR /app -# The directories the runtime uses by default. Where they actually live and -# which port it listens on stays configurable through env vars or a mounted -# queue.properties, see QueueStartCommand. RUN useradd --system --uid 1000 queue \ && mkdir -p types .secrets logs \ && chown -R queue:queue /app @@ -17,9 +12,4 @@ COPY --chown=queue:queue ${JAR} queue-runtime.jar USER queue -# Netty loads a native library and the protobuf shaded into the simplecloud api -# still uses sun.misc.Unsafe. Both only warn, the flags keep the log clean. -ENTRYPOINT ["java", \ - "--enable-native-access=ALL-UNNAMED", \ - "--sun-misc-unsafe-memory-access=allow", \ - "-jar", "queue-runtime.jar"] +ENTRYPOINT ["java","-jar", "queue-runtime.jar"] 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() } + } + +} From d5dc790f7e2db23c14bf48a5616d9d9eaca1238c Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 16:13:47 +0200 Subject: [PATCH 11/13] feat: update readme --- README.md | 102 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 98 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7867458..6726ad8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,105 @@ # Queue v2 -A microservice for queuing players into minigames. +A microservice that queue players into minigames and moves them to a game server. + +## Concepts + +| Concept | What it is | +|----------------|----------------------------------------------------------------------------| +| **Ticket** | A player or a whole party that wants to play. | +| **Match** | A set of tickets that will play together on one server. | +| **Assignment** | The server a match was given. | +| **Queue type** | The configuration: which server group, how many players, how long to wait. | + +Splitting the intent (ticket) from the result (match) is what makes the rest work. A ticket +can wait in several queue types at once, and matches can form independently of who asked +for what. + +## How a match comes together + +```mermaid +flowchart TD + A["CreateTicket(players, queueTypes)"] --> B["Ticket: SEARCHING"] + B --> C{"Matchmaker"} + C -->|not enough players yet| B + C -->|full, or minimum reached
and the oldest ticket waited long enough| D["Match: ALLOCATING"] + D -->|no server within 60s| F["Match: FAILED"] + F -->|tickets go back| B + D -->|server moved to INGAME| E["Match: COUNTDOWN
Ticket: ASSIGNED"] + E -->|countdown over| G["Match: TRANSFERRING"] + G --> H["Match: COMPLETED
players are on the game server"] +``` + +1. **Searching** — the ticket sits in the pool of every queue type it asked for. +2. **Matchmaking** — a match is formed as soon as the queue type is full, or once it holds + at least `minPlayers` and the oldest ticket has waited longer than `waitingDuration`. + There is no countdown to keep track of anywhere; the waiting time is derived from the + ticket's own age. +3. **Allocating** — a free server of the group is moved to `INGAME`, which is what keeps the + next match from taking it too. +4. **Countdown** — the ticket learns its server and when it will be moved, so a client can + render the countdown itself instead of polling. +5. **Transferring** — every player is connected, then the match is done and its tickets are + removed. + +## Multi-Queue + +A ticket can search in several queue types at the same time and joins whichever match fills +up first: + +```kotlin +api.ticket().create { + party(leader, member) + queues("battle", "skywars") +} +``` + +The ticket shows up in both pools. The moment one of them takes it, its state flips to +`MATCHED` and it disappears from the other — a ticket can never end up in two matches. + +## Modules + +| Module | What is in it | +|-----------------|----------------------------------------------------------------------------| +| `queue-runtime` | The service: ticket store, matchmaker, match reconciler, server allocator. | +| `queue-api` | Java and Kotlin client, talks gRPC and listens to the NATS events. | +| `queue-shared` | Common shared files for the runtime and API. | +| `proto` | The protobuf definitions, published to the Buf Schema Registry. | + +## Configuration + +Queue types are YAML files in the types directory, one per queue: + +```yaml +# types/battle.yml +name: battle +group: battle +min-players: 8 +max-players: 16 +waiting-duration-seconds: 30 +countdown-duration-seconds: 10 +``` + +Everything else comes from environment variables or a `queue.properties` + +| Variable | Default | +|-------------------------|--------------------------------------| +| `GRPC_PORT` | `4564` | +| `NATS_URL` | `nats://localhost:4222` | +| `TYPE_PATH` | `types` | +| `AUTH_KEY_PATH` | `.secrets/auth.key` | +| `CONTROLLER_URL` | `https://controller.simplecloud.app` | +| `CONTROLLER_NATS_URL` | `wss://nats.simplecloud.app:443` | ## TODO + - [x] **Proto Specs**: Write the Protocol Buffers for v2 - [x] **Runtime Implementation**: Make the microservice work - [x] **API Implementation**: Write the Java and Kotlin API for v2 -- [x] **Multi Queue**: After v2 ist working good implement multi-queue for players -- [ ] **Queue Rating**: Maybe implement ratings for queues depend on the player count that player in the last weeks idk things like: Good Okay Dead etc. -- [ ] **Readme and Concepts**: Cool readme, concepts and api docs \ No newline at end of file +- [x] **Multi Queue**: Let a player search in several queue types at once +- [x] **Readme and Concepts**: Cool readme, concepts and api docs +- [ ] **Ticket TTL**: Drop tickets whose players went offline without leaving the queue +- [ ] **Metrics**: Time to match, fill rate, allocation latency, failed matches +- [ ] **Estimated wait**: Show players how long they will probably wait +- [ ] **Drain mode**: Finish the running matches before shutting down +- [ ] **Queue Rating**: Rate queues by how alive they are — Good, Okay, Dead From 00e585b2c59fd13e75be67b590a4b71629f80990 Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 16:26:23 +0200 Subject: [PATCH 12/13] refactor: remove old todos --- README.md | 29 ++++++++--------------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 6726ad8..320718b 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ for what. ```mermaid flowchart TD - A["CreateTicket(players, queueTypes)"] --> B["Ticket: SEARCHING"] + A["CreateTicket(players, types)"] --> B["Ticket: SEARCHING"] B --> C{"Matchmaker"} C -->|not enough players yet| B C -->|full, or minimum reached
and the oldest ticket waited long enough| D["Match: ALLOCATING"] @@ -30,17 +30,11 @@ flowchart TD G --> H["Match: COMPLETED
players are on the game server"] ``` -1. **Searching** — the ticket sits in the pool of every queue type it asked for. -2. **Matchmaking** — a match is formed as soon as the queue type is full, or once it holds - at least `minPlayers` and the oldest ticket has waited longer than `waitingDuration`. - There is no countdown to keep track of anywhere; the waiting time is derived from the - ticket's own age. -3. **Allocating** — a free server of the group is moved to `INGAME`, which is what keeps the - next match from taking it too. -4. **Countdown** — the ticket learns its server and when it will be moved, so a client can - render the countdown itself instead of polling. -5. **Transferring** — every player is connected, then the match is done and its tickets are - removed. +1. **Searching**: the ticket sits in the pool of every queue type it asked for. +2. **Matchmaking**: a match is formed as soon as the queue type is full +3. **Allocating**: a free server of the group is moved to `INGAME`, which i.s what keeps the next match from taking it too. +4. **Countdown**: the ticket learns its server and when it will be moved, so a client can render the countdown itself instead of polling. +5. **Transferring**: every player is connected, then the match is done and its tickets are removed. ## Multi-Queue @@ -49,14 +43,11 @@ up first: ```kotlin api.ticket().create { - party(leader, member) + party(members) queues("battle", "skywars") } ``` -The ticket shows up in both pools. The moment one of them takes it, its state flips to -`MATCHED` and it disappears from the other — a ticket can never end up in two matches. - ## Modules | Module | What is in it | @@ -93,13 +84,9 @@ Everything else comes from environment variables or a `queue.properties` ## TODO -- [x] **Proto Specs**: Write the Protocol Buffers for v2 -- [x] **Runtime Implementation**: Make the microservice work -- [x] **API Implementation**: Write the Java and Kotlin API for v2 - [x] **Multi Queue**: Let a player search in several queue types at once -- [x] **Readme and Concepts**: Cool readme, concepts and api docs - [ ] **Ticket TTL**: Drop tickets whose players went offline without leaving the queue - [ ] **Metrics**: Time to match, fill rate, allocation latency, failed matches - [ ] **Estimated wait**: Show players how long they will probably wait - [ ] **Drain mode**: Finish the running matches before shutting down -- [ ] **Queue Rating**: Rate queues by how alive they are — Good, Okay, Dead +- [ ] **Queue Rating**: Rate queues by how alive they are From 621926eccafd7692d1eb8c8ff6fc3dc4bff8611c Mon Sep 17 00:00:00 2001 From: xXJanisXx Date: Tue, 4 Aug 2026 16:28:52 +0200 Subject: [PATCH 13/13] refactor: make graph better readable [skip ci] --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 320718b..df4f2d7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ for what. ```mermaid flowchart TD - A["CreateTicket(players, types)"] --> B["Ticket: SEARCHING"] + A["CreateTicket"] --> B["Ticket: SEARCHING"] B --> C{"Matchmaker"} C -->|not enough players yet| B C -->|full, or minimum reached
and the oldest ticket waited long enough| D["Match: ALLOCATING"]