diff --git a/mapsync-mod/build.gradle.kts b/mapsync-mod/build.gradle.kts index 61905a4f..8926c349 100644 --- a/mapsync-mod/build.gradle.kts +++ b/mapsync-mod/build.gradle.kts @@ -41,11 +41,11 @@ dependencies { libs.voxelmap.also { modCompileOnly(it) - // modLocalDep(it) // Uncomment to test VoxelMap + modLocalDep(it) // Uncomment to test VoxelMap } libs.journeymap.also { modCompileOnly(it) - modLocalDep(it) // Uncomment to test JourneyMap + //modLocalDep(it) // Uncomment to test JourneyMap } libs.xaerosmap.also { modCompileOnly(it) diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java index 8242ba47..62a75397 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/MapSyncMod.java @@ -1,18 +1,18 @@ package gjum.minecraft.mapsync.mod; -import static gjum.minecraft.mapsync.mod.Cartography.chunkTileFromLevel; +import static gjum.minecraft.mapsync.mod.sync.Cartography.chunkTileFromLevel; import com.mojang.blaze3d.platform.InputConstants; import gjum.minecraft.mapsync.mod.config.ModConfig; -import gjum.minecraft.mapsync.mod.config.ServerConfig; +import gjum.minecraft.mapsync.mod.config.gui.SyncConnectionsGui; import gjum.minecraft.mapsync.mod.data.CatchupChunk; import gjum.minecraft.mapsync.mod.data.ChunkTile; -import gjum.minecraft.mapsync.mod.data.GameAddress; import gjum.minecraft.mapsync.mod.data.RegionPos; import gjum.minecraft.mapsync.mod.net.CloseContext; import gjum.minecraft.mapsync.mod.net.Packet; import gjum.minecraft.mapsync.mod.net.SyncClient; -import gjum.minecraft.mapsync.mod.net.SyncClients; +import gjum.minecraft.mapsync.mod.sync.DimensionState; +import gjum.minecraft.mapsync.mod.sync.GameContext; import gjum.minecraft.mapsync.mod.net.UnexpectedPacketException; import gjum.minecraft.mapsync.mod.net.auth.AuthProcess; import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; @@ -22,11 +22,11 @@ import gjum.minecraft.mapsync.mod.net.packet.ClientboundWelcomePacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundCatchupRequestPacket; import gjum.minecraft.mapsync.mod.net.packet.ServerboundChunkTimestampsRequestPacket; +import gjum.minecraft.mapsync.mod.sync.RenderQueue; import it.unimi.dsi.fastutil.objects.Object2LongArrayMap; import it.unimi.dsi.fastutil.objects.Object2LongMap; import java.io.File; -import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.HashMap; @@ -34,18 +34,17 @@ import java.util.List; import java.util.Map; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientChunkEvents; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; import net.minecraft.client.KeyMapping; import net.minecraft.client.Minecraft; -import net.minecraft.client.multiplayer.ServerData; -import net.minecraft.network.protocol.game.ClientboundRespawnPacket; +import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.resources.Identifier; import net.minecraft.world.level.ChunkPos; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; import org.lwjgl.glfw.GLFW; public final class MapSyncMod implements ClientModInitializer { @@ -74,18 +73,6 @@ public static MapSyncMod getMod() { //"category.map-sync" ); - /** - * Tracks state and render thread for current mc dimension. - * Never access this directly; always go through `getDimensionState()`. - */ - private @Nullable DimensionState dimensionState; - - /** - * Tracks configuration for current mc server. - * Never access this directly; always go through `getServerConfig()`. - */ - private @Nullable ServerConfig serverConfig; - public MapSyncMod() { if (INSTANCE != null) throw new IllegalStateException("Constructor called twice"); INSTANCE = this; @@ -106,28 +93,54 @@ public void onInitializeClient() { e.printStackTrace(); } }); - SyncClients.initEvents(); + GameContext.initEvents(); + ClientChunkEvents.CHUNK_LOAD.register((level, chunk) -> { + final GameContext gameContext = GameContext.get().orElse(null); + if (gameContext == null) { + return; + } + // TODO batch this up and send multiple chunks at once + // TODO disable in nether (no meaningful "surface layer") + final DimensionState dimensionState = gameContext.getDimensionState().orElse(null); + if (dimensionState == null) { + return; + } + final ChunkPos chunkPos = chunk.getPos(); + debugLog("received mc chunk: %d,%d".formatted( + chunkPos.x, + chunkPos.z + )); + final ChunkTile chunkTile = chunkTileFromLevel(level, chunk); + // TODO handle journeymap skipping chunks due to rate limiting - probably need mixin on render function + if (RenderQueue.areAllMapModsMapping()) { + dimensionState.setChunkTimestamp(chunkTile.chunkPos(), chunkTile.timestamp()); + } + for (final SyncClient client : gameContext.getSyncConnections()) { + client.sendChunkTile(chunkTile); + } + }); } public void handleTick( final @NotNull Minecraft minecraft ) { + final GameContext gameContext = GameContext.get().orElse(null); + if (gameContext == null) { // This *shouldn't* ever happen, but just case + return; + } + while (openGuiKey.consumeClick()) { - minecraft.setScreen(new ModGui(minecraft.screen)); + minecraft.setScreen(new SyncConnectionsGui(minecraft.screen, gameContext)); } - var dimensionState = getDimensionState(); - if (dimensionState != null) dimensionState.onTick(); + gameContext.getDimensionState().ifPresent(DimensionState::onTick); } public void handleSyncConnection( final @NotNull SyncClient client ) throws Exception { client.authState.set(null); - AuthProcess.sendHandshake( - client, - this.getDimensionState() - ); + AuthProcess.sendHandshake(client); } public void handleSyncDisconnection( @@ -152,76 +165,16 @@ public void handleSyncPacket( } } - public void handleRespawn(ClientboundRespawnPacket packet) { - debugLog("handleRespawn"); + /// @param clientLevel This is the *new* dimension. + public void handleDimensionChange( + final @NotNull Minecraft minecraft, + final @NotNull ClientLevel clientLevel, + final @NotNull GameContext gameContext + ) { + debugLog("handleDimensionChange"); // TODO tell sync server to only send chunks for this dimension now } - /** - * only null when not connected to a server - */ - public @Nullable ServerConfig getServerConfig() { - final ServerData currentServer = Minecraft.getInstance().getCurrentServer(); - if (currentServer == null) { - serverConfig = null; - return null; - } - GameAddress gameAddress = new GameAddress(currentServer.ip); - if (serverConfig == null) { - serverConfig = ServerConfig.load(gameAddress); - } - return serverConfig; - } - - /** - * for current dimension - */ - public @Nullable DimensionState getDimensionState() { - if (mc.level == null) return null; - var serverConfig = getServerConfig(); - if (serverConfig == null) return null; - - if (dimensionState != null && dimensionState.dimension != mc.level.dimension()) { - shutDownDimensionState(); - } - if (dimensionState == null || dimensionState.hasShutDown) { - dimensionState = new DimensionState(serverConfig.gameAddress, mc.level.dimension()); - } - return dimensionState; - } - - private void shutDownDimensionState() { - if (dimensionState != null) { - dimensionState.shutDown(); - dimensionState = null; - } - } - - /** - * an entire chunk was received from the mc server; - * send it to the map data server right away. - */ - public void handleMcFullChunk(int cx, int cz) { - // TODO batch this up and send multiple chunks at once - - if (mc.level == null) return; - // TODO disable in nether (no meaningful "surface layer") - var dimensionState = getDimensionState(); - if (dimensionState == null) return; - - debugLog("received mc chunk: " + cx + "," + cz); - - var chunkTile = chunkTileFromLevel(mc.level, cx, cz); - - // TODO handle journeymap skipping chunks due to rate limiting - probably need mixin on render function - if (RenderQueue.areAllMapModsMapping()) { - dimensionState.setChunkTimestamp(chunkTile.chunkPos(), chunkTile.timestamp()); - } - for (SyncClient client : SyncClients.get().orElseThrow()) { - client.sendChunkTile(chunkTile); - } - } - /** * part of a chunk changed, and the chunk is likely to change again soon, * so a ChunkTile update is queued, instead of updating instantly. @@ -237,7 +190,7 @@ public void handleSyncServerEncryptionSuccess() { public void handleRegionTimestamps(SyncClient client, ClientboundRegionTimestampsPacket packet) { client.authState.requireWelcomed(); - DimensionState dimension = getDimensionState(); + DimensionState dimension = client.gameContext.getDimensionState().orElse(null); if (dimension == null) return; if (!dimension.dimension.identifier().toString().equals(packet.dimension())) { return; @@ -262,13 +215,11 @@ public void handleRegionTimestamps(SyncClient client, ClientboundRegionTimestamp public void handleSharedChunk(SyncClient client, ChunkTile chunkTile) { client.authState.requireWelcomed(); debugLog("received shared chunk: " + chunkTile.chunkPos()); - for (SyncClient syncClient : SyncClients.get().orElseThrow()) { + for (SyncClient syncClient : client.gameContext.getSyncConnections()) { syncClient.setServerKnownChunkHash(chunkTile.chunkPos(), chunkTile.dataHash()); } - var dimensionState = getDimensionState(); - if (dimensionState == null) return; - dimensionState.processSharedChunk(chunkTile); + client.gameContext.getDimensionState().ifPresent((dimensionState) -> dimensionState.processSharedChunk(chunkTile)); } public void handleCatchupData(SyncClient client, ClientboundChunkTimestampsResponsePacket packet) { @@ -276,10 +227,10 @@ public void handleCatchupData(SyncClient client, ClientboundChunkTimestampsRespo for (CatchupChunk chunk : packet.chunks()) { chunk.syncClient = client; } - var dimensionState = getDimensionState(); - if (dimensionState == null) return; - debugLog("received catchup: " + packet.chunks().size() + " " + client.syncAddress); - dimensionState.addCatchupChunks(packet.chunks()); + client.gameContext.getDimensionState().ifPresent((dimensionState) -> { + debugLog("received catchup: " + packet.chunks().size() + " " + client.syncAddress); + dimensionState.addCatchupChunks(packet.chunks()); + }); } public void requestCatchupData( diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java deleted file mode 100644 index 74514f13..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/ModGui.java +++ /dev/null @@ -1,184 +0,0 @@ -package gjum.minecraft.mapsync.mod; - -import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; - -import gjum.minecraft.mapsync.mod.config.ServerConfig; -import gjum.minecraft.mapsync.mod.net.SyncClients; -import java.util.HashSet; -import java.util.List; -import net.minecraft.client.gui.GuiGraphics; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.EditBox; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; -import org.jetbrains.annotations.NotNull; - -public class ModGui extends Screen { - final Screen parentScreen; - - ServerConfig serverConfig = getMod().getServerConfig(); - - int innerWidth = 300; - int left; - int right; - int top; - - int centerX = width / 2; - int centerY = width / 2; - - EditBox syncServerAddressField; - Button syncServerConnectBtn; - Button syncServerDisconnectBtn; - Button syncServerPurgeBtn; - - public ModGui(Screen parentScreen) { - super(Component.literal("Map-Sync")); - this.parentScreen = parentScreen; - } - - @Override - public void resize(int width, int height) { - super.resize(width, height); - init(); - } - - @Override - protected void init() { - try { - left = width / 2 - innerWidth / 2; - right = width / 2 + innerWidth / 2; - top = height / 3; - - centerX = width / 2; - centerY = width / 2; - - int buttonWidth = 100; - int buttonHeight = 20; - - clearWidgets(); - - addRenderableWidget( - Button.builder(Component.literal("Close"), (button) -> minecraft.setScreen(parentScreen)) - .bounds(centerX - (buttonWidth / 2), centerY, buttonWidth, buttonHeight) - .build() - ); - - if (serverConfig != null) { - addWidget(syncServerAddressField = new EditBox(font, - left, - top + 40, - innerWidth - 110, 20, - Component.literal("Sync Server Address"))); - syncServerAddressField.setMaxLength(256); - syncServerAddressField.setValue(String.join(" ", - serverConfig.getSyncServerAddresses())); - - addRenderableWidget( - syncServerConnectBtn = Button.builder(Component.literal("Connect"), this::connectClicked) - .bounds(right - 100, top + 40, 100, 20) - .build() - ); - - addRenderableWidget( - syncServerDisconnectBtn = Button.builder(Component.literal("Disconnect"), this::disconnectClicked) - .bounds(right - 100, syncServerAddressField.getY() + 25, 100, 20) - .build() - ); - - addRenderableWidget( - syncServerPurgeBtn = Button.builder(Component.literal("Purge"), this::purgeClicked) - .bounds(width - 60, top + 40, 60, 20) - .bounds(10, height - 30, 60, 20) - .build() - ); - } - } catch (Throwable e) { - e.printStackTrace(); - } - } - - public void connectClicked(Button btn) { - try { - if (syncServerAddressField == null) return; - var addresses = List.of(syncServerAddressField.getValue().split("[^-_.:A-Za-z0-9/]+")); - serverConfig.setSyncServerAddresses(addresses); - SyncClients.get().orElseThrow().setAll(new HashSet<>(addresses)); - btn.active = false; - syncServerDisconnectBtn.active = true; - } catch (Throwable e) { - e.printStackTrace(); - } - } - - // TODO: not working - public void disconnectClicked(Button btn) { - if (syncServerAddressField == null) return; - SyncClients.get().orElseThrow().closeAll(true); - btn.active = false; - } - - public void purgeClicked(Button btn) { - DimensionState dimState = getMod().getDimensionState(); - if (dimState != null) { - dimState.PurgeRegionTimeStamps(); - } - } - - @Override - public void render(@NotNull GuiGraphics guiGraphics, int i, int j, float f) { - - try { - // wait for init() to finish - if (syncServerAddressField == null) return; - if (syncServerConnectBtn == null) return; - super.render(guiGraphics, i, j, f); - - guiGraphics.drawCenteredString(font, title, centerX, top, 0xFFFFFFFF); - syncServerAddressField.render(guiGraphics, i, j, f); - - var dimensionState = getMod().getDimensionState(); - if (dimensionState != null) { - String counterText = String.format( - "In dimension %s, received %d chunks, rendered %d, rendering %d", - dimensionState.dimension.identifier(), - dimensionState.getNumChunksReceived(), - dimensionState.getNumChunksRendered(), - dimensionState.getRenderQueueSize() - ); - guiGraphics.drawCenteredString(font, counterText, centerX, syncServerAddressField.getY() - 20, 0xFF888888); - } - - int msgY = syncServerAddressField.getY() + 25; - for (var client : SyncClients.get().orElseThrow()) { - int statusColor; - String statusText; - - var connectionState = client.state(); - switch (connectionState) { - case DISCONNECTED -> { - statusColor = 0xFFff8888; - statusText = "Disconnected"; - } - case CONNECTED -> { - statusColor = 0xFF8888ff; - statusText = "Connected (not authed)"; - } - case WELCOMED -> { - statusColor = 0xFF88ff88; - statusText = "Connected and authed"; - } - default -> { - statusColor = 0xFFFFFF00; - statusText = "Unknown state: " + connectionState; - } - } - - statusText = client.syncAddress + " " + statusText; - guiGraphics.drawString(font, statusText, left, msgY, statusColor); - msgY += 10; - } - } catch (Throwable e) { - e.printStackTrace(); - } - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java new file mode 100644 index 00000000..fca4acd6 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/config/gui/SyncConnectionsGui.java @@ -0,0 +1,179 @@ +package gjum.minecraft.mapsync.mod.config.gui; + +import gjum.minecraft.mapsync.mod.sync.DimensionState; +import gjum.minecraft.mapsync.mod.net.SyncClient; +import gjum.minecraft.mapsync.mod.sync.GameContext; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Stream; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.components.EditBox; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.CommonComponents; +import net.minecraft.network.chat.Component; +import org.apache.commons.lang3.StringUtils; +import org.jetbrains.annotations.NotNull; + +public final class SyncConnectionsGui extends Screen { + private final Screen parentScreen; + private final GameContext gameContext; + private String addressFieldValue; + + public SyncConnectionsGui( + final Screen parentScreen, + final @NotNull GameContext gameContext + ) { + super(Component.literal("MapSync")); + this.parentScreen = parentScreen; + this.gameContext = Objects.requireNonNull(gameContext); + this.addressFieldValue = String.join(",", gameContext.getGameConfig().getSyncServerAddresses()); + } + + private volatile int offsetTop; + private volatile int offsetLeft; + + @Override + protected void init() { + final int innerWidth = 300; + this.offsetLeft = this.width / 2 - innerWidth / 2; + final int offsetRight = this.width / 2 + innerWidth / 2; + this.offsetTop = this.height / 3; + + this.addRenderableWidget( + Button.builder(Component.literal("Close"), (button) -> this.minecraft.setScreen(this.parentScreen)) + .pos(offsetRight - 100, this.offsetTop) + .width(100) + .build() + ); + + final EditBox addressField = this.addRenderableWidget(new EditBox( + this.font, + this.offsetLeft, + this.offsetTop + 40, + innerWidth - 110, + 20, + Component.literal("Sync Server Addresses") + )); + addressField.setValue(this.addressFieldValue); + addressField.setResponder((value) -> this.addressFieldValue = value); + + this.addRenderableWidget( + Button + .builder( + Component.literal("Connect"), + (button) -> { + final List syncAddresses = Stream.of(StringUtils.split(this.addressFieldValue, ',')) + .filter(StringUtils::isNotBlank) + .distinct() + .toList(); + this.gameContext.getGameConfig().setSyncServerAddresses(syncAddresses); + this.gameContext.getSyncConnections().setAll(Set.copyOf(syncAddresses)); + } + ) + .pos(offsetRight - 100, this.offsetTop + 40) + .width(100) + .build() + ); + + this.addRenderableWidget( + Button + .builder( + CommonComponents.GUI_DISCONNECT, + (button) -> this.gameContext.getSyncConnections().closeAll(true) + ) + .pos(offsetRight - 100, this.offsetTop + 65) + .width(100) + .build() + ); + + this.addRenderableWidget( + Button + .builder( + Component.literal("Purge"), + (button) -> this.gameContext.getDimensionState().ifPresent(DimensionState::PurgeRegionTimeStamps) + ) + .pos(10, this.height - 30) + .width(60) + .build() + ); + + addRenderableWidget( + Button + .builder( + Component.literal("Close"), + (button) -> this.onClose() + ) + .pos((this.width / 2) - (100 / 2), this.height - 30) + .width(100) + .build() + ); + } + + @Override + public void render( + final @NotNull GuiGraphics guiGraphics, + final int mouseX, + final int mouseY, + final float partialTick + ) { + super.render(guiGraphics, mouseX, mouseY, partialTick); + + int top = this.offsetTop; + guiGraphics.drawCenteredString(this.font, this.title, this.width / 2, top, 0xFF_FF_FF); + + top += 70; + if (this.gameContext.getDimensionState().orElse(null) instanceof final DimensionState dimensionState) { + guiGraphics.drawString( + this.font, + "In dimension %s, received %d chunks, rendered %d, rendering %d".formatted( + dimensionState.dimension.identifier(), + dimensionState.getNumChunksReceived(), + dimensionState.getNumChunksRendered(), + dimensionState.getRenderQueueSize() + ), + this.offsetLeft, + top, + 0x88_88_88 + ); + top += 20; + } + + for (final SyncClient client : this.gameContext.getSyncConnections()) { + String statusText = client.syncAddress; + final int statusColor; + var connectionState = client.state(); + switch (connectionState) { + case DISCONNECTED -> { + statusColor = 0xFFff8888; + statusText += " Disconnected"; + } + case CONNECTED -> { + statusColor = 0xFF8888ff; + statusText += " Connected (not authed)"; + } + case WELCOMED -> { + statusColor = 0xFF88ff88; + statusText += " Connected and authed"; + } + default -> { + statusColor = 0xFFFFFF00; + statusText += " Unknown state: " + connectionState; + } + } + guiGraphics.drawString(this.font, statusText, this.offsetLeft, top, statusColor); + top += 10; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } + + @Override + public void onClose() { + this.minecraft.setScreen(this.parentScreen); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/modmenu/ModMenuIntegration.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/modmenu/ModMenuIntegration.java index c21f01e1..6508b5cf 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/modmenu/ModMenuIntegration.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/integrations/modmenu/ModMenuIntegration.java @@ -2,14 +2,18 @@ import com.terraformersmc.modmenu.api.ConfigScreenFactory; import com.terraformersmc.modmenu.api.ModMenuApi; -import gjum.minecraft.mapsync.mod.ModGui; +import gjum.minecraft.mapsync.mod.config.gui.SyncConnectionsGui; +import gjum.minecraft.mapsync.mod.sync.GameContext; +import net.minecraft.client.gui.screens.Screen; import org.jetbrains.annotations.NotNull; /** * Adds support for https://github.com/TerraformersMC/ModMenu (Fabric only) */ public class ModMenuIntegration implements ModMenuApi { - public @NotNull ConfigScreenFactory getModConfigScreenFactory() { - return ModGui::new; + public @NotNull ConfigScreenFactory getModConfigScreenFactory() { + return (previousScreen) -> GameContext.get() + .map((gameContext) -> new SyncConnectionsGui(previousScreen, gameContext)) + .orElse(null); } } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java index 04f3865d..f8d5eae4 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/mixins/MixinClientPacketListener.java @@ -8,8 +8,6 @@ import net.minecraft.core.BlockPos; import net.minecraft.network.protocol.game.ClientboundBlockDestructionPacket; import net.minecraft.network.protocol.game.ClientboundBlockUpdatePacket; -import net.minecraft.network.protocol.game.ClientboundLevelChunkWithLightPacket; -import net.minecraft.network.protocol.game.ClientboundRespawnPacket; import org.spongepowered.asm.mixin.Mixin; import org.spongepowered.asm.mixin.injection.At; import org.spongepowered.asm.mixin.injection.Inject; @@ -17,28 +15,6 @@ @Mixin(ClientPacketListener.class) public abstract class MixinClientPacketListener { - - @Inject(method = "handleRespawn", at = @At("RETURN")) - protected void onHandleRespawn(ClientboundRespawnPacket packet, CallbackInfo ci) { - if (!Minecraft.getInstance().isSameThread()) return; // will be called again on mc thread in a moment - try { - getMod().handleRespawn(packet); - } catch (Throwable e) { - printErrorRateLimited(e); - } - } - - @Inject(method = "handleLevelChunkWithLight", at = @At("RETURN")) - protected void onHandleLevelChunkWithLight(ClientboundLevelChunkWithLightPacket packet, CallbackInfo ci) { - if (!Minecraft.getInstance().isSameThread()) return; // will be called again on mc thread in a moment - try { - getMod().handleMcFullChunk(packet.getX(), packet.getZ()); - } catch (Throwable e) { - printErrorRateLimited(e); - } - } - - @Inject(method = "handleBlockUpdate", at = @At("RETURN")) protected void onHandleBlockUpdate(ClientboundBlockUpdatePacket packet, CallbackInfo ci) { if (!Minecraft.getInstance().isSameThread()) return; // will be called again on mc thread in a moment diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java index f6914dfb..cc345f46 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClient.java @@ -2,7 +2,6 @@ import gjum.minecraft.mapsync.mod.MapSyncMod; import gjum.minecraft.mapsync.mod.data.ChunkTile; -import gjum.minecraft.mapsync.mod.data.GameAddress; import gjum.minecraft.mapsync.mod.deps.websockets.client.WebSocketClient; import gjum.minecraft.mapsync.mod.deps.websockets.drafts.Draft; import gjum.minecraft.mapsync.mod.deps.websockets.drafts.Draft_6455; @@ -13,6 +12,7 @@ import gjum.minecraft.mapsync.mod.net.buffers.BufferReader; import gjum.minecraft.mapsync.mod.net.buffers.BufferWriter; import gjum.minecraft.mapsync.mod.net.packet.ChunkTilePacket; +import gjum.minecraft.mapsync.mod.sync.GameContext; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.URI; @@ -62,9 +62,9 @@ public synchronized void setServerKnownChunkHash(ChunkPos chunkPos, byte[] hash) private static final AtomicLong LAST_CLIENT_ID = new AtomicLong(0L); private static final int MAX_PAYLOAD_LENGTH = (1 << Short.SIZE) - 1; + public final GameContext gameContext; public final long clientId; public final String syncAddress; - public final GameAddress gameAddress; /// false = don't auto-reconnect but maintain connection as long as it stays up. /// can be set to true again later. @@ -74,12 +74,12 @@ public synchronized void setServerKnownChunkHash(ChunkPos chunkPos, byte[] hash) public final AuthStateHolder authState = new AuthStateHolder(); public SyncClient( - final @NotNull String syncAddress, - final @NotNull GameAddress gameAddress + final @NotNull GameContext gameContext, + final @NotNull String syncAddress ) { this.clientId = LAST_CLIENT_ID.incrementAndGet(); + this.gameContext = Objects.requireNonNull(gameContext); this.syncAddress = Objects.requireNonNull(syncAddress); - this.gameAddress = Objects.requireNonNull(gameAddress); this.websocket = new WsClient(URI.create(syncAddress)); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java deleted file mode 100644 index 4a48a11a..00000000 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/SyncClients.java +++ /dev/null @@ -1,154 +0,0 @@ -package gjum.minecraft.mapsync.mod.net; - -import gjum.minecraft.mapsync.mod.MapSyncMod; -import gjum.minecraft.mapsync.mod.config.ServerConfig; -import gjum.minecraft.mapsync.mod.data.GameAddress; -import java.lang.invoke.MethodHandles; -import java.lang.invoke.VarHandle; -import java.util.HashSet; -import java.util.Iterator; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; -import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; -import net.minecraft.client.multiplayer.ServerData; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; -import org.jspecify.annotations.NonNull; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public final class SyncClients implements Iterable { - private static final Logger LOGGER = LoggerFactory.getLogger(SyncClients.class); - - public final GameAddress gameAddress; - public final ServerConfig serverConfig; - private final Map clients = new ConcurrentHashMap<>(); - - public SyncClients( - final @NotNull GameAddress gameAddress, - final @NotNull ServerConfig serverConfig - ) { - this.gameAddress = Objects.requireNonNull(gameAddress); - this.serverConfig = Objects.requireNonNull(serverConfig); - } - - @Override - public @NonNull Iterator iterator() { - return this.clients.values().iterator(); - } - - public void setAll( - final @NotNull Set<@NotNull String> syncAddresses - ) { - final var syncAddressesCopy = Set.copyOf(syncAddresses); - this.clients.values().removeIf((syncClient) -> { - if (syncAddressesCopy.contains(syncClient.syncAddress)) { - MapSyncMod.debugLog("Closing sync client as %s is not contained within %s".formatted( - syncClient.syncAddress, - syncAddressesCopy - )); - return false; - } - syncClient.shouldReconnect = false; - syncClient.websocket.close(); - return true; - }); - for (final String syncAddress : syncAddressesCopy) { - this.clients.compute(syncAddress, this::computeClient); - } - } - - private @Nullable SyncClient computeClient( - final @NotNull String syncAddress, - SyncClient syncClient - ) { - if (syncClient != null) { - if (!Objects.equals(this.gameAddress, syncClient.gameAddress)) { - MapSyncMod.debugLog("Closing client %s as it doesn't match game address %s".formatted( - syncClient.name(), - this.gameAddress - )); - syncClient.websocket.close(); - return null; - } - switch (syncClient.websocket.getReadyState()) { - case NOT_YET_CONNECTED: - syncClient.websocket.connect(); - // fallthrough - case OPEN: - return syncClient; - case CLOSING: - case CLOSED: - if (!syncClient.shouldReconnect) { - return null; - } - syncClient.websocket.reconnect(); - return syncClient; - } - } - syncClient = new SyncClient(syncAddress, this.gameAddress); - syncClient.websocket.connect(); - return syncClient; - } - - public void closeAll( - final boolean preventReconnect - ) { - MapSyncMod.debugLog("Closing all sync clients (preventReconnect=%s)".formatted( - preventReconnect - )); - this.clients.values().removeIf((syncClient) -> { - if (preventReconnect) { - syncClient.shouldReconnect = false; - } - syncClient.websocket.close(); - return true; - }); - } - - // ============================================================ - // Event Hooks - // ============================================================ - - private static volatile SyncClients instance = null; - private static final VarHandle INSTANCE; static { - try { - INSTANCE = MethodHandles.lookup().findStaticVarHandle(SyncClients.class, "instance", SyncClients.class); - } - catch (final ReflectiveOperationException e) { - throw new ExceptionInInitializerError(e); - } - } - public static Optional get() { - return Optional.ofNullable(instance); - } - - public static void initEvents() { - ClientPlayConnectionEvents.JOIN.register((gameConnection, sender, minecraft) -> { - if (INSTANCE.getAndSet((Object) null) instanceof final SyncClients syncClients) { - syncClients.closeAll(true); - } - if (!(gameConnection.getServerData() instanceof final ServerData serverData)) { - LOGGER.error("Connection doesn't have server data yet... backing out"); - return; - } - final GameAddress gameAddress = new GameAddress(serverData.ip); - final ServerConfig serverConfig; - try { - serverConfig = ServerConfig.load(gameAddress); - } - catch (final Exception e) { - LOGGER.error("Could not load server config for {}... backing out", gameAddress, e); - return; - } - final SyncClients syncClients = instance = new SyncClients( - gameAddress, - serverConfig - ); - syncClients.setAll(new HashSet<>(serverConfig.getSyncServerAddresses())); - }); - } -} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java index 64e17182..6c3514a4 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/net/auth/AuthProcess.java @@ -1,6 +1,6 @@ package gjum.minecraft.mapsync.mod.net.auth; -import gjum.minecraft.mapsync.mod.DimensionState; +import gjum.minecraft.mapsync.mod.sync.DimensionState; import gjum.minecraft.mapsync.mod.net.SyncClient; import gjum.minecraft.mapsync.mod.net.UnexpectedPacketException; import gjum.minecraft.mapsync.mod.net.packet.ClientboundIdentityRequestPacket; @@ -19,9 +19,9 @@ public final class AuthProcess { private record AwaitingIdentityRequest() implements AuthState {} public static void sendHandshake( - final @NotNull SyncClient client, - final DimensionState dimensionState + final @NotNull SyncClient client ) throws Exception { + final DimensionState dimensionState = client.gameContext.getDimensionState().orElse(null); if (dimensionState == null) { throw new IllegalStateException("no dimension state"); } @@ -30,7 +30,7 @@ public static void sendHandshake( } client.send(new ServerboundHandshakePacket( MagicValues.VERSION, - client.gameAddress, + client.gameContext.getGameAddress(), dimensionState.dimension.identifier().toString() )); } diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/Cartography.java similarity index 88% rename from mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java rename to mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/Cartography.java index 6c56e659..d5d5a8ee 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/Cartography.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/Cartography.java @@ -1,4 +1,4 @@ -package gjum.minecraft.mapsync.mod; +package gjum.minecraft.mapsync.mod.sync; import gjum.minecraft.mapsync.mod.data.BlockColumn; import gjum.minecraft.mapsync.mod.data.BlockInfo; @@ -10,6 +10,7 @@ import java.util.ArrayList; import net.minecraft.client.Minecraft; import net.minecraft.core.BlockPos; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; import net.minecraft.world.level.LightLayer; import net.minecraft.world.level.chunk.LevelChunk; @@ -17,10 +18,9 @@ import org.apache.commons.lang3.function.Failable; public class Cartography { - public static ChunkTile chunkTileFromLevel(Level level, int cx, int cz) { + public static ChunkTile chunkTileFromLevel(Level level, LevelChunk chunk) { long timestamp = System.currentTimeMillis(); var dimension = level.dimension(); - var chunk = level.getChunk(cx, cz); var columns = new BlockColumn[256]; var pos = new BlockPos.MutableBlockPos(0, 0, 0); @@ -42,7 +42,8 @@ public static ChunkTile chunkTileFromLevel(Level level, int cx, int cz) { dataHash = md.digest(); } - return new ChunkTile(dimension, cx, cz, timestamp, dataVersion, dataHash, columns); + final ChunkPos chunkPos = chunk.getPos(); + return new ChunkTile(dimension, chunkPos.x, chunkPos.z, timestamp, dataVersion, dataHash, columns); } public static BlockColumn blockColumnFromChunk(LevelChunk chunk, BlockPos.MutableBlockPos pos) { diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/CatchupLogic.java similarity index 98% rename from mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java rename to mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/CatchupLogic.java index c2fcb703..2425ebb1 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/CatchupLogic.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/CatchupLogic.java @@ -1,9 +1,10 @@ -package gjum.minecraft.mapsync.mod; +package gjum.minecraft.mapsync.mod.sync; import static gjum.minecraft.mapsync.mod.MapSyncMod.debugLog; import static gjum.minecraft.mapsync.mod.MapSyncMod.getMod; import static gjum.minecraft.mapsync.mod.MapSyncMod.logger; +import gjum.minecraft.mapsync.mod.MapSyncMod; import gjum.minecraft.mapsync.mod.data.CatchupChunk; import gjum.minecraft.mapsync.mod.data.ChunkTile; import java.util.ArrayList; diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionChunkMeta.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java similarity index 97% rename from mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionChunkMeta.java rename to mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java index 16488d50..a1006836 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionChunkMeta.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionChunkMeta.java @@ -1,5 +1,6 @@ -package gjum.minecraft.mapsync.mod; +package gjum.minecraft.mapsync.mod.sync; +import gjum.minecraft.mapsync.mod.MapSyncMod; import gjum.minecraft.mapsync.mod.data.GameAddress; import gjum.minecraft.mapsync.mod.data.RegionPos; import java.io.FileNotFoundException; diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionState.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java similarity index 98% rename from mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionState.java rename to mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java index 5d35841c..f4d0b8d2 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/DimensionState.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/DimensionState.java @@ -1,4 +1,4 @@ -package gjum.minecraft.mapsync.mod; +package gjum.minecraft.mapsync.mod.sync; import static gjum.minecraft.mapsync.mod.MapSyncMod.debugLog; diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/GameContext.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/GameContext.java new file mode 100644 index 00000000..9f41819a --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/GameContext.java @@ -0,0 +1,148 @@ +package gjum.minecraft.mapsync.mod.sync; + +import gjum.minecraft.mapsync.mod.MapSyncMod; +import gjum.minecraft.mapsync.mod.config.ServerConfig; +import gjum.minecraft.mapsync.mod.data.GameAddress; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.util.Objects; +import java.util.Optional; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientWorldEvents; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.minecraft.client.multiplayer.ServerData; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class GameContext { + private static final Logger LOGGER = LoggerFactory.getLogger(GameContext.class); + + private final GameAddress gameAddress; + private final ServerConfig gameConfig; + private final SyncConnections syncConnections; + + private GameContext( + final @NotNull GameAddress gameAddress, + final @NotNull ServerConfig gameConfig + ) { + this.gameAddress = Objects.requireNonNull(gameAddress); + this.gameConfig = Objects.requireNonNull(gameConfig); + this.syncConnections = new SyncConnections(this); + } + + public void shutdown() { + this.syncConnections.closeAll(true); + if (DIMENSION_STATE.getAndSet(this, null) instanceof final DimensionState dimensionState) { + dimensionState.shutDown(); + } + } + + public @NotNull GameAddress getGameAddress() { + return this.gameAddress; + } + + public @NotNull ServerConfig getGameConfig() { + return this.gameConfig; + } + + public @NotNull SyncConnections getSyncConnections() { + return this.syncConnections; + } + + // ============================================================ + // Dimension State + // ============================================================ + + private volatile DimensionState dimensionState = null; + private static final VarHandle DIMENSION_STATE; static { + try { + DIMENSION_STATE = MethodHandles.lookup().findVarHandle(GameContext.class, "dimensionState", DimensionState.class); + } + catch (final ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + public Optional getDimensionState() { + return Optional.ofNullable(this.dimensionState); + } + + // ============================================================ + // Event Hooks + // ============================================================ + + private static volatile GameContext instance = null; + private static final VarHandle INSTANCE; static { + try { + INSTANCE = MethodHandles.lookup().findStaticVarHandle(GameContext.class, "instance", GameContext.class); + } + catch (final ReflectiveOperationException e) { + throw new ExceptionInInitializerError(e); + } + } + public static Optional get() { + return Optional.ofNullable(instance); + } + + public static void initEvents() { + ClientPlayConnectionEvents.INIT.register((gameConnection, minecraft) -> { + GameContext gameContext = null; + try { + if (!(gameConnection.getServerData() instanceof final ServerData serverData)) { + LOGGER.error("Connection doesn't have server data yet... backing out"); + return; + } + final GameAddress gameAddress; { + final String ip = serverData.ip; + try { + gameAddress = new GameAddress(ip); + } + catch (final Exception e) { + LOGGER.error("Weirdly could not parse {} as a valid game address... backing out", ip, e); + return; + } + } + final ServerConfig gameConfig; + try { + gameConfig = ServerConfig.load(gameAddress); + } + catch (final Exception e) { + LOGGER.error("Could not load game config for {}... backing out", gameAddress, e); + return; + } + gameContext = new GameContext( + gameAddress, + gameConfig + ); + } + finally { + if (INSTANCE.getAndSet(gameContext) instanceof final GameContext previous) { + previous.shutdown(); + } + } + }); + ClientPlayConnectionEvents.DISCONNECT.register((gameConnection, minecraft) -> { + if (INSTANCE.getAndSet((Object) null) instanceof final GameContext context) { + context.shutdown(); + } + }); + ClientLifecycleEvents.CLIENT_STOPPING.register((minecraft) -> { + if (INSTANCE.getAndSet((Object) null) instanceof final GameContext context) { + context.shutdown(); + } + }); + ClientWorldEvents.AFTER_CLIENT_WORLD_CHANGE.register((minecraft, level) -> { + if (!(instance instanceof final GameContext gameContext)) { + return; + } + final var dimensionState = new DimensionState( + gameContext.getGameAddress(), + level.dimension() + ); + if (DIMENSION_STATE.getAndSet(gameContext, dimensionState) instanceof final DimensionState previous) { + previous.shutDown(); + } + MapSyncMod.getMod().handleDimensionChange(minecraft, level, gameContext); + }); + } +} diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/RenderQueue.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/RenderQueue.java similarity index 98% rename from mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/RenderQueue.java rename to mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/RenderQueue.java index 1325eef9..83b63b47 100644 --- a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/RenderQueue.java +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/RenderQueue.java @@ -1,4 +1,4 @@ -package gjum.minecraft.mapsync.mod; +package gjum.minecraft.mapsync.mod.sync; import static gjum.minecraft.mapsync.mod.MapSyncMod.debugLog; diff --git a/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/SyncConnections.java b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/SyncConnections.java new file mode 100644 index 00000000..e28ff199 --- /dev/null +++ b/mapsync-mod/src/main/java/gjum/minecraft/mapsync/mod/sync/SyncConnections.java @@ -0,0 +1,90 @@ +package gjum.minecraft.mapsync.mod.sync; + +import gjum.minecraft.mapsync.mod.MapSyncMod; +import gjum.minecraft.mapsync.mod.net.SyncClient; +import java.util.Iterator; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NonNull; + +public final class SyncConnections implements Iterable { + public final GameContext gameContext; + private final Map clients; + + SyncConnections( + final @NotNull GameContext gameContext + ) { + this.gameContext = Objects.requireNonNull(gameContext); + this.clients = new ConcurrentHashMap<>(); + } + + @Override + public @NonNull Iterator iterator() { + return this.clients.values().iterator(); + } + + public void setAll( + final @NotNull Set<@NotNull String> syncAddresses + ) { + final var syncAddressesCopy = Set.copyOf(syncAddresses); + this.clients.values().removeIf((syncClient) -> { + if (syncAddressesCopy.contains(syncClient.syncAddress)) { + MapSyncMod.debugLog("Closing sync client as %s is not contained within %s".formatted( + syncClient.syncAddress, + syncAddressesCopy + )); + return false; + } + syncClient.shouldReconnect = false; + syncClient.websocket.close(); + return true; + }); + for (final String syncAddress : syncAddressesCopy) { + this.clients.compute(syncAddress, this::computeClient); + } + } + + private @Nullable SyncClient computeClient( + final @NotNull String syncAddress, + SyncClient syncClient + ) { + if (syncClient != null) { + switch (syncClient.websocket.getReadyState()) { + case NOT_YET_CONNECTED: + syncClient.websocket.connect(); + // fallthrough + case OPEN: + return syncClient; + case CLOSING: + case CLOSED: + if (!syncClient.shouldReconnect) { + return null; + } + syncClient.websocket.reconnect(); + return syncClient; + } + } + syncClient = new SyncClient(this.gameContext, syncAddress); + syncClient.websocket.connect(); + return syncClient; + } + + public void closeAll( + final boolean preventReconnect + ) { + MapSyncMod.debugLog("Closing all sync clients (preventReconnect=%s)".formatted( + preventReconnect + )); + this.clients.values().removeIf((syncClient) -> { + if (preventReconnect) { + syncClient.shouldReconnect = false; + } + syncClient.websocket.close(); + return true; + }); + } +}