From 0e5d801a3bf20c154db4c6d94c2bb2e01fc02612 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Sun, 5 Jul 2026 18:18:04 +1000 Subject: [PATCH 1/8] fix: initialise service dependencies before the blocking init call SkyBlockService.init(...) never returns (it awaits a latch), so any setup placed after it in a service main was dead code. AuctionService set its cache and connected its databases after init, leaving AuctionService.cacheService null and NPEing every auction fetch; OrchestratorService scheduled its cleanup task after init, so it never ran. Move both setups before init. --- .../java/net/swofty/service/auction/AuctionService.java | 9 ++++++--- .../swofty/service/orchestrator/OrchestratorService.java | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/service.auctionhouse/src/main/java/net/swofty/service/auction/AuctionService.java b/service.auctionhouse/src/main/java/net/swofty/service/auction/AuctionService.java index 390689b9a..8f93c624c 100644 --- a/service.auctionhouse/src/main/java/net/swofty/service/auction/AuctionService.java +++ b/service.auctionhouse/src/main/java/net/swofty/service/auction/AuctionService.java @@ -11,12 +11,15 @@ public class AuctionService implements SkyBlockService { public static AuctionsCacheService cacheService; static void main(String[] args) { - SkyBlockService.init(new AuctionService()); + // SkyBlockService.init(...) blocks forever (it awaits a latch), so every dependency the + // endpoints touch must be initialised BEFORE it is called. Databases first (the cache reads + // their static collections), then the cache, then start handling messages. + new AuctionActiveDatabase("_placeholder").connect(ConfigProvider.settings().getMongodb()); + new AuctionInactiveDatabase("_placeholder").connect(ConfigProvider.settings().getMongodb()); cacheService = new AuctionsCacheService(); - new AuctionActiveDatabase("_placeholder").connect(ConfigProvider.settings().getMongodb()); - new AuctionInactiveDatabase("_placeholder").connect(ConfigProvider.settings().getMongodb()); + SkyBlockService.init(new AuctionService()); } @Override diff --git a/service.orchestrator/src/main/java/net/swofty/service/orchestrator/OrchestratorService.java b/service.orchestrator/src/main/java/net/swofty/service/orchestrator/OrchestratorService.java index e0a81b0db..388b687aa 100644 --- a/service.orchestrator/src/main/java/net/swofty/service/orchestrator/OrchestratorService.java +++ b/service.orchestrator/src/main/java/net/swofty/service/orchestrator/OrchestratorService.java @@ -13,8 +13,8 @@ public class OrchestratorService implements SkyBlockService { static void main() { - SkyBlockService.init(new OrchestratorService()); - + // SkyBlockService.init(...) blocks forever, so schedule the cleanup task before calling it, + // otherwise the scheduler is never started. ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "orchestrator-cleanup"); t.setDaemon(true); @@ -22,6 +22,8 @@ static void main() { }); scheduler.scheduleAtFixedRate(OrchestratorCache::cleanup, 5, 5, TimeUnit.SECONDS); Logger.info("Started orchestrator service"); + + SkyBlockService.init(new OrchestratorService()); } @Override From 04a446b87ef2419a1d7e4e7171348f0015cb12f4 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Sun, 5 Jul 2026 18:18:04 +1000 Subject: [PATCH 2/8] fix: serialise AuctionItem through its dedicated serializer over the wire The auction protocols Jackson-serialise AuctionItem, whose nested item carries abstract ItemAttribute values Jackson cannot reconstruct, so listing or fetching auctions failed to deserialize. Delegate AuctionItem (de)serialization to the existing AuctionItemSerializer via @JsonValue/@JsonCreator. --- .../skyblock/auctions/AuctionItem.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/commons/src/main/java/net/swofty/commons/skyblock/auctions/AuctionItem.java b/commons/src/main/java/net/swofty/commons/skyblock/auctions/AuctionItem.java index f52d24403..9436cbcd7 100644 --- a/commons/src/main/java/net/swofty/commons/skyblock/auctions/AuctionItem.java +++ b/commons/src/main/java/net/swofty/commons/skyblock/auctions/AuctionItem.java @@ -1,8 +1,11 @@ package net.swofty.commons.skyblock.auctions; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; import lombok.Getter; import lombok.Setter; import net.swofty.commons.skyblock.item.UnderstandableSkyBlockItem; +import net.swofty.commons.protocol.serializers.AuctionItemSerializer; import net.swofty.commons.protocol.serializers.UnderstandableSkyBlockItemSerializer; import org.bson.Document; import org.jetbrains.annotations.NotNull; @@ -39,6 +42,23 @@ public AuctionItem(UnderstandableSkyBlockItem item, UUID originator, long endTim this.bids = new ArrayList<>(); } + /** + * Jackson round-trips an AuctionItem as its purpose-built serialized string rather than as a + * bean graph. The graph contains a {@link UnderstandableSkyBlockItem} whose {@code attributes} + * are abstract {@link net.swofty.commons.skyblock.item.attribute.ItemAttribute}s, which Jackson + * cannot reconstruct (no concrete type, no default creator). Delegating to + * {@link AuctionItemSerializer} reuses the same key-based format used for MongoDB storage. + */ + @JsonValue + public String toSerializedString() { + return new AuctionItemSerializer<>().serialize(this); + } + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AuctionItem fromSerializedString(String json) { + return new AuctionItemSerializer<>().deserialize(json); + } + public Document toDocument() { return new Document() .append("_id", uuid.toString()) From 9acc626b7d3282b86776252bd6d5b5bd8a975932 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Sun, 5 Jul 2026 18:18:15 +1000 Subject: [PATCH 3/8] refactor: replace proxy liveness poll with a redis heartbeat Each game server polled the proxy with a ProxyIsOnlineProtocol request/response once per second and shut itself down on a single missed reply within ~1s (20 ticks). Under many concurrent servers that round-trip regularly exceeded the window and killed healthy hubs, and the timeout was coupled to the game tick rate. The proxy now refreshes a short-lived Redis key (one write regardless of server count) and each server reads it on an independent daemon thread, shutting down only after several consecutive absences. --- .../swofty/commons/redis/ProxyHeartbeat.java | 49 +++++++++++++++ .../main/java/net/swofty/loader/Hypixel.java | 63 ++++++++++++------- .../net/swofty/velocity/SkyBlockVelocity.java | 7 +++ 3 files changed, 97 insertions(+), 22 deletions(-) create mode 100644 commons/src/main/java/net/swofty/commons/redis/ProxyHeartbeat.java diff --git a/commons/src/main/java/net/swofty/commons/redis/ProxyHeartbeat.java b/commons/src/main/java/net/swofty/commons/redis/ProxyHeartbeat.java new file mode 100644 index 000000000..5af89ff6d --- /dev/null +++ b/commons/src/main/java/net/swofty/commons/redis/ProxyHeartbeat.java @@ -0,0 +1,49 @@ +package net.swofty.commons.redis; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPool; + +/** + * Liveness signalling between the Velocity proxy and the game servers. + * + *

The old design had every game server send a {@code ProxyIsOnlineProtocol} + * request/response to the proxy once per second and shut down on a single missed + * reply within ~1s. Under many concurrent servers that round-trip regularly + * exceeded the window and killed healthy servers. + * + *

This replaces it with a push/expiry model: the proxy refreshes a single + * short-lived Redis key on a fixed interval (one write regardless of how many + * servers exist). Game servers simply read the key — if it is absent (the proxy + * stopped refreshing it and Redis let it expire) for several consecutive checks, + * the proxy is considered dead. No per-server request/response, no coupling to + * the game tick loop, and transient Redis latency is harmless. + */ +public final class ProxyHeartbeat { + private static final String KEY = "proxy:heartbeat"; + /** How long a single beat keeps the key alive. Must exceed the beat interval. */ + private static final int TTL_SECONDS = 6; + + private static volatile JedisPool pool; + + private ProxyHeartbeat() {} + + public static synchronized void init(String redisUri) { + if (pool == null) { + pool = RedisConnectionPool.connect(redisUri, RedisConnectionPool.Settings.standard()); + } + } + + /** Proxy: call on a fixed interval shorter than {@link #TTL_SECONDS} to advertise liveness. */ + public static void beat() { + try (Jedis jedis = pool.getResource()) { + jedis.setex(KEY, TTL_SECONDS, Long.toString(System.currentTimeMillis())); + } + } + + /** Game server: true while the proxy is refreshing its heartbeat key. */ + public static boolean isProxyAlive() { + try (Jedis jedis = pool.getResource()) { + return jedis.exists(KEY); + } + } +} diff --git a/loader/src/main/java/net/swofty/loader/Hypixel.java b/loader/src/main/java/net/swofty/loader/Hypixel.java index d91f637e6..26459f49c 100644 --- a/loader/src/main/java/net/swofty/loader/Hypixel.java +++ b/loader/src/main/java/net/swofty/loader/Hypixel.java @@ -21,6 +21,7 @@ import net.swofty.commons.config.ConfigProvider; import net.swofty.commons.protocol.RedisProtocol; import net.swofty.commons.protocol.objects.proxy.to.*; +import net.swofty.commons.redis.ProxyHeartbeat; import net.swofty.commons.redis.RedisClient; import net.swofty.proxyapi.ProxyAPI; import net.swofty.proxyapi.ProxyService; @@ -48,8 +49,11 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; @@ -246,7 +250,7 @@ static void main(String[] args) { Logger.info("Received server name: " + HypixelConst.getServerName()); }); - checkProxyConnected(MinecraftServer.getSchedulerManager()); + checkProxyConnected(); // Initialize anticheat if (ConfigProvider.settings().getIntegrations().isAnticheat()) { @@ -340,32 +344,47 @@ private static Map parseOptionalArgs(String[] args) { return options; } - private static void checkProxyConnected(Scheduler scheduler) { - scheduler.submitTask(() -> { - AtomicBoolean responded = new AtomicBoolean(false); + // How often to read the proxy heartbeat key, and how many consecutive absences + // to tolerate before declaring the proxy dead. 3 misses x 3s = ~9s of confirmed + // silence on top of the key's 6s TTL — resilient to transient Redis/GC blips, + // while still cleaning up genuinely orphaned servers promptly. + private static final int PROXY_HEARTBEAT_CHECK_SECONDS = 3; + private static final int PROXY_HEARTBEAT_MAX_MISSES = 3; + private static void checkProxyConnected() { + ProxyHeartbeat.init(ConfigProvider.settings().getRedisUri()); + + ScheduledExecutorService monitor = Executors.newSingleThreadScheduledExecutor(runnable -> { + Thread thread = new Thread(runnable, "proxy-heartbeat-monitor"); + thread.setDaemon(true); + return thread; + }); + + AtomicInteger missed = new AtomicInteger(0); + monitor.scheduleAtFixedRate(() -> { + boolean alive; try { - RedisClient.requestProxy(new ProxyIsOnlineProtocol(), - new ProxyIsOnlineProtocol.Request()).thenAccept(response -> { - if (response.online()) { - responded.set(true); - } - }); + alive = ProxyHeartbeat.isProxyAlive(); } catch (Exception e) { - MinecraftServer.getConnectionManager().getOnlinePlayers().forEach(player -> player.kick("§cServer has lost connection to the proxy, please rejoin")); - CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS) - .execute(() -> System.exit(0)); - return TaskSchedule.stop(); + // A Redis hiccup is treated as a miss, but never a fatal one on its own. + alive = false; } - scheduler.scheduleTask(() -> { - if (!responded.get()) { - Logger.error("Proxy did not respond to alive check. Shutting down..."); - System.exit(0); - } - }, TaskSchedule.tick(20), TaskSchedule.stop()); + if (alive) { + missed.set(0); + return; + } - return TaskSchedule.seconds(1); - }, ExecutionType.TICK_END); + int misses = missed.incrementAndGet(); + Logger.warn("Proxy heartbeat missing ({}/{})", misses, PROXY_HEARTBEAT_MAX_MISSES); + if (misses >= PROXY_HEARTBEAT_MAX_MISSES) { + Logger.error("Proxy heartbeat absent for ~{}s. Shutting down...", + PROXY_HEARTBEAT_MAX_MISSES * PROXY_HEARTBEAT_CHECK_SECONDS); + MinecraftServer.getConnectionManager().getOnlinePlayers() + .forEach(player -> player.kick("§cServer has lost connection to the proxy, please rejoin")); + CompletableFuture.delayedExecutor(500, TimeUnit.MILLISECONDS) + .execute(() -> System.exit(0)); + } + }, PROXY_HEARTBEAT_CHECK_SECONDS, PROXY_HEARTBEAT_CHECK_SECONDS, TimeUnit.SECONDS); } } 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 7b7da6da2..f2d3482c0 100644 --- a/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java +++ b/velocity.extension/src/main/java/net/swofty/velocity/SkyBlockVelocity.java @@ -60,6 +60,7 @@ import net.swofty.commons.punishment.PunishmentReason; import net.swofty.commons.punishment.PunishmentTag; import net.swofty.commons.punishment.PunishmentType; +import net.swofty.commons.redis.ProxyHeartbeat; import net.swofty.commons.redis.RedisClient; import net.swofty.commons.redis.RedisEndpoint; import net.swofty.commons.redis.RedisMessageHandler; @@ -265,6 +266,12 @@ public void onProxyInitialization(ProxyInitializeEvent event) { RedisAPI.generateInstance(ConfigProvider.settings().getRedisUri()); RedisAPI.getInstance().setFilterId("proxy"); RedisClient.identify(RedisEndpoint.proxy()); + + // Advertise proxy liveness via a short-lived Redis key. Game servers watch this + // instead of polling the proxy with a per-server request/response every second. + ProxyHeartbeat.init(ConfigProvider.settings().getRedisUri()); + server.getScheduler().buildTask(SkyBlockVelocity.getPlugin(), ProxyHeartbeat::beat) + .repeat(Duration.ofSeconds(2)).schedule(); loopThroughPackage("net.swofty.velocity.redis.listeners", RedisMessageHandler.class) .forEach(RedisHandlerRegistry::register); RedisProtocol[] fromProxyProtocols = { From 3fa477c5ed5c099a32ca8cf2e1ece6b809f0d012 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Sun, 5 Jul 2026 18:35:52 +1000 Subject: [PATCH 4/8] fix: create auctions without blocking the redis response thread GUIAuctionCreateItem ran the add-item request inside the isOnline() callback and then blocked on future.join(). That callback executes on the single Redis subscriber thread, and the add-item reply arrives on that same thread, so the join deadlocked and the auction UI froze on "setting up the auction". Chain the response asynchronously instead. --- .../gui/inventories/auction/GUIAuctionCreateItem.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java index 383d3d9db..b4c1983a2 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java @@ -169,12 +169,15 @@ public void run(InventoryPreClickEvent e, HypixelPlayer p) { AuctionAddItemProtocol.AuctionAddItemMessage message = new AuctionAddItemProtocol.AuctionAddItemMessage(item, category); + // Never block here: this callback runs on the Redis response thread, and the + // add-item reply arrives on that same thread — a join() would deadlock forever. CompletableFuture future = auctionService.handleRequest(message); - UUID auctionUUID = future.join().uuid(); - - player.sendMessage(I18n.t("gui_auction.create.started_message", Component.text(itemName))); - player.sendMessage(I18n.t("gui_auction.create.started_id", Component.text(auctionUUID.toString()))); + future.thenAccept(addResponse -> { + UUID auctionUUID = addResponse.uuid(); + player.sendMessage(I18n.t("gui_auction.create.started_message", Component.text(itemName))); + player.sendMessage(I18n.t("gui_auction.create.started_id", Component.text(auctionUUID.toString()))); + }); }); } From ac83f98ec1a4b5d5ff609145f9932c3d0fa32030 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Sun, 5 Jul 2026 19:05:08 +1000 Subject: [PATCH 5/8] refactor: make the auction house GUIs fully non-blocking Every auction GUI blocked a thread on .join(): isOnline() guards ran on the tick thread each refresh (~150ms stalls), and the fetch/manage/bids views joined on fetch responses. Convert the online checks and fetch renders to async continuations (thenAccept/thenRun), and run the BIN purchase handler on a virtual thread so its sequential joins park cheaply instead of freezing the tick. --- .../auction/GUIAuctionBrowser.java | 14 ++-- .../auction/GUIAuctionCreateItem.java | 10 ++- .../inventories/auction/GUIAuctionHouse.java | 15 ++-- .../auction/GUIAuctionViewItem.java | 75 +++++++++--------- .../auction/GUIManageAuctions.java | 78 ++++++++++--------- .../gui/inventories/auction/GUIViewBids.java | 78 ++++++++++--------- .../auction/view/AuctionViewThirdBin.java | 4 + 7 files changed, 145 insertions(+), 129 deletions(-) diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java index c05580870..dd501af52 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java @@ -276,12 +276,14 @@ public void onBottomClick(InventoryPreClickEvent e) { @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.browser.offline_message")); - player.closeInventory(); - } - - setItems(); + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.browser.offline_message")); + player.closeInventory(); + return; + } + setItems(); + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java index b4c1983a2..7e6b7414e 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionCreateItem.java @@ -281,10 +281,12 @@ public void onBottomClick(InventoryPreClickEvent e) { @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.create.offline_message")); - player.closeInventory(); - } + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.create.offline_message")); + player.closeInventory(); + } + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionHouse.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionHouse.java index 56e6c7373..73896d764 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionHouse.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionHouse.java @@ -21,9 +21,8 @@ public class GUIAuctionHouse extends HypixelInventoryGUI implements RefreshingGUI { public GUIAuctionHouse() { super(I18n.t("gui_auction.house.title"), InventoryType.CHEST_4_ROW); - - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) - fill(Material.BLACK_STAINED_GLASS_PANE, ""); + // onOpen paints the panes and refreshItems() handles the offline case async — never + // block the tick thread on an isOnline() round-trip here. } @Override @@ -133,10 +132,12 @@ public void onBottomClick(InventoryPreClickEvent e) { @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.house.offline_message")); - player.closeInventory(); - } + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.house.offline_message")); + player.closeInventory(); + } + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionViewItem.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionViewItem.java index 3b2865826..b3fc34269 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionViewItem.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionViewItem.java @@ -52,48 +52,51 @@ public void onOpen(InventoryGUIOpenEvent e) { public void updateItems() { AuctionFetchItemProtocol.AuctionFetchItemMessage message = new AuctionFetchItemProtocol.AuctionFetchItemMessage(auctionID); - CompletableFuture future = - new ProxyService(ServiceType.AUCTION_HOUSE).handleRequest(message); - AuctionItem item = future.join().item(); - - set(GUIClickableItem.getGoBackItem(49, previousGUI)); - - set(new GUIItem(13) { - @Override - public ItemStack.Builder getItem(HypixelPlayer p) { - SkyBlockPlayer player = (SkyBlockPlayer) p; - return ItemStackCreator.updateLore( - PlayerItemUpdater.playerUpdate(player, new SkyBlockItem(item.getItem()).getItemStack()), - new AuctionItemLoreHandler(item).getLore(player) - ); + // Fully async: never block on the reply (which arrives on the Redis thread). + new ProxyService(ServiceType.AUCTION_HOUSE).handleRequest(message).thenAccept(response -> { + AuctionItem item = ((AuctionFetchItemProtocol.AuctionFetchItemResponse) response).item(); + + set(GUIClickableItem.getGoBackItem(49, previousGUI)); + + set(new GUIItem(13) { + @Override + public ItemStack.Builder getItem(HypixelPlayer p) { + SkyBlockPlayer player = (SkyBlockPlayer) p; + return ItemStackCreator.updateLore( + PlayerItemUpdater.playerUpdate(player, new SkyBlockItem(item.getItem()).getItemStack()), + new AuctionItemLoreHandler(item).getLore(player) + ); + } + }); + + if (!item.getOriginator().equals(getPlayer().getUuid())) { + if (item.isBin()) { + new AuctionViewThirdBin().open(this, item, (SkyBlockPlayer) getPlayer()); + return; + } + + new AuctionViewThirdNormal().open(this, item, (SkyBlockPlayer) getPlayer()); + } else { + if (item.isBin()) { + new AuctionViewSelfBIN().open(this, item, (SkyBlockPlayer) getPlayer()); + return; + } + + new AuctionViewSelfNormal().open(this, item, (SkyBlockPlayer) getPlayer()); } }); - - if (!item.getOriginator().equals(getPlayer().getUuid())) { - if (item.isBin()) { - new AuctionViewThirdBin().open(this, item, (SkyBlockPlayer) getPlayer()); - return; - } - - new AuctionViewThirdNormal().open(this, item, (SkyBlockPlayer) getPlayer()); - } else { - if (item.isBin()) { - new AuctionViewSelfBIN().open(this, item, (SkyBlockPlayer) getPlayer()); - return; - } - - new AuctionViewSelfNormal().open(this, item, (SkyBlockPlayer) getPlayer()); - } } @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.view.offline_message")); - player.closeInventory(); - } - - updateItems(); + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.view.offline_message")); + player.closeInventory(); + return; + } + updateItems(); + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIManageAuctions.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIManageAuctions.java index 1ff586d33..aaf26011f 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIManageAuctions.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIManageAuctions.java @@ -82,46 +82,47 @@ public void setItems() { futures.add(future); }); + // Render the page once every fetch has resolved — without blocking the caller. CompletableFuture allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); - allDone.join(); - - // Sort the items by the time they were added - auctionItems.sort((o1, o2) -> Long.compare(o2.getEndTime(), o1.getEndTime())); - - List auctionItemsPage = auctionItems.getPage(1); + allDone.thenRun(() -> { + // Sort the items by the time they were added + auctionItems.sort((o1, o2) -> Long.compare(o2.getEndTime(), o1.getEndTime())); + + List auctionItemsPage = auctionItems.getPage(1); + + for (int i = 0; i < 7; i++) { + int slot = i + 10; + + if (i >= auctionItems.size()) { + set(new GUIItem(slot) { + @Override + public ItemStack.Builder getItem(HypixelPlayer p) { + SkyBlockPlayer player = (SkyBlockPlayer) p; + return ItemStack.builder(Material.AIR); + } + }); + continue; + } - for (int i = 0; i < 7; i++) { - int slot = i + 10; + AuctionItem item = auctionItemsPage.get(i); + set(new GUIClickableItem(slot) { + @Override + public void run(InventoryPreClickEvent e, HypixelPlayer p) { + SkyBlockPlayer player = (SkyBlockPlayer) p; + new GUIAuctionViewItem(item.getUuid(), GUIManageAuctions.this).open(player); + } - if (i >= auctionItems.size()) { - set(new GUIItem(slot) { @Override public ItemStack.Builder getItem(HypixelPlayer p) { SkyBlockPlayer player = (SkyBlockPlayer) p; - return ItemStack.builder(Material.AIR); + return ItemStackCreator.getStack( + StringUtility.getTextFromComponent(new NonPlayerItemUpdater(item.getItem()).getUpdatedItem().build() + .get(DataComponents.CUSTOM_NAME)), + item.getItem().material(), item.getItem().amount(), new AuctionItemLoreHandler(item).getLore(player)); } }); - continue; } - - AuctionItem item = auctionItemsPage.get(i); - set(new GUIClickableItem(slot) { - @Override - public void run(InventoryPreClickEvent e, HypixelPlayer p) { - SkyBlockPlayer player = (SkyBlockPlayer) p; - new GUIAuctionViewItem(item.getUuid(), GUIManageAuctions.this).open(player); - } - - @Override - public ItemStack.Builder getItem(HypixelPlayer p) { - SkyBlockPlayer player = (SkyBlockPlayer) p; - return ItemStackCreator.getStack( - StringUtility.getTextFromComponent(new NonPlayerItemUpdater(item.getItem()).getUpdatedItem().build() - .get(DataComponents.CUSTOM_NAME)), - item.getItem().material(), item.getItem().amount(), new AuctionItemLoreHandler(item).getLore(player)); - } - }); - } + }); } @Override @@ -146,13 +147,14 @@ public void onBottomClick(InventoryPreClickEvent e) { @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.manage.offline_message")); - player.closeInventory(); - return; - } - - setItems(); + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.manage.offline_message")); + player.closeInventory(); + return; + } + setItems(); + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIViewBids.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIViewBids.java index 641260b3a..f506a2318 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIViewBids.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIViewBids.java @@ -67,46 +67,47 @@ public void setItems() { futures.add(future); }); + // Render the page once every fetch has resolved — without blocking the caller. CompletableFuture allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); - allDone.join(); - - // Sort the items by the time they were added - auctionItems.sort((o1, o2) -> Long.compare(o2.getEndTime(), o1.getEndTime())); - - List auctionItemsPage = auctionItems.getPage(1); + allDone.thenRun(() -> { + // Sort the items by the time they were added + auctionItems.sort((o1, o2) -> Long.compare(o2.getEndTime(), o1.getEndTime())); + + List auctionItemsPage = auctionItems.getPage(1); + + for (int i = 0; i < 7; i++) { + int slot = i + 10; + + if (i >= auctionItems.size()) { + set(new GUIItem(slot) { + @Override + public ItemStack.Builder getItem(HypixelPlayer p) { + SkyBlockPlayer player = (SkyBlockPlayer) p; + return ItemStack.builder(Material.AIR); + } + }); + continue; + } - for (int i = 0; i < 7; i++) { - int slot = i + 10; + AuctionItem item = auctionItemsPage.get(i); + set(new GUIClickableItem(slot) { + @Override + public void run(InventoryPreClickEvent e, HypixelPlayer p) { + SkyBlockPlayer player = (SkyBlockPlayer) p; + new GUIAuctionViewItem(item.getUuid(), GUIViewBids.this).open(player); + } - if (i >= auctionItems.size()) { - set(new GUIItem(slot) { @Override public ItemStack.Builder getItem(HypixelPlayer p) { SkyBlockPlayer player = (SkyBlockPlayer) p; - return ItemStack.builder(Material.AIR); + return ItemStackCreator.getStack( + StringUtility.getTextFromComponent(new NonPlayerItemUpdater(item.getItem()).getUpdatedItem().build() + .get(DataComponents.CUSTOM_NAME)), + item.getItem().material(), item.getItem().amount(), new AuctionItemLoreHandler(item).getLore(player)); } }); - continue; } - - AuctionItem item = auctionItemsPage.get(i); - set(new GUIClickableItem(slot) { - @Override - public void run(InventoryPreClickEvent e, HypixelPlayer p) { - SkyBlockPlayer player = (SkyBlockPlayer) p; - new GUIAuctionViewItem(item.getUuid(), GUIViewBids.this).open(player); - } - - @Override - public ItemStack.Builder getItem(HypixelPlayer p) { - SkyBlockPlayer player = (SkyBlockPlayer) p; - return ItemStackCreator.getStack( - StringUtility.getTextFromComponent(new NonPlayerItemUpdater(item.getItem()).getUpdatedItem().build() - .get(DataComponents.CUSTOM_NAME)), - item.getItem().material(), item.getItem().amount(), new AuctionItemLoreHandler(item).getLore(player)); - } - }); - } + }); } @Override @@ -131,13 +132,14 @@ public void onBottomClick(InventoryPreClickEvent e) { @Override public void refreshItems(HypixelPlayer player) { - if (!new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().join()) { - player.sendMessage(I18n.t("gui_auction.bids.offline_message")); - player.closeInventory(); - return; - } - - setItems(); + new ProxyService(ServiceType.AUCTION_HOUSE).isOnline().thenAccept(online -> { + if (!online) { + player.sendMessage(I18n.t("gui_auction.bids.offline_message")); + player.closeInventory(); + return; + } + setItems(); + }); } @Override diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/view/AuctionViewThirdBin.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/view/AuctionViewThirdBin.java index 00df3929e..8e3c2bd3a 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/view/AuctionViewThirdBin.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/view/AuctionViewThirdBin.java @@ -94,6 +94,9 @@ public ItemStack.Builder getItem(HypixelPlayer p) { gui.set(new GUIClickableItem(31) { @Override public void run(InventoryPreClickEvent e, HypixelPlayer p) { + // Run the purchase off the tick thread; the blocking joins below park a + // cheap virtual thread instead of freezing the server. + Thread.startVirtualThread(() -> { SkyBlockPlayer player = (SkyBlockPlayer) p; Locale l = p.getLocale(); double coins = player.getSkyblockDataHandler().get(net.swofty.type.skyblockgeneric.data.SkyBlockDataHandler.Data.COINS, DatapointDouble.class).getValue(); @@ -156,6 +159,7 @@ public void run(InventoryPreClickEvent e, HypixelPlayer p) { ClickEvent.runCommand("/ahview " + item.getUuid()) )); } + }); } @Override From d1e7b91d00bcf311035b48631254b02172bf58ad Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Mon, 6 Jul 2026 12:56:07 +1000 Subject: [PATCH 6/8] fix: let chest GUIs use slot 45 (offhand guard is player-inventory only) The click handler cancelled every click on slot 45 to protect the offhand slot, but slot 45 is a normal slot in an open chest GUI. The 6th auction category button (TOOLS) sits there, so its clicks were swallowed before reaching the GUI item. Scope the offhand cancel to the player's own inventory. --- .../event/actions/gui/ActionPlayerInventoryClick.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/type.generic/src/main/java/net/swofty/type/generic/event/actions/gui/ActionPlayerInventoryClick.java b/type.generic/src/main/java/net/swofty/type/generic/event/actions/gui/ActionPlayerInventoryClick.java index 004847cd4..5f270093a 100644 --- a/type.generic/src/main/java/net/swofty/type/generic/event/actions/gui/ActionPlayerInventoryClick.java +++ b/type.generic/src/main/java/net/swofty/type/generic/event/actions/gui/ActionPlayerInventoryClick.java @@ -40,8 +40,11 @@ public void handle(InventoryPreClickEvent event, final HypixelPlayer player) { ItemStack clickedItem = event.getClickedItem(); ItemStack cursorItem = player.getInventory().getCursorItem(); - // Check for offhand - if (event.getSlot() == 45) { + // Cancel interaction with the offhand slot, but ONLY in the player's own + // inventory. Slot 45 is the offhand there, yet in an open chest GUI (e.g. a + // CHEST_6_ROW) it is a normal, clickable slot — the 6th auction category button + // lives there, and blanket-cancelling slot 45 swallowed its clicks entirely. + if (event.getSlot() == 45 && event.getInventory() instanceof PlayerInventory) { event.setCancelled(true); return; } From 250331fddf6b42dd3e1c87e62e279102decce3b8 Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Mon, 6 Jul 2026 12:56:07 +1000 Subject: [PATCH 7/8] fix: remove stray braces around the tablist player count The players module rendered wrapped in literal { }, showing 'Players ({1})' instead of 'Players (1)'; the murder mystery line had a stray }. --- configuration/i18n/en_US/tablist.properties | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/configuration/i18n/en_US/tablist.properties b/configuration/i18n/en_US/tablist.properties index dc7461da7..5d912b23f 100644 --- a/configuration/i18n/en_US/tablist.properties +++ b/configuration/i18n/en_US/tablist.properties @@ -27,8 +27,8 @@ tablist.skywars_game.footer.store = Ranks, Boosters & MORE! ST # Module Headers tablist.module.server_info = Server Info -tablist.module.players=Players ({}) -tablist.module.players_murder_mystery=Players (}) +tablist.module.players=Players () +tablist.module.players_murder_mystery=Players () tablist.module.account_info = Account Info tablist.module.island=Island () tablist.module.guests = Guests From be868b40e7c4fce94762bd3f54a3ad959a8451df Mon Sep 17 00:00:00 2001 From: "Jacob Nardella (Swofty)" Date: Mon, 6 Jul 2026 12:56:07 +1000 Subject: [PATCH 8/8] fix: skip unreadable auction documents instead of failing the category A single malformed stored auction made the whole category fetch throw; deserialize each document defensively and log+skip bad ones. Also surface browse failures via exceptionally() instead of swallowing them. --- .../auction/endpoints/EndpointFetchItems.java | 13 ++++++++++++- .../gui/inventories/auction/GUIAuctionBrowser.java | 5 +++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/service.auctionhouse/src/main/java/net/swofty/service/auction/endpoints/EndpointFetchItems.java b/service.auctionhouse/src/main/java/net/swofty/service/auction/endpoints/EndpointFetchItems.java index 07ff7d5c7..4a5028707 100644 --- a/service.auctionhouse/src/main/java/net/swofty/service/auction/endpoints/EndpointFetchItems.java +++ b/service.auctionhouse/src/main/java/net/swofty/service/auction/endpoints/EndpointFetchItems.java @@ -66,6 +66,17 @@ public AuctionFetchItemsProtocol.AuctionFetchItemsResponse handle(AuctionFetchIt break; } - return new AuctionFetchItemsProtocol.AuctionFetchItemsResponse(results.stream().map(AuctionItem::fromDocument).toList(), true, null); + // Deserialize each document defensively: one malformed stored item should not + // take down the whole category fetch. + List items = new ArrayList<>(results.size()); + for (Document document : results) { + try { + items.add(AuctionItem.fromDocument(document)); + } catch (Exception e) { + System.err.println("Skipping unreadable auction _id=" + document.get("_id") + " in category " + category); + e.printStackTrace(); + } + } + return new AuctionFetchItemsProtocol.AuctionFetchItemsResponse(items, true, null); } } diff --git a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java index dd501af52..d4b18d617 100644 --- a/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java +++ b/type.skyblockgeneric/src/main/java/net/swofty/type/skyblockgeneric/gui/inventories/auction/GUIAuctionBrowser.java @@ -28,6 +28,7 @@ import net.swofty.type.generic.i18n.I18n; import net.swofty.type.generic.user.HypixelPlayer; import net.swofty.type.generic.utility.PaginationList; +import org.tinylog.Logger; import net.swofty.type.skyblockgeneric.auction.AuctionItemLoreHandler; import net.swofty.type.skyblockgeneric.item.SkyBlockItem; import net.swofty.type.skyblockgeneric.item.updater.PlayerItemUpdater; @@ -82,6 +83,10 @@ private void updateItemsCache() { // Set the items in the GUI List paginatedItems = paginationList.getPage(page); setItemCache(paginatedItems); + }) + .exceptionally(ex -> { + Logger.error(ex, "Auction browse failed for category {}", category); + return null; }); }