Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df42e2b7c | ||
|
|
b3fc524763 | ||
|
|
98da42992e | ||
|
|
2de394ebbf | ||
|
|
510760d590 | ||
|
|
3b4e1ab4cb | ||
|
|
8cb458c9bd | ||
|
|
9e3bf14ffa | ||
|
|
2cfd93c197 | ||
|
|
1ddfd9d024 | ||
|
|
5e44a71054 | ||
|
|
5dd4b17db5 | ||
|
|
3379acef80 | ||
|
|
f07750cdcb |
@@ -7,5 +7,5 @@ java {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.github.selimaj-dev:session-java:0.1.6"
|
||||
implementation "dev.selimaj:session-java:0.2.0"
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package org.saturnclient.client;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
|
||||
/**
|
||||
* Mojang's session server "join" call, the same one vanilla makes when joining
|
||||
* an online-mode server. The access token is only ever sent to Mojang; the
|
||||
* Saturn server then confirms the join with {@code hasJoined}.
|
||||
*/
|
||||
public final class MojangSession {
|
||||
private static final URI JOIN_URI = URI.create("https://sessionserver.mojang.com/session/minecraft/join");
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
public enum JoinResult {
|
||||
JOINED,
|
||||
/** Mojang refused the access token; retrying won't help. */
|
||||
REJECTED,
|
||||
/** Network or server error; worth retrying. */
|
||||
FAILED
|
||||
}
|
||||
|
||||
private MojangSession() {
|
||||
}
|
||||
|
||||
public static JoinResult join(HttpClient http, String accessToken, UUID profile, String serverId) {
|
||||
ObjectNode body = MAPPER.createObjectNode()
|
||||
.put("accessToken", accessToken)
|
||||
.put("selectedProfile", profile.toString().replace("-", ""))
|
||||
.put("serverId", serverId);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder(JOIN_URI)
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body.toString()))
|
||||
.build();
|
||||
|
||||
try {
|
||||
int status = http.send(request, HttpResponse.BodyHandlers.discarding()).statusCode();
|
||||
|
||||
if (status == 204 || status == 200) {
|
||||
return JoinResult.JOINED;
|
||||
}
|
||||
|
||||
return status == 401 || status == 403 ? JoinResult.REJECTED : JoinResult.FAILED;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return JoinResult.FAILED;
|
||||
} catch (Exception e) {
|
||||
return JoinResult.FAILED;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,15 @@
|
||||
package org.saturnclient.client;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
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;
|
||||
@@ -9,155 +17,392 @@ import org.saturnclient.common.ref.game.MinecraftClientRef;
|
||||
import org.saturnclient.cosmetics.Cloaks;
|
||||
import org.saturnclient.cosmetics.Hats;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import dev.selimaj.session.Session;
|
||||
import dev.selimaj.session.types.SessionErrorException;
|
||||
|
||||
public class ServiceClient {
|
||||
private static Session session;
|
||||
public static UUID uuid;
|
||||
/** 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;
|
||||
|
||||
public static boolean connectTimeout() {
|
||||
try {
|
||||
session = Session.connect("wss://saturn-server.selimaj.dev", 10, TimeUnit.SECONDS);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
/** 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;
|
||||
|
||||
private static final HttpClient HTTP = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SECONDS))
|
||||
.build();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private enum VersionStatus {
|
||||
SUPPORTED,
|
||||
/** Still accepted, but the player should update. */
|
||||
DEPRECATED,
|
||||
/** Not accepted; don't connect. */
|
||||
UNSUPPORTED
|
||||
}
|
||||
|
||||
public static boolean authenticate() {
|
||||
MinecraftClientRef client = Providers.saturn.getClient();
|
||||
/** Last status the player was warned about, so reconnects don't repeat the toast. */
|
||||
private static volatile VersionStatus warnedStatus = VersionStatus.SUPPORTED;
|
||||
|
||||
client.onClientStopping(() -> {
|
||||
if (session != null) {
|
||||
public static void initialize() {
|
||||
Providers.saturn.getClient().onClientStopping(() -> {
|
||||
stopping = true;
|
||||
Session current = session;
|
||||
session = null;
|
||||
|
||||
if (current != null) {
|
||||
try {
|
||||
session.close();
|
||||
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 = client.getUuid();
|
||||
UUID playerUuid = client.getUuid();
|
||||
String username = client.getUsername();
|
||||
|
||||
if (accessToken == null || uuid == null || username == null) {
|
||||
if (accessToken == null || playerUuid == null || username == null) {
|
||||
Providers.saturn.logError("No active Minecraft session found");
|
||||
return false;
|
||||
return ConnectResult.GIVE_UP;
|
||||
}
|
||||
|
||||
Providers.saturn.logInfo("Authenticating with UUID: " + uuid);
|
||||
uuid = playerUuid;
|
||||
|
||||
if (!connectTimeout()) {
|
||||
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;
|
||||
VersionStatus version;
|
||||
try {
|
||||
version = checkVersion();
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Authentication failed", e);
|
||||
return false;
|
||||
Providers.saturn.logError("Unable to fetch the Saturn version manifest: " + rootMessage(e));
|
||||
return ConnectResult.RETRY;
|
||||
}
|
||||
|
||||
if (version == VersionStatus.UNSUPPORTED) {
|
||||
return ConnectResult.GIVE_UP;
|
||||
}
|
||||
|
||||
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 {
|
||||
// 1. The server hands out a one-time server id for this connection.
|
||||
String serverId;
|
||||
try {
|
||||
serverId = s.request(ServiceMethods.AuthChallenge, username, 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;
|
||||
}
|
||||
|
||||
// 2. Join with it at Mojang; the access token only goes to Mojang.
|
||||
switch (MojangSession.join(HTTP, accessToken, playerUuid, serverId)) {
|
||||
case JOINED -> {
|
||||
}
|
||||
case REJECTED -> {
|
||||
s.close();
|
||||
Providers.saturn.logError("Mojang rejected the Minecraft session");
|
||||
return ConnectResult.GIVE_UP;
|
||||
}
|
||||
case FAILED -> {
|
||||
s.close();
|
||||
Providers.saturn.logError("Unable to reach the Mojang session server");
|
||||
return ConnectResult.RETRY;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The server confirms the join with Mojang and logs us in.
|
||||
try {
|
||||
response = s.request(ServiceMethods.AuthVerify, null, REQUEST_TIMEOUT).get();
|
||||
} catch (ExecutionException e) {
|
||||
s.close();
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks this client's version against the server's manifest and warns the
|
||||
* player if it's deprecated or unsupported.
|
||||
*/
|
||||
private static VersionStatus checkVersion() throws Exception {
|
||||
URI server = URI.create(SERVER_URI);
|
||||
String scheme = "wss".equals(server.getScheme()) ? "https" : "http";
|
||||
URI manifestUri = new URI(scheme, server.getAuthority(), "/versions", null, null);
|
||||
|
||||
HttpResponse<String> res = HTTP.send(
|
||||
HttpRequest.newBuilder(manifestUri).timeout(REQUEST_TIMEOUT).GET().build(),
|
||||
HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (res.statusCode() != 200) {
|
||||
throw new IllegalStateException("GET /versions returned " + res.statusCode());
|
||||
}
|
||||
|
||||
ServiceMethods.VersionManifest manifest = MAPPER.readValue(res.body(), ServiceMethods.VersionManifest.class);
|
||||
|
||||
// Drop build metadata such as "+1.21.11": the manifest lists mod versions.
|
||||
String version = Providers.saturn.getModVersion().split("\\+", 2)[0];
|
||||
|
||||
VersionStatus status;
|
||||
if (manifest.supported() != null && manifest.supported().contains(version)) {
|
||||
status = VersionStatus.SUPPORTED;
|
||||
} else if (manifest.deprecated() != null && manifest.deprecated().contains(version)) {
|
||||
status = VersionStatus.DEPRECATED;
|
||||
} else {
|
||||
status = VersionStatus.UNSUPPORTED;
|
||||
}
|
||||
|
||||
if (status != warnedStatus) {
|
||||
warnedStatus = status;
|
||||
|
||||
switch (status) {
|
||||
case DEPRECATED -> {
|
||||
Providers.saturn.logError("Saturn Client " + version + " is deprecated");
|
||||
Providers.saturn.showWarning("Saturn Client update available",
|
||||
"Version " + version + " is deprecated and will stop working soon. Please update.");
|
||||
}
|
||||
case UNSUPPORTED -> {
|
||||
Providers.saturn.logError("Saturn Client " + version + " is no longer supported, not connecting");
|
||||
Providers.saturn.showWarning("Saturn Client is outdated",
|
||||
"Version " + version + " is no longer supported. Update to use cosmetics and emotes.");
|
||||
}
|
||||
case SUPPORTED -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
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) {
|
||||
try {
|
||||
session.request(ServiceMethods.SetCloak, itemId).whenComplete((msg, throwable) -> {
|
||||
Session s = session;
|
||||
if (s == null)
|
||||
return;
|
||||
|
||||
s.request(ServiceMethods.SetCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
||||
if (throwable != null) {
|
||||
throwable.printStackTrace();
|
||||
Providers.saturn.logError("Failed to set cloak (service): " + rootMessage(throwable));
|
||||
} else {
|
||||
try {
|
||||
session.request(ServiceMethods.SendPlayer,
|
||||
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
sendPlayer(s);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to set cloak (service): ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void setHat(String itemId) {
|
||||
try {
|
||||
session.request(ServiceMethods.SetHat, itemId).whenComplete((msg, throwable) -> {
|
||||
Session s = session;
|
||||
if (s == null)
|
||||
return;
|
||||
|
||||
s.request(ServiceMethods.SetHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
||||
if (throwable != null) {
|
||||
throwable.printStackTrace();
|
||||
Providers.saturn.logError("Failed to set hat (service): " + rootMessage(throwable));
|
||||
} else {
|
||||
try {
|
||||
session.request(ServiceMethods.SendPlayer,
|
||||
new ServiceMethods.Types.SendPlayerRequest(SaturnPlayer.getExternalUUIDAsString()));
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
sendPlayer(s);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to set hat (service): ", e);
|
||||
}
|
||||
|
||||
/** 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) {
|
||||
try {
|
||||
session.request(ServiceMethods.BuyCloak, itemId).whenComplete((msg, throwable) -> {
|
||||
Session s = session;
|
||||
if (s == null)
|
||||
return;
|
||||
|
||||
s.request(ServiceMethods.BuyCloak, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
||||
if (throwable != null) {
|
||||
throwable.printStackTrace();
|
||||
} else {
|
||||
Providers.saturn.logError("Failed to buy cloak (service): " + rootMessage(throwable));
|
||||
} else if (!Cloaks.availableCloaks.contains(itemId)) {
|
||||
Cloaks.availableCloaks.add(itemId);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to buy cloak (service): ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void buyHat(String itemId) {
|
||||
try {
|
||||
session.request(ServiceMethods.BuyHat, itemId).whenComplete((msg, throwable) -> {
|
||||
Session s = session;
|
||||
if (s == null)
|
||||
return;
|
||||
|
||||
s.request(ServiceMethods.BuyHat, itemId, REQUEST_TIMEOUT).whenComplete((msg, throwable) -> {
|
||||
if (throwable != null) {
|
||||
throwable.printStackTrace();
|
||||
} else {
|
||||
Providers.saturn.logError("Failed to buy hat (service): " + rootMessage(throwable));
|
||||
} else if (!Hats.availableHats.contains(itemId)) {
|
||||
Hats.availableHats.add(itemId);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to buy hat (service): ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void emote(String emote) {
|
||||
try {
|
||||
session.request(ServiceMethods.Emote,
|
||||
new ServiceMethods.Types.EmoteRequest(emote, SaturnPlayer.getExternalUUIDAsString()))
|
||||
.whenComplete((msg, throwable) -> {
|
||||
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) {
|
||||
throwable.printStackTrace();
|
||||
Providers.saturn.logError("Failed to emote (service): " + rootMessage(throwable));
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to emote (service): ", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static void eventHandlers() {
|
||||
session.onNotification(ServiceMethods.EmoteEvent, (data) -> {
|
||||
private static void eventHandlers(Session s) {
|
||||
s.onNotification(ServiceMethods.EmoteEvent, (data) -> {
|
||||
if (data == null) {
|
||||
return;
|
||||
}
|
||||
@@ -168,31 +413,34 @@ public class ServiceClient {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.emote() != null && !data.emote().isEmpty()) {
|
||||
Providers.saturn.playEmote(from, data.emote());
|
||||
} else {
|
||||
Providers.saturn.playEmote(from, null);
|
||||
}
|
||||
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));
|
||||
});
|
||||
|
||||
session.onNotification(ServiceMethods.Player, (player) -> {
|
||||
System.out.println(player);
|
||||
s.onNotification(ServiceMethods.Player, (player) -> {
|
||||
SaturnPlayer.set(player.toSaturnPlayer());
|
||||
});
|
||||
}
|
||||
|
||||
public static SaturnPlayer getPlayer(UUID uuid, String name) {
|
||||
try {
|
||||
ServiceMethods.Types.Player player = session.request(ServiceMethods.GetPlayer, uuid.toString()).join();
|
||||
/**
|
||||
* 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);
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to get player", e);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,11 +43,25 @@ public class ServiceMethods {
|
||||
// =========================
|
||||
// AUTH
|
||||
// =========================
|
||||
public static final Method<String, Types.Player, String> Authenticate = new Method<>("auth",
|
||||
/** Username in, random server id out, to pass to Mojang's session join. */
|
||||
public static final Method<String, String, String> AuthChallenge = new Method<>("auth_challenge",
|
||||
String.class,
|
||||
String.class,
|
||||
String.class);
|
||||
|
||||
/** After joining at Mojang: the server verifies with hasJoined. */
|
||||
public static final Method<Void, Types.Player, String> AuthVerify = new Method<>("auth_verify",
|
||||
Void.class,
|
||||
Types.Player.class,
|
||||
String.class);
|
||||
|
||||
// =========================
|
||||
// VERSION MANIFEST (GET /versions)
|
||||
// =========================
|
||||
@com.fasterxml.jackson.annotation.JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record VersionManifest(java.util.List<String> supported, java.util.List<String> deprecated) {
|
||||
}
|
||||
|
||||
// =========================
|
||||
// EQUIP
|
||||
// =========================
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
package org.saturnclient.client.player;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.saturnclient.client.ServiceClient;
|
||||
import org.saturnclient.common.provider.Providers;
|
||||
|
||||
public class SaturnPlayer {
|
||||
private static final Map<UUID, SaturnPlayer> PLAYERS = new HashMap<>();
|
||||
/** Players known to be using Saturn. */
|
||||
private static final Map<UUID, SaturnPlayer> PLAYERS = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Queue<UUID> QUEUE = new ConcurrentLinkedQueue<>();
|
||||
/**
|
||||
* Earliest time (ms) a player without a Saturn entry may be looked up again,
|
||||
* so non-Saturn players aren't re-requested every frame.
|
||||
*/
|
||||
private static final Map<UUID, Long> NEXT_LOOKUP = new ConcurrentHashMap<>();
|
||||
|
||||
private static volatile boolean RUNNING = false;
|
||||
private static Thread WORKER;
|
||||
/** Players queued or being looked up. */
|
||||
private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public String cloak = "";
|
||||
public String hat = "";
|
||||
private static final long NOT_SATURN_RECHECK_MS = 60_000;
|
||||
private static final long FAILED_LOOKUP_RETRY_MS = 5_000;
|
||||
|
||||
private static final ExecutorService LOOKUPS = Executors.newSingleThreadExecutor(runnable -> {
|
||||
Thread thread = new Thread(runnable, "SaturnPlayer-Worker");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
public volatile String cloak = "";
|
||||
public volatile String hat = "";
|
||||
public final UUID uuid;
|
||||
public final String name;
|
||||
|
||||
@@ -30,12 +45,18 @@ public class SaturnPlayer {
|
||||
}
|
||||
|
||||
public static SaturnPlayer get() {
|
||||
if (ServiceClient.uuid == null) {
|
||||
UUID self = ServiceClient.uuid;
|
||||
if (self == null) {
|
||||
return null;
|
||||
}
|
||||
return PLAYERS.get(ServiceClient.uuid);
|
||||
return PLAYERS.get(self);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the player if they are known to use Saturn, otherwise null. An
|
||||
* unknown player is looked up in the background, at most once at a time and
|
||||
* no more often than {@link #NEXT_LOOKUP} allows. Safe to call every frame.
|
||||
*/
|
||||
public static SaturnPlayer get(String name, UUID uuid) {
|
||||
if (uuid == null)
|
||||
return null;
|
||||
@@ -43,9 +64,7 @@ public class SaturnPlayer {
|
||||
SaturnPlayer player = PLAYERS.get(uuid);
|
||||
|
||||
if (player == null) {
|
||||
PLAYERS.put(uuid, null);
|
||||
QUEUE.add(uuid);
|
||||
startPlayerThread();
|
||||
requestLookup(name, uuid);
|
||||
}
|
||||
|
||||
return player;
|
||||
@@ -59,60 +78,54 @@ 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);
|
||||
}
|
||||
|
||||
public static String[] getExternalUUIDAsString() {
|
||||
return PLAYERS.keySet().stream().filter(id -> ServiceClient.uuid == null || !id.equals(ServiceClient.uuid))
|
||||
UUID self = ServiceClient.uuid;
|
||||
|
||||
return PLAYERS.keySet().stream().filter(id -> self == null || !id.equals(self))
|
||||
.map(UUID::toString)
|
||||
.toArray(String[]::new);
|
||||
}
|
||||
|
||||
public static synchronized void startPlayerThread() {
|
||||
if (RUNNING)
|
||||
private static void requestLookup(String name, UUID uuid) {
|
||||
if (!ServiceClient.isConnected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
RUNNING = true;
|
||||
Long next = NEXT_LOOKUP.get(uuid);
|
||||
if (next != null && System.currentTimeMillis() < next) {
|
||||
return;
|
||||
}
|
||||
|
||||
WORKER = new Thread(() -> {
|
||||
long lastWorkTime = System.currentTimeMillis();
|
||||
if (!PENDING.add(uuid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
UUID uuid = QUEUE.poll();
|
||||
|
||||
if (uuid != null) {
|
||||
LOOKUPS.execute(() -> {
|
||||
try {
|
||||
String name = Providers.saturn.getClient().getPlayerListEntry(uuid);
|
||||
|
||||
SaturnPlayer player = ServiceClient.getPlayer(uuid, name);
|
||||
|
||||
// Apply on main thread
|
||||
Providers.saturn.getClient().executeOnThread(() -> {
|
||||
PLAYERS.put(uuid, player);
|
||||
});
|
||||
|
||||
if (player != null) {
|
||||
set(player);
|
||||
} else {
|
||||
NEXT_LOOKUP.put(uuid, System.currentTimeMillis() + NOT_SATURN_RECHECK_MS);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
Providers.saturn.logError("Failed to fetch player " + uuid, e);
|
||||
NEXT_LOOKUP.put(uuid, System.currentTimeMillis() + FAILED_LOOKUP_RETRY_MS);
|
||||
} finally {
|
||||
PENDING.remove(uuid);
|
||||
}
|
||||
|
||||
lastWorkTime = System.currentTimeMillis();
|
||||
} else {
|
||||
if (System.currentTimeMillis() - lastWorkTime > 5000) {
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
Thread.sleep(50);
|
||||
} catch (InterruptedException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RUNNING = false;
|
||||
}, "SaturnPlayer-Worker");
|
||||
|
||||
WORKER.setDaemon(true);
|
||||
WORKER.start();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,4 +18,10 @@ public interface SaturnProvider {
|
||||
public void logError(String message, Throwable throwable);
|
||||
|
||||
public void registerBufferedImageTexture(IdentifierRef i, BufferedImage bi);
|
||||
|
||||
/** The Saturn Client mod version, e.g. {@code 0.1.0-beta3+1.21.11}. */
|
||||
public String getModVersion();
|
||||
|
||||
/** Shows a warning toast. Safe to call from any thread. */
|
||||
public void showWarning(String title, String message);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.saturnclient.common.ref.game;
|
||||
|
||||
import org.saturnclient.common.ref.asset.IdentifierRef;
|
||||
import org.saturnclient.common.ref.asset.SpriteRef;
|
||||
|
||||
public interface EffectRef {
|
||||
@@ -7,6 +8,10 @@ public interface EffectRef {
|
||||
|
||||
SpriteRef getIcon();
|
||||
|
||||
default IdentifierRef getIconId() {
|
||||
return null;
|
||||
}
|
||||
|
||||
boolean isInfinite();
|
||||
|
||||
String getInfiniteText();
|
||||
|
||||
@@ -5,6 +5,7 @@ import org.saturnclient.client.player.SaturnPlayer;
|
||||
import org.saturnclient.common.ref.asset.IdentifierRef;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
public class Cloaks {
|
||||
public static final String[] ALL_CLOAKS = {
|
||||
@@ -30,7 +31,8 @@ public class Cloaks {
|
||||
"black_hole_flame",
|
||||
"black_hole_white" };
|
||||
|
||||
public static final List<String> availableCloaks = new ArrayList<>();
|
||||
// Written by network callbacks, read by the UI thread.
|
||||
public static final List<String> availableCloaks = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static void initialize() {
|
||||
availableCloaks.add(0, "");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package org.saturnclient.cosmetics;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
import org.saturnclient.client.ServiceClient;
|
||||
import org.saturnclient.client.player.SaturnPlayer;
|
||||
@@ -9,7 +9,8 @@ import org.saturnclient.client.player.SaturnPlayer;
|
||||
public class Hats {
|
||||
public static final String[] ALL_HATS = { "horns_black", "horns_white", "halo_white", "halo_black", "horns_end",
|
||||
"halo_end", "bucket_black", "bucket_end" };
|
||||
public static List<String> availableHats = new ArrayList<>();
|
||||
// Written by network callbacks, read by the UI thread.
|
||||
public static List<String> availableHats = new CopyOnWriteArrayList<>();
|
||||
|
||||
public static void initialize() {
|
||||
availableHats.add(0, "");
|
||||
|
||||
@@ -4,6 +4,7 @@ import java.util.List;
|
||||
|
||||
import org.saturnclient.common.provider.FeatureProvider;
|
||||
import org.saturnclient.common.provider.Providers;
|
||||
import org.saturnclient.common.ref.asset.SpriteRef;
|
||||
import org.saturnclient.common.ref.game.EffectRef;
|
||||
import org.saturnclient.config.property.BoolProperty;
|
||||
import org.saturnclient.config.property.Property;
|
||||
@@ -63,7 +64,12 @@ public class StatusEffectsMod extends Mod implements HudMod {
|
||||
if (!effect.shouldShowIcon())
|
||||
continue;
|
||||
|
||||
scope.drawSpriteStretched(effect.getIcon(), 0, 18 * row, 16, 16);
|
||||
SpriteRef sprite = effect.getIcon();
|
||||
if (sprite == null) {
|
||||
scope.drawTexture(effect.getIconId(), 0, 18 * row, 0, 0, 16, 16);
|
||||
} else {
|
||||
scope.drawSpriteStretched(sprite, 0, 18 * row, 16, 16);
|
||||
}
|
||||
scope.drawText(0.5f, durationString(effect),
|
||||
18, 18 * row + 3,
|
||||
layout.font.value, -1);
|
||||
|
||||
@@ -64,19 +64,18 @@ public abstract class Input extends Element {
|
||||
public void render(RenderScope renderScope, ElementContext ctx) {
|
||||
checkReset();
|
||||
|
||||
renderScope.drawRoundedRectangle(0, 0, width, height, 10, 0xFF000000);
|
||||
renderScope.drawRoundedRectangle(0, 0, width, height, 10, Theme.PRIMARY.value);
|
||||
|
||||
int scrollOffset = getScrollOffset();
|
||||
String visibleText = getVisibleText(scrollOffset);
|
||||
int textColor = focused ? 0xFFFFFF : 0xAAAAAA;
|
||||
renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, textColor);
|
||||
renderScope.drawText(0.6f, visibleText, 4, 4, Theme.FONT.value, Theme.PRIMARY_FG.value);
|
||||
|
||||
if (focused) {
|
||||
int cursorX = 4 +
|
||||
(int) (Fonts.getWidth(visibleText.substring(0, cursorPosition - scrollOffset), Theme.FONT.value)
|
||||
* 0.6f);
|
||||
|
||||
renderScope.drawRectangle(cursorX, 2, 1, height - 4, 0xFFFFFFFF);
|
||||
renderScope.drawRectangle(cursorX, 2, 1, height - 4, Theme.ACCENT.value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 5.8 MiB |
|
Before Width: | Height: | Size: 19 MiB After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 16 MiB After Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 21 MiB After Width: | Height: | Size: 5.3 MiB |
|
Before Width: | Height: | Size: 18 MiB After Width: | Height: | Size: 5.6 MiB |