package org.saturnclient.client; import java.time.Duration; import java.util.UUID; import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import org.saturnclient.client.player.SaturnPlayer; import org.saturnclient.common.provider.Providers; import org.saturnclient.common.ref.game.MinecraftClientRef; import org.saturnclient.cosmetics.Cloaks; import org.saturnclient.cosmetics.Hats; import dev.selimaj.session.Session; import dev.selimaj.session.types.SessionErrorException; public class ServiceClient { /** Override with -Dsaturn.serverUri=ws://127.0.0.1:8080 to use a local server. */ private static final String SERVER_URI = System.getProperty("saturn.serverUri", "wss://saturn-server.selimaj.dev"); private static final long CONNECT_TIMEOUT_SECONDS = 10; private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); private static final long MIN_RECONNECT_DELAY_MS = 1_000; private static final long MAX_RECONNECT_DELAY_MS = 60_000; /** The authenticated session, or null while disconnected or connecting. */ private static volatile Session session; public static volatile UUID uuid; private static final AtomicBoolean CONNECTING = new AtomicBoolean(); private static volatile boolean stopping = false; public static void initialize() { Providers.saturn.getClient().onClientStopping(() -> { stopping = true; Session current = session; session = null; if (current != null) { try { current.close(); } catch (Exception e) { Providers.saturn.logError("Unable to close Saturn session", e); } } }); connectInBackground(); } public static boolean isConnected() { return session != null; } /** Starts the connect loop unless one is already running. */ private static void connectInBackground() { if (stopping || !CONNECTING.compareAndSet(false, true)) { return; } Thread thread = new Thread(() -> { boolean connected = false; try { connected = connectLoop(); } finally { CONNECTING.set(false); } // A drop between connecting and clearing CONNECTING couldn't start a new loop. if (connected && session == null) { connectInBackground(); } }, "Saturn-Connect"); thread.setDaemon(true); thread.start(); } /** * Connects and authenticates, retrying with exponential backoff. Returns * whether it connected. */ private static boolean connectLoop() { long delay = MIN_RECONNECT_DELAY_MS; while (!stopping) { switch (connectOnce()) { case CONNECTED -> { Providers.saturn.logInfo("Saturn Session initialization complete"); return true; } case GIVE_UP -> { Providers.saturn.logError("Failed to authenticate with the server"); return false; } case RETRY -> { Providers.saturn.logError("Saturn server unavailable, retrying in " + delay / 1000 + "s"); try { Thread.sleep(delay); } catch (InterruptedException e) { return false; } delay = Math.min(delay * 2, MAX_RECONNECT_DELAY_MS); } } } return false; } private enum ConnectResult { CONNECTED, /** Retrying cannot help, e.g. no Minecraft session or the token was rejected. */ GIVE_UP, RETRY } private static ConnectResult connectOnce() { MinecraftClientRef client = Providers.saturn.getClient(); String accessToken = client.getAccessToken(); UUID playerUuid = client.getUuid(); String username = client.getUsername(); if (accessToken == null || playerUuid == null || username == null) { Providers.saturn.logError("No active Minecraft session found"); return ConnectResult.GIVE_UP; } uuid = playerUuid; Providers.saturn.logInfo("Authenticating with UUID: " + playerUuid); Session s; try { s = Session.connect(SERVER_URI, CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS); } catch (Exception e) { Providers.saturn.logError("Unable to connect to the Saturn server: " + rootMessage(e)); return ConnectResult.RETRY; } ServiceMethods.Types.Player response; try { response = s.request(ServiceMethods.Authenticate, accessToken, REQUEST_TIMEOUT).get(); } catch (ExecutionException e) { s.close(); if (e.getCause() instanceof SessionErrorException rejected) { Providers.saturn.logError("Saturn server rejected authentication: " + rejected.getMessage()); return ConnectResult.GIVE_UP; } Providers.saturn.logError("Authentication failed: " + rootMessage(e)); return ConnectResult.RETRY; } catch (InterruptedException e) { s.close(); return ConnectResult.GIVE_UP; } Cloaks.availableCloaks.clear(); Cloaks.availableCloaks.addAll(java.util.List.of(response.cloaks())); Cloaks.initialize(); Hats.availableHats.clear(); Hats.availableHats.addAll(java.util.List.of(response.hats())); Hats.initialize(); // Other players may have changed cosmetics while we were disconnected. SaturnPlayer.clear(); SaturnPlayer.set(new SaturnPlayer(playerUuid, username, response.cloak(), response.hat())); eventHandlers(s); if (stopping) { s.close(); return ConnectResult.GIVE_UP; } session = s; // Registered after publishing so an immediate drop still clears it. s.onClose(() -> onDisconnected(s)); return ConnectResult.CONNECTED; } private static void onDisconnected(Session closed) { if (session != closed) { return; } session = null; if (!stopping) { Providers.saturn.logError("Disconnected from the Saturn server, reconnecting"); connectInBackground(); } } private static String rootMessage(Throwable e) { while ((e instanceof CompletionException || e instanceof ExecutionException) && e.getCause() != null) { e = e.getCause(); } return e.getClass().getSimpleName() + ": " + e.getMessage(); } public static void setCloak(String itemId) { Session s = session; if (s == null) return; s.request(ServiceMethods.SetCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to set cloak (service): " + rootMessage(throwable)); } else { sendPlayer(s); } }); } public static void setHat(String itemId) { Session s = session; if (s == null) return; s.request(ServiceMethods.SetHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to set hat (service): " + rootMessage(throwable)); } else { sendPlayer(s); } }); } /** Tells nearby Saturn players about our current cosmetics. */ private static void sendPlayer(Session s) { s.request(ServiceMethods.SendPlayer, new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()), REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to send player (service): " + rootMessage(throwable)); } }); } public static void buyCloak(String itemId) { Session s = session; if (s == null) return; s.request(ServiceMethods.BuyCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to buy cloak (service): " + rootMessage(throwable)); } else if (!Cloaks.availableCloaks.contains(itemId)) { Cloaks.availableCloaks.add(itemId); } }); } public static void buyHat(String itemId) { Session s = session; if (s == null) return; s.request(ServiceMethods.BuyHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to buy hat (service): " + rootMessage(throwable)); } else if (!Hats.availableHats.contains(itemId)) { Hats.availableHats.add(itemId); } }); } public static void emote(String emote) { Session s = session; if (s == null) return; s.request(ServiceMethods.Emote, new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString()), REQUEST_TIMEOUT).whenComplete((msg, throwable) -> { if (throwable != null) { Providers.saturn.logError("Failed to emote (service): " + rootMessage(throwable)); } }); } private static void eventHandlers(Session s) { s.onNotification(ServiceMethods.EmoteEvent, (data) -> { if (data == null) { return; } UUID from; try { from = UUID.fromString(data.from()); } catch (Exception e) { return; } String emote = data.emote() != null && !data.emote().isEmpty() ? data.emote() : null; // Notifications arrive on a background thread. Providers.saturn.getClient().executeOnThread(() -> Providers.saturn.playEmote(from, emote)); }); s.onNotification(ServiceMethods.Player, (player) -> { SaturnPlayer.set(player.toSaturnPlayer()); }); } /** * Returns the player's Saturn data, or null if the server says they aren't * using Saturn. Throws if the lookup itself fails or times out, so callers * can retry. */ public static SaturnPlayer getPlayer(UUID uuid, String name) throws Exception { Session s = session; if (s == null) throw new IllegalStateException("Not connected to the Saturn server"); ServiceMethods.Types.Player player = s.request(ServiceMethods.GetPlayer, uuid.toString(), REQUEST_TIMEOUT) .join(); if (player == null) return null; return player.toSaturnPlayer(uuid, name); } }