Apollo
Roundtrip Packets

Roundtrip Packets

Overview

This example demonstrates how to handle roundtrip packets between the server and the Lunar Client. These packets are sent from the server, expecting a corresponding response from the client. The example utilizes a map to track the requests and their corresponding responses. For instance, this pattern is common in modules like TransferModule where a request packet is sent and a response is awaited.

Integration

public class ApolloRoundtripProtoListener implements PluginMessageListener {
 
    @Getter
    private static ApolloRoundtripProtoListener instance;
 
    private final Map<UUID, Map<UUID, CompletableFuture<GeneratedMessageV3>>> roundTripPacketFutures = new ConcurrentHashMap<>();
    private final Map<UUID, CompletableFuture<List<Mod>>> paginatedFutures = new ConcurrentHashMap<>();
    private final Map<UUID, List<Mod>> paginatedAccumulator = new ConcurrentHashMap<>();
    private final ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
 
    public ApolloRoundtripProtoListener(ApolloExamplePlugin plugin) {
        instance = this;
        Bukkit.getServer().getMessenger().registerIncomingPluginChannel(plugin, "lunar:apollo", this);
    }
 
    @Override
    public void onPluginMessageReceived(@NonNull String channel, @NonNull Player player, byte[] bytes) {
        try {
            Any any = Any.parseFrom(bytes);
 
            if (any.is(PingResponse.class)) {
                PingResponse message = any.unpack(PingResponse.class);
                UUID requestId = UUID.fromString(message.getRequestId().toStringUtf8());
                this.handleResponse(player, requestId, message);
            } else if (any.is(TransferResponse.class)) {
                TransferResponse message = any.unpack(TransferResponse.class);
                UUID requestId = UUID.fromString(message.getRequestId().toStringUtf8());
                this.handleResponse(player, requestId, message);
            } else if (any.is(InstalledModsResponse.class)) {
                InstalledModsResponse message = any.unpack(InstalledModsResponse.class);
                UUID requestId = UUID.fromString(message.getRequestId().toStringUtf8());
                this.handlePagedResponse(requestId, message);
            }
 
        } catch (InvalidProtocolBufferException e) {
            throw new RuntimeException(e);
        }
    }
 
    public <T extends GeneratedMessageV3> CompletableFuture<T> sendRequest(Player player, UUID requestId,
                                                                           GeneratedMessageV3 request,
                                                                           Class<T> responseType) {
        ProtobufPacketUtil.sendPacket(player, request);
 
        CompletableFuture<T> future = new CompletableFuture<>();
 
        this.roundTripPacketFutures
            .computeIfAbsent(player.getUniqueId(), k -> new ConcurrentHashMap<>())
            .put(requestId, (CompletableFuture<GeneratedMessageV3>) future);
 
        ScheduledFuture<?> timeoutTask = this.executorService.schedule(() ->
                future.completeExceptionally(new TimeoutException("Response timed out")),
            10, TimeUnit.SECONDS
        );
 
        future.whenComplete((result, throwable) -> timeoutTask.cancel(false));
        return future;
    }
 
    public CompletableFuture<List<Mod>> sendPaginatedRequest(Player player, UUID requestId, GeneratedMessageV3 request) {
        ProtobufPacketUtil.sendPacket(player, request);
 
        CompletableFuture<List<Mod>> future = new CompletableFuture<>();
        this.paginatedFutures.put(requestId, future);
 
        ScheduledFuture<?> timeoutTask = this.executorService.schedule(() ->
                future.completeExceptionally(new TimeoutException("Response timed out")),
            10, TimeUnit.SECONDS
        );
 
        future.whenComplete((result, throwable) -> {
            timeoutTask.cancel(false);
            this.paginatedAccumulator.remove(requestId);
        });
 
        return future;
    }
 
    private void handlePagedResponse(UUID requestId, InstalledModsResponse response) {
        List<Mod> accumulated = this.paginatedAccumulator.computeIfAbsent(requestId, k -> new ArrayList<>());
 
        response.getModGroupsList().forEach(group ->
            accumulated.addAll(group.getModsList())
        );
 
        if (response.getPage() == response.getTotalPages() - 1) {
            this.paginatedAccumulator.remove(requestId);
            CompletableFuture<List<Mod>> future = this.paginatedFutures.remove(requestId);
            if (future != null) {
                future.complete(accumulated);
            }
        }
    }
 
    private <T extends GeneratedMessageV3> void handleResponse(Player player, UUID requestId, T message) {
        Map<UUID, CompletableFuture<GeneratedMessageV3>> futures = this.roundTripPacketFutures.get(player.getUniqueId());
        if (futures == null) {
            return;
        }
 
        CompletableFuture<GeneratedMessageV3> future = futures.remove(requestId);
        if (future != null) {
            future.complete(message);
        }
    }
}

Here's an example demonstrating how to use the code to handle a server-to-client TransferModule transfer, where the client responds with a status (accepted or rejected).

public void transferExample(Player player) {
    UUID requestId = UUID.randomUUID();
 
    TransferRequest transferRequestMessage = TransferRequest.newBuilder()
        .setRequestId(ByteString.copyFromUtf8(requestId.toString()))
        .setServerIp("mc.hypixel.net")
        .build();
 
    ApolloRoundtripProtoListener.getInstance()
        .sendRequest(player, requestId, transferRequestMessage, TransferResponse.class)
        .thenAccept(response -> {
            String message = "";
 
            switch (response.getStatus()) {
                case STATUS_ACCEPTED: {
                    message = "Transfer accepted! Goodbye!";
                    break;
                }
 
                case STATUS_REJECTED: {
                    message = "Transfer rejected by client!";
                    break;
                }
            }
 
            player.sendMessage(message);
        }).exceptionally(throwable -> {
            player.sendMessage("Failed to receive a response in time.");
            return null;
        });
}