Stop per-frame player lookups and make shared state thread-safe #1

Merged
selimaj-dev merged 1 commits from fix-player-lookups into master 2026-09-25 12:01:27 +00:00
4 changed files with 88 additions and 75 deletions
@@ -13,8 +13,8 @@ import org.saturnclient.cosmetics.Hats;
import dev.selimaj.session.Session; import dev.selimaj.session.Session;
public class ServiceClient { public class ServiceClient {
private static Session session; private static volatile Session session;
public static UUID uuid; public static volatile UUID uuid;
public static void initialize() { public static void initialize() {
new Thread(() -> { new Thread(() -> {
@@ -28,6 +28,10 @@ public class ServiceClient {
}).start(); }).start();
} }
public static boolean isConnected() {
return session != null;
}
public static boolean connectTimeout() { public static boolean connectTimeout() {
try { try {
session = Session.connect("wss://saturn-server.selimaj.dev", 2, TimeUnit.MINUTES); session = Session.connect("wss://saturn-server.selimaj.dev", 2, TimeUnit.MINUTES);
@@ -220,11 +224,10 @@ public class ServiceClient {
return; return;
} }
if (data.emote() != null && !data.emote().isEmpty()) { String emote = data.emote() != null && !data.emote().isEmpty() ? data.emote() : null;
Providers.saturn.playEmote(from, data.emote());
} else { // Notifications arrive on a background thread.
Providers.saturn.playEmote(from, null); Providers.saturn.getClient().executeOnThread(() -> Providers.saturn.playEmote(from, emote));
}
}); });
session.onNotification(ServiceMethods.Player, (player) -> { session.onNotification(ServiceMethods.Player, (player) -> {
@@ -233,21 +236,21 @@ public class ServiceClient {
}); });
} }
public static SaturnPlayer getPlayer(UUID uuid, String name) { /**
* 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.
*/
public static SaturnPlayer getPlayer(UUID uuid, String name) throws Exception {
Session session = ServiceClient.session;
if (session == null) if (session == null)
return null; throw new IllegalStateException("Not connected to the Saturn server");
try {
ServiceMethods.Types.Player player = session.request(ServiceMethods.GetPlayer, uuid.toString()).join(); ServiceMethods.Types.Player player = session.request(ServiceMethods.GetPlayer, uuid.toString()).join();
if (player == null) if (player == null)
return null; return null;
return player.toSaturnPlayer(uuid, name); return player.toSaturnPlayer(uuid, name);
} catch (Exception e) {
Providers.saturn.logError("Failed to get player", e);
}
return null;
} }
} }
@@ -1,24 +1,39 @@
package org.saturnclient.client.player; package org.saturnclient.client.player;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Queue; import java.util.Set;
import java.util.UUID; 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.client.ServiceClient;
import org.saturnclient.common.provider.Providers; import org.saturnclient.common.provider.Providers;
public class SaturnPlayer { 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; /** Players queued or being looked up. */
private static Thread WORKER; private static final Set<UUID> PENDING = ConcurrentHashMap.newKeySet();
public String cloak = ""; private static final long NOT_SATURN_RECHECK_MS = 60_000;
public String hat = ""; 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 UUID uuid;
public final String name; public final String name;
@@ -30,12 +45,18 @@ public class SaturnPlayer {
} }
public static SaturnPlayer get() { public static SaturnPlayer get() {
if (ServiceClient.uuid == null) { UUID self = ServiceClient.uuid;
if (self == null) {
return 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) { public static SaturnPlayer get(String name, UUID uuid) {
if (uuid == null) if (uuid == null)
return null; return null;
@@ -43,9 +64,7 @@ public class SaturnPlayer {
SaturnPlayer player = PLAYERS.get(uuid); SaturnPlayer player = PLAYERS.get(uuid);
if (player == null) { if (player == null) {
PLAYERS.put(uuid, null); requestLookup(name, uuid);
QUEUE.add(uuid);
startPlayerThread();
} }
return player; return player;
@@ -61,58 +80,46 @@ public class SaturnPlayer {
public static void set(SaturnPlayer player) { public static void set(SaturnPlayer player) {
PLAYERS.put(player.uuid, player); PLAYERS.put(player.uuid, player);
NEXT_LOOKUP.remove(player.uuid);
} }
public static String[] getExternalUUIDAsString() { 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) .map(UUID::toString)
.toArray(String[]::new); .toArray(String[]::new);
} }
public static synchronized void startPlayerThread() { private static void requestLookup(String name, UUID uuid) {
if (RUNNING) if (!ServiceClient.isConnected()) {
return; return;
}
RUNNING = true; Long next = NEXT_LOOKUP.get(uuid);
if (next != null && System.currentTimeMillis() < next) {
return;
}
WORKER = new Thread(() -> { if (!PENDING.add(uuid)) {
long lastWorkTime = System.currentTimeMillis(); return;
}
while (true) { LOOKUPS.execute(() -> {
UUID uuid = QUEUE.poll();
if (uuid != null) {
try { try {
String name = Providers.saturn.getClient().getPlayerListEntry(uuid);
SaturnPlayer player = ServiceClient.getPlayer(uuid, name); SaturnPlayer player = ServiceClient.getPlayer(uuid, name);
// Apply on main thread if (player != null) {
Providers.saturn.getClient().executeOnThread(() -> { set(player);
PLAYERS.put(uuid, player); } else {
}); NEXT_LOOKUP.put(uuid, System.currentTimeMillis() + NOT_SATURN_RECHECK_MS);
}
} catch (Exception e) { } catch (Exception e) {
Providers.saturn.logError("Failed to fetch player " + uuid, 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();
} }
} }
@@ -5,6 +5,7 @@ import org.saturnclient.client.player.SaturnPlayer;
import org.saturnclient.common.ref.asset.IdentifierRef; import org.saturnclient.common.ref.asset.IdentifierRef;
import java.util.*; import java.util.*;
import java.util.concurrent.CopyOnWriteArrayList;
public class Cloaks { public class Cloaks {
public static final String[] ALL_CLOAKS = { public static final String[] ALL_CLOAKS = {
@@ -30,7 +31,8 @@ public class Cloaks {
"black_hole_flame", "black_hole_flame",
"black_hole_white" }; "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() { public static void initialize() {
availableCloaks.add(0, ""); availableCloaks.add(0, "");
@@ -1,7 +1,7 @@
package org.saturnclient.cosmetics; package org.saturnclient.cosmetics;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
import org.saturnclient.client.ServiceClient; import org.saturnclient.client.ServiceClient;
import org.saturnclient.client.player.SaturnPlayer; import org.saturnclient.client.player.SaturnPlayer;
@@ -9,7 +9,8 @@ import org.saturnclient.client.player.SaturnPlayer;
public class Hats { public class Hats {
public static final String[] ALL_HATS = { "horns_black", "horns_white", "halo_white", "halo_black", "horns_end", public static final String[] ALL_HATS = { "horns_black", "horns_white", "halo_white", "halo_black", "horns_end",
"halo_end", "bucket_black", "bucket_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() { public static void initialize() {
availableHats.add(0, ""); availableHats.add(0, "");