diff --git a/build.gradle b/build.gradle index 8421ae6..eec78e0 100644 --- a/build.gradle +++ b/build.gradle @@ -7,5 +7,5 @@ java { } dependencies { - implementation "com.github.selimaj-dev:session-java:0.1.6" + implementation "dev.selimaj:session-java:0.2.0" } \ No newline at end of file diff --git a/src/main/java/org/saturnclient/client/ServiceClient.java b/src/main/java/org/saturnclient/client/ServiceClient.java index 885026c..760085a 100644 --- a/src/main/java/org/saturnclient/client/ServiceClient.java +++ b/src/main/java/org/saturnclient/client/ServiceClient.java @@ -1,8 +1,11 @@ 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.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import org.saturnclient.client.player.SaturnPlayer; import org.saturnclient.common.provider.Providers; @@ -11,209 +14,278 @@ 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() { - new Thread(() -> { - if (authenticate()) { - Cloaks.initialize(); - Hats.initialize(); - Providers.saturn.logInfo("Saturn Session initialization complete"); - } else { - Providers.saturn.logError("Failed to authenticate with the server"); - } - }).start(); - } + Providers.saturn.getClient().onClientStopping(() -> { + stopping = true; + Session current = session; + session = null; - public static boolean isConnected() { - return session != null; - } - - public static boolean connectTimeout() { - try { - session = Session.connect("wss://saturn-server.selimaj.dev", 2, TimeUnit.MINUTES); - return true; - } catch (TimeoutException e) { - System.out.println("Session Server timeout"); - return false; - } catch (Exception e) { - e.printStackTrace(); - return false; - } - } - - public static boolean connectMultiTimeout() { - for (int i = 0; i < 5; i++) { - if (connectTimeout()) { - return true; - } - - Providers.saturn.logError("Failed to authenticate (attempt " + (i + 1) + ")"); - - try { - Thread.sleep(500); - } catch (InterruptedException ignored) { - } - } - - return false; - } - - public static boolean authenticate() { - MinecraftClientRef client = Providers.saturn.getClient(); - - client.onClientStopping(() -> { - if (session != null) { + if (current != null) { try { - session.close(); + current.close(); } catch (Exception e) { Providers.saturn.logError("Unable to close Saturn session", e); } } }); - try { - String accessToken = client.getAccessToken(); - uuid = client.getUuid(); - String username = client.getUsername(); + connectInBackground(); + } - if (accessToken == null || uuid == null || username == null) { - Providers.saturn.logError("No active Minecraft session found"); - return false; - } + public static boolean isConnected() { + return session != null; + } - Providers.saturn.logInfo("Authenticating with UUID: " + uuid); - - if (!connectMultiTimeout()) { - Providers.saturn.logError("Unable to authenticate: Session Server Timeout"); - return false; - } - - ServiceMethods.Types.Player response = session.request(ServiceMethods.Authenticate, accessToken) - .get(); - - for (String availableCloak : response.cloaks()) { - Cloaks.availableCloaks.add(availableCloak); - } - - for (String availableHat : response.hats()) { - Hats.availableHats.add(availableHat); - } - - eventHandlers(); - - SaturnPlayer.set(new SaturnPlayer(uuid, username, response.cloak(), response.hat())); - - return true; - } catch (Exception e) { - Providers.saturn.logError("Authentication failed", e); - return false; + /** 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) { - if (session == null) + Session s = session; + if (s == null) return; - try { - session.request(ServiceMethods.SetCloak, itemId).whenComplete((msg, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); - } else { - try { - session.request(ServiceMethods.SendPlayer, - new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString())); - } catch (Exception e) { - e.printStackTrace(); - } - } - }); - } catch (Exception e) { - Providers.saturn.logError("Failed to set cloak (service): ", e); - } + 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) { - if (session == null) + Session s = session; + if (s == null) return; - try { - session.request(ServiceMethods.SetHat, itemId).whenComplete((msg, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); - } else { - try { - session.request(ServiceMethods.SendPlayer, - new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString())); - } catch (Exception e) { - e.printStackTrace(); + 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)); } - } - }); - } catch (Exception e) { - Providers.saturn.logError("Failed to set hat (service): ", e); - } + }); } public static void buyCloak(String itemId) { - if (session == null) + Session s = session; + if (s == null) return; - try { - session.request(ServiceMethods.BuyCloak, itemId).whenComplete((msg, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); - } else { - Cloaks.availableCloaks.add(itemId); - } - }); - } catch (Exception e) { - Providers.saturn.logError("Failed to buy cloak (service): ", e); - } + 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) { - if (session == null) + Session s = session; + if (s == null) return; - try { - session.request(ServiceMethods.BuyHat, itemId).whenComplete((msg, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); - } else { - Hats.availableHats.add(itemId); - } - }); - } catch (Exception e) { - Providers.saturn.logError("Failed to buy hat (service): ", e); - } + 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) { - if (session == null) + Session s = session; + if (s == null) return; - try { - session.request(ServiceMethods.Emote, - new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString())) - .whenComplete((msg, throwable) -> { - if (throwable != null) { - throwable.printStackTrace(); - } - }); - } catch (Exception e) { - Providers.saturn.logError("Failed to emote (service): ", e); - } + 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)); + } + }); } - public static void eventHandlers() { - if (session == null) - return; - - session.onNotification(ServiceMethods.EmoteEvent, (data) -> { + private static void eventHandlers(Session s) { + s.onNotification(ServiceMethods.EmoteEvent, (data) -> { if (data == null) { return; } @@ -230,23 +302,24 @@ public class ServiceClient { Providers.saturn.getClient().executeOnThread(() -> Providers.saturn.playEmote(from, emote)); }); - session.onNotification(ServiceMethods.Player, (player) -> { - System.out.println(player); + 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, so callers can retry. + * 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 session = ServiceClient.session; + Session s = session; - if (session == null) + if (s == null) throw new IllegalStateException("Not connected to the Saturn server"); - ServiceMethods.Types.Player player = session.request(ServiceMethods.GetPlayer, uuid.toString()).join(); + ServiceMethods.Types.Player player = s.request(ServiceMethods.GetPlayer, uuid.toString(), REQUEST_TIMEOUT) + .join(); if (player == null) return null; diff --git a/src/main/java/org/saturnclient/client/player/SaturnPlayer.java b/src/main/java/org/saturnclient/client/player/SaturnPlayer.java index 23d4a2f..47e37dd 100644 --- a/src/main/java/org/saturnclient/client/player/SaturnPlayer.java +++ b/src/main/java/org/saturnclient/client/player/SaturnPlayer.java @@ -78,6 +78,12 @@ public class SaturnPlayer { return get(name, Providers.saturn.getClient().getPlayerListEntryUUID(name)); } + /** Forgets every cached player, e.g. after reconnecting to the server. */ + public static void clear() { + PLAYERS.clear(); + NEXT_LOOKUP.clear(); + } + public static void set(SaturnPlayer player) { PLAYERS.put(player.uuid, player); NEXT_LOOKUP.remove(player.uuid);