Skip to content
Draft
49 changes: 49 additions & 0 deletions commons/src/main/java/net/swofty/commons/redis/ProxyHeartbeat.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p>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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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())
Expand Down
4 changes: 2 additions & 2 deletions configuration/i18n/en_US/tablist.properties
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ tablist.skywars_game.footer.store = <green>Ranks, Boosters & MORE! <red><bold>ST

# Module Headers
tablist.module.server_info = <dark_aqua><bold>Server Info
tablist.module.players=<green><bold>Players <white>({<arg:0>})
tablist.module.players_murder_mystery=<red><bold>Players <white>(<arg:0>})
tablist.module.players=<green><bold>Players <white>(<arg:0>)
tablist.module.players_murder_mystery=<red><bold>Players <white>(<arg:0>)
tablist.module.account_info = <gold><bold>Account Info
tablist.module.island=<aqua><bold>Island <white>(<arg:0>)
tablist.module.guests = <light_purple><bold>Guests
Expand Down
63 changes: 41 additions & 22 deletions loader/src/main/java/net/swofty/loader/Hypixel.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;


Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -340,32 +344,47 @@ private static Map<String, String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuctionItem> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,17 @@
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);
return t;
});
scheduler.scheduleAtFixedRate(OrchestratorCache::cleanup, 5, 5, TimeUnit.SECONDS);
Logger.info("Started orchestrator service");

SkyBlockService.init(new OrchestratorService());
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +83,10 @@ private void updateItemsCache() {
// Set the items in the GUI
List<AuctionItem> paginatedItems = paginationList.getPage(page);
setItemCache(paginatedItems);
})
.exceptionally(ex -> {
Logger.error(ex, "Auction browse failed for category {}", category);
return null;
});
}

Expand Down Expand Up @@ -276,12 +281,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuctionAddItemProtocol.AuctionAddItemResponse> 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())));
});
});
}

Expand Down Expand Up @@ -278,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading