aboutsummaryrefslogtreecommitdiff
path: root/src/main/java
diff options
context:
space:
mode:
authorAaron <51387595+AzureAaron@users.noreply.github.com>2024-08-05 14:26:00 -0400
committerGitHub <noreply@github.com>2024-08-05 14:26:00 -0400
commit088583a5a5fff6758748e152d30de0adbbb12388 (patch)
tree536f0c3588c2dc61ca78d30684b57c681477c483 /src/main/java
parentd99528b34b6f7afed380c6c655ecc461e0082d46 (diff)
downloadSkyblocker-088583a5a5fff6758748e152d30de0adbbb12388.tar.gz
Skyblocker-088583a5a5fff6758748e152d30de0adbbb12388.tar.bz2
Skyblocker-088583a5a5fff6758748e152d30de0adbbb12388.zip
Crystal Waypoints server-sided sharing via WebSocket (#895)
Diffstat (limited to 'src/main/java')
-rw-r--r--src/main/java/de/hysky/skyblocker/SkyblockerMod.java4
-rw-r--r--src/main/java/de/hysky/skyblocker/debug/Debug.java15
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dwarven/CrystalsLocationsManager.java49
-rw-r--r--src/main/java/de/hysky/skyblocker/skyblock/dwarven/MiningLocationLabel.java12
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/Http.java2
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/Payload.java16
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/Service.java16
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/SkyblockerWebSocket.java141
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/Type.java26
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/WsMessageHandler.java72
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/WsStateManager.java39
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/message/CrystalsWaypointMessage.java49
-rw-r--r--src/main/java/de/hysky/skyblocker/utils/ws/message/Message.java8
13 files changed, 440 insertions, 9 deletions
diff --git a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java
index 6982ad21..4e110e15 100644
--- a/src/main/java/de/hysky/skyblocker/SkyblockerMod.java
+++ b/src/main/java/de/hysky/skyblocker/SkyblockerMod.java
@@ -60,6 +60,8 @@ import de.hysky.skyblocker.utils.container.ContainerSolverManager;
import de.hysky.skyblocker.utils.render.title.TitleContainer;
import de.hysky.skyblocker.utils.scheduler.MessageScheduler;
import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import de.hysky.skyblocker.utils.ws.SkyblockerWebSocket;
+import de.hysky.skyblocker.utils.ws.WsStateManager;
import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents;
import net.fabricmc.loader.api.FabricLoader;
@@ -186,6 +188,8 @@ public class SkyblockerMod implements ClientModInitializer {
SecretsTracker.init();
ApiAuthentication.init();
ApiUtils.init();
+ SkyblockerWebSocket.init();
+ WsStateManager.init();
Debug.init();
Kuudra.init();
DojoManager.init();
diff --git a/src/main/java/de/hysky/skyblocker/debug/Debug.java b/src/main/java/de/hysky/skyblocker/debug/Debug.java
index 446de66f..f1240a1c 100644
--- a/src/main/java/de/hysky/skyblocker/debug/Debug.java
+++ b/src/main/java/de/hysky/skyblocker/debug/Debug.java
@@ -29,6 +29,7 @@ public class Debug {
private static final boolean DEBUG_ENABLED = Boolean.parseBoolean(System.getProperty("skyblocker.debug", "false"));
private static boolean showInvisibleArmorStands = false;
+ private static boolean webSocketDebug = false;
public static boolean debugEnabled() {
return DEBUG_ENABLED || FabricLoader.getInstance().isDevelopmentEnvironment();
@@ -38,6 +39,10 @@ public class Debug {
return showInvisibleArmorStands;
}
+ public static boolean webSocketDebug() {
+ return webSocketDebug;
+ }
+
public static void init() {
if (debugEnabled()) {
SnapshotDebug.init();
@@ -46,6 +51,7 @@ public class Debug {
.then(ItemUtils.dumpHeldItemCommand())
.then(toggleShowingInvisibleArmorStands())
.then(dumpArmorStandHeadTextures())
+ .then(toggleWebSocketDebug())
)));
ScreenEvents.BEFORE_INIT.register((client, screen, scaledWidth, scaledHeight) -> {
if (screen instanceof HandledScreen<?> handledScreen) {
@@ -76,6 +82,15 @@ public class Debug {
return Command.SINGLE_SUCCESS;
});
}
+
+ private static LiteralArgumentBuilder<FabricClientCommandSource> toggleWebSocketDebug() {
+ return literal("toggleWebSocketDebug")
+ .executes(context -> {
+ webSocketDebug = !webSocketDebug;
+ context.getSource().sendFeedback(Constants.PREFIX.get().append(Text.translatable("skyblocker.debug.toggledWebSocketDebug", webSocketDebug)));
+ return Command.SINGLE_SUCCESS;
+ });
+ }
private static LiteralArgumentBuilder<FabricClientCommandSource> dumpArmorStandHeadTextures() {
return literal("dumpArmorStandHeadTextures")
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CrystalsLocationsManager.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CrystalsLocationsManager.java
index 22e494ab..8b8a7737 100644
--- a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CrystalsLocationsManager.java
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/CrystalsLocationsManager.java
@@ -6,12 +6,18 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.logging.LogUtils;
import de.hysky.skyblocker.SkyblockerMod;
import de.hysky.skyblocker.config.SkyblockerConfigManager;
+import de.hysky.skyblocker.events.SkyblockEvents;
import de.hysky.skyblocker.utils.Constants;
+import de.hysky.skyblocker.utils.Location;
import de.hysky.skyblocker.utils.Utils;
import de.hysky.skyblocker.utils.command.argumenttypes.blockpos.ClientBlockPosArgumentType;
import de.hysky.skyblocker.utils.command.argumenttypes.blockpos.ClientPosArgument;
import de.hysky.skyblocker.utils.scheduler.MessageScheduler;
import de.hysky.skyblocker.utils.scheduler.Scheduler;
+import de.hysky.skyblocker.utils.ws.WsMessageHandler;
+import de.hysky.skyblocker.utils.ws.Service;
+import de.hysky.skyblocker.utils.ws.WsStateManager;
+import de.hysky.skyblocker.utils.ws.message.CrystalsWaypointMessage;
import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback;
import net.fabricmc.fabric.api.client.command.v2.FabricClientCommandSource;
import net.fabricmc.fabric.api.client.message.v1.ClientReceiveMessageEvents;
@@ -60,6 +66,7 @@ public class CrystalsLocationsManager {
protected static Map<String, MiningLocationLabel> activeWaypoints = new HashMap<>();
protected static List<String> verifiedWaypoints = new ArrayList<>();
+ private static List<MiningLocationLabel.CrystalHollowsLocationsCategory> waypointsSent2Socket = new ArrayList<>();
public static void init() {
// Crystal Hollows Waypoints
@@ -67,6 +74,7 @@ public class CrystalsLocationsManager {
WorldRenderEvents.AFTER_TRANSLUCENT.register(CrystalsLocationsManager::render);
ClientReceiveMessageEvents.GAME.register(CrystalsLocationsManager::extractLocationFromMessage);
ClientCommandRegistrationCallback.EVENT.register(CrystalsLocationsManager::registerWaypointLocationCommands);
+ SkyblockEvents.LOCATION_CHANGE.register(CrystalsLocationsManager::onLocationChange);
ClientPlayConnectionEvents.JOIN.register((_handler, _sender, _client) -> reset());
// Nucleus Waypoints
@@ -127,6 +135,7 @@ public class CrystalsLocationsManager {
if (waypointLinkedMessage != null && text.contains(waypointLinkedMessage) && !verifiedWaypoints.contains(waypointName)) {
addCustomWaypoint(waypointLocation.getName(), CLIENT.player.getBlockPos());
verifiedWaypoints.add(waypointName);
+ trySendWaypoint2Socket(waypointLocation);
}
}
}
@@ -311,6 +320,14 @@ public class CrystalsLocationsManager {
return Command.SINGLE_SUCCESS;
}
+ public static void addCustomWaypointFromSocket(MiningLocationLabel.CrystalHollowsLocationsCategory category, BlockPos pos) {
+ if (activeWaypoints.containsKey(category.name())) return;
+
+ removeUnknownNear(pos);
+ MiningLocationLabel waypoint = new MiningLocationLabel(category, pos);
+ waypointsSent2Socket.add(category);
+ activeWaypoints.put(category.name(), waypoint);
+ }
protected static void addCustomWaypoint(String waypointName, BlockPos pos) {
removeUnknownNear(pos);
@@ -335,7 +352,7 @@ public class CrystalsLocationsManager {
}
}
- public static void render(WorldRenderContext context) {
+ private static void render(WorldRenderContext context) {
if (SkyblockerConfigManager.get().mining.crystalsWaypoints.enabled) {
for (MiningLocationLabel crystalsWaypoint : activeWaypoints.values()) {
crystalsWaypoint.render(context);
@@ -343,23 +360,41 @@ public class CrystalsLocationsManager {
}
}
+ private static void onLocationChange(Location newLocation) {
+ if (newLocation == Location.CRYSTAL_HOLLOWS) {
+ WsStateManager.subscribe(Service.CRYSTAL_WAYPOINTS);
+ }
+ }
+
private static void reset() {
activeWaypoints.clear();
verifiedWaypoints.clear();
+ waypointsSent2Socket.clear();
}
- public static void update() {
+ private static void update() {
if (CLIENT.player == null || CLIENT.getNetworkHandler() == null || !SkyblockerConfigManager.get().mining.crystalsWaypoints.enabled || !Utils.isInCrystalHollows()) {
return;
}
//get if the player is in the crystals
String location = Utils.getIslandArea().substring(2);
- //if new location and needs waypoint add waypoint
- if (!location.equals("Unknown") && WAYPOINT_LOCATIONS.containsKey(location) && !activeWaypoints.containsKey(location)) {
- //add waypoint at player location
- BlockPos playerLocation = CLIENT.player.getBlockPos();
- addCustomWaypoint(location, playerLocation);
+ //if new location and needs waypoint add waypoint, and if socket hasn't received waypoint send it
+ if (!location.equals("Unknown") && WAYPOINT_LOCATIONS.containsKey(location)) {
+ if (!activeWaypoints.containsKey(location)) {
+ //add waypoint at player location
+ BlockPos playerLocation = CLIENT.player.getBlockPos();
+ addCustomWaypoint(location, playerLocation);
+ }
+
+ trySendWaypoint2Socket(WAYPOINT_LOCATIONS.get(location));
+ }
+ }
+
+ private static void trySendWaypoint2Socket(MiningLocationLabel.CrystalHollowsLocationsCategory category) {
+ if (!waypointsSent2Socket.contains(category)) {
+ WsMessageHandler.sendMessage(Service.CRYSTAL_WAYPOINTS, new CrystalsWaypointMessage(category, CLIENT.player.getBlockPos()));
+ waypointsSent2Socket.add(category);
}
}
}
diff --git a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/MiningLocationLabel.java b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/MiningLocationLabel.java
index 3817f6c7..eb40088f 100644
--- a/src/main/java/de/hysky/skyblocker/skyblock/dwarven/MiningLocationLabel.java
+++ b/src/main/java/de/hysky/skyblocker/skyblock/dwarven/MiningLocationLabel.java
@@ -9,11 +9,14 @@ import net.minecraft.client.MinecraftClient;
import net.minecraft.text.Text;
import net.minecraft.util.DyeColor;
import net.minecraft.util.Formatting;
+import net.minecraft.util.StringIdentifiable;
import net.minecraft.util.math.BlockPos;
import net.minecraft.util.math.Vec3d;
import java.awt.*;
+import com.mojang.serialization.Codec;
+
// TODO: Clean up into the waypoint system with a new `DistancedWaypoint` that extends `NamedWaypoint` for this and secret waypoints.
public record MiningLocationLabel(Category category, Vec3d centerPos) implements Renderable {
public MiningLocationLabel(Category category, BlockPos pos) {
@@ -166,7 +169,7 @@ public record MiningLocationLabel(Category category, Vec3d centerPos) implements
/**
* enum for the different waypoints used int the crystals hud each with a {@link CrystalHollowsLocationsCategory#name} and associated {@link CrystalHollowsLocationsCategory#color}
*/
- enum CrystalHollowsLocationsCategory implements Category {
+ public enum CrystalHollowsLocationsCategory implements Category, StringIdentifiable {
UNKNOWN("Unknown", Color.WHITE, null), //used when a location is known but what's at the location is not known
JUNGLE_TEMPLE("Jungle Temple", new Color(DyeColor.PURPLE.getSignColor()), "[NPC] Kalhuiki Door Guardian:"),
MINES_OF_DIVAN("Mines of Divan", Color.GREEN, " Jade Crystal"),
@@ -180,6 +183,8 @@ public record MiningLocationLabel(Category category, Vec3d centerPos) implements
ODAWA("Odawa", Color.MAGENTA, "[NPC] Odawa:"),
KEY_GUARDIAN("Key Guardian", Color.LIGHT_GRAY, null);
+ public static final Codec<CrystalHollowsLocationsCategory> CODEC = StringIdentifiable.createBasicCodec(CrystalHollowsLocationsCategory::values);
+
public final Color color;
private final String name;
private final String linkedMessage;
@@ -203,6 +208,11 @@ public record MiningLocationLabel(Category category, Vec3d centerPos) implements
public String getLinkedMessage() {
return this.linkedMessage;
}
+
+ @Override
+ public String asString() {
+ return name();
+ }
}
}
diff --git a/src/main/java/de/hysky/skyblocker/utils/Http.java b/src/main/java/de/hysky/skyblocker/utils/Http.java
index 051bd52e..227cc5a7 100644
--- a/src/main/java/de/hysky/skyblocker/utils/Http.java
+++ b/src/main/java/de/hysky/skyblocker/utils/Http.java
@@ -27,7 +27,7 @@ import java.util.zip.InflaterInputStream;
public class Http {
private static final String NAME_2_UUID = "https://api.minecraftservices.com/minecraft/profile/lookup/name/";
private static final String HYPIXEL_PROXY = "https://hysky.de/api/hypixel/v2/";
- private static final String USER_AGENT = "Skyblocker/" + SkyblockerMod.VERSION + " (" + SharedConstants.getGameVersion().getName() + ")";
+ public static final String USER_AGENT = "Skyblocker/" + SkyblockerMod.VERSION + " (" + SharedConstants.getGameVersion().getName() + ")";
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(Redirect.NORMAL)
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/Payload.java b/src/main/java/de/hysky/skyblocker/utils/ws/Payload.java
new file mode 100644
index 00000000..c943f371
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/Payload.java
@@ -0,0 +1,16 @@
+package de.hysky.skyblocker.utils.ws;
+
+import java.util.Optional;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.Dynamic;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+record Payload(Type type, Service service, String serverId, Optional<Dynamic<?>> message) {
+ static final Codec<Payload> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ Type.CODEC.fieldOf("type").forGetter(Payload::type),
+ Service.CODEC.fieldOf("service").forGetter(Payload::service),
+ Codec.STRING.fieldOf("serverId").forGetter(Payload::serverId),
+ Codec.PASSTHROUGH.optionalFieldOf("message").forGetter(Payload::message))
+ .apply(instance, Payload::new));
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/Service.java b/src/main/java/de/hysky/skyblocker/utils/ws/Service.java
new file mode 100644
index 00000000..f26e90bb
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/Service.java
@@ -0,0 +1,16 @@
+package de.hysky.skyblocker.utils.ws;
+
+import com.mojang.serialization.Codec;
+
+import net.minecraft.util.StringIdentifiable;
+
+public enum Service implements StringIdentifiable {
+ CRYSTAL_WAYPOINTS;
+
+ public static final Codec<Service> CODEC = StringIdentifiable.createBasicCodec(Service::values);
+
+ @Override
+ public String asString() {
+ return name();
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/SkyblockerWebSocket.java b/src/main/java/de/hysky/skyblocker/utils/ws/SkyblockerWebSocket.java
new file mode 100644
index 00000000..be2b979b
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/SkyblockerWebSocket.java
@@ -0,0 +1,141 @@
+package de.hysky.skyblocker.utils.ws;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpClient.Redirect;
+import java.net.http.HttpClient.Version;
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+import org.slf4j.Logger;
+
+import com.mojang.logging.LogUtils;
+
+import de.hysky.skyblocker.debug.Debug;
+import de.hysky.skyblocker.events.SkyblockEvents;
+import de.hysky.skyblocker.utils.ApiAuthentication;
+import de.hysky.skyblocker.utils.Http;
+import net.minecraft.util.Util;
+
+public class SkyblockerWebSocket {
+ private static final Logger LOGGER = LogUtils.getLogger();
+ private static final String WS_URL = "wss://ws.hysky.de";
+ private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .followRedirects(Redirect.NORMAL)
+ .version(Version.HTTP_2)
+ .build();
+ private static final ExecutorService MESSAGE_SEND_QUEUE = Executors.newSingleThreadExecutor(Thread.ofVirtual()
+ .name("Skyblocker WebSocket Message Send Queue")
+ .factory());
+
+ private static volatile WebSocket socket;
+
+ public static void init() {
+ SkyblockEvents.JOIN.register(() -> {
+ if (!isConnectionOpen()) setupSocket();
+ });
+ }
+
+ private static CompletableFuture<Void> setupSocket() {
+ return CompletableFuture.runAsync(() -> {
+ try {
+ socket = HTTP_CLIENT.newWebSocketBuilder()
+ .header("Authorization", "Bearer " + Objects.requireNonNull(ApiAuthentication.getToken(), "Token cannot be null"))
+ .header("User-Agent", Http.USER_AGENT)
+ .buildAsync(URI.create(WS_URL), new SocketListener())
+ .get();
+
+ LOGGER.info("[Skyblocker WebSocket] Successfully connected to the Skyblocker WebSocket!");
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker WebSocket] Failed to setup WebSocket connection!", e);
+ }
+ });
+ }
+
+ private static boolean isConnectionOpen() {
+ return socket != null && !socket.isInputClosed() && !socket.isOutputClosed();
+ }
+
+ static void send(String message) {
+ if (isConnectionOpen()) {
+ sendInternal(message);
+ } else {
+ setupSocket().thenRun(() -> sendInternal(message));
+ }
+ }
+
+ private static void sendInternal(String message) {
+ MESSAGE_SEND_QUEUE.submit(() -> {
+ try {
+ if (Debug.debugEnabled() && Debug.webSocketDebug()) LOGGER.info("[Skyblocker WebSocket] Sending Message: {}", message);
+
+ socket.sendText(message, true).join();
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker WebSocket] Failed to send message!", e);
+ }
+ });
+ }
+
+ private static class SocketListener implements WebSocket.Listener {
+ private List<CharSequence> parts = new ArrayList<>();
+ private CompletableFuture<?> accumulatedMessage = new CompletableFuture<>();
+
+ @Override
+ public CompletionStage<?> onText(WebSocket webSocket, CharSequence message, boolean last) {
+ parts.add(message);
+ webSocket.request(1);
+
+ if (last) {
+ //Process message once we've got all the text
+ handleWholeMessage(parts);
+
+ //Reset state and allow CharSequences to be reclaimed or something? Java WebSockets are very confusing
+ parts = new ArrayList<>();
+ accumulatedMessage.complete(null);
+ CompletionStage<?> future = accumulatedMessage;
+ accumulatedMessage = new CompletableFuture<>();
+
+ return future;
+ }
+
+ return accumulatedMessage;
+ }
+
+ @Override
+ public CompletionStage<?> onPing(WebSocket webSocket, ByteBuffer message) {
+ if (Debug.debugEnabled() && Debug.webSocketDebug()) LOGGER.info("[Skyblocker WebSocket] Received ping");
+
+ return WebSocket.Listener.super.onPing(webSocket, message);
+ }
+
+ @Override
+ public CompletionStage<?> onClose(WebSocket webSocket, int statusCode, String reason) {
+ LOGGER.info("[Skyblocker WebSocket] Connection closing. Status Code: {}, Reason: {}", statusCode, reason);
+
+ return WebSocket.Listener.super.onClose(webSocket, statusCode, reason);
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ LOGGER.error("[Skyblocker WebSocket] Encountered an error and closed the connection!", error);
+ }
+
+ private void handleWholeMessage(List<CharSequence> parts) {
+ StringBuilder builder = Util.make(new StringBuilder(), sb -> parts.forEach(sb::append));
+ String message = builder.toString();
+
+ if (Debug.debugEnabled() && Debug.webSocketDebug()) LOGGER.info("[Skyblocker WebSocket] Received Message: {}", message);
+
+ WsMessageHandler.handleMessage(message);
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/Type.java b/src/main/java/de/hysky/skyblocker/utils/ws/Type.java
new file mode 100644
index 00000000..c4a0e283
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/Type.java
@@ -0,0 +1,26 @@
+package de.hysky.skyblocker.utils.ws;
+
+import com.mojang.serialization.Codec;
+
+import net.minecraft.util.StringIdentifiable;
+
+public enum Type implements StringIdentifiable {
+ SUBSCRIBE("subscribe"),
+ INITIAL_MESSAGE("initialMessage"),
+ PUBLISH("publish"),
+ RESPONSE("response"),
+ UNSUBSCRIBE("unsubscribe");
+
+ public static final Codec<Type> CODEC = StringIdentifiable.createBasicCodec(Type::values);
+
+ private final String id;
+
+ Type(String id) {
+ this.id = id;
+ }
+
+ @Override
+ public String asString() {
+ return this.id;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/WsMessageHandler.java b/src/main/java/de/hysky/skyblocker/utils/ws/WsMessageHandler.java
new file mode 100644
index 00000000..d85ccbb8
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/WsMessageHandler.java
@@ -0,0 +1,72 @@
+package de.hysky.skyblocker.utils.ws;
+
+import java.util.Optional;
+
+import org.slf4j.Logger;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+import com.mojang.logging.LogUtils;
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.Dynamic;
+import com.mojang.serialization.JsonOps;
+
+import de.hysky.skyblocker.SkyblockerMod;
+import de.hysky.skyblocker.utils.Utils;
+import de.hysky.skyblocker.utils.ws.message.CrystalsWaypointMessage;
+import de.hysky.skyblocker.utils.ws.message.Message;
+
+public class WsMessageHandler {
+ private static final Logger LOGGER = LogUtils.getLogger();
+
+ /**
+ * Used for sending messages to the current channel/server
+ */
+ @SuppressWarnings("unchecked")
+ public static void sendMessage(Service service, Message<? extends Message<?>> message) {
+ try {
+ Codec<Message<?>> codec = (Codec<Message<?>>) message.getCodec();
+ Dynamic<JsonElement> dynamic = new Dynamic<>(JsonOps.INSTANCE, codec.encodeStart(JsonOps.INSTANCE, message).getOrThrow());
+
+ send(Type.PUBLISH, service, Utils.getServer(), Optional.of(dynamic));
+ } catch (Exception e) {
+ LOGGER.info("[Skyblocker WebSocket Message Handler] Failed to encode message! Message: {}", message, e);
+ }
+ }
+
+ /**
+ * Useful for sending simple state updates
+ */
+ static void sendSimple(Type type, Service service, String serverId) {
+ send(type, service, serverId, Optional.empty());
+ }
+
+ private static void send(Type type, Service service, String serverId, Optional<Dynamic<?>> message) {
+ try {
+ Payload payload = new Payload(type, service, serverId, message);
+ JsonObject encoded = Payload.CODEC.encodeStart(JsonOps.INSTANCE, payload).getOrThrow().getAsJsonObject();
+
+ SkyblockerWebSocket.send(SkyblockerMod.GSON_COMPACT.toJson(encoded));
+ } catch (Exception e) {
+ LOGGER.info("[Skyblocker WebSocket Message Handler] Failed to send message! Type: {}, Service: {}, Message: {}", type, service, message, e);
+ }
+ }
+
+ static void handleMessage(String message) {
+ try {
+ JsonObject payloadEncoded = JsonParser.parseString(message).getAsJsonObject();
+
+ //When status is present its usually a response to a packet being sent or some error, we don't need to pay attention to those
+ if (payloadEncoded.has("type")) {
+ Payload payload = Payload.CODEC.parse(JsonOps.INSTANCE, payloadEncoded).getOrThrow();
+
+ switch (payload.service()) {
+ case Service.CRYSTAL_WAYPOINTS -> CrystalsWaypointMessage.handle(payload.type(), payload.message());
+ }
+ }
+ } catch (Exception e) {
+ LOGGER.error("[Skyblocker WebSocket Message Handler] Failed to handle incoming message!", e);
+ }
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/WsStateManager.java b/src/main/java/de/hysky/skyblocker/utils/ws/WsStateManager.java
new file mode 100644
index 00000000..6715c1f6
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/WsStateManager.java
@@ -0,0 +1,39 @@
+package de.hysky.skyblocker.utils.ws;
+
+import de.hysky.skyblocker.events.SkyblockEvents;
+import de.hysky.skyblocker.utils.Utils;
+import it.unimi.dsi.fastutil.objects.ReferenceOpenHashSet;
+import it.unimi.dsi.fastutil.objects.ReferenceSet;
+import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents;
+
+public class WsStateManager {
+ private static final ReferenceSet<Service> SUBSCRIBED_SERVICES = new ReferenceOpenHashSet<>();
+ private static String lastServerId = "";
+
+ public static void init() {
+ ClientPlayConnectionEvents.JOIN.register((handler, sender, client) -> reset());
+ ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> reset());
+ }
+
+ private static void reset() {
+ if (!lastServerId.isEmpty()) {
+ for (Service service : SUBSCRIBED_SERVICES) {
+ WsMessageHandler.sendSimple(Type.UNSUBSCRIBE, service, lastServerId);
+ }
+
+ lastServerId = "";
+ }
+ }
+
+ /**
+ * @implNote The service must be registered after the {@link ClientPlayConnectionEvents#JOIN} event fires, one good
+ * place is inside of the {@link SkyblockEvents#LOCATION_CHANGE} event.
+ */
+ public static void subscribe(Service service) {
+ SUBSCRIBED_SERVICES.add(service);
+ WsMessageHandler.sendSimple(Type.SUBSCRIBE, service, Utils.getServer());
+
+ //Update tracked server id
+ lastServerId = Utils.getServer();
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/message/CrystalsWaypointMessage.java b/src/main/java/de/hysky/skyblocker/utils/ws/message/CrystalsWaypointMessage.java
new file mode 100644
index 00000000..8e9ed87f
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/message/CrystalsWaypointMessage.java
@@ -0,0 +1,49 @@
+package de.hysky.skyblocker.utils.ws.message;
+
+import java.util.List;
+import java.util.Optional;
+
+import com.mojang.serialization.Codec;
+import com.mojang.serialization.Dynamic;
+import com.mojang.serialization.codecs.RecordCodecBuilder;
+
+import de.hysky.skyblocker.skyblock.dwarven.CrystalsLocationsManager;
+import de.hysky.skyblocker.skyblock.dwarven.MiningLocationLabel.CrystalHollowsLocationsCategory;
+import de.hysky.skyblocker.utils.render.RenderHelper;
+import de.hysky.skyblocker.utils.ws.Type;
+import net.minecraft.util.math.BlockPos;
+
+public record CrystalsWaypointMessage(CrystalHollowsLocationsCategory location, BlockPos coordinates) implements Message<CrystalsWaypointMessage> {
+ private static final Codec<CrystalsWaypointMessage> CODEC = RecordCodecBuilder.create(instance -> instance.group(
+ CrystalHollowsLocationsCategory.CODEC.fieldOf("name").forGetter(CrystalsWaypointMessage::location),
+ BlockPos.CODEC.fieldOf("coordinates").forGetter(CrystalsWaypointMessage::coordinates))
+ .apply(instance, CrystalsWaypointMessage::new));
+ private static final Codec<List<CrystalsWaypointMessage>> LIST_CODEC = CODEC.listOf();
+
+ public static void handle(Type type, Optional<Dynamic<?>> message) {
+ switch (type) {
+ case Type.RESPONSE -> {
+ CrystalsWaypointMessage waypoint = CODEC.parse(message.get()).getOrThrow();
+
+ RenderHelper.runOnRenderThread(() -> CrystalsLocationsManager.addCustomWaypointFromSocket(waypoint.location(), waypoint.coordinates()));
+ }
+
+ case Type.INITIAL_MESSAGE -> {
+ List<CrystalsWaypointMessage> waypoints = LIST_CODEC.parse(message.get()).getOrThrow();
+
+ RenderHelper.runOnRenderThread(() -> {
+ for (CrystalsWaypointMessage waypoint : waypoints) {
+ CrystalsLocationsManager.addCustomWaypointFromSocket(waypoint.location(), waypoint.coordinates());
+ }
+ });
+ }
+
+ default -> {}
+ }
+ }
+
+ @Override
+ public Codec<CrystalsWaypointMessage> getCodec() {
+ return CODEC;
+ }
+}
diff --git a/src/main/java/de/hysky/skyblocker/utils/ws/message/Message.java b/src/main/java/de/hysky/skyblocker/utils/ws/message/Message.java
new file mode 100644
index 00000000..2d145ca4
--- /dev/null
+++ b/src/main/java/de/hysky/skyblocker/utils/ws/message/Message.java
@@ -0,0 +1,8 @@
+package de.hysky.skyblocker.utils.ws.message;
+
+import com.mojang.serialization.Codec;
+
+public sealed interface Message<T extends Message<T>> permits CrystalsWaypointMessage {
+
+ Codec<T> getCodec();
+}