From 988699b9d54e4891d21ee88c3dee4d210ffd5125 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:30:42 +1300 Subject: [PATCH 01/11] Add proxy based friend list and name resolution in strings rather than player or unknown --- service.friend/build.gradle.kts | 1 + .../swofty/service/friend/FriendCache.java | 108 ++++++++++++++++-- .../FriendEventToServiceEndpoint.java | 39 +++++-- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/service.friend/build.gradle.kts b/service.friend/build.gradle.kts index f849567d5..b18330edc 100644 --- a/service.friend/build.gradle.kts +++ b/service.friend/build.gradle.kts @@ -25,6 +25,7 @@ repositories { dependencies { implementation(project(":service.generic")) implementation(project(":commons")) + implementation(project(":proxy.api")) implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") implementation("org.tinylog:tinylog-api:2.7.0") implementation("org.tinylog:tinylog-impl:2.7.0") diff --git a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java index 7e01c85c7..b2b68dbc8 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java +++ b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java @@ -1,17 +1,27 @@ package net.swofty.service.friend; +import com.mongodb.client.MongoCollection; +import com.mongodb.client.model.Filters; import net.swofty.commons.friend.*; import net.swofty.commons.friend.events.*; import net.swofty.commons.friend.events.response.*; import net.swofty.commons.service.FromServiceChannels; +import net.swofty.proxyapi.ProxyPlayer; import net.swofty.service.generic.redis.ServiceToServerManager; import org.json.JSONObject; +import org.bson.Document; +import org.tinylog.Logger; import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -276,7 +286,7 @@ public static void handleToggleSettingRequest(FriendToggleSettingRequestEvent ev sendEvent(new FriendSettingToggledResponseEvent(player, settingType, newValue)); } - public static void handleListRequest(FriendListRequestEvent event, Map playerNames, Map onlineStatus) { + public static void handleListRequest(FriendListRequestEvent event) { UUID player = event.getPlayer(); int page = event.getPage(); boolean bestOnly = event.isBestOnly(); @@ -291,9 +301,16 @@ public static void handleListRequest(FriendListRequestEvent event, Map pageFriends = friends.subList(startIndex, endIndex); + Map playerNames = resolvePlayerNames(pageFriends.stream() + .map(Friend::getUuid) + .toList()); + Map onlineStatus = resolveOnlineStatus(pageFriends.stream() + .map(Friend::getUuid) + .toList()); + List entries = new ArrayList<>(); - for (int i = startIndex; i < endIndex; i++) { - Friend friend = friends.get(i); + for (Friend friend : pageFriends) { String name = playerNames.getOrDefault(friend.getUuid(), "Unknown"); boolean isOnline = onlineStatus.getOrDefault(friend.getUuid(), false); entries.add(new FriendListResponseEvent.FriendListEntry( @@ -308,7 +325,7 @@ public static void handleListRequest(FriendListRequestEvent event, Map playerNames) { + public static void handleRequestsListRequest(FriendRequestsListEvent event) { UUID player = event.getPlayer(); int page = event.getPage(); @@ -322,10 +339,14 @@ public static void handleRequestsListRequest(FriendRequestsListEvent event, Map< int startIndex = (page - 1) * FRIENDS_PER_PAGE; int endIndex = Math.min(startIndex + FRIENDS_PER_PAGE, totalRequests); + List pageRequests = requests.subList(startIndex, endIndex); + Map playerNames = resolvePlayerNames(pageRequests.stream() + .map(PendingFriendRequest::getFrom) + .toList()); + List entries = new ArrayList<>(); - for (int i = startIndex; i < endIndex; i++) { - PendingFriendRequest request = requests.get(i); - String senderName = playerNames.getOrDefault(request.getFrom(), "Unknown"); + for (PendingFriendRequest request : pageRequests) { + String senderName = playerNames.getOrDefault(request.getFrom(), request.getFromName()); entries.add(new FriendRequestsListResponseEvent.FriendRequestEntry( request.getFrom(), senderName, @@ -361,6 +382,79 @@ public static void handlePlayerLeave(UUID playerUuid, String playerName) { cachedFriendData.remove(playerUuid); } + public static String getPlayerName(UUID uuid) { + return resolvePlayerNames(List.of(uuid)).getOrDefault(uuid, "Unknown"); + } + + private static Map resolvePlayerNames(Collection uuids) { + Map names = new HashMap<>(); + if (uuids == null || uuids.isEmpty() || FriendDatabase.database == null) return names; + + List idStrings = uuids.stream().map(UUID::toString).toList(); + names.putAll(fetchNamesFromCollection("data", idStrings)); + + Set unresolved = new HashSet<>(uuids); + unresolved.removeAll(names.keySet()); + if (!unresolved.isEmpty()) { + List unresolvedIds = unresolved.stream().map(UUID::toString).toList(); + names.putAll(fetchNamesFromCollection("profiles", unresolvedIds)); + } + + return names; + } + + private static Map resolveOnlineStatus(Collection uuids) { + Map status = new ConcurrentHashMap<>(); + if (uuids == null || uuids.isEmpty()) return status; + + List> futures = new ArrayList<>(); + for (UUID uuid : uuids) { + CompletableFuture future = new ProxyPlayer(uuid) + .isOnline() + .orTimeout(2, TimeUnit.SECONDS) + .exceptionally(e -> false) + .thenAccept(isOnline -> status.put(uuid, isOnline)); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + return status; + } + + private static Map fetchNamesFromCollection(String collectionName, List ids) { + Map names = new HashMap<>(); + try { + MongoCollection collection = FriendDatabase.database.getCollection(collectionName); + if (collection == null || ids.isEmpty()) return names; + + for (Document doc : collection.find(Filters.in("_id", ids))) { + String id = doc.getString("_id"); + if (id == null) continue; + + String ign = parseStoredName(doc.getString("ign")); + if (ign == null && doc.containsKey("ignLowercase")) { + ign = parseStoredName(doc.getString("ignLowercase")); + } + + if (ign != null) { + names.put(UUID.fromString(id), ign); + } + } + } catch (Exception e) { + Logger.error(e, "Failed to resolve player names from collection {}", collectionName); + } + return names; + } + + private static String parseStoredName(String raw) { + if (raw == null) return null; + raw = raw.trim(); + if (raw.startsWith("\"") && raw.endsWith("\"") && raw.length() >= 2) { + raw = raw.substring(1, raw.length() - 1); + } + return raw.isEmpty() ? null : raw; + } + private static void persistFriendData(UUID playerUuid) { FriendData data = cachedFriendData.get(playerUuid); if (data != null) { diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java index 94ad551e5..d084e7732 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/FriendEventToServiceEndpoint.java @@ -8,8 +8,6 @@ import net.swofty.service.generic.redis.ServiceEndpoint; import org.tinylog.Logger; -import java.util.HashMap; - public class FriendEventToServiceEndpoint implements ServiceEndpoint< SendFriendEventToServiceProtocolObject.SendFriendEventToServiceMessage, SendFriendEventToServiceProtocolObject.SendFriendEventToServiceResponse> { @@ -29,16 +27,37 @@ public SendFriendEventToServiceProtocolObject.SendFriendEventToServiceResponse o System.out.println("Received friend event: " + event.getClass().getSimpleName()); switch (event) { - case FriendAddRequestEvent e -> FriendCache.handleAddRequest(e, "Player", "Player"); - case FriendAcceptRequestEvent e -> FriendCache.handleAcceptRequest(e, "Player", "Player"); - case FriendDenyRequestEvent e -> FriendCache.handleDenyRequest(e, "Player"); - case FriendRemoveRequestEvent e -> FriendCache.handleRemoveRequest(e, "Player", "Player"); + case FriendAddRequestEvent e -> FriendCache.handleAddRequest( + e, + FriendCache.getPlayerName(e.getSender()), + FriendCache.getPlayerName(e.getTarget()) + ); + case FriendAcceptRequestEvent e -> FriendCache.handleAcceptRequest( + e, + FriendCache.getPlayerName(e.getAccepter()), + FriendCache.getPlayerName(e.getRequester()) + ); + case FriendDenyRequestEvent e -> FriendCache.handleDenyRequest( + e, + FriendCache.getPlayerName(e.getDenier()) + ); + case FriendRemoveRequestEvent e -> FriendCache.handleRemoveRequest( + e, + FriendCache.getPlayerName(e.getRemover()), + FriendCache.getPlayerName(e.getTarget()) + ); case FriendRemoveAllRequestEvent e -> FriendCache.handleRemoveAllRequest(e); - case FriendToggleBestRequestEvent e -> FriendCache.handleToggleBestRequest(e, "Player"); - case FriendSetNicknameRequestEvent e -> FriendCache.handleSetNicknameRequest(e, "Player"); + case FriendToggleBestRequestEvent e -> FriendCache.handleToggleBestRequest( + e, + FriendCache.getPlayerName(e.getTarget()) + ); + case FriendSetNicknameRequestEvent e -> FriendCache.handleSetNicknameRequest( + e, + FriendCache.getPlayerName(e.getTarget()) + ); case FriendToggleSettingRequestEvent e -> FriendCache.handleToggleSettingRequest(e); - case FriendListRequestEvent e -> FriendCache.handleListRequest(e, new HashMap<>(), new HashMap<>()); - case FriendRequestsListEvent e -> FriendCache.handleRequestsListRequest(e, new HashMap<>()); + case FriendListRequestEvent e -> FriendCache.handleListRequest(e); + case FriendRequestsListEvent e -> FriendCache.handleRequestsListRequest(e); default -> Logger.warn("Unknown friend event type: " + event.getClass().getSimpleName()); } From 78ef98d02927bd7140e43b40b4a8c16b29abfb39 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 18:47:14 +1300 Subject: [PATCH 02/11] Friends info now pulls from the new Presence Handler service within friends, stores in redis, updated by velocity extension on join leave and server change --- .../swofty/commons/presence/PresenceInfo.java | 57 +++++++++++++ .../GetPresenceBulkProtocolObject.java | 85 +++++++++++++++++++ .../UpdatePresenceProtocolObject.java | 60 +++++++++++++ service.friend/build.gradle.kts | 1 - .../swofty/service/friend/FriendCache.java | 38 ++++----- .../service/friend/PresenceStorage.java | 37 ++++++++ .../friend/endpoints/GetPresenceEndpoint.java | 29 +++++++ .../endpoints/UpdatePresenceEndpoint.java | 26 ++++++ velocity.extension/build.gradle.kts | 1 + .../net/swofty/velocity/SkyBlockVelocity.java | 12 +++ .../velocity/presence/PresencePublisher.java | 44 ++++++++++ 11 files changed, 368 insertions(+), 22 deletions(-) create mode 100644 commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java create mode 100644 commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java create mode 100644 commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java create mode 100644 service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java create mode 100644 service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java create mode 100644 service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java create mode 100644 velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java diff --git a/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java b/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java new file mode 100644 index 000000000..1c8b09291 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/presence/PresenceInfo.java @@ -0,0 +1,57 @@ +package net.swofty.commons.presence; + +import lombok.Getter; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONObject; + +import java.util.UUID; + +@Getter +public class PresenceInfo { + private final UUID uuid; + private final boolean online; + private final String serverType; + private final String serverId; + private final long lastSeen; + + public PresenceInfo(UUID uuid, boolean online, String serverType, String serverId, long lastSeen) { + this.uuid = uuid; + this.online = online; + this.serverType = serverType; + this.serverId = serverId; + this.lastSeen = lastSeen; + } + + public static Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(PresenceInfo value) { + JSONObject json = new JSONObject(); + json.put("uuid", value.uuid.toString()); + json.put("online", value.online); + json.put("serverType", value.serverType != null ? value.serverType : JSONObject.NULL); + json.put("serverId", value.serverId != null ? value.serverId : JSONObject.NULL); + json.put("lastSeen", value.lastSeen); + return json.toString(); + } + + @Override + public PresenceInfo deserialize(String json) { + JSONObject jsonObject = new JSONObject(json); + return new PresenceInfo( + UUID.fromString(jsonObject.getString("uuid")), + jsonObject.getBoolean("online"), + jsonObject.isNull("serverType") ? null : jsonObject.getString("serverType"), + jsonObject.isNull("serverId") ? null : jsonObject.getString("serverId"), + jsonObject.getLong("lastSeen") + ); + } + + @Override + public PresenceInfo clone(PresenceInfo value) { + return new PresenceInfo(value.uuid, value.online, value.serverType, value.serverId, value.lastSeen); + } + }; + } +} + diff --git a/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java new file mode 100644 index 000000000..7bb662339 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/GetPresenceBulkProtocolObject.java @@ -0,0 +1,85 @@ +package net.swofty.commons.protocol.objects.presence; + +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.ProtocolObject; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONArray; +import org.json.JSONObject; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +public class GetPresenceBulkProtocolObject extends ProtocolObject< + GetPresenceBulkProtocolObject.GetPresenceBulkMessage, + GetPresenceBulkProtocolObject.GetPresenceBulkResponse> { + + @Override + public Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(GetPresenceBulkMessage value) { + JSONArray array = new JSONArray(); + for (UUID uuid : value.uuids) { + array.put(uuid.toString()); + } + JSONObject json = new JSONObject(); + json.put("uuids", array); + return json.toString(); + } + + @Override + public GetPresenceBulkMessage deserialize(String json) { + JSONObject obj = new JSONObject(json); + JSONArray array = obj.getJSONArray("uuids"); + List ids = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + ids.add(UUID.fromString(array.getString(i))); + } + return new GetPresenceBulkMessage(ids); + } + + @Override + public GetPresenceBulkMessage clone(GetPresenceBulkMessage value) { + return new GetPresenceBulkMessage(new ArrayList<>(value.uuids)); + } + }; + } + + @Override + public Serializer getReturnSerializer() { + return new Serializer<>() { + @Override + public String serialize(GetPresenceBulkResponse value) { + JSONArray array = new JSONArray(); + for (PresenceInfo info : value.presence()) { + array.put(new JSONObject(PresenceInfo.getSerializer().serialize(info))); + } + JSONObject json = new JSONObject(); + json.put("presence", array); + return json.toString(); + } + + @Override + public GetPresenceBulkResponse deserialize(String json) { + JSONObject obj = new JSONObject(json); + JSONArray array = obj.getJSONArray("presence"); + List presence = new ArrayList<>(); + for (int i = 0; i < array.length(); i++) { + presence.add(PresenceInfo.getSerializer().deserialize(array.getJSONObject(i).toString())); + } + return new GetPresenceBulkResponse(presence); + } + + @Override + public GetPresenceBulkResponse clone(GetPresenceBulkResponse value) { + return new GetPresenceBulkResponse(new ArrayList<>(value.presence())); + } + }; + } + + public record GetPresenceBulkMessage(List uuids) {} + + public record GetPresenceBulkResponse(List presence) {} +} + diff --git a/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java new file mode 100644 index 000000000..689d5cc09 --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/protocol/objects/presence/UpdatePresenceProtocolObject.java @@ -0,0 +1,60 @@ +package net.swofty.commons.protocol.objects.presence; + +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.ProtocolObject; +import net.swofty.commons.protocol.Serializer; +import org.json.JSONObject; + +public class UpdatePresenceProtocolObject extends ProtocolObject< + UpdatePresenceProtocolObject.UpdatePresenceMessage, + UpdatePresenceProtocolObject.UpdatePresenceResponse> { + + @Override + public Serializer getSerializer() { + return new Serializer<>() { + @Override + public String serialize(UpdatePresenceMessage value) { + JSONObject json = new JSONObject(); + json.put("presence", new JSONObject(PresenceInfo.getSerializer().serialize(value.presence()))); + return json.toString(); + } + + @Override + public UpdatePresenceMessage deserialize(String json) { + JSONObject obj = new JSONObject(json); + PresenceInfo presence = PresenceInfo.getSerializer().deserialize(obj.getJSONObject("presence").toString()); + return new UpdatePresenceMessage(presence); + } + + @Override + public UpdatePresenceMessage clone(UpdatePresenceMessage value) { + return new UpdatePresenceMessage(value.presence()); + } + }; + } + + @Override + public Serializer getReturnSerializer() { + return new Serializer<>() { + @Override + public String serialize(UpdatePresenceResponse value) { + return value.success ? "true" : "false"; + } + + @Override + public UpdatePresenceResponse deserialize(String json) { + return new UpdatePresenceResponse(Boolean.parseBoolean(json)); + } + + @Override + public UpdatePresenceResponse clone(UpdatePresenceResponse value) { + return new UpdatePresenceResponse(value.success); + } + }; + } + + public record UpdatePresenceMessage(PresenceInfo presence) {} + + public record UpdatePresenceResponse(boolean success) {} +} + diff --git a/service.friend/build.gradle.kts b/service.friend/build.gradle.kts index b18330edc..f849567d5 100644 --- a/service.friend/build.gradle.kts +++ b/service.friend/build.gradle.kts @@ -25,7 +25,6 @@ repositories { dependencies { implementation(project(":service.generic")) implementation(project(":commons")) - implementation(project(":proxy.api")) implementation("com.github.ben-manes.caffeine:caffeine:3.1.8") implementation("org.tinylog:tinylog-api:2.7.0") implementation("org.tinylog:tinylog-impl:2.7.0") diff --git a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java index b2b68dbc8..93e71f636 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java +++ b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java @@ -6,7 +6,6 @@ import net.swofty.commons.friend.events.*; import net.swofty.commons.friend.events.response.*; import net.swofty.commons.service.FromServiceChannels; -import net.swofty.proxyapi.ProxyPlayer; import net.swofty.service.generic.redis.ServiceToServerManager; import org.json.JSONObject; import org.bson.Document; @@ -21,7 +20,6 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CompletableFuture; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -305,7 +303,7 @@ public static void handleListRequest(FriendListRequestEvent event) { Map playerNames = resolvePlayerNames(pageFriends.stream() .map(Friend::getUuid) .toList()); - Map onlineStatus = resolveOnlineStatus(pageFriends.stream() + Map onlineStatus = PresenceStorage.getOnlineStatus(pageFriends.stream() .map(Friend::getUuid) .toList()); @@ -358,6 +356,14 @@ public static void handleRequestsListRequest(FriendRequestsListEvent event) { } public static void handlePlayerJoin(UUID playerUuid, String playerName) { + PresenceStorage.upsert(new net.swofty.commons.presence.PresenceInfo( + playerUuid, + true, + null, + null, + System.currentTimeMillis() + )); + FriendData playerData = getFriendData(playerUuid); for (Friend friend : playerData.getFriends()) { @@ -369,6 +375,14 @@ public static void handlePlayerJoin(UUID playerUuid, String playerName) { } public static void handlePlayerLeave(UUID playerUuid, String playerName) { + PresenceStorage.upsert(new net.swofty.commons.presence.PresenceInfo( + playerUuid, + false, + null, + null, + System.currentTimeMillis() + )); + FriendData playerData = cachedFriendData.get(playerUuid); if (playerData == null) return; @@ -403,24 +417,6 @@ private static Map resolvePlayerNames(Collection uuids) { return names; } - private static Map resolveOnlineStatus(Collection uuids) { - Map status = new ConcurrentHashMap<>(); - if (uuids == null || uuids.isEmpty()) return status; - - List> futures = new ArrayList<>(); - for (UUID uuid : uuids) { - CompletableFuture future = new ProxyPlayer(uuid) - .isOnline() - .orTimeout(2, TimeUnit.SECONDS) - .exceptionally(e -> false) - .thenAccept(isOnline -> status.put(uuid, isOnline)); - futures.add(future); - } - - CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); - return status; - } - private static Map fetchNamesFromCollection(String collectionName, List ids) { Map names = new HashMap<>(); try { diff --git a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java new file mode 100644 index 000000000..dff623c95 --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java @@ -0,0 +1,37 @@ +package net.swofty.service.friend; + +import net.swofty.commons.presence.PresenceInfo; + +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +public class PresenceStorage { + private static final Map presenceByUuid = new ConcurrentHashMap<>(); + + public static void upsert(PresenceInfo presence) { + presenceByUuid.put(presence.getUuid(), presence); + } + + public static List getBulk(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return List.of(); + return uuids.stream() + .map(presenceByUuid::get) + .filter(java.util.Objects::nonNull) + .toList(); + } + + public static Map getOnlineStatus(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return Map.of(); + return uuids.stream() + .collect(Collectors.toMap( + uuid -> uuid, + uuid -> presenceByUuid.getOrDefault(uuid, new PresenceInfo(uuid, false, null, null, System.currentTimeMillis())).isOnline(), + (a, b) -> a + )); + } +} + diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java new file mode 100644 index 000000000..925c5d2f4 --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/GetPresenceEndpoint.java @@ -0,0 +1,29 @@ +package net.swofty.service.friend.endpoints; + +import net.swofty.commons.impl.ServiceProxyRequest; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.GetPresenceBulkProtocolObject; +import net.swofty.service.friend.PresenceStorage; +import net.swofty.service.generic.redis.ServiceEndpoint; + +import java.util.List; + +public class GetPresenceEndpoint implements ServiceEndpoint< + GetPresenceBulkProtocolObject.GetPresenceBulkMessage, + GetPresenceBulkProtocolObject.GetPresenceBulkResponse> { + + @Override + public GetPresenceBulkProtocolObject associatedProtocolObject() { + return new GetPresenceBulkProtocolObject(); + } + + @Override + public GetPresenceBulkProtocolObject.GetPresenceBulkResponse onMessage( + ServiceProxyRequest message, + GetPresenceBulkProtocolObject.GetPresenceBulkMessage messageObject) { + + List presence = PresenceStorage.getBulk(messageObject.uuids()); + return new GetPresenceBulkProtocolObject.GetPresenceBulkResponse(presence); + } +} + diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java new file mode 100644 index 000000000..5b001c847 --- /dev/null +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java @@ -0,0 +1,26 @@ +package net.swofty.service.friend.endpoints; + +import net.swofty.commons.impl.ServiceProxyRequest; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.service.friend.PresenceStorage; +import net.swofty.service.generic.redis.ServiceEndpoint; + +public class UpdatePresenceEndpoint implements ServiceEndpoint< + UpdatePresenceProtocolObject.UpdatePresenceMessage, + UpdatePresenceProtocolObject.UpdatePresenceResponse> { + + @Override + public UpdatePresenceProtocolObject associatedProtocolObject() { + return new UpdatePresenceProtocolObject(); + } + + @Override + public UpdatePresenceProtocolObject.UpdatePresenceResponse onMessage( + ServiceProxyRequest message, + UpdatePresenceProtocolObject.UpdatePresenceMessage messageObject) { + + PresenceStorage.upsert(messageObject.presence()); + return new UpdatePresenceProtocolObject.UpdatePresenceResponse(true); + } +} + diff --git a/velocity.extension/build.gradle.kts b/velocity.extension/build.gradle.kts index 9f0b8230b..d8ba4c113 100644 --- a/velocity.extension/build.gradle.kts +++ b/velocity.extension/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("com.github.Swofty-Developments:AtlasRedisAPI:1.1.3") // implementation("net.swofty:AtlasRedisAPI:1.1.4") implementation(project(":commons")) + implementation(project(":proxy.api")) implementation("org.mongodb:bson:4.11.2") implementation("org.mongodb:mongodb-driver-sync:4.11.2") diff --git a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java index f139cd9b2..d0177ec79 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -12,6 +12,7 @@ import com.velocitypowered.api.event.permission.PermissionsSetupEvent; import com.velocitypowered.api.event.player.KickedFromServerEvent; import com.velocitypowered.api.event.player.PlayerChooseInitialServerEvent; +import com.velocitypowered.api.event.player.ServerConnectedEvent; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.event.proxy.ProxyPingEvent; import com.velocitypowered.api.permission.PermissionFunction; @@ -43,6 +44,7 @@ import net.swofty.velocity.gamemanager.BalanceConfigurations; import net.swofty.velocity.gamemanager.GameManager; import net.swofty.velocity.gamemanager.TransferHandler; +import net.swofty.velocity.presence.PresencePublisher; import net.swofty.velocity.packet.PlayerChannelHandler; import net.swofty.velocity.redis.ChannelListener; import net.swofty.velocity.redis.RedisListener; @@ -114,6 +116,7 @@ public void onProxyInitialization(ProxyInitializeEvent event) { (AwaitingEventExecutor) postLoginEvent -> EventTask.withContinuation(continuation -> { injectPlayer(postLoginEvent.getPlayer()); TestFlowManager.handlePlayerJoin(postLoginEvent.getPlayer().getUsername()); + PresencePublisher.publish(postLoginEvent.getPlayer(), true, (String) null, null); continuation.resume(); })); server.getEventManager().register(this, PermissionsSetupEvent.class, @@ -128,10 +131,19 @@ public void onProxyInitialization(ProxyInitializeEvent event) { : EventTask.async(() -> { // Handle test flow player leave TestFlowManager.handlePlayerLeave(disconnectEvent.getPlayer().getUsername()); + PresencePublisher.publish(disconnectEvent.getPlayer(), false, (String) null, null); removePlayer(disconnectEvent.getPlayer()); }) ); + server.getEventManager().register(this, ServerConnectedEvent.class, + (AwaitingEventExecutor) event -> + EventTask.async(() -> { + RegisteredServer newServer = event.getServer(); + var type = GameManager.getTypeFromRegisteredServer(newServer); + PresencePublisher.publish(event.getPlayer(), true, newServer, type != null ? type.name() : null); + })); + /** * Register commands */ diff --git a/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java new file mode 100644 index 000000000..778c347f9 --- /dev/null +++ b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java @@ -0,0 +1,44 @@ +package net.swofty.velocity.presence; + +import com.velocitypowered.api.proxy.Player; +import com.velocitypowered.api.proxy.server.RegisteredServer; +import net.swofty.commons.ServiceType; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.proxyapi.redis.ServerOutboundMessage; + +import java.util.UUID; + +public final class PresencePublisher { + + private PresencePublisher() {} + + public static void publish(Player player, boolean online, String serverType, UUID serverId) { + PresenceInfo info = new PresenceInfo( + player.getUniqueId(), + online, + serverType, + serverId != null ? serverId.toString() : null, + System.currentTimeMillis() + ); + + ServerOutboundMessage.sendMessageToService( + ServiceType.FRIEND, + new UpdatePresenceProtocolObject(), + new UpdatePresenceProtocolObject.UpdatePresenceMessage(info), + (ignored) -> {} + ); + } + + public static void publish(Player player, boolean online, RegisteredServer server, String serverType) { + UUID serverId = null; + if (server != null) { + try { + serverId = UUID.fromString(server.getServerInfo().getName()); + } catch (Exception ignored) { + } + } + publish(player, online, serverType, serverId); + } +} + From 5b1a532ddc5432724449d5c230857bdff9fd41f5 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:01:09 +1300 Subject: [PATCH 03/11] Friends List hover text, last seen and friendadded date stored. TODO update presence service to probably exist in main API and publish updates on a TTL --- .../response/FriendListResponseEvent.java | 12 +++++- .../swofty/service/friend/FriendCache.java | 13 ++++-- .../service/friend/PresenceStorage.java | 8 ++++ .../service/RedisPropagateFriendEvent.java | 41 ++++++++++++++++++- 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java index 228d5332f..92ec9a25f 100644 --- a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java +++ b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java @@ -48,6 +48,8 @@ public String serialize(FriendListResponseEvent value) { entryJson.put("nickname", entry.nickname != null ? entry.nickname : JSONObject.NULL); entryJson.put("isBest", entry.isBest); entryJson.put("isOnline", entry.isOnline); + entryJson.put("lastSeen", entry.lastSeen); + entryJson.put("friendSince", entry.friendSince); friendsArray.put(entryJson); } json.put("friends", friendsArray); @@ -71,7 +73,9 @@ public FriendListResponseEvent deserialize(String json) { entryJson.getString("name"), entryJson.isNull("nickname") ? null : entryJson.getString("nickname"), entryJson.getBoolean("isBest"), - entryJson.getBoolean("isOnline") + entryJson.getBoolean("isOnline"), + entryJson.optLong("lastSeen", 0L), + entryJson.optLong("friendSince", 0L) )); } @@ -98,13 +102,17 @@ public static class FriendListEntry { private final String nickname; private final boolean isBest; private final boolean isOnline; + private final long lastSeen; + private final long friendSince; - public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline) { + public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline, long lastSeen, long friendSince) { this.uuid = uuid; this.name = name; this.nickname = nickname; this.isBest = isBest; this.isOnline = isOnline; + this.lastSeen = lastSeen; + this.friendSince = friendSince; } } } diff --git a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java index 93e71f636..e07eef96d 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java +++ b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java @@ -303,20 +303,25 @@ public static void handleListRequest(FriendListRequestEvent event) { Map playerNames = resolvePlayerNames(pageFriends.stream() .map(Friend::getUuid) .toList()); - Map onlineStatus = PresenceStorage.getOnlineStatus(pageFriends.stream() - .map(Friend::getUuid) - .toList()); + List friendUuids = pageFriends.stream().map(Friend::getUuid).toList(); + Map onlineStatus = PresenceStorage.getOnlineStatus(friendUuids); + Map presenceInfo = PresenceStorage.getMap(friendUuids); List entries = new ArrayList<>(); for (Friend friend : pageFriends) { String name = playerNames.getOrDefault(friend.getUuid(), "Unknown"); boolean isOnline = onlineStatus.getOrDefault(friend.getUuid(), false); + long lastSeen = presenceInfo.getOrDefault(friend.getUuid(), + new net.swofty.commons.presence.PresenceInfo(friend.getUuid(), false, null, null, 0L)).getLastSeen(); + long friendSince = friend.getAddedTimestamp(); entries.add(new FriendListResponseEvent.FriendListEntry( friend.getUuid(), name, friend.getNickname(), friend.isBestFriend(), - isOnline + isOnline, + lastSeen, + friendSince )); } diff --git a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java index dff623c95..d7e265759 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java +++ b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java @@ -24,6 +24,14 @@ public static List getBulk(Collection uuids) { .toList(); } + public static Map getMap(Collection uuids) { + if (uuids == null || uuids.isEmpty()) return Map.of(); + return uuids.stream() + .map(presenceByUuid::get) + .filter(java.util.Objects::nonNull) + .collect(Collectors.toMap(PresenceInfo::getUuid, p -> p, (a, b) -> a)); + } + public static Map getOnlineStatus(Collection uuids) { if (uuids == null || uuids.isEmpty()) return Map.of(); return uuids.stream() diff --git a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java index 8d898348b..4253d8ef3 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java +++ b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java @@ -231,7 +231,30 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even sb.append("§e").append(friend.getName()); } - player.sendMessage(sb.toString()); + TextComponent line = LegacyComponentSerializer.legacySection().deserialize(sb.toString()); + + String friendsSinceText; + if (friend.getFriendSince() > 0) { + long secondsSince = Math.max(0, (System.currentTimeMillis() - friend.getFriendSince()) / 1000); + friendsSinceText = "Friends for " + formatDuration(secondsSince); + } else { + friendsSinceText = "Friends since: Unknown"; + } + + TextComponent hovered; + if (friend.isOnline()) { + hovered = line.hoverEvent(Component.text(friendsSinceText)); + } else { + String lastSeenText; + if (friend.getLastSeen() > 0) { + long secondsAgo = Math.max(0, (System.currentTimeMillis() - friend.getLastSeen()) / 1000); + lastSeenText = "Last seen " + formatDuration(secondsAgo) + " ago"; + } else { + lastSeenText = "Last seen: Unknown"; + } + hovered = line.hoverEvent(Component.text(lastSeenText + "\n" + friendsSinceText)); + } + player.sendMessage(hovered); } } @@ -278,6 +301,22 @@ private void sendMessage(HypixelPlayer player, String message) { player.sendMessage("§9§m-----------------------------------------------------"); } + private String formatDuration(long seconds) { + long days = seconds / 86400; + long hours = (seconds % 86400) / 3600; + long minutes = (seconds % 3600) / 60; + if (days > 0) { + return days + "d " + hours + "h"; + } + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + if (minutes > 0) { + return minutes + "m"; + } + return seconds + "s"; + } + private JSONObject createSuccessResponse(int playersHandled, List playersHandledUuids) { JSONObject response = new JSONObject(); response.put("success", true); From 09f4480ceb7977fdca8975a384372b24b50ac8ee Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:04:32 +1300 Subject: [PATCH 04/11] Fix param --- .../src/main/java/net/swofty/velocity/SkyBlockVelocity.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java index d0177ec79..4fca94043 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -137,11 +137,11 @@ public void onProxyInitialization(ProxyInitializeEvent event) { ); server.getEventManager().register(this, ServerConnectedEvent.class, - (AwaitingEventExecutor) event -> + (AwaitingEventExecutor) serverConnectedEvent -> EventTask.async(() -> { - RegisteredServer newServer = event.getServer(); + RegisteredServer newServer = serverConnectedEvent.getServer(); var type = GameManager.getTypeFromRegisteredServer(newServer); - PresencePublisher.publish(event.getPlayer(), true, newServer, type != null ? type.name() : null); + PresencePublisher.publish(serverConnectedEvent.getPlayer(), true, newServer, type != null ? type.name() : null); })); /** From 0ee7d65b306819d5800e436c2c5d4a0ab41ba9e7 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:17:21 +1300 Subject: [PATCH 05/11] String formatting and join / leave notifs --- .../service/friend/PresenceStorage.java | 4 ++++ .../endpoints/UpdatePresenceEndpoint.java | 19 +++++++++++++++++- .../service/RedisPropagateFriendEvent.java | 20 ++++++++++++------- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java index d7e265759..a71fcba2b 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java +++ b/service.friend/src/main/java/net/swofty/service/friend/PresenceStorage.java @@ -32,6 +32,10 @@ public static Map getMap(Collection uuids) { .collect(Collectors.toMap(PresenceInfo::getUuid, p -> p, (a, b) -> a)); } + public static PresenceInfo get(UUID uuid) { + return presenceByUuid.get(uuid); + } + public static Map getOnlineStatus(Collection uuids) { if (uuids == null || uuids.isEmpty()) return Map.of(); return uuids.stream() diff --git a/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java index 5b001c847..7352eba94 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java +++ b/service.friend/src/main/java/net/swofty/service/friend/endpoints/UpdatePresenceEndpoint.java @@ -1,7 +1,9 @@ package net.swofty.service.friend.endpoints; import net.swofty.commons.impl.ServiceProxyRequest; +import net.swofty.commons.presence.PresenceInfo; import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.service.friend.FriendCache; import net.swofty.service.friend.PresenceStorage; import net.swofty.service.generic.redis.ServiceEndpoint; @@ -19,7 +21,22 @@ public UpdatePresenceProtocolObject.UpdatePresenceResponse onMessage( ServiceProxyRequest message, UpdatePresenceProtocolObject.UpdatePresenceMessage messageObject) { - PresenceStorage.upsert(messageObject.presence()); + PresenceInfo incoming = messageObject.presence(); + PresenceInfo previous = PresenceStorage.get(incoming.getUuid()); + + // Detect state change to trigger friend join/leave notifications + boolean stateChanged = previous == null || previous.isOnline() != incoming.isOnline(); + PresenceStorage.upsert(incoming); + + if (stateChanged) { + String playerName = FriendCache.getPlayerName(incoming.getUuid()); + if (incoming.isOnline()) { + FriendCache.handlePlayerJoin(incoming.getUuid(), playerName); + } else { + FriendCache.handlePlayerLeave(incoming.getUuid(), playerName); + } + } + return new UpdatePresenceProtocolObject.UpdatePresenceResponse(true); } } diff --git a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java index 4253d8ef3..12d4cd7ce 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java +++ b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java @@ -224,11 +224,17 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even sb.append("§6✦ "); } - // Name (with nickname if set) + // Name (rank-colored, matches tablist). Fallback to plain name if unavailable. + String displayName = HypixelPlayer.getDisplayName(friend.getUuid()); + if (displayName == null || displayName.isEmpty()) { + displayName = "§e" + friend.getName(); + } + + // Append nickname if set if (friend.getNickname() != null && !friend.getNickname().isEmpty()) { - sb.append("§e").append(friend.getName()).append(" §7(").append(friend.getNickname()).append(")"); + sb.append(displayName).append(" §7(").append(friend.getNickname()).append(")"); } else { - sb.append("§e").append(friend.getName()); + sb.append(displayName); } TextComponent line = LegacyComponentSerializer.legacySection().deserialize(sb.toString()); @@ -236,9 +242,9 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even String friendsSinceText; if (friend.getFriendSince() > 0) { long secondsSince = Math.max(0, (System.currentTimeMillis() - friend.getFriendSince()) / 1000); - friendsSinceText = "Friends for " + formatDuration(secondsSince); + friendsSinceText = "§7Friends for " + formatDuration(secondsSince); } else { - friendsSinceText = "Friends since: Unknown"; + friendsSinceText = "§7Friends since: Unknown"; } TextComponent hovered; @@ -248,9 +254,9 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even String lastSeenText; if (friend.getLastSeen() > 0) { long secondsAgo = Math.max(0, (System.currentTimeMillis() - friend.getLastSeen()) / 1000); - lastSeenText = "Last seen " + formatDuration(secondsAgo) + " ago"; + lastSeenText = "§7Last seen " + formatDuration(secondsAgo) + " ago"; } else { - lastSeenText = "Last seen: Unknown"; + lastSeenText = "§7Last seen: Unknown"; } hovered = line.hoverEvent(Component.text(lastSeenText + "\n" + friendsSinceText)); } From 4e6522dcee92fd05dfd2dc0261b0f403d14bc1f2 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:33:58 +1300 Subject: [PATCH 06/11] Fixed /msg to use HypixelDataPlayer, also setup heartbeats for pressence tracking and improved strings in /friend list --- .../response/FriendListResponseEvent.java | 8 ++- .../swofty/service/friend/FriendCache.java | 18 +++++-- .../type/generic/HypixelGenericLoader.java | 5 ++ .../command/commands/MessageCommand.java | 6 +-- .../generic/presence/PresenceHeartbeat.java | 49 +++++++++++++++++++ .../service/RedisPropagateFriendEvent.java | 14 +++++- 6 files changed, 90 insertions(+), 10 deletions(-) create mode 100644 type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java diff --git a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java index 92ec9a25f..fd5d0097d 100644 --- a/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java +++ b/commons/src/main/java/net/swofty/commons/friend/events/response/FriendListResponseEvent.java @@ -50,6 +50,7 @@ public String serialize(FriendListResponseEvent value) { entryJson.put("isOnline", entry.isOnline); entryJson.put("lastSeen", entry.lastSeen); entryJson.put("friendSince", entry.friendSince); + entryJson.put("server", entry.server != null ? entry.server : JSONObject.NULL); friendsArray.put(entryJson); } json.put("friends", friendsArray); @@ -75,7 +76,8 @@ public FriendListResponseEvent deserialize(String json) { entryJson.getBoolean("isBest"), entryJson.getBoolean("isOnline"), entryJson.optLong("lastSeen", 0L), - entryJson.optLong("friendSince", 0L) + entryJson.optLong("friendSince", 0L), + entryJson.isNull("server") ? null : entryJson.getString("server") )); } @@ -104,8 +106,9 @@ public static class FriendListEntry { private final boolean isOnline; private final long lastSeen; private final long friendSince; + private final String server; - public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline, long lastSeen, long friendSince) { + public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, boolean isOnline, long lastSeen, long friendSince, String server) { this.uuid = uuid; this.name = name; this.nickname = nickname; @@ -113,6 +116,7 @@ public FriendListEntry(UUID uuid, String name, String nickname, boolean isBest, this.isOnline = isOnline; this.lastSeen = lastSeen; this.friendSince = friendSince; + this.server = server; } } } diff --git a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java index e07eef96d..e60e8e131 100644 --- a/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java +++ b/service.friend/src/main/java/net/swofty/service/friend/FriendCache.java @@ -311,8 +311,11 @@ public static void handleListRequest(FriendListRequestEvent event) { for (Friend friend : pageFriends) { String name = playerNames.getOrDefault(friend.getUuid(), "Unknown"); boolean isOnline = onlineStatus.getOrDefault(friend.getUuid(), false); - long lastSeen = presenceInfo.getOrDefault(friend.getUuid(), - new net.swofty.commons.presence.PresenceInfo(friend.getUuid(), false, null, null, 0L)).getLastSeen(); + net.swofty.commons.presence.PresenceInfo pInfo = presenceInfo.get(friend.getUuid()); + long lastSeen = pInfo != null ? pInfo.getLastSeen() : 0L; + String server = (pInfo != null && pInfo.isOnline() && pInfo.getServerType() != null) + ? formatServerDisplay(pInfo) + : null; long friendSince = friend.getAddedTimestamp(); entries.add(new FriendListResponseEvent.FriendListEntry( friend.getUuid(), @@ -321,7 +324,8 @@ public static void handleListRequest(FriendListRequestEvent event) { friend.isBestFriend(), isOnline, lastSeen, - friendSince + friendSince, + server )); } @@ -456,6 +460,14 @@ private static String parseStoredName(String raw) { return raw.isEmpty() ? null : raw; } + private static String formatServerDisplay(net.swofty.commons.presence.PresenceInfo info) { + String type = info.getServerType(); + String id = info.getServerId(); + if (type == null && id == null) return null; + if (type != null && id != null) return type + " - " + id; + return type != null ? type : id; + } + private static void persistFriendData(UUID playerUuid) { FriendData data = cachedFriendData.get(playerUuid); if (data != null) { diff --git a/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java b/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java index 0433da43e..a6592204d 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java +++ b/type.generic/src/main/java/net/swofty/type/generic/HypixelGenericLoader.java @@ -165,6 +165,11 @@ public void initialize(MinecraftServer server) { */ loader.getTablistManager().runScheduler(MinecraftServer.getSchedulerManager()); + /** + * Presence heartbeat to keep friend status fresh + */ + net.swofty.type.generic.presence.PresenceHeartbeat.start(); + /** * Register databases */ diff --git a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java index 0e2653b0e..ebefe3f53 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java +++ b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java @@ -6,14 +6,14 @@ import net.swofty.proxyapi.ProxyPlayer; import net.swofty.type.generic.command.CommandParameters; import net.swofty.type.generic.command.HypixelCommand; -import net.swofty.type.generic.data.DataHandler; +import net.swofty.type.generic.data.HypixelDataHandler; import net.swofty.type.generic.user.HypixelPlayer; import net.swofty.type.generic.user.categories.Rank; import org.jetbrains.annotations.Nullable; import java.util.UUID; -@CommandParameters(aliases = "msg", +@CommandParameters(aliases = "msg message whipser", description = "Sends a message to another player", usage = "/msg ", permission = Rank.DEFAULT, @@ -31,7 +31,7 @@ public void registerUsage(MinestomCommand command) { String[] message = context.get(messageArgument); HypixelPlayer player = (HypixelPlayer) sender; - @Nullable UUID targetUUID = DataHandler.getPotentialUUIDFromName(playerName); + @Nullable UUID targetUUID = HypixelDataHandler.getPotentialUUIDFromName(playerName); if (targetUUID == null) { player.sendMessage("§cCan't find a player by the name of '" + playerName + "'"); return; diff --git a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java new file mode 100644 index 000000000..1e3cfaaec --- /dev/null +++ b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java @@ -0,0 +1,49 @@ +package net.swofty.type.generic.presence; + +import net.minestom.server.MinecraftServer; +import net.swofty.commons.ServiceType; +import net.swofty.commons.presence.PresenceInfo; +import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; +import net.swofty.proxyapi.redis.ServerOutboundMessage; +import net.swofty.type.generic.HypixelConst; +import net.swofty.type.generic.user.HypixelPlayer; + +/** + * Periodically refreshes player presence to the friend service to avoid stale status. + */ +public final class PresenceHeartbeat { + private PresenceHeartbeat() {} + + public static void start() { + MinecraftServer.getSchedulerManager().buildTask(PresenceHeartbeat::pulse) + .delay(20, net.minestom.server.timer.SchedulerManager.TimeUnit.SECOND) + .repeat(45, net.minestom.server.timer.SchedulerManager.TimeUnit.SECOND) + .schedule(); + } + + private static void pulse() { + String serverType = null; + try { + serverType = HypixelConst.getTypeLoader().getType().name(); + } catch (Exception ignored) { + } + + for (HypixelPlayer player : HypixelConst.getLoadedPlayers()) { + PresenceInfo info = new PresenceInfo( + player.getUuid(), + true, + serverType, + null, + System.currentTimeMillis() + ); + + ServerOutboundMessage.sendMessageToService( + ServiceType.FRIEND, + new UpdatePresenceProtocolObject(), + new UpdatePresenceProtocolObject.UpdatePresenceMessage(info), + (ignored) -> {} + ); + } + } +} + diff --git a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java index 12d4cd7ce..f547115cf 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java +++ b/type.generic/src/main/java/net/swofty/type/generic/redis/service/RedisPropagateFriendEvent.java @@ -224,8 +224,13 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even sb.append("§6✦ "); } - // Name (rank-colored, matches tablist). Fallback to plain name if unavailable. - String displayName = HypixelPlayer.getDisplayName(friend.getUuid()); + // Name (rank-colored, matches tablist). Fallback to plain name if unavailable or failure. + String displayName; + try { + displayName = HypixelPlayer.getDisplayName(friend.getUuid()); + } catch (Exception e) { + displayName = "§e" + friend.getName(); + } if (displayName == null || displayName.isEmpty()) { displayName = "§e" + friend.getName(); } @@ -237,6 +242,11 @@ private void handleFriendList(HypixelPlayer player, FriendListResponseEvent even sb.append(displayName); } + // Server info (when online) + if (friend.isOnline() && friend.getServer() != null && !friend.getServer().isEmpty()) { + sb.append(" §7- §e").append(friend.getServer()); + } + TextComponent line = LegacyComponentSerializer.legacySection().deserialize(sb.toString()); String friendsSinceText; From e46398f41dd2ff503d79782601baec64275f6344 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 19:39:42 +1300 Subject: [PATCH 07/11] fix ttl task --- .../swofty/type/generic/presence/PresenceHeartbeat.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java index 1e3cfaaec..01caff017 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java +++ b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java @@ -1,11 +1,13 @@ package net.swofty.type.generic.presence; import net.minestom.server.MinecraftServer; +import net.minestom.server.timer.TaskSchedule; import net.swofty.commons.ServiceType; import net.swofty.commons.presence.PresenceInfo; import net.swofty.commons.protocol.objects.presence.UpdatePresenceProtocolObject; import net.swofty.proxyapi.redis.ServerOutboundMessage; import net.swofty.type.generic.HypixelConst; +import net.swofty.type.generic.HypixelGenericLoader; import net.swofty.type.generic.user.HypixelPlayer; /** @@ -16,8 +18,8 @@ private PresenceHeartbeat() {} public static void start() { MinecraftServer.getSchedulerManager().buildTask(PresenceHeartbeat::pulse) - .delay(20, net.minestom.server.timer.SchedulerManager.TimeUnit.SECOND) - .repeat(45, net.minestom.server.timer.SchedulerManager.TimeUnit.SECOND) + .delay(TaskSchedule.seconds(20)) + .repeat(TaskSchedule.seconds(45)) .schedule(); } @@ -28,7 +30,7 @@ private static void pulse() { } catch (Exception ignored) { } - for (HypixelPlayer player : HypixelConst.getLoadedPlayers()) { + for (HypixelPlayer player : HypixelGenericLoader.getLoadedPlayers()) { PresenceInfo info = new PresenceInfo( player.getUuid(), true, From 87c0f1f7d94d992808a254c34ac7df6457fe10ae Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:49:14 +1300 Subject: [PATCH 08/11] Msg cmd fix --- .../generic/command/commands/MessageCommand.java | 14 +++++++++++++- .../type/generic/presence/PresenceHeartbeat.java | 12 +++++++++--- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java index ebefe3f53..178c0a141 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java +++ b/type.generic/src/main/java/net/swofty/type/generic/command/commands/MessageCommand.java @@ -4,6 +4,7 @@ import net.minestom.server.command.builder.arguments.ArgumentStringArray; import net.minestom.server.command.builder.arguments.ArgumentType; import net.swofty.proxyapi.ProxyPlayer; +import net.swofty.type.generic.HypixelGenericLoader; import net.swofty.type.generic.command.CommandParameters; import net.swofty.type.generic.command.HypixelCommand; import net.swofty.type.generic.data.HypixelDataHandler; @@ -31,7 +32,7 @@ public void registerUsage(MinestomCommand command) { String[] message = context.get(messageArgument); HypixelPlayer player = (HypixelPlayer) sender; - @Nullable UUID targetUUID = HypixelDataHandler.getPotentialUUIDFromName(playerName); + @Nullable UUID targetUUID = resolveUUID(playerName); if (targetUUID == null) { player.sendMessage("§cCan't find a player by the name of '" + playerName + "'"); return; @@ -49,4 +50,15 @@ public void registerUsage(MinestomCommand command) { target.sendMessage("§dFrom " + ourName + "§7: " + String.join(" ", message)); }, playerArgument, messageArgument); } + + private @Nullable UUID resolveUUID(String name) { + // First, try online players (case-insensitive) + for (HypixelPlayer online : HypixelGenericLoader.getLoadedPlayers()) { + if (online.getUsername().equalsIgnoreCase(name)) { + return online.getUuid(); + } + } + // Fallback to stored data lookup + return HypixelDataHandler.getPotentialUUIDFromName(name); + } } diff --git a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java index 01caff017..8b200b7bf 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java +++ b/type.generic/src/main/java/net/swofty/type/generic/presence/PresenceHeartbeat.java @@ -18,15 +18,21 @@ private PresenceHeartbeat() {} public static void start() { MinecraftServer.getSchedulerManager().buildTask(PresenceHeartbeat::pulse) - .delay(TaskSchedule.seconds(20)) - .repeat(TaskSchedule.seconds(45)) + .delay(TaskSchedule.seconds(2)) + .repeat(TaskSchedule.seconds(10)) .schedule(); } private static void pulse() { String serverType = null; + String serverId = null; try { serverType = HypixelConst.getTypeLoader().getType().name(); + if (HypixelConst.getServerUUID() != null) { + serverId = HypixelConst.getServerUUID().toString(); + } else if (HypixelConst.getServerName() != null) { + serverId = HypixelConst.getServerName(); + } } catch (Exception ignored) { } @@ -35,7 +41,7 @@ private static void pulse() { player.getUuid(), true, serverType, - null, + serverId, System.currentTimeMillis() ); From e73eaf6c53892583d16280d016e29506000808c5 Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Mon, 29 Dec 2025 20:59:34 +1300 Subject: [PATCH 09/11] Proxy online status backup incase events arnt called --- .../java/net/swofty/velocity/SkyBlockVelocity.java | 10 ++++++++++ .../redis/listeners/ListenerPlayerHandler.java | 14 ++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java index 4fca94043..ff703aa60 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -144,6 +144,16 @@ public void onProxyInitialization(ProxyInitializeEvent event) { PresencePublisher.publish(serverConnectedEvent.getPlayer(), true, newServer, type != null ? type.name() : null); })); + // Heartbeat to refresh presence in case events are missed + MinecraftServer.getSchedulerManager().buildTask(() -> { + server.getAllPlayers().forEach(p -> { + var current = p.getCurrentServer(); + var type = current.map(conn -> GameManager.getTypeFromRegisteredServer(conn.getServer())).orElse(null); + PresencePublisher.publish(p, true, current.map(ServerConnection::getServer).orElse(null), + type != null ? type.name() : null); + }); + }).delay(TaskSchedule.seconds(5)).repeat(TaskSchedule.seconds(10)).schedule(); + /** * Register commands */ diff --git a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java index 25c6b3945..7149e2168 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/redis/listeners/ListenerPlayerHandler.java @@ -14,6 +14,7 @@ import net.swofty.velocity.SkyBlockVelocity; import net.swofty.velocity.gamemanager.GameManager; import net.swofty.velocity.gamemanager.TransferHandler; +import net.swofty.velocity.presence.PresencePublisher; import net.swofty.velocity.redis.ChannelListener; import net.swofty.velocity.redis.RedisListener; import net.swofty.velocity.redis.RedisMessage; @@ -43,6 +44,8 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { return new JSONObject(); } if (action == PlayerHandlerRequirements.PlayerHandlerActions.IS_ONLINE) { + Player player = potentialPlayer.get(); + publishPresence(player, true); return new JSONObject().put("isOnline", true); } Player player = potentialPlayer.get(); @@ -56,6 +59,7 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { return new JSONObject(); } + publishPresence(player, true); return new JSONObject().put("server", new UnderstandableProxyServer( serverInfo.displayName(), serverInfo.internalID(), @@ -152,4 +156,14 @@ public JSONObject receivedMessage(JSONObject message, UUID serverUUID) { } return new JSONObject(); } + + private void publishPresence(Player player, boolean online) { + try { + Optional serverConn = player.getCurrentServer(); + var type = serverConn.map(conn -> GameManager.getTypeFromRegisteredServer(conn.getServer())).orElse(null); + PresencePublisher.publish(player, online, serverConn.map(ServerConnection::getServer).orElse(null), + type != null ? type.name() : null); + } catch (Exception ignored) { + } + } } From 248e39ff2c9811f4a1dc58a79f385ae6c5cc964d Mon Sep 17 00:00:00 2001 From: petethepossum <47347759+petethepossum@users.noreply.github.com> Date: Tue, 30 Dec 2025 08:37:57 +1300 Subject: [PATCH 10/11] fire and forget presence --- .../proxyapi/redis/ServerOutboundMessage.java | 36 +++++++++++++++++++ .../velocity/presence/PresencePublisher.java | 6 ++-- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java b/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java index 424ea9e6d..9a721cd3f 100644 --- a/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java +++ b/proxy.api/src/main/java/net/swofty/proxyapi/redis/ServerOutboundMessage.java @@ -90,6 +90,42 @@ public static void sendMessageToService(ServiceType service, specification.channel(), message).toJSON().toString()); } + /** + * Fire-and-forget: send to a service and do not wait for or register a response. + */ + public static void sendMessageToServiceFireAndForget(ServiceType service, + ProtocolObject specification, + Object rawMessage) { + UUID requestId = UUID.randomUUID(); + String callback = null; + try { + callback = RedisAPI.getInstance().getFilterId(); + } catch (Exception ignored) { + } + + String message = specification.translateToString(rawMessage); + RedisAPI.getInstance().publishMessage( + service.name(), + ChannelRegistry.getFromName(specification.channel()), + new ServiceProxyRequest( + requestId, + callback != null ? callback : "proxy", + specification.channel(), + message + ).toJSON().toString() + ); + } + + /** + * Fire-and-forget broadcast to all service types. + */ + public static void sendMessageToAllServicesFireAndForget(ProtocolObject specification, + Object rawMessage) { + for (ServiceType serviceType : ServiceType.values()) { + sendMessageToServiceFireAndForget(serviceType, specification, rawMessage); + } + } + private static String getRequestTypeName(ProtocolObject protocolObject) { Class clazz = protocolObject.getClass(); Type genericSuperclass = clazz.getGenericSuperclass(); diff --git a/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java index 778c347f9..1916ae413 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/presence/PresencePublisher.java @@ -22,11 +22,9 @@ public static void publish(Player player, boolean online, String serverType, UUI System.currentTimeMillis() ); - ServerOutboundMessage.sendMessageToService( - ServiceType.FRIEND, + ServerOutboundMessage.sendMessageToAllServicesFireAndForget( new UpdatePresenceProtocolObject(), - new UpdatePresenceProtocolObject.UpdatePresenceMessage(info), - (ignored) -> {} + new UpdatePresenceProtocolObject.UpdatePresenceMessage(info) ); } From 44aeb1b907a23dda497c4dcd1b446e01bcaa83c6 Mon Sep 17 00:00:00 2001 From: Swofty Date: Tue, 30 Dec 2025 20:48:51 +1100 Subject: [PATCH 11/11] chore: fix dependency usage --- .../java/net/swofty/velocity/SkyBlockVelocity.java | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java index ff703aa60..dc7c89bf8 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -20,6 +20,7 @@ import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.ServerConnection; import com.velocitypowered.api.proxy.server.RegisteredServer; import com.velocitypowered.api.proxy.server.ServerInfo; import com.velocitypowered.api.proxy.server.ServerPing; @@ -58,6 +59,7 @@ import java.lang.reflect.InvocationTargetException; import java.net.InetSocketAddress; import java.nio.file.Path; +import java.time.Duration; import java.util.List; import java.util.Set; import java.util.concurrent.CompletableFuture; @@ -144,20 +146,18 @@ public void onProxyInitialization(ProxyInitializeEvent event) { PresencePublisher.publish(serverConnectedEvent.getPlayer(), true, newServer, type != null ? type.name() : null); })); - // Heartbeat to refresh presence in case events are missed - MinecraftServer.getSchedulerManager().buildTask(() -> { - server.getAllPlayers().forEach(p -> { - var current = p.getCurrentServer(); + server.getScheduler().buildTask(SkyBlockVelocity.getPlugin(), () -> { + server.getAllPlayers().forEach(player -> { + var current = player.getCurrentServer(); var type = current.map(conn -> GameManager.getTypeFromRegisteredServer(conn.getServer())).orElse(null); - PresencePublisher.publish(p, true, current.map(ServerConnection::getServer).orElse(null), + PresencePublisher.publish(player, true, current.map(ServerConnection::getServer).orElse(null), type != null ? type.name() : null); }); - }).delay(TaskSchedule.seconds(5)).repeat(TaskSchedule.seconds(10)).schedule(); + }).repeat(Duration.ofSeconds(10)).schedule(); /** * Register commands */ - CommandManager commandManager = proxy.getCommandManager(); CommandMeta statusCommandMeta = commandManager.metaBuilder("serverstatus") .aliases("status")