diff --git a/.gitignore b/.gitignore index 7a12682357f..33539e8304f 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,9 @@ GEMINI.md # JFR /.profileconfig.json + +# Web +*.db +*.db-shm +*.db-wal +/assets diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index f8d161ef6c6..b3ee61eb68e 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -4,6 +4,7 @@ plugins { dependencies { implementation(rootProject) + implementation(project(":web")) runtimeOnly(libs.bundles.logback) } @@ -13,4 +14,12 @@ application { mainModule.set("net.minestom.demo") applicationDefaultJvmArgs += "-ea" + + // Javalin / its Jetty deps are automatic modules with no explicit requires from anyone; + // ALL-MODULE-PATH makes the JVM resolve every module on the module path so they load. + applicationDefaultJvmArgs = listOf("--add-modules", "ALL-MODULE-PATH") +} + +tasks.named("run") { + jvmArgs("--add-modules", "ALL-MODULE-PATH") } diff --git a/demo/src/main/java/module-info.java b/demo/src/main/java/module-info.java index 55e9b48c676..5b9e11c3e26 100644 --- a/demo/src/main/java/module-info.java +++ b/demo/src/main/java/module-info.java @@ -1,3 +1,6 @@ module net.minestom.demo { requires net.minestom.server; + requires net.minestom.web; + requires java.management; + requires jdk.management; } \ No newline at end of file diff --git a/demo/src/main/java/net/minestom/demo/Main.java b/demo/src/main/java/net/minestom/demo/Main.java index 5c83d1cdc93..05ad1ee1eae 100644 --- a/demo/src/main/java/net/minestom/demo/Main.java +++ b/demo/src/main/java/net/minestom/demo/Main.java @@ -39,6 +39,7 @@ public class Main { static void main(String[] args) { + System.setProperty("minestom.registry.unsafe-ops", "true"); // TEMP for proxy System.setProperty("minestom.new-socket-write-lock", "true"); System.setProperty("minestom.registry.unsafe-ops", "true"); MinecraftServer.setCompressionThreshold(0); @@ -174,7 +175,10 @@ static void main(String[] args) { // useful for testing - we don't need to worry about event calls so just set this to a long time OpenToLAN.open(new OpenToLANConfig().eventCallDelay(Duration.of(1, TimeUnit.DAY))); - minecraftServer.start("0.0.0.0", 25565); + // Optional web dashboard. When enabled the proxy holds the public port and forwards to + // the server below; when disabled the server binds the public port directly. + WebInterface.register(); + minecraftServer.start(WebInterface.bindHost(), WebInterface.bindPort()); // minecraftServer.start(java.net.UnixDomainSocketAddress.of("minestom-demo.sock")); //Runtime.getRuntime().addShutdownHook(new Thread(MinecraftServer::stopCleanly)); } diff --git a/demo/src/main/java/net/minestom/demo/WebInterface.java b/demo/src/main/java/net/minestom/demo/WebInterface.java new file mode 100644 index 00000000000..67e01b708bd --- /dev/null +++ b/demo/src/main/java/net/minestom/demo/WebInterface.java @@ -0,0 +1,208 @@ +package net.minestom.demo; + +import com.sun.management.OperatingSystemMXBean; +import net.kyori.adventure.nbt.BinaryTagIO; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.minestom.server.MinecraftServer; +import net.minestom.server.adventure.audience.Audiences; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.event.server.ServerTickMonitorEvent; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.web.ControlBridge; +import net.minestom.web.ControlPacket; +import net.minestom.web.ProxyConfig; +import net.minestom.web.ProxyServer; + +import java.io.ByteArrayOutputStream; +import java.io.OutputStream; +import java.io.PrintStream; +import java.lang.management.ManagementFactory; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; + +/// Demo wiring for the web interface. The proxy holds the public Minecraft port and forwards +/// to an upstream Minestom server bound to a loopback port; the dashboard binds to +/// `MINESTOM_WEB_DASHBOARD_PORT` (default 8080). The control bridge carries console lines, +/// 1 Hz JVM/tick metrics, and global NBT into the dashboard. +public final class WebInterface { + + private static final boolean ENABLED = Boolean.parseBoolean( + System.getenv().getOrDefault("MINESTOM_WEB_INTERFACE", "true")); + + public static String bindHost() { + return ENABLED ? "127.0.0.1" : "0.0.0.0"; + } + + public static int bindPort() { + return ENABLED ? env("MINESTOM_WEB_UPSTREAM_PORT", 25566) : 25565; + } + + public static void register() { + if (!ENABLED) return; + final int proxy = env("MINESTOM_WEB_PROXY_PORT", 25565); + final int dashboard = env("MINESTOM_WEB_DASHBOARD_PORT", 8080); + + final ProxyServer web = ProxyServer.builder() + .bindProxy(new InetSocketAddress("0.0.0.0", proxy)) + .defaultBackend(new InetSocketAddress("127.0.0.1", bindPort())) + .bindDashboard(new InetSocketAddress("127.0.0.1", dashboard)) + .token(System.getenv("MINESTOM_WEB_TOKEN")) + .build(); + web.start(); + Runtime.getRuntime().addShutdownHook(new Thread(web::close, "Minestom-Web-Shutdown")); + + final ControlBridge bridge = web.control(); + bridge.setOnOutbound(WebInterface::handleOutbound); + teeConsole(bridge); + schedulePumps(bridge); + + System.out.printf("[web] proxy on 0.0.0.0:%d → 127.0.0.1:%d · dashboard http://127.0.0.1:%d/%n", + proxy, bindPort(), dashboard); + } + + /// Run dashboard-initiated packets on the tick thread so handlers see the same threading + /// guarantees as a player-typed command. + private static void handleOutbound(ControlPacket packet) { + MinecraftServer.getSchedulerManager().scheduleNextTick(() -> { + final var cm = MinecraftServer.getConnectionManager(); + switch (packet) { + case ControlPacket.Command(String c) -> { + final var commands = MinecraftServer.getCommandManager(); + commands.execute(commands.getConsoleSender(), c.startsWith("/") ? c.substring(1) : c); + } + case ControlPacket.Broadcast(Component m) -> Audiences.players().sendMessage(m); + case ControlPacket.Kick(UUID id, String reason) -> { + final var p = cm.getOnlinePlayerByUuid(id); + if (p != null) p.kick(Component.text(reason)); + } + default -> { + } + } + }); + } + + /// Tee stdout/stderr into ConsoleLine packets so dashboard subscribers see SLF4J output. + /// Logback resolves the underlying stream per-write, so swapping in after init still works. + private static void teeConsole(ControlBridge bridge) { + System.setOut(linePump(System.out, bridge, "INFO")); + System.setErr(linePump(System.err, bridge, "ERROR")); + } + + private static PrintStream linePump(PrintStream original, ControlBridge bridge, String level) { + final ByteArrayOutputStream buf = new ByteArrayOutputStream(256); + final ThreadLocal reentrant = ThreadLocal.withInitial(() -> false); + return new PrintStream(new OutputStream() { + @Override + public synchronized void write(int b) { + original.write(b); + capture(b); + } + + @Override + public synchronized void write(byte[] b, int off, int len) { + original.write(b, off, len); + for (int i = 0; i < len; i++) capture(b[off + i] & 0xFF); + } + + @Override + public void flush() { + original.flush(); + } + + private void capture(int b) { + if (b == '\n') flushLine(); + else if (b != '\r') buf.write(b); + } + + private void flushLine() { + if (buf.size() == 0 || reentrant.get()) { + buf.reset(); + return; + } + final String msg = buf.toString(StandardCharsets.UTF_8); + buf.reset(); + reentrant.set(true); + try { + bridge.receive(new ControlPacket.ConsoleLine(System.currentTimeMillis(), level, msg)); + } catch (Throwable ignored) { + } finally { + reentrant.set(false); + } + } + }, true); + } + + private static void schedulePumps(ControlBridge bridge) { + final var os = (OperatingSystemMXBean) ManagementFactory.getOperatingSystemMXBean(); + final var runtime = ManagementFactory.getRuntimeMXBean(); + final var threads = ManagementFactory.getThreadMXBean(); + final var heap = ManagementFactory.getMemoryMXBean(); + final long maxMem = Runtime.getRuntime().maxMemory(); + final var scheduler = MinecraftServer.getSchedulerManager(); + final var connections = MinecraftServer.getConnectionManager(); + + final AtomicLong msptNanos = new AtomicLong(); + MinecraftServer.getGlobalEventHandler().addListener(ServerTickMonitorEvent.class, + e -> msptNanos.set((long) (e.getTickMonitor().getTickTime() * 1_000_000.0))); + + scheduler.submitTask(() -> { + final double mspt = msptNanos.get() / 1_000_000.0; + final double tps = mspt > 0 ? Math.min(MinecraftServer.TICK_PER_SECOND, 1000.0 / mspt) : MinecraftServer.TICK_PER_SECOND; + bridge.receive(new ControlPacket.Metrics( + System.currentTimeMillis(), + Math.max(0.0, os.getCpuLoad()), + heap.getHeapMemoryUsage().getUsed(), maxMem, + threads.getThreadCount(), runtime.getUptime(), + mspt, tps, + connections.getOnlinePlayers().size())); + return TaskSchedule.seconds(1); + }); + + scheduler.submitTask(() -> { + bridge.receive(new ControlPacket.ServerData(CompoundBinaryTag.builder() + .putString("event", "winter_celebration") + .putInt("season", 2) + .putInt("onlinePlayers", connections.getOnlinePlayers().size()) + .putLong("epochMs", System.currentTimeMillis()) + .build())); + return TaskSchedule.seconds(2); + }); + + // Per-player NBT on the reserved minestom:web/data channel — proxy intercepts it, the + // client never sees the packet but the dashboard sees the decoded NBT. + MinecraftServer.getGlobalEventHandler().addListener(PlayerSpawnEvent.class, event -> { + final var player = event.getPlayer(); + player.scheduler().submitTask(() -> { + if (!player.isOnline()) return TaskSchedule.stop(); + final CompoundBinaryTag data = CompoundBinaryTag.builder() + .putString("rank", (player.getUuid().hashCode() & 0xF) == 0 ? "vip" : "member") + .putInt("kills", (int) ((System.currentTimeMillis() / 1000) % 50)) + .putString("partyId", UUID.nameUUIDFromBytes(player.getUuid().toString().getBytes()).toString()) + .putLong("lastSeenMs", System.currentTimeMillis()) + .build(); + player.sendPluginMessage(ProxyConfig.DEFAULT_DATA_CHANNEL, encode(data)); + return TaskSchedule.seconds(1); + }); + }); + } + + private static byte[] encode(CompoundBinaryTag tag) { + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try { + BinaryTagIO.writer().write(tag, out, BinaryTagIO.Compression.NONE); + } catch (java.io.IOException e) { + throw new RuntimeException(e); + } + return out.toByteArray(); + } + + private static int env(String name, int def) { + return Integer.parseInt(System.getenv().getOrDefault(name, Integer.toString(def))); + } + + private WebInterface() { + } +} diff --git a/demo/src/main/resources/logback.xml b/demo/src/main/resources/logback.xml new file mode 100644 index 00000000000..4492439b285 --- /dev/null +++ b/demo/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -- %msg%n + + + + + + + + + + diff --git a/settings.gradle.kts b/settings.gradle.kts index 1b63de7fe1d..149fd8dd075 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,3 +9,4 @@ include("jmh-benchmarks") include("jcstress-tests") include("demo") +include("web") diff --git a/src/main/java/net/minestom/server/item/ItemStack.java b/src/main/java/net/minestom/server/item/ItemStack.java index 32f52661ccd..55010c8451c 100644 --- a/src/main/java/net/minestom/server/item/ItemStack.java +++ b/src/main/java/net/minestom/server/item/ItemStack.java @@ -321,6 +321,10 @@ static Hash of(ItemStack itemStack) { return ItemStackHashImpl.of(new RegistryTranscoder<>(Transcoder.CRC32_HASH, MinecraftServer.process()), itemStack); } + default ItemStack asItemStack() { + return ItemStack.AIR; + } + NetworkBuffer.Type NETWORK_TYPE = ItemStackHashImpl.NETWORK_TYPE; } diff --git a/src/main/java/net/minestom/server/item/ItemStackHashImpl.java b/src/main/java/net/minestom/server/item/ItemStackHashImpl.java index c76fc7c222f..1d4047d6b0a 100644 --- a/src/main/java/net/minestom/server/item/ItemStackHashImpl.java +++ b/src/main/java/net/minestom/server/item/ItemStackHashImpl.java @@ -75,5 +75,10 @@ record Item( addedComponents = Map.copyOf(addedComponents); removedComponents = Set.copyOf(removedComponents); } + + @Override + public ItemStack asItemStack() { + return ItemStack.of(material, amount); + } } } diff --git a/src/main/java/net/minestom/server/registry/DynamicRegistry.java b/src/main/java/net/minestom/server/registry/DynamicRegistry.java index 37cec02ccba..9d12a2882c6 100644 --- a/src/main/java/net/minestom/server/registry/DynamicRegistry.java +++ b/src/main/java/net/minestom/server/registry/DynamicRegistry.java @@ -7,6 +7,7 @@ import net.minestom.server.gamedata.DataPack; import net.minestom.server.item.enchant.Enchantment; import net.minestom.server.network.packet.server.SendablePacket; +import net.minestom.server.network.packet.server.configuration.RegistryDataPacket; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.Nullable; @@ -180,4 +181,7 @@ default RegistryKey register(String id, T object, DataPack pack) { @ApiStatus.Internal SendablePacket registryDataPacket(Registries registries, boolean excludeVanilla); + @ApiStatus.Internal + void applyRegistryDataPacket(Registries registries, RegistryDataPacket packet); + } diff --git a/src/main/java/net/minestom/server/registry/DynamicRegistryImpl.java b/src/main/java/net/minestom/server/registry/DynamicRegistryImpl.java index d32dd3d08a2..05e80917a70 100644 --- a/src/main/java/net/minestom/server/registry/DynamicRegistryImpl.java +++ b/src/main/java/net/minestom/server/registry/DynamicRegistryImpl.java @@ -48,7 +48,7 @@ final class DynamicRegistryImpl implements DynamicRegistry { private final Map, RegistryTagImpl.Backed> tags; private final Key key; - private final Codec codec; + private final @Nullable Codec codec; DynamicRegistryImpl(Key key, @Nullable Codec codec) { this.key = key; @@ -115,9 +115,8 @@ public Key key() { @Override public @Nullable RegistryKey getKey(Key key) { - if (!keyToValue.containsKey(key)) - return null; - return new RegistryKeyImpl<>(key); + final RegistryKey registryKey = new RegistryKeyImpl<>(key); + return keyToId.containsKey(registryKey) ? registryKey : null; } @Override @@ -246,6 +245,51 @@ public SendablePacket registryDataPacket(Registries registries, boolean excludeV return createRegistryDataPacket(registries, false); } + @Override + public void applyRegistryDataPacket(Registries registries, RegistryDataPacket packet) { + Check.argCondition(!key.asString().equals(packet.registryId()), + "Registry data packet {0} cannot be applied to registry {1}", packet.registryId(), key); + final Transcoder transcoder = codec != null ? new RegistryTranscoder<>(Transcoder.NBT, registries) : null; + synchronized (REGISTRY_LOCK) { + final Map previousValues = new HashMap<>(keyToValue); + final Map, DataPack> previousPacks = new HashMap<>(packById.size() * 2); + for (int i = 0; i < idToKey.size(); i++) { + previousPacks.put(idToKey.get(i), packById.get(i)); + } + + idToValue.clear(); + idToKey.clear(); + keyToId.clear(); + keyToValue.clear(); + valueToKey.clear(); + packById.clear(); + + final List entries = packet.entries(); + for (int id = 0; id < entries.size(); id++) { + final RegistryDataPacket.Entry entry = entries.get(id); + final RegistryKey registryKey = new RegistryKeyImpl<>(Key.key(entry.id())); + final T value = decodeRegistryDataValue(transcoder, entry, previousValues.get(registryKey.key())); + + idToKey.add(registryKey); + idToValue.add(value); + keyToId.put(registryKey, id); + if (value != null) { + keyToValue.put(registryKey.key(), value); + valueToKey.put(value, registryKey); + } + packById.add(previousPacks.get(registryKey)); + } + vanillaRegistryDataPacket.invalidate(); + } + } + + private @Nullable T decodeRegistryDataValue(@Nullable Transcoder transcoder, + RegistryDataPacket.Entry entry, @Nullable T fallback) { + if (transcoder == null || entry.data() == null) return fallback; + final Result result = codec.decode(transcoder, entry.data()); + return result instanceof Result.Ok(T value) ? value : fallback; + } + @Override public TagsPacket.Registry tagRegistry() { final List tagList = new ArrayList<>(tags.size()); diff --git a/src/main/java/net/minestom/server/registry/Registries.java b/src/main/java/net/minestom/server/registry/Registries.java index e580c041791..c07eb3d35d8 100644 --- a/src/main/java/net/minestom/server/registry/Registries.java +++ b/src/main/java/net/minestom/server/registry/Registries.java @@ -24,6 +24,7 @@ import net.minestom.server.message.ChatType; import net.minestom.server.network.packet.server.SendablePacket; import net.minestom.server.network.packet.server.common.TagsPacket; +import net.minestom.server.network.packet.server.configuration.RegistryDataPacket; import net.minestom.server.potion.PotionEffect; import net.minestom.server.world.DimensionType; import net.minestom.server.world.biome.Biome; @@ -51,6 +52,10 @@ static TagsPacket tagsPacket(Registries registries) { return RegistriesImpl.tagsPacket(registries); } + static void applyRegistryDataPacket(Registries registries, RegistryDataPacket packet) { + RegistriesImpl.applyRegistryDataPacket(registries, packet); + } + // Static registries // The name block conflicts with blockmanager :( diff --git a/src/main/java/net/minestom/server/registry/RegistriesImpl.java b/src/main/java/net/minestom/server/registry/RegistriesImpl.java index cd0aeae0991..699e2d8e958 100644 --- a/src/main/java/net/minestom/server/registry/RegistriesImpl.java +++ b/src/main/java/net/minestom/server/registry/RegistriesImpl.java @@ -2,6 +2,7 @@ import net.minestom.server.network.packet.server.SendablePacket; import net.minestom.server.network.packet.server.common.TagsPacket; +import net.minestom.server.network.packet.server.configuration.RegistryDataPacket; import java.util.ArrayList; import java.util.List; @@ -26,6 +27,15 @@ static TagsPacket tagsPacket(Registries registries) { return new TagsPacket(entries); } + static void applyRegistryDataPacket(Registries registries, RegistryDataPacket packet) { + for (DynamicRegistry registry : configurationRegistries(registries)) { + if (registry.key().asString().equals(packet.registryId())) { + registry.applyRegistryDataPacket(registries, packet); + return; + } + } + } + private static List> configurationRegistries(Registries registries) { return List.of( registries.chatType(), diff --git a/src/main/java/net/minestom/server/scoreboard/Sidebar.java b/src/main/java/net/minestom/server/scoreboard/Sidebar.java index a8f94d0819d..d77bc5bbeb8 100644 --- a/src/main/java/net/minestom/server/scoreboard/Sidebar.java +++ b/src/main/java/net/minestom/server/scoreboard/Sidebar.java @@ -574,7 +574,7 @@ public NumberFormat copyWithOperator(UnaryOperator operator) { ); } - private enum FormatType { + public enum FormatType { BLANK, STYLED, FIXED } } diff --git a/src/main/java/net/minestom/server/utils/mojang/MojangUtils.java b/src/main/java/net/minestom/server/utils/mojang/MojangUtils.java index c87ce6edc28..67ee01c2b59 100644 --- a/src/main/java/net/minestom/server/utils/mojang/MojangUtils.java +++ b/src/main/java/net/minestom/server/utils/mojang/MojangUtils.java @@ -9,9 +9,12 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.io.OutputStream; +import java.net.HttpURLConnection; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.SocketAddress; +import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.UUID; @@ -27,6 +30,7 @@ public final class MojangUtils { // Auth private static final String BASE_AUTH_URL = ServerFlag.AUTH_URL.concat("?username=%s&serverId=%s"); private static final String PREVENT_PROXY_CONNECTIONS_AUTH_URL = BASE_AUTH_URL.concat("&ip=%s"); + private static final String JOIN_SESSION_URL = "https://sessionserver.mojang.com/session/minecraft/join"; private static final Pattern USERNAME_PATTERN = Pattern.compile("[a-zA-Z0-9_]{3,16}"); @@ -140,6 +144,39 @@ private static String validateUsername(String username) throws IOException { return username; } + /** + * Client-side counterpart to {@link #authenticateSession}: announces to Mojang that the + * holder of {@code accessToken} is about to join a server with the given {@code serverId} + * hash. After this call returns, the server can call {@code hasJoined} for the same + * {@code serverId} and Mojang will return the player's profile. + * + * @param accessToken the minecraftservices access_token (NOT the Microsoft token) + * @param selectedProfile the UUID associated with that access_token + * @param serverId the SHA-1 hex hash of {@code serverId ‖ sharedSecret ‖ serverPubKey} + * @throws IOException on transport failure or a non-204 response + */ + @Blocking + @ApiStatus.Internal + public static void joinSession(String accessToken, UUID selectedProfile, String serverId) throws IOException { + final String body = "{\"accessToken\":\"" + accessToken + + "\",\"selectedProfile\":\"" + selectedProfile.toString().replace("-", "") + + "\",\"serverId\":\"" + serverId + "\"}"; + final HttpURLConnection conn = (HttpURLConnection) URI.create(JOIN_SESSION_URL).toURL().openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(15_000); + conn.setReadTimeout(30_000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setDoOutput(true); + final byte[] payload = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(payload.length); + try (OutputStream out = conn.getOutputStream()) { out.write(payload); } + final int status = conn.getResponseCode(); + // 204 is documented; some Mojang deployments return 200. Anything else is a failure. + if (status != 204 && status != 200) { + throw new IOException("session join failed (HTTP " + status + ")"); + } + } + /** * Gets the JsonObject from a URL, expects a mojang player URL so the errors might not make sense if it is not * diff --git a/src/test/java/net/minestom/server/registry/RegistryIntegrationTest.java b/src/test/java/net/minestom/server/registry/RegistryIntegrationTest.java index c4b5e2735a7..488c2e9bdf8 100644 --- a/src/test/java/net/minestom/server/registry/RegistryIntegrationTest.java +++ b/src/test/java/net/minestom/server/registry/RegistryIntegrationTest.java @@ -2,11 +2,15 @@ import net.kyori.adventure.key.Key; import net.minestom.server.gamedata.DataPack; +import net.minestom.server.network.packet.server.configuration.RegistryDataPacket; import net.minestom.server.world.DimensionType; +import net.minestom.server.world.biome.Biome; import net.minestom.testing.Env; import net.minestom.testing.EnvTest; import org.junit.jupiter.api.Test; +import java.util.List; + import static org.junit.jupiter.api.Assertions.*; @@ -33,7 +37,32 @@ void testDifferentPacksInterlaced(Env env) { DimensionType dimensionType = DimensionType.builder() .ambientLight(2f) .build(); - assertDoesNotThrow(()-> dimensionRegistry.register(Key.key("toocool:fortests"), dimensionType, DataPack.MINESTOM_UNNAMED)); + assertDoesNotThrow(() -> dimensionRegistry.register(Key.key("toocool:fortests"), dimensionType, DataPack.MINESTOM_UNNAMED)); assertDoesNotThrow(() -> dimensionRegistry.register(Key.key("toocool:fortests2"), dimensionType, DataPack.MINECRAFT_CORE)); } + + @Test + void registryDataPacketReplacesWireOrder() { + Registries registries = Registries.vanilla(); + Registries.applyRegistryDataPacket(registries, new RegistryDataPacket("minecraft:worldgen/biome", List.of( + new RegistryDataPacket.Entry("example:first", null), + new RegistryDataPacket.Entry("minecraft:plains", null), + new RegistryDataPacket.Entry("example:last", null) + ))); + + Registry biomes = registries.biome(); + RegistryKey first = biomes.getKey(0); + RegistryKey plains = biomes.getKey(1); + RegistryKey last = biomes.getKey(2); + + assertNotNull(first); + assertNotNull(plains); + assertNotNull(last); + assertEquals("example:first", first.key().asString()); + assertEquals("minecraft:plains", plains.key().asString()); + assertEquals("example:last", last.key().asString()); + assertEquals(0, biomes.getId(first)); + assertEquals(1, biomes.getId(plains)); + assertEquals(2, biomes.getId(last)); + } } diff --git a/web/CLAUDE.md b/web/CLAUDE.md new file mode 100644 index 00000000000..ddb9c7c81fa --- /dev/null +++ b/web/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +This module (`:web`) is slated to move out of the Minestom monorepo. Treat it as an independent project — avoid coupling new code to anything in `../src` or `../demo` beyond the existing `api(rootProject)` boundary. The exported surface is the `net.minestom.web` package only (see `module-info.java`); `net.minestom.web.internal.*` is implementation detail and `net.minestom.web.cli.*` is the standalone CLI runner — neither is exported, both are free to change. + +## Build and run + +Gradle is invoked from the repo root via `./gradlew :web:`. Java 25 toolchain. + +``` +./gradlew :web:build # compile + frontend bundle + tests +./gradlew :web:run --args="…" # launch net.minestom.web.cli.Main with CLI flags (see cli/Main.java USAGE) +./gradlew :web:test # JUnit 5 +./gradlew :web:test --tests SessionRegistryTest # single class +./gradlew :web:test --tests SessionRegistryTest.someName # single method +./gradlew :web:buildFrontend # esbuild → src/main/resources/web/app.js +``` + +Frontend can be iterated standalone from `web/frontend/`: + +``` +npm ci # one-time +npm run watch # esbuild --watch, writes app.js into resources +npm run check # tsc --noEmit +``` + +The Gradle `processResources` step depends on `buildFrontend`, which depends on `installFrontend` (`npm ci`). The frontend bundle lands at `src/main/resources/web/app.js` and is served alongside the static `index.html`/`style.css`. Component-specific styling lives in each component's ` diff --git a/web/frontend/src/components/Sidebar.svelte b/web/frontend/src/components/Sidebar.svelte new file mode 100644 index 00000000000..01454302c4d --- /dev/null +++ b/web/frontend/src/components/Sidebar.svelte @@ -0,0 +1,240 @@ + + + + + + + diff --git a/web/frontend/src/components/editors/ActionEditor.svelte b/web/frontend/src/components/editors/ActionEditor.svelte new file mode 100644 index 00000000000..a239bbc37a7 --- /dev/null +++ b/web/frontend/src/components/editors/ActionEditor.svelte @@ -0,0 +1,213 @@ + + + + +
+
+ {#each KINDS as k (k.id)} + + {/each} +
+
{kind?.detail || ''}
+
+ {#if state.type === ActionType.inject} + +
+ Fields + ) || {}} + onChange={v => set({ ...state, fields: v })} + /> +
+ {:else if state.type === ActionType.chat} + {@const isJson = state.component != null && typeof state.component === 'object'} + + {:else if state.type === ActionType.setCustom} + + + {:else if state.type === ActionType.move} + + {:else if state.type === ActionType.sequence} +
Actions run in order.
+ {#each (state.actions as Record[]) || [] as act, i (i)} +
+
+ Step {i + 1} + +
+ updateSequenceAction(i, v)} /> +
+ {/each} + + {/if} +
+
diff --git a/web/frontend/src/components/editors/ActionSelector.svelte b/web/frontend/src/components/editors/ActionSelector.svelte new file mode 100644 index 00000000000..aea4d5afab2 --- /dev/null +++ b/web/frontend/src/components/editors/ActionSelector.svelte @@ -0,0 +1,79 @@ + + +
+
+ + +
+
+ {#if mode === 'inline'} + + {:else if (registered.data?.length ?? 0) === 0} +
No registered actions. Create one.
+ {:else} + + {/if} +
+
diff --git a/web/frontend/src/components/editors/ComponentBuilder.svelte b/web/frontend/src/components/editors/ComponentBuilder.svelte new file mode 100644 index 00000000000..1c44dbc2d87 --- /dev/null +++ b/web/frontend/src/components/editors/ComponentBuilder.svelte @@ -0,0 +1,146 @@ + + + + +
+
+ text component + {#if extras.length > 0} + +{extras.length} extra + {/if} +
+ +
+
+ text + patch('text', v)} + rows={1} + placeholder={'hello, or player.name, or "score: " + player.health'} + /> + + color +
+ + {#each NAMED_COLORS as c (c)} + + {/each} + patch('color', (e.currentTarget as HTMLInputElement).value || null)} + /> +
+ + style +
+ {#each DECORATIONS as d (d)} + + {/each} +
+ + extra +
+ {#each extras as child, i (i)} +
+ {i} +
+ setExtraAt(i, c)} embedded={true} /> +
+ +
+ {/each} + +
+
+
+
diff --git a/web/frontend/src/components/editors/ElementSlot.svelte b/web/frontend/src/components/editors/ElementSlot.svelte new file mode 100644 index 00000000000..509b87b8be4 --- /dev/null +++ b/web/frontend/src/components/editors/ElementSlot.svelte @@ -0,0 +1,78 @@ + + +{#if element.kind === 'boolean'} + + onChange((e.currentTarget as HTMLInputElement).checked)} + /> + +{:else if element.kind === 'enum'} + +{:else if isExpressionKind(element.kind)} + +{:else if element.kind === 'item'} + ) ?? null} onChange={onChange} /> +{:else if element.kind === 'component'} + ) ?? null} onChange={onChange} /> +{:else if element.kind === 'record' && element.components} + ) ?? {}} + onChange={onChange} + /> +{:else if element.kind === 'list' && element.element} + +{:else if element.kind === 'map' && element.key && element.value} + ) ?? {}} + onChange={onChange} + keyField={element.key} + valueField={element.value} + /> +{/if} diff --git a/web/frontend/src/components/editors/FieldRow.svelte b/web/frontend/src/components/editors/FieldRow.svelte new file mode 100644 index 00000000000..e7ee1ed6733 --- /dev/null +++ b/web/frontend/src/components/editors/FieldRow.svelte @@ -0,0 +1,67 @@ + + +
+ + {name} + {kind} + +
+ +
+
+ {#if saveBucket} + + + {/if} + +
+
+ +{#if showRecall && saveBucket && recallBtn} + onChange(v)} + onClose={() => showRecall = false} + /> +{/if} diff --git a/web/frontend/src/components/editors/ItemBuilder.svelte b/web/frontend/src/components/editors/ItemBuilder.svelte new file mode 100644 index 00000000000..b8908e54066 --- /dev/null +++ b/web/frontend/src/components/editors/ItemBuilder.svelte @@ -0,0 +1,213 @@ + + + + +
+
+ item stack + {#if usedKeys.length > 0} + +{usedKeys.length} component{usedKeys.length === 1 ? '' : 's'} + {/if} +
+
+
+ + patch({ id: (e.currentTarget as HTMLInputElement).value })} + /> + + + patch({ count: Math.max(1, Math.min(99, Number((e.currentTarget as HTMLInputElement).value) || 1)) })} + /> + + +
+ + {#if usedKeys.length > 0} +
+ {#each usedKeys as key (key)} + {@const spec = specFor(key)} +
+ {spec?.label ?? key} +
+ {#if spec?.kind === 'enum' && spec.values} + + {:else if spec?.kind === 'component'} + ) ?? null} + onChange={(v) => setComp(key, v)} + /> + {:else if spec?.kind === 'list-component'} + setComp(key, v)} + element={{ name: 'line', kind: 'component' }} + /> + {:else} + + {JSON.stringify(components[key])} + + {/if} +
+ +
+ {/each} +
+ {/if} + +
+ + {#if addDCOpen} + + {/if} +
+
+
+ +{#if pickMat && matBtn} + patch({ id: next })} + onClose={() => pickMat = false} + /> +{/if} diff --git a/web/frontend/src/components/editors/LibraryRecallPopover.svelte b/web/frontend/src/components/editors/LibraryRecallPopover.svelte new file mode 100644 index 00000000000..aedd59863c2 --- /dev/null +++ b/web/frontend/src/components/editors/LibraryRecallPopover.svelte @@ -0,0 +1,56 @@ + + + diff --git a/web/frontend/src/components/editors/ListEditor.svelte b/web/frontend/src/components/editors/ListEditor.svelte new file mode 100644 index 00000000000..c5a1e3973ce --- /dev/null +++ b/web/frontend/src/components/editors/ListEditor.svelte @@ -0,0 +1,107 @@ + + +
+
+ + +
+ {#if open} + {#if tableMode && element.kind === 'record'} +
+
+ {#each recordCols as c (c.name)}{c.name}{/each} +
+ +
+ {/if} +
+ {#if items.length === 0} +
Empty list. Add an entry below.
+ {/if} + {#each items as item, i (i)} +
+ {#if tableMode && element.kind === 'record'} +
+ {#each recordCols as c (c.name)} + )?.[c.name]} + onChange={(v) => replaceAt(i, { ...(item as Record ?? {}), [c.name]: v })} + /> + {/each} +
+ {:else} +
+ replaceAt(i, v)} + /> +
+ {/if} + +
+ {/each} + +
+ {/if} +
diff --git a/web/frontend/src/components/editors/MapEditor.svelte b/web/frontend/src/components/editors/MapEditor.svelte new file mode 100644 index 00000000000..770bde3675a --- /dev/null +++ b/web/frontend/src/components/editors/MapEditor.svelte @@ -0,0 +1,103 @@ + + +
+
+ + +
+ {#if open} +
+ {#if entries.length === 0} +
Empty map. Add an entry below.
+ {/if} + {#each entries as [k, v], i (i)} +
+
+ renameKey(i, String(nk))} + /> +
+ +
+ setValueAt(i, nv)} + /> +
+ +
+ {/each} + +
+ {/if} +
diff --git a/web/frontend/src/components/editors/MaterialPickerPopover.svelte b/web/frontend/src/components/editors/MaterialPickerPopover.svelte new file mode 100644 index 00000000000..3fbfdd3aeba --- /dev/null +++ b/web/frontend/src/components/editors/MaterialPickerPopover.svelte @@ -0,0 +1,67 @@ + + +
+
+ MATERIAL + q = (e.currentTarget as HTMLInputElement).value} + /> +
+
+ {#each filtered as id (id)} + {@const sid = stripNamespace(id)} + + {/each} +
+
+ {filtered.length} of {materials.length} +
+ onPick((e.currentTarget as HTMLInputElement).value)} + spellcheck="false" + /> +
diff --git a/web/frontend/src/components/editors/PacketFieldsEditor.svelte b/web/frontend/src/components/editors/PacketFieldsEditor.svelte new file mode 100644 index 00000000000..3ecd5a50c9e --- /dev/null +++ b/web/frontend/src/components/editors/PacketFieldsEditor.svelte @@ -0,0 +1,95 @@ + + +{#if components === undefined && !packet} +
Select a packet to edit its fields.
+{:else if loading} +
Loading packet schema…
+{:else if err} +
Failed to describe {packet}: {err}
+{:else if unknownPacket} +
+ {packet} is not in the analyzable packet catalog. Pick a different packet, or fix the name. +
+{:else if components === undefined && schema && !schema.analyzable} +
+ {packet} is not analyzable. It contains components this editor can't break down. +
+{:else if list && list.length === 0} +
No fields — this packet has no components.
+{:else if list} +
+ {#each list as f (f.name)} + setField(f.name, v)} + /> + {/each} +
+{/if} diff --git a/web/frontend/src/components/editors/RecordEditor.svelte b/web/frontend/src/components/editors/RecordEditor.svelte new file mode 100644 index 00000000000..dd6d3c5877f --- /dev/null +++ b/web/frontend/src/components/editors/RecordEditor.svelte @@ -0,0 +1,29 @@ + + +
+ {#each components as c (c.name)} + onChange({ ...(value ?? {}), [c.name]: v })} + /> + {/each} +
diff --git a/web/frontend/src/components/editors/TriggerEditor.svelte b/web/frontend/src/components/editors/TriggerEditor.svelte new file mode 100644 index 00000000000..956fd74f601 --- /dev/null +++ b/web/frontend/src/components/editors/TriggerEditor.svelte @@ -0,0 +1,118 @@ + + + + +
+
+ {#each KINDS as k (k.id)} + + {/each} +
+
{kind?.detail || ''}
+
+ {#if state.type === TriggerType.interval} + {@const millis = state.millis} + {@const isPreset = INTERVAL_PRESETS.some(p => p.ms === millis)} +
+
Fire every
+
+ {#each INTERVAL_PRESETS as p (p.ms)} + + {/each} + + set({ ...state, millis: Math.max(0, Number(e.currentTarget.value) || 0) })} + aria-label="Custom interval in milliseconds" + /> + ms + +
+
≈ {humanInterval(millis)}
+
+ {:else if state.type === TriggerType.onPacket} + +
Simple class name (e.g. ClientChatMessagePacket) — matched against every decoded packet.
+ {/if} +
+
\ No newline at end of file diff --git a/web/frontend/src/components/mctext/ChatLine.svelte b/web/frontend/src/components/mctext/ChatLine.svelte new file mode 100644 index 00000000000..e504f29a147 --- /dev/null +++ b/web/frontend/src/components/mctext/ChatLine.svelte @@ -0,0 +1,13 @@ + + +
+ {#if withTimestamp && ts != null} + {fmtTime(ts).slice(0, 8)} + {/if} + +
diff --git a/web/frontend/src/components/mctext/ChatListScrollBottom.svelte b/web/frontend/src/components/mctext/ChatListScrollBottom.svelte new file mode 100644 index 00000000000..206eec75256 --- /dev/null +++ b/web/frontend/src/components/mctext/ChatListScrollBottom.svelte @@ -0,0 +1,13 @@ + + +
+ {@render children?.()} +
diff --git a/web/frontend/src/components/mctext/MinecraftText.svelte b/web/frontend/src/components/mctext/MinecraftText.svelte new file mode 100644 index 00000000000..d435102775a --- /dev/null +++ b/web/frontend/src/components/mctext/MinecraftText.svelte @@ -0,0 +1,20 @@ + + +{#if value != null && value !== ''} + mcJsonTooltip.track(value, e)} + onpointermove={(e) => mcJsonTooltip.track(value, e)} + onpointerleave={() => mcJsonTooltip.track(null, null)} + onclickcapture={(e) => altCopyClick(e, value, 'Text JSON copied')} + > + + +{/if} diff --git a/web/frontend/src/components/mctext/MinecraftTextNode.svelte b/web/frontend/src/components/mctext/MinecraftTextNode.svelte new file mode 100644 index 00000000000..6afe98e083d --- /dev/null +++ b/web/frontend/src/components/mctext/MinecraftTextNode.svelte @@ -0,0 +1,53 @@ + + +{#snippet runEl(run: McRun)} + {#if run.kind === 'icon'} + {#if run.head} + + {:else} + + + + {/if} + {:else if runNeedsSpan(run)} + {run.text}{#if run.hover}{@render hoverTip(run.hover)}{/if} + {:else} + {run.text} + {/if} +{/snippet} + +{#snippet hoverTip(hover: Record)} + {@const body = hoverBody(hover)} + + {#if hover.action === 'show_text' || typeof body === 'string' + || asMcObject(body)?.text != null || asMcObject(body)?.translate != null + || Array.isArray(body)} + + {:else if hover.action === 'show_item' || asMcObject(body)?.id != null} + {@const item = asMcObject(body)!} +
{String(item.id ?? '?')}
+ {#if (item.count ?? 1) > 1}
×{item.count}
{/if} + {:else if hover.action === 'show_entity' || asMcObject(body)?.type != null} + {@const ent = asMcObject(body)!} +
{String(ent.type ?? 'entity')}
+ {#if ent.name}
{/if} + {:else} +
{JSON.stringify(body, null, 2)}
+ {/if} +
+{/snippet} + +{#each runs as run, i (i)}{@render runEl(run)}{/each} diff --git a/web/frontend/src/components/overlay/ContextMenuHost.svelte b/web/frontend/src/components/overlay/ContextMenuHost.svelte new file mode 100644 index 00000000000..ca026b3b59e --- /dev/null +++ b/web/frontend/src/components/overlay/ContextMenuHost.svelte @@ -0,0 +1,104 @@ + + +{#if contextMenu.state} + +{/if} diff --git a/web/frontend/src/components/overlay/EntityTooltipHost.svelte b/web/frontend/src/components/overlay/EntityTooltipHost.svelte new file mode 100644 index 00000000000..5e3a6919c76 --- /dev/null +++ b/web/frontend/src/components/overlay/EntityTooltipHost.svelte @@ -0,0 +1,68 @@ + + +{#if entityTooltip.state} + {@const e = entityTooltip.state.entity} +
+
{prettifyType(e.type)}
+
+ id #{e.id} + {#if e.uuid} · {String(e.uuid).slice(0, 8)}{/if} +
+ {#if e.group} +
group {e.group}
+ {/if} +
+ pos + {Math.round(e.x)} {Math.round(e.y)} {Math.round(e.z)} +
+ {#if e.distance != null} +
dist {Math.round(e.distance)}m
+ {/if} +
+{/if} + + diff --git a/web/frontend/src/components/overlay/McJsonTooltipHost.svelte b/web/frontend/src/components/overlay/McJsonTooltipHost.svelte new file mode 100644 index 00000000000..83125081991 --- /dev/null +++ b/web/frontend/src/components/overlay/McJsonTooltipHost.svelte @@ -0,0 +1,48 @@ + + +{#if mcJsonTooltip.tip} + +{/if} + + diff --git a/web/frontend/src/components/overlay/ProvBadge.svelte b/web/frontend/src/components/overlay/ProvBadge.svelte new file mode 100644 index 00000000000..1bd5c9328a1 --- /dev/null +++ b/web/frontend/src/components/overlay/ProvBadge.svelte @@ -0,0 +1,79 @@ + + + + +{#snippet content()} + + {#if children}{@render children()}{:else}{value}{/if} + {#if suffix}{suffix}{/if} + +{/snippet} + +{#if interactive} + +{:else} + + {@render content()} + +{/if} diff --git a/web/frontend/src/components/overlay/ProvTooltipHost.svelte b/web/frontend/src/components/overlay/ProvTooltipHost.svelte new file mode 100644 index 00000000000..9834faf0a5e --- /dev/null +++ b/web/frontend/src/components/overlay/ProvTooltipHost.svelte @@ -0,0 +1,172 @@ + + +{#if tip} + +{/if} + + diff --git a/web/frontend/src/components/overlay/ProvenancePopover.svelte b/web/frontend/src/components/overlay/ProvenancePopover.svelte new file mode 100644 index 00000000000..3ec866cd9ab --- /dev/null +++ b/web/frontend/src/components/overlay/ProvenancePopover.svelte @@ -0,0 +1,113 @@ + + + diff --git a/web/frontend/src/components/packet-trace/PacketTrace.svelte b/web/frontend/src/components/packet-trace/PacketTrace.svelte new file mode 100644 index 00000000000..175e72705e3 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTrace.svelte @@ -0,0 +1,1355 @@ + + + + +
+ b.enabled)} + bind:searchRef + onQuery={v => { query = v; }} + onPaused={v => { paused = v; }} + onStep={step} + onLive={goLive} + onJump={onJumpSubmit} + onJumpChange={v => { jump = v; }} + onHelp={() => { helpOpen = true; }} + onTweaks={() => { tweaksOpen = !tweaksOpen; }} + /> + + { selectSeq(tape?.nearestSeq(seq) ?? seq); paused = true; }} + /> + +
+ { sideTab = t; }} + onSetFilter={setFilter} + onSetClassFilter={v => { classFilter = v; }} + onSetClassQuery={v => { classQuery = v; }} + onJumpBookmark={selectSeq} + onAddBookmark={b => { bookmarks = [...bookmarks, b]; }} + onRemoveBookmark={i => { bookmarks = bookmarks.filter((_, j) => j !== i); }} + onToggleBreakpoint={i => { breakpoints = breakpoints.map((b, j) => j === i ? { ...b, enabled: !b.enabled } : b); }} + onAddBreakpoint={b => { breakpoints = [...breakpoints, { ...b, id: 'b' + Date.now() }]; }} + onRemoveBreakpoint={i => { breakpoints = breakpoints.filter((_, j) => j !== i); }} + onLoadSaved={q => { query = q; }} + onAddSaved={s => { saved = [...saved, s]; }} + onRemoveSaved={i => { saved = saved.filter((_, j) => j !== i); }} + /> + +
+ { collapseExpanded = true; selectSeq(a); }} + /> +
+ + + + + + +
+ +
+ shown{filtered.length.toLocaleString()} / {allRows.length.toLocaleString()} + · + CB{cbCount.toLocaleString()} + SB{(filtered.length - cbCount).toLocaleString()} + · + bw{fmtBytesShort(totalBytes)} + · + + sel + {selected ? '#' + selected.seq : '—'} + {#if multi.size > 1} (+{multi.size - 1} multi){/if} + + · + marks{bookmarks.length} + breaks{breakpoints.filter(b => b.enabled).length} + + + ? help + / search + space {paused ? 'resume' : 'pause'} + step + B bookmark + +
+ + {#if helpOpen} + { helpOpen = false; }} /> + {/if} + + {#if tweaksOpen} + { accent = a; }} + onDensity={d => { density = d; }} + onToggleCollapse={() => { collapse = !collapse; collapseExpanded = false; }} + onReset={resetTrace} + onClose={() => { tweaksOpen = false; }} + /> + {/if} +
+ + diff --git a/web/frontend/src/components/packet-trace/PacketTraceFacets.svelte b/web/frontend/src/components/packet-trace/PacketTraceFacets.svelte new file mode 100644 index 00000000000..b5415912c93 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceFacets.svelte @@ -0,0 +1,329 @@ + + + diff --git a/web/frontend/src/components/packet-trace/PacketTraceHelp.svelte b/web/frontend/src/components/packet-trace/PacketTraceHelp.svelte new file mode 100644 index 00000000000..366bb180b72 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceHelp.svelte @@ -0,0 +1,65 @@ + + +
{ if (e.key === 'Escape' || e.key === 'Enter') onClose(); }} +> + +
diff --git a/web/frontend/src/components/packet-trace/PacketTraceInspector.svelte b/web/frontend/src/components/packet-trace/PacketTraceInspector.svelte new file mode 100644 index 00000000000..78789cee09d --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceInspector.svelte @@ -0,0 +1,297 @@ + + + diff --git a/web/frontend/src/components/packet-trace/PacketTraceMinimap.svelte b/web/frontend/src/components/packet-trace/PacketTraceMinimap.svelte new file mode 100644 index 00000000000..c365c6e6042 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceMinimap.svelte @@ -0,0 +1,160 @@ + + +
+ +
+ + {#each view.a as b, i (i)} + {@const cbH = (b.cb / view.max) * HALF} + {@const sbH = (b.sb / view.max) * HALF} +
+ + +
+ {/each} + + {#if viewStart != null && viewEnd != null} +
+ {/if} + + {#each lifecycle as l, i (i)} +
+ { e.stopPropagation(); onSeek(l.seq); }} + onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); onSeek(l.seq); } }} + >◆ +
+ {/each} + + {#each bookmarks as b, i (i)} +
+ { e.stopPropagation(); onSeek(b.seq); }} + onkeydown={e => { if (e.key === 'Enter') { e.stopPropagation(); onSeek(b.seq); } }} + >★ +
+ {/each} + + {#each breakpoints as bp (bp.id)} + {#each bp.matchedSeqs ?? [] as s, j (j)} +
+ +
+ {/each} + {/each} + + {#each related as s, i (i)} +
+ {/each} + + {#if playhead != null} +
+ {/if} + +
+ #{view.minSeq.toLocaleString()} — #{view.maxSeq.toLocaleString()} +
+
diff --git a/web/frontend/src/components/packet-trace/PacketTraceStream.svelte b/web/frontend/src/components/packet-trace/PacketTraceStream.svelte new file mode 100644 index 00000000000..d3e0c84724e --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceStream.svelte @@ -0,0 +1,194 @@ + + +
+ + #seq + Δt + dir + class · summary + subject + size +
+ +
{ if (!scrollGuard) scrollTop = e.currentTarget.scrollTop; }} +> +
+ + {#each visible as e, i (e.kind === 'row' ? `r-${e.p.seq}` : e.kind === 'group' ? `g-${e.seqStart}` : `l-${e.seq}-${i}`)} + {#if e.kind === 'lifecycle'} +
+ + {e.label} + #{e.seq.toLocaleString()} +
+ {:else if e.kind === 'group'} + + {:else} + {@const p = e.p} + {@const isCb = isClientBound(p.direction)} + {@const isPlay = playhead === p.seq} + {@const isMulti = multi.has(p.seq)} + {@const isRel = related.has(p.seq)} +
onRowClick(ev, p)} + oncontextmenu={ev => { ev.preventDefault(); onContext(ev, p); }} + onkeydown={ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onSelect(p.seq); } }} + > + + {#if e.bookmark}{/if} + + #{p.seq} + {fmtDelta(e.delta)} + {isCb ? '↓' : '↑'} + + + {pktLabel(p.className)} + {summaryOf(p)} + + + + {p.subjectLabel || p.subjectGroup} + + {fmtBytesShort(p.sizeBytes)} +
+ {/if} + {/each} + +
+ + {#if entries.length === 0} +
No packets match the current filters.
+ {/if} +
diff --git a/web/frontend/src/components/packet-trace/PacketTraceTopBar.svelte b/web/frontend/src/components/packet-trace/PacketTraceTopBar.svelte new file mode 100644 index 00000000000..94bb7857f3f --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceTopBar.svelte @@ -0,0 +1,122 @@ + + +
+
+ + onQuery(e.currentTarget.value)} + placeholder={'filter — try class:Position dir:sb size:>20 or just "chest"'} + spellcheck={false} + /> + {#if query} + + {/if} +
+ + {#if parsed && parsed.tokens.length} +
+ {#each parsed.tokens as t, i (i)} + + {t.neg ? '−' : '+'} {t.raw} + + {/each} +
+ {/if} + +
+ + + + + +
+ +
+ #seq + onJumpChange(e.currentTarget.value)} + onkeydown={e => { if (e.key === 'Enter') onJump(); }} + placeholder="…" + /> +
+ +
+ +
+ {rate} + p/s +
+
+ {totalPackets.toLocaleString()} + pkts +
+ +
+ + + +
+
diff --git a/web/frontend/src/components/packet-trace/PacketTraceTweaks.svelte b/web/frontend/src/components/packet-trace/PacketTraceTweaks.svelte new file mode 100644 index 00000000000..c806967fbd9 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceTweaks.svelte @@ -0,0 +1,56 @@ + + + diff --git a/web/frontend/src/components/packet-trace/types.ts b/web/frontend/src/components/packet-trace/types.ts new file mode 100644 index 00000000000..83db7860750 --- /dev/null +++ b/web/frontend/src/components/packet-trace/types.ts @@ -0,0 +1,25 @@ +import type { PacketRow } from '../../lib/packetAgg.ts'; + +export type FacetMode = 'include' | 'exclude' | null; + +export type StreamEntry = + | { kind: 'row'; p: PacketRow; delta: number | null; bookmark?: { seq: number; label: string } } + | { kind: 'group'; first: PacketRow; last: PacketRow; count: number; seqStart: number; seqEnd: number } + | { kind: 'lifecycle'; seq: number; label: string }; + +export type Related = { row: PacketRow; dt: number; reason: 'Same subject' | 'Same class' }; + +export type Bookmark = { seq: number; label: string }; + +export type Breakpoint = { + id: string; + match: string; + label: string; + enabled: boolean; + matchedSeqs?: number[]; + hitCount?: number; +}; + +export type Saved = { name: string; q: string }; + +export type SideTab = 'filters' | 'bookmarks' | 'breaks' | 'saved'; diff --git a/web/frontend/src/components/packets/CodeEditor.svelte b/web/frontend/src/components/packets/CodeEditor.svelte new file mode 100644 index 00000000000..03ff6975db4 --- /dev/null +++ b/web/frontend/src/components/packets/CodeEditor.svelte @@ -0,0 +1,219 @@ + + + + +
+ + + {#if language === 'mql'} + ? + {/if} + {#if status} +
{status.message}
+ {/if} +
diff --git a/web/frontend/src/components/packets/Heatmap.svelte b/web/frontend/src/components/packets/Heatmap.svelte new file mode 100644 index 00000000000..49e33412d6b --- /dev/null +++ b/web/frontend/src/components/packets/Heatmap.svelte @@ -0,0 +1,84 @@ + + +
+ + {#each SUBJECTS as s (s)}{s}{/each} + ↓ Inbound + {#each grid.cb as c (c.s)} + + + {fmt(c.val)} + + {/each} + ↑ Outbound + {#each grid.sb as c (c.s)} + + + {fmt(c.val)} + + {/each} +
+ + diff --git a/web/frontend/src/components/packets/Leaderboard.svelte b/web/frontend/src/components/packets/Leaderboard.svelte new file mode 100644 index 00000000000..75204b7eabf --- /dev/null +++ b/web/frontend/src/components/packets/Leaderboard.svelte @@ -0,0 +1,112 @@ + + +{#if rows.length === 0} +
No packets yet.
+{:else} +
+ {#each rows as r, i (r.cls)} + {@const dirChip = r.info.cb > r.info.sb ? 'cb' : 'sb'} + {@const dirGlyph = dirChip === 'cb' ? '↓' : '↑'} + {@const [pN, pU] = splitUnit(sortBy === 'bytes' ? humanBytes(r.info.bytes) : humanNumber(r.info.count))} + {@const [sN, sU] = splitUnit(sortBy === 'bytes' ? humanNumber(r.info.count) : humanBytes(r.info.bytes))} +
+ {i + 1} + {pktLabel(r.cls)} + + + {pN}{pU} + + + {dirGlyph} + + {sN}{sU} + + +
+ {/each} +
+{/if} + + diff --git a/web/frontend/src/components/packets/MqlSnippet.svelte b/web/frontend/src/components/packets/MqlSnippet.svelte new file mode 100644 index 00000000000..ce3fe18e3a5 --- /dev/null +++ b/web/frontend/src/components/packets/MqlSnippet.svelte @@ -0,0 +1,17 @@ + + +{@html html} diff --git a/web/frontend/src/components/packets/PacketAggregatePanels.svelte b/web/frontend/src/components/packets/PacketAggregatePanels.svelte new file mode 100644 index 00000000000..fca188a2645 --- /dev/null +++ b/web/frontend/src/components/packets/PacketAggregatePanels.svelte @@ -0,0 +1,31 @@ + + +{#snippet countBytesToggle()} +
+ + +
+{/snippet} + + + {#snippet actions()}{@render countBytesToggle()}{/snippet} + + + + {#snippet actions()}{@render countBytesToggle()}{/snippet} + + diff --git a/web/frontend/src/components/packets/PacketSelector.svelte b/web/frontend/src/components/packets/PacketSelector.svelte new file mode 100644 index 00000000000..2edf4dd1ec9 --- /dev/null +++ b/web/frontend/src/components/packets/PacketSelector.svelte @@ -0,0 +1,99 @@ + + + + +
+ { onChange?.(e.target.value); refresh(); }} + onfocus={refresh} + onclick={refresh} + onblur={() => setTimeout(() => pop?.hide(), 100)} + onkeydown={e => pop?.handleKey(e)} + /> +
diff --git a/web/frontend/src/components/packets/SwimlaneRow.svelte b/web/frontend/src/components/packets/SwimlaneRow.svelte new file mode 100644 index 00000000000..b423d965a20 --- /dev/null +++ b/web/frontend/src/components/packets/SwimlaneRow.svelte @@ -0,0 +1,88 @@ + + +
{ if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); onclick(); } }} + role="button" + tabindex="0" +> + + {(player.username || '?').slice(0, 2).toUpperCase()} + + + {player.username || player.uuid.slice(0, 8)} + {(player.dimension || '').replace('minecraft:', '')} · {player.gamemode || '—'} + + + + {#each Array.from(lane.buckets) as v, i (i)} + {#if v} + {@const cb = lane.cb[i] || 0} + {@const sb = v - cb} + {@const cbH = Math.max(2, (cb / gmax) * 92)} + {@const sbH = Math.max(0, (sb / gmax) * 92)} + + {#if cbH > 0} + + {/if} + {#if sbH > 0} + + {/if} + + {/if} + {/each} + + +
+
pkt{humanNumber(lane.count)}
+
↓ in{humanBytes(lane.cbBytes)}
+
↑ out{humanBytes(lane.sbBytes)}
+
+
+ + diff --git a/web/frontend/src/components/profile/AbilitiesPanel.svelte b/web/frontend/src/components/profile/AbilitiesPanel.svelte new file mode 100644 index 00000000000..89b5076636e --- /dev/null +++ b/web/frontend/src/components/profile/AbilitiesPanel.svelte @@ -0,0 +1,16 @@ + + + +
+
Flying
+
Invulnerable
+
Allow flying
+
Fly speed
+
Walk speed
+
+
diff --git a/web/frontend/src/components/profile/AttributesPanel.svelte b/web/frontend/src/components/profile/AttributesPanel.svelte new file mode 100644 index 00000000000..47c96aaf401 --- /dev/null +++ b/web/frontend/src/components/profile/AttributesPanel.svelte @@ -0,0 +1,35 @@ + + +{#if Object.entries(p.attributes || {}).length === 0} +
No attributes reported.
+{:else} + + + + {#each Object.entries(p.attributes) as [k, v] (k)} + {@const fld = 'attributes.' + k} + {@const src = provFor(p, fld)} + + + + + {/each} + +
{k.replace(/^minecraft:/, '')} + {#if src} + + {:else} + + {Number(v).toFixed(3)} + no source + + {/if} +
+
+{/if} diff --git a/web/frontend/src/components/profile/DashboardStats.svelte b/web/frontend/src/components/profile/DashboardStats.svelte new file mode 100644 index 00000000000..9e5022607ed --- /dev/null +++ b/web/frontend/src/components/profile/DashboardStats.svelte @@ -0,0 +1,203 @@ + + +
+
+
+ + Sessions live +
+
+ {cur} + / {SESSION_CAP} +
+
+ + {deltaGlyph(delta)} + {delta > 0 ? '+' : ''}{delta} in last 5s + + · + ε {everSeen} ever +
+ {#if sparkSessions} + + {/if} +
+ +
+
+ Throughput +
+
+ {humanBytes(bytesNow).replace(/ \w+$/, '')} + {humanBytes(bytesNow).replace(/^[\d.]+ /, '')} + /s +
+
+ + {deltaGlyph(bytesDeltaPct)} + {bytesDeltaPct > 0 ? '+' : ''}{bytesDeltaPct}% vs 1m avg + +
+ {#if sparkBytes} + + {/if} +
+ +
+
+ Packets +
+
+ {humanNumber(pktTotal)} + /s +
+
+ + {humanNumber(pktIn)} + + · + + {humanNumber(pktOut)} + +
+ +
+ +
+
+ Tick +
+
+ {msptDisplay} + ms +
+
+ budget {TICK_BUDGET_MS} + · + {tickMood.word} + {#if tps != null} + · + {tps.toFixed(0)} tps + {/if} +
+ +
+
diff --git a/web/frontend/src/components/profile/EffectsPanel.svelte b/web/frontend/src/components/profile/EffectsPanel.svelte new file mode 100644 index 00000000000..fd89a72ba76 --- /dev/null +++ b/web/frontend/src/components/profile/EffectsPanel.svelte @@ -0,0 +1,31 @@ + + +{#if Object.values(p.activeEffects || {}).length === 0} +
No active effects.
+{:else} + +
+ {#each Object.values(p.activeEffects) as e, i (i)} + {@const url = effectUrl(e.id)} + {@const secs = Math.round((e.durationTicks || 0) / 20)} + {@const dur = secs > 9999 ? '∞' : formatEffectDuration(secs)} + {@const amp = e.amplifier ? toRoman(e.amplifier + 1) : ''} +
+ {#if url} + {e.id} + {:else} +
{(e.id || '').replace(/^minecraft:/, '').slice(0, 3)}
+ {/if} + {#if amp}{amp}{/if} + {dur} +
+ {/each} +
+
+{/if} diff --git a/web/frontend/src/components/profile/EntityCard.svelte b/web/frontend/src/components/profile/EntityCard.svelte new file mode 100644 index 00000000000..9b12a1defe6 --- /dev/null +++ b/web/frontend/src/components/profile/EntityCard.svelte @@ -0,0 +1,39 @@ + + +
+ {#if icon != null}
{@render icon()}
{/if} +
+
+ {title} + {#if badges}{@render badges()}{/if} +
+ {#if detail != null} +
+ {#if detailIsSnippet}{@render detail()}{:else}{detail}{/if} +
+ {/if} +
+ {#if actions != null}
{@render actions()}
{/if} +
diff --git a/web/frontend/src/components/profile/HudPanel.svelte b/web/frontend/src/components/profile/HudPanel.svelte new file mode 100644 index 00000000000..132d72cfefc --- /dev/null +++ b/web/frontend/src/components/profile/HudPanel.svelte @@ -0,0 +1,52 @@ + + + + {#snippet title()}HUD theater{/snippet} +
+ {#if Object.keys(p.bossBars || {}).length > 0} +
+ {#each Object.entries(p.bossBars || {}).filter(([, b]) => b != null) as [id, b], i (id)} +
+
+ +
+ {/each} +
+ {/if} + {#if p.scoreboard} +
+
+ {#each sidebarRows(p.scoreboard.rows) as row (row.key)} +
+ + {#if row.numberFormat?.format === 'FIXED'} + + {:else if row.numberFormat?.format === 'BLANK'} + + {:else} + {row.score} + {/if} +
+ {/each} +
+ {/if} + {#if p.lastActionBar != null} +
+ {/if} +
+
+ {#each (p.recentChat || []).slice(-12) as line, i (i)} + + {/each} +
+
+
+
diff --git a/web/frontend/src/components/profile/IdentityPanel.svelte b/web/frontend/src/components/profile/IdentityPanel.svelte new file mode 100644 index 00000000000..2a7fca6b2e2 --- /dev/null +++ b/web/frontend/src/components/profile/IdentityPanel.svelte @@ -0,0 +1,23 @@ + + + +
+
UUID
+
Locale
+
Client
+
Server
+
Address
+ + {p.address || '—'} + tcp-accept + +
+
Protocol
+
Compression
+
+
diff --git a/web/frontend/src/components/profile/InventoryPanel.svelte b/web/frontend/src/components/profile/InventoryPanel.svelte new file mode 100644 index 00000000000..dbdc34a6b81 --- /dev/null +++ b/web/frontend/src/components/profile/InventoryPanel.svelte @@ -0,0 +1,25 @@ + + + + + diff --git a/web/frontend/src/components/profile/NbtTree.svelte b/web/frontend/src/components/profile/NbtTree.svelte new file mode 100644 index 00000000000..82b074c366c --- /dev/null +++ b/web/frontend/src/components/profile/NbtTree.svelte @@ -0,0 +1,163 @@ + + + + +{#snippet body()} + {#if leaf} +
+ {name} + {leaf.text} + {leaf.type} +
+ {:else if isObject} + {@const entries = Object.entries(value)} +
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }} + > + + + {name} + + + {#if entries.length === 0} + {'{ }'} + {:else} + {compoundSummary(value)} + {/if} + + Object · {entries.length} +
+ {#if open && entries.length > 0} +
+ {#each entries as [k, v] (k)} + + {/each} +
+ {/if} +
+ {:else if isArray} + {@const homogeneous = listHomogeneous(value)} +
+
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } }} + > + + + {name} + + + {#if value.length === 0} + [ ] + {:else} + {listSummary(value)} + {/if} + + List{homogeneous ? '·' + homogeneous : ''} · {value.length} +
+ {#if open && value.length > 0} +
+ {#each value as v, i (i)} + + {/each} +
+ {/if} +
+ {:else} +
+ {name} + {String(value)} + Unknown +
+ {/if} +{/snippet} + +{#if wrap} +
{@render body()}
+{:else} + {@render body()} +{/if} diff --git a/web/frontend/src/components/profile/PingPanel.svelte b/web/frontend/src/components/profile/PingPanel.svelte new file mode 100644 index 00000000000..f61165948ee --- /dev/null +++ b/web/frontend/src/components/profile/PingPanel.svelte @@ -0,0 +1,23 @@ + + + + {#snippet meta()}{/snippet} + Math.round(v) + ''} + gridX={5} + gridY={3} + showAxes + showLegend={false} + className="chart-sm" + /> + diff --git a/web/frontend/src/components/profile/PlayerEntities.svelte b/web/frontend/src/components/profile/PlayerEntities.svelte new file mode 100644 index 00000000000..deb56f69464 --- /dev/null +++ b/web/frontend/src/components/profile/PlayerEntities.svelte @@ -0,0 +1,563 @@ + + + + +
+ + {#snippet actions()} +
+ + + +
+ {/snippet} +
+ {#each GROUPS as g (g.id)} + + {/each} + search = e.target.value} + /> +
+
+ +
+ + {#snippet actions()} +
+ {#each RANGES as r (r)} + + {/each} +
+ {/snippet} +
+ + {#each [25, 50, 75, 100] as r (r)} + + {/each} + + + {#each [25, 50, 75, 100] as r (r)} + + {Math.round((r / 100) * range)} + + {/each} + + {#if hasPlayer} + {#each visible as e (e.id)} + {@const p = project(e)} + {#if p} + {@const c = COLOR_OF[e.group] || 'var(--ink-3)'} + {@const sel = selectedId === e.id} + selectedId = e.id} + onkeydown={ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); selectedId = e.id; } }} + onpointerenter={ev => showEntityTooltip({ ...e, distance: p.d }, ev)} + onpointermove={moveEntityTooltip} + onpointerleave={hideEntityTooltip} + style="cursor: pointer" + role="button" + tabindex="0" + aria-label={'Entity ' + (e.type || 'unknown') + (sel ? ' (selected)' : '')} + > + {#if sel} + + + {:else} + + {/if} + + {/if} + {/each} + + + + {/if} + N + +
+ {#each GROUPS as g (g.id)} + + + {g.label} + + {/each} +
+
+
+ + +
+ {#each visible as e (e.id)} + {@const d = distXZ(e, px, pz)} + {@const closeness = Math.max(0.4, 1 - d / 256)} + {@const c = COLOR_OF[e.group] || 'var(--ink-3)'} +
selectedId = selectedId === e.id ? null : e.id} + onkeydown={ev => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); selectedId = selectedId === e.id ? null : e.id; } }} + role="button" + tabindex="0" + > + {GLYPH_OF[e.group] || '·'} +
+
+ {prettifyType(e.type)} + #{e.id} +
+
+ {e.x.toFixed(0)} {e.y.toFixed(0)} {e.z.toFixed(0)} + {d < 1 ? '·' : Math.round(d) + 'm'} +
+
+
+ {/each} + {#if visible.length === 0}
No entities match.
{/if} +
+
+
+ + {#if selectedId != null} + + {#snippet actions()} + + {/snippet} + {#if !detail} +
Loading detail…
+ {:else if detail.loading} +
Loading detail…
+ {:else if detail.error} +
{detail.error}
+ {:else if !detail.data} +
Entity is no longer in view.
+ {:else} + {@const e = detail.data} + {@const provenance = Object.entries(e.provenance || {})} + {@const log = (e.changeLog || []).slice().reverse().slice(0, 20)} +
+
+ {prettifyType(e.type)} + #{e.id} + {#if e.uuid}{String(e.uuid).slice(0, 8)}{/if} + {e.x?.toFixed(1)} · {e.y?.toFixed(1)} · {e.z?.toFixed(1)} + spawn #{e.spawnSeq} + {e.packetCount} packets +
+
+
+
Field state
+
+ {#each provenance as [field, src] (field)} +
+ {field} + + {shortClass(src.packetClass || '').replace(/Packet$/, '')} + #{src.seq} · {fmtAge(now - src.ts)} ago + +
+ {/each} + {#if provenance.length === 0}
No fields tracked yet.
{/if} +
+
+
+
Recent changes · {log.length}
+
+ {#each log as c, i (i)} + {@const delta = formatDelta(c.prev, c.value)} +
+ {fmtAge(now - (c.source?.ts || 0))} ago + {c.field} + + {String(c.prev ?? '—')} + + {String(c.value ?? '—')} + + + {delta?.text ?? ''} + +
+ {/each} + {#if log.length === 0}
No mutations yet.
{/if} +
+
+
+
+ {/if} +
+ {/if} +
+ + diff --git a/web/frontend/src/components/profile/PlayerInventory.svelte b/web/frontend/src/components/profile/PlayerInventory.svelte new file mode 100644 index 00000000000..c0593fce140 --- /dev/null +++ b/web/frontend/src/components/profile/PlayerInventory.svelte @@ -0,0 +1,217 @@ + + +{#snippet itemIcon(id)} + {@const bare = bareId(id)} + {#if bare} + { e.target.replaceWith(Object.assign(document.createElement('span'), { className: 'mc-icon-fallback', textContent: bare.slice(0, 3) })); }} + /> + {/if} +{/snippet} + +{#snippet flashOverlay(flashKey)} + {#if flashKey} + {#key flashKey} + + {/key} + {/if} +{/snippet} + +{#snippet slot(item, kind, idx, extraClass = '')} + {@const flashKey = flashKeyFor(kind, idx)} + {#if !item || !item.id} +
+ {@render flashOverlay(flashKey)} +
+ {:else} + {@const dur = durability(item)} +
onHover(item, e)} + onmousemove={e => onHover(item, e)} + onmouseleave={onLeave} + onclickcapture={(e) => altCopyClick(e, item, 'Item JSON copied')} + role="img" + > + {@render itemIcon(item.id)} + {#if item.count > 1}{item.count}{/if} + {#if dur != null} + + {/if} + {@render flashOverlay(flashKey)} +
+ {/if} +{/snippet} + +
+
+ {#if openedWindow} + {@const slots = openedWindow.slots || []} + {@const w = gridWidthFor(slots.length)} + {@const typeLabel = prettifyId(openedWindow.type) || 'window'} +
+
+ + Open container + + + {typeLabel} + {slots.length} slot{slots.length === 1 ? '' : 's'} + id {openedWindow.id} + +
+ {#if slots.length === 0} +
awaiting first Window-Items packet…
+ {:else} +
+ {#each slots as it, i (i)} + {@render slot(it, 'container', i)} + {/each} +
+ {/if} +
+
+ Player inventory · live mirror while {typeLabel} is open +
+ {/if} +
+
+ {#each Array(4) as _, i (i)} + {@render slot(armor[i], 'armor', i)} + {/each} +
+
+ +
+
+ {@render slot(offHand, 'offhand', 0)} +
+
+ {#each Array(27) as _, i (i)} + {@render slot(main[i], 'main', i)} + {/each} +
+
+ {#each Array(9) as _, i (i)} + {@render slot(hotbar[i], 'hotbar', i, i === selectedHotbar ? 'selected' : '')} + {/each} +
+
+
+ + {#if tip?.data} +
+
+ {#each tip.data.lore as l, i (i)} +
+ {/each} + {#each tip.data.enchants as e, i (i)} +
{e}
+ {/each} +
{tip.data.id}
+
+ {/if} +
diff --git a/web/frontend/src/components/profile/PlayerLifecycle.svelte b/web/frontend/src/components/profile/PlayerLifecycle.svelte new file mode 100644 index 00000000000..e381e760baf --- /dev/null +++ b/web/frontend/src/components/profile/PlayerLifecycle.svelte @@ -0,0 +1,127 @@ + + + + +{#snippet lifecycleContent(e, meta, dt)} + {meta.glyph} +
+
+ {meta.label} + {#if dt != null}+{fmtAge(dt)}{/if} + {#if e.packetSeq > 0}#{e.packetSeq}{/if} +
+ {#each flattenLeaves(e.data) as [k, v] (k)} +
+ {k} + {v} +
+ {/each} +
+ + {new Date(e.ts).toLocaleTimeString('en-GB', { hour12: false })} + +{/snippet} + + + {#if err} +
Error · {err}
+ {:else if events.length === 0} +
No lifecycle events captured yet.
+ {:else} +
    + {#each events as e, i (e.seq)} + {@const meta = KIND_META[e.kind] || { label: e.kind, glyph: '·', accent: 'var(--ink-3)' }} + {@const dt = i === 0 ? null : e.ts - events[i - 1].ts} +
  1. + {#if e.packetSeq > 0} + + {:else} +
    + {@render lifecycleContent(e, meta, dt)} +
    + {/if} +
  2. + {/each} +
+ {/if} +
diff --git a/web/frontend/src/components/profile/PlayerPackets.svelte b/web/frontend/src/components/profile/PlayerPackets.svelte new file mode 100644 index 00000000000..783c9121241 --- /dev/null +++ b/web/frontend/src/components/profile/PlayerPackets.svelte @@ -0,0 +1,272 @@ + + + + +
+
+
+ aggSort = v} + /> +
+ + + feed.ingestRows(rows, player?.uuid ?? '')} + onPlayheadChange={row => { if (row) playheadClass = row.className; }} + onResetFeed={() => feed.reset()} + /> + +
+ + +
+ + diff --git a/web/frontend/src/components/profile/PlayerRegistries.svelte b/web/frontend/src/components/profile/PlayerRegistries.svelte new file mode 100644 index 00000000000..b26725335b2 --- /dev/null +++ b/web/frontend/src/components/profile/PlayerRegistries.svelte @@ -0,0 +1,788 @@ + + + + +{#snippet meter(ratio)} + {@const pct = ratio * 100} + + + +{/snippet} + +{#snippet registryRow(a)} + {@const active = a.id === selectedId} + {@const path = pathOf(a.id)} + {@const ns = namespaceOf(a.id)} + +{/snippet} + +{#snippet entryRow(e, idx)} + {@const ns = namespaceOf(e.id) || 'minecraft'} + {@const path = pathOf(e.id)} +
  • + + {String(idx + 1).padStart(3, '0')} + + {ns}:{path} + + {e.vanilla ? 'vanilla' : 'custom'} +
  • +{/snippet} + +
    + {#if loadErr} + +
    +
    {loadErr}
    +
    +
    + {:else if registries === null} + +
    Reading per-connection registry tables…
    +
    + {:else} + +
    +
    + {totals.regs} + Registries +
    +
    + {totals.entries.toLocaleString()} + Entries +
    +
    + {totals.custom.toLocaleString()} + Custom +
    +
    + {totals.vanilla.toLocaleString()} + Vanilla +
    + + {(totals.ratio * 100).toFixed(2)}% + custom density · {totals.customRegs} of {totals.regs} registries diverge from vanilla + +
    + + +
    + + + + +
    + {#if !selected} +
    Select a registry on the left.
    + {:else} + {@const a = selected} +
    +
    +
    + {#if namespaceOf(a.id) && namespaceOf(a.id) !== 'minecraft'} + {namespaceOf(a.id)}: + {:else} + {namespaceOf(a.id) || 'minecraft'}: + {/if} + {pathOf(a.id)} +
    +
    + registry + client registry + + + {a.customCount} + / + {a.total} + custom of total + +
    +
    +
    +
    + + +
    + query = (e.target as HTMLInputElement).value} + spellcheck="false" + autocomplete="off" + /> +
    +
    + + + + {#if visibleCount === 0} +
    + {#if customOnly && a.customCount === 0} + No custom additions in this registry. + Every entry here is baseline Mojang content — switch to All to inspect vanilla entries. + {:else if query} + No entries match “{query}”. + Clear the filter or switch to All to widen the search. + {:else} + No entries. + {/if} +
    + {:else} +
      + {#each filteredEntries as e, i (e.id)} + {@render entryRow(e, i)} + {/each} +
    + {#if hiddenByFilter > 0} +
    + {hiddenByFilter} + + {customOnly ? 'vanilla' : 'filtered'} {hiddenByFilter === 1 ? 'entry' : 'entries'} hidden + + {#if customOnly} + + {:else if query} + + {/if} +
    + {/if} + {/if} + {/if} +
    +
    + {/if} +
    + + diff --git a/web/frontend/src/components/profile/PositionPanel.svelte b/web/frontend/src/components/profile/PositionPanel.svelte new file mode 100644 index 00000000000..e8ccba82b99 --- /dev/null +++ b/web/frontend/src/components/profile/PositionPanel.svelte @@ -0,0 +1,21 @@ + + + + {#snippet meta()}{/snippet} +
    +
    X
    +
    Y
    +
    Z
    +
    Yaw
    +
    Pitch
    +
    On ground
    +
    Bytes in
    {humanBytes(p.traffic.bytesIn)}
    +
    Bytes out
    {humanBytes(p.traffic.bytesOut)}
    +
    +
    diff --git a/web/frontend/src/components/profile/ProvValue.svelte b/web/frontend/src/components/profile/ProvValue.svelte new file mode 100644 index 00000000000..95f0a5743f9 --- /dev/null +++ b/web/frontend/src/components/profile/ProvValue.svelte @@ -0,0 +1,22 @@ + + +{#if !provFor(p, field)} + + {value}{#if suffix}{suffix}{/if} + no source yet + +{:else} + +{/if} diff --git a/web/frontend/src/components/profile/ServerDataPanel.svelte b/web/frontend/src/components/profile/ServerDataPanel.svelte new file mode 100644 index 00000000000..3534ba455cb --- /dev/null +++ b/web/frontend/src/components/profile/ServerDataPanel.svelte @@ -0,0 +1,17 @@ + + +{#if Object.keys(p.serverData || {}).length === 0} + +
    No server data pushed for this player.
    +
    +{:else} + + +

    Available in MQL as server.*.

    +
    +{/if} diff --git a/web/frontend/src/components/profile/SkinCanvas.svelte b/web/frontend/src/components/profile/SkinCanvas.svelte new file mode 100644 index 00000000000..8629d03a49b --- /dev/null +++ b/web/frontend/src/components/profile/SkinCanvas.svelte @@ -0,0 +1,101 @@ + + + + + diff --git a/web/frontend/src/components/profile/VitalsPanel.svelte b/web/frontend/src/components/profile/VitalsPanel.svelte new file mode 100644 index 00000000000..1ac963379f0 --- /dev/null +++ b/web/frontend/src/components/profile/VitalsPanel.svelte @@ -0,0 +1,64 @@ + + +{#snippet hearts(value, max, hardcore)} + {#each Array(Math.max(1, Math.ceil(max / 2))) as _, i (i)} + {@const remaining = value - i * 2} + {@const sprites = hardcore + ? { empty: HEART_SPRITES.hcEmpty, full: HEART_SPRITES.hcFull, half: HEART_SPRITES.hcHalf } + : { empty: HEART_SPRITES.empty, full: HEART_SPRITES.full, half: HEART_SPRITES.half }} + {@const layer = remaining >= 2 ? sprites.full : remaining >= 1 ? sprites.half : null} + {#if layer} + + {:else} + + {/if} + {/each} +{/snippet} + +{#snippet foodIcons(value)} + {#each Array(10) as _, i (i)} + {@const remaining = value - i * 2} + {@const layer = remaining >= 2 ? FOOD_SPRITES.full : remaining >= 1 ? FOOD_SPRITES.half : null} + {#if layer} + + {:else} + + {/if} + {/each} +{/snippet} + + +
    + HP + + + + {@render hearts(p.health || 0, p.maxHealth || 20, p.hardcore)} +
    +
    + Food + + + + {@render foodIcons(p.food || 0)} +
    +
    + XP · Lvl + + +
    +
    + {#if p.flying}flying{/if} + {#if p.invulnerable}invuln{/if} + {#if p.allowFlying}may fly{/if} + {#if p.onGround}grounded{:else}airborne{/if} +
    +
    diff --git a/web/frontend/src/components/ui/Chart.svelte b/web/frontend/src/components/ui/Chart.svelte new file mode 100644 index 00000000000..ac12c7b922b --- /dev/null +++ b/web/frontend/src/components/ui/Chart.svelte @@ -0,0 +1,33 @@ + + +
    diff --git a/web/frontend/src/components/ui/Crumbs.svelte b/web/frontend/src/components/ui/Crumbs.svelte new file mode 100644 index 00000000000..660d42e724a --- /dev/null +++ b/web/frontend/src/components/ui/Crumbs.svelte @@ -0,0 +1,14 @@ + + +
    + {#each steps as step, i} + + {@render step()} + {#if i < steps.length - 1}/{/if} + + {/each} +
    diff --git a/web/frontend/src/components/ui/EmptyState.svelte b/web/frontend/src/components/ui/EmptyState.svelte new file mode 100644 index 00000000000..d507cc43672 --- /dev/null +++ b/web/frontend/src/components/ui/EmptyState.svelte @@ -0,0 +1,15 @@ + + +
    +
    {title}
    + {#if hint}
    {hint}
    {/if} + {#if cta}
    {@render cta()}
    {/if} +
    diff --git a/web/frontend/src/components/ui/Panel.svelte b/web/frontend/src/components/ui/Panel.svelte new file mode 100644 index 00000000000..21fde0293bb --- /dev/null +++ b/web/frontend/src/components/ui/Panel.svelte @@ -0,0 +1,49 @@ + + +
    + {#if !headless} +
    + {#if title != null} +

    + {#if titleIsSnippet}{@render title()}{:else}{title}{/if} +

    + {/if} +
    + {#if meta != null} + + {#if metaIsSnippet}{@render meta()}{:else}{meta}{/if} + + {/if} + {#if actions}{@render actions()}{/if} +
    +
    + {/if} +
    + {@render children?.()} +
    +
    diff --git a/web/frontend/src/components/ui/Pill.svelte b/web/frontend/src/components/ui/Pill.svelte new file mode 100644 index 00000000000..cdcba0d6cea --- /dev/null +++ b/web/frontend/src/components/ui/Pill.svelte @@ -0,0 +1,18 @@ + + + + {#if dot}{/if} + {@render children?.()} + diff --git a/web/frontend/src/components/ui/ProgressBar.svelte b/web/frontend/src/components/ui/ProgressBar.svelte new file mode 100644 index 00000000000..67c5f803155 --- /dev/null +++ b/web/frontend/src/components/ui/ProgressBar.svelte @@ -0,0 +1,39 @@ + + +
    +
    +
    +
    + {#if children} +
    {@render children()}
    + {/if} +
    diff --git a/web/frontend/src/components/ui/ReferenceList.svelte b/web/frontend/src/components/ui/ReferenceList.svelte new file mode 100644 index 00000000000..6baeac8d09e --- /dev/null +++ b/web/frontend/src/components/ui/ReferenceList.svelte @@ -0,0 +1,25 @@ + + + +
    + {#if items.length === 0} +
    Loading…
    + {:else} + {#each items as it (it.name)} +
    +
    + {it.name} + {it.kind} +
    +
    {it.detail || ''}
    +
    + {/each} + {/if} +
    +
    diff --git a/web/frontend/src/components/ui/RunActionPanel.svelte b/web/frontend/src/components/ui/RunActionPanel.svelte new file mode 100644 index 00000000000..95b51fab0f9 --- /dev/null +++ b/web/frontend/src/components/ui/RunActionPanel.svelte @@ -0,0 +1,27 @@ + + + + {#snippet actions()}{/snippet} + action = v} /> + {#if result} +
    {result}
    + {/if} +
    diff --git a/web/frontend/src/components/ui/Sparkline.svelte b/web/frontend/src/components/ui/Sparkline.svelte new file mode 100644 index 00000000000..33fc8e14ed6 --- /dev/null +++ b/web/frontend/src/components/ui/Sparkline.svelte @@ -0,0 +1,19 @@ + + + diff --git a/web/frontend/src/components/ui/Toasts.svelte b/web/frontend/src/components/ui/Toasts.svelte new file mode 100644 index 00000000000..76cff8b4464 --- /dev/null +++ b/web/frontend/src/components/ui/Toasts.svelte @@ -0,0 +1,9 @@ + + + + {#each toasts.items as t (t.id)} +
    {t.message}
    + {/each} +
    diff --git a/web/frontend/src/components/ui/Toggle.svelte b/web/frontend/src/components/ui/Toggle.svelte new file mode 100644 index 00000000000..66a42d5e88b --- /dev/null +++ b/web/frontend/src/components/ui/Toggle.svelte @@ -0,0 +1,24 @@ + + + diff --git a/web/frontend/src/components/ui/TweaksPanel.svelte b/web/frontend/src/components/ui/TweaksPanel.svelte new file mode 100644 index 00000000000..da705a1cc06 --- /dev/null +++ b/web/frontend/src/components/ui/TweaksPanel.svelte @@ -0,0 +1,70 @@ + + + diff --git a/web/frontend/src/components/ui/ViewHead.svelte b/web/frontend/src/components/ui/ViewHead.svelte new file mode 100644 index 00000000000..9051b4b5596 --- /dev/null +++ b/web/frontend/src/components/ui/ViewHead.svelte @@ -0,0 +1,26 @@ + + +
    +
    + +

    {@render title()}

    + {#if subtitle}{@render subtitle()}{/if} +
    + {#if actions} +
    {@render actions()}
    + {/if} +
    diff --git a/web/frontend/src/lib/api.ts b/web/frontend/src/lib/api.ts new file mode 100644 index 00000000000..42cf89175ca --- /dev/null +++ b/web/frontend/src/lib/api.ts @@ -0,0 +1,150 @@ +import type { JsonValue, PacketTopicMessage } from './types.ts'; + +// HTTP + WebSocket bridge — singleton; lives outside of Svelte. + +const params = new URLSearchParams(location.search); +const token = params.get('token') || sessionStorage.getItem('mw-token') || ''; +if (params.get('token')) sessionStorage.setItem('mw-token', token); + +/// Replay scope id — identifies "which uploaded SQLite this browser tab is viewing". Stored in +/// sessionStorage so a page reload re-attaches to the same scope, but not localStorage so a +/// fresh tab starts blank (each tab can hold its own replay). +/// +/// The reactive view of this value lives in `state/mode.svelte.ts`; this module-level cache +/// is just what `headers()` and the bus URL read at request/connect time. +const SCOPE_STORAGE_KEY = 'mw-scope'; +let scopeId: string | null = sessionStorage.getItem(SCOPE_STORAGE_KEY); + +export function getScope(): string | null { return scopeId; } + +/// Update the active scope. Triggers a bus reconnect so the new `?replay=` param takes effect. +/// Callers in `state/mode.svelte.ts` also mirror this into their reactive `$state` for UI. +export function setScope(id: string | null): void { + if (id === scopeId) return; + scopeId = id; + if (id) sessionStorage.setItem(SCOPE_STORAGE_KEY, id); + else sessionStorage.removeItem(SCOPE_STORAGE_KEY); + bus.reconnect(); +} + +type ApiBody = BodyInit | JsonValue | Record; +type ApiInit = Omit & { body?: ApiBody }; + +const headers = (): Record => { + const h: Record = {}; + if (token) h['X-Auth-Token'] = token; + if (scopeId) h['X-Replay-Id'] = scopeId; + return h; +}; + +export async function api(path: string, opts: ApiInit = {}): Promise { + const { body, ...rest } = opts; + const init: RequestInit = { ...rest }; + const requestHeaders = new Headers(opts.headers); + for (const [key, value] of Object.entries(headers())) requestHeaders.set(key, value); + init.headers = requestHeaders; + init.body = body as BodyInit | null | undefined; + if (body && typeof body === 'object' && !(body instanceof FormData) && !(body instanceof URLSearchParams) && !(body instanceof Blob) && !(body instanceof ArrayBuffer)) { + requestHeaders.set('Content-Type', 'application/json'); + init.body = JSON.stringify(body); + } + const r = await fetch('/api' + path, init); + if (!r.ok) { + const text = await r.text().catch(() => ''); + const ct = r.headers.get('content-type') || ''; + let msg = r.statusText || `HTTP ${r.status}`; + if (text && ct.includes('application/json')) { + try { msg = JSON.parse(text).error ?? msg; } catch {} + } + const err = new Error(msg) as Error & { status?: number }; + err.status = r.status; + throw err; + } + const ct = r.headers.get('content-type') || ''; + if (ct.includes('application/json')) return r.json() as Promise; + return r.text() as Promise; +} + +/// Reconnecting WebSocket multiplex with topic subscriptions. +type TopicHandler = (message: T) => void; + +class TopicEvent extends CustomEvent {} + +class Bus extends EventTarget { + ws: WebSocket | null = null; + subs = new Map(); // topic → refcount + connected = false; + reconnectMs = 500; + #started = false; + + connect(): void { + this.#started = true; + this.#open(); + } + + #open(): void { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + const qs = new URLSearchParams(); + if (token) qs.set('token', token); + if (scopeId) qs.set('replay', scopeId); + const q = qs.toString(); + const url = `${proto}://${location.host}/ws${q ? '?' + q : ''}`; + this.ws = new WebSocket(url); + this.ws.addEventListener('open', () => { + this.connected = true; + this.reconnectMs = 500; + this.dispatchEvent(new Event('open')); + if (this.subs.size) this.send({ subscribe: [...this.subs.keys()] }); + }); + this.ws.addEventListener('close', () => { + this.connected = false; + this.dispatchEvent(new Event('close')); + setTimeout(() => this.#open(), this.reconnectMs = Math.min(this.reconnectMs * 1.8, 10_000)); + }); + this.ws.addEventListener('message', (e: MessageEvent) => { + let outer: PacketTopicMessage & { batch?: PacketTopicMessage[] }; + try { outer = JSON.parse(e.data); } catch { return; } + const msgs = Array.isArray(outer.batch) ? outer.batch : [outer]; + for (const msg of msgs) { + if (msg.topic) this.dispatchEvent(new CustomEvent('topic:' + msg.topic, { detail: msg })); + this.dispatchEvent(new CustomEvent('message', { detail: msg })); + } + }); + } + + reconnect(): void { + // No-op before boot — the eventual `connect()` picks up the current scopeId. + if (!this.#started) return; + if (this.ws) { + try { this.ws.close(); } catch {} + this.ws = null; + } + this.#open(); + } + + send(obj: JsonValue | Record): void { + if (this.ws && this.ws.readyState === 1) this.ws.send(JSON.stringify(obj)); + } + + /// Subscribe to a topic. Returns an unsubscribe function. Refcounted so multiple components + /// on the same topic only result in one server subscription. + subscribe(topic: string, handler: TopicHandler): () => void { + const count = this.subs.get(topic) || 0; + if (count === 0 && this.connected) this.send({ subscribe: [topic] }); + this.subs.set(topic, count + 1); + const wrapped = (e: Event) => handler((e as TopicEvent).detail); + this.addEventListener('topic:' + topic, wrapped); + return () => { + this.removeEventListener('topic:' + topic, wrapped); + const n = (this.subs.get(topic) || 1) - 1; + if (n <= 0) { + this.subs.delete(topic); + if (this.connected) this.send({ unsubscribe: [topic] }); + } else { + this.subs.set(topic, n); + } + }; + } +} + +export const bus = new Bus(); diff --git a/web/frontend/src/lib/assets.ts b/web/frontend/src/lib/assets.ts new file mode 100644 index 00000000000..17bd43258a6 --- /dev/null +++ b/web/frontend/src/lib/assets.ts @@ -0,0 +1,54 @@ +// Vanilla asset registry — lazy-loaded sets of known item / effect / block ids. + +type AssetState = { + items: Set | null; + effects: Set | null; + blocks: Set | null; + ready: Promise | null; +}; + +const STATE: AssetState = { items: null, effects: null, blocks: null, ready: null }; + +async function loadList(path: string): Promise> { + try { + const r = await fetch('/assets/' + path); + if (!r.ok) return new Set(); + return new Set(await r.json()); + } catch { return new Set(); } +} + +export function ready(): Promise { + if (!STATE.ready) { + STATE.ready = (async () => { + const [items, effects, blocks] = await Promise.all([ + loadList('items.json'), + loadList('effects.json'), + loadList('blocks.json'), + ]); + STATE.items = items; + STATE.effects = effects; + STATE.blocks = blocks; + return STATE; + })(); + } + return STATE.ready; +} + +export function effectUrl(idOrName: unknown): string | null { + if (!STATE.effects) return null; + const name = String(idOrName || '').replace(/^minecraft:/, ''); + return STATE.effects.has(name) ? `/assets/textures/mob_effect/${name}.png` : null; +} + +export function prettifyId(id: unknown): string { + return String(id || '').replace(/^minecraft:/, '').split('_') + .map(w => (w[0] || '').toUpperCase() + w.slice(1)) + .join(' '); +} + +/// Like [prettifyId] but falls back to `?` for empty input — for entity-type labels that +/// must always show something. +export function prettifyType(type: unknown): string { + if (!type) return '?'; + return prettifyId(type); +} diff --git a/web/frontend/src/lib/charts.ts b/web/frontend/src/lib/charts.ts new file mode 100644 index 00000000000..3ea3dde7c92 --- /dev/null +++ b/web/frontend/src/lib/charts.ts @@ -0,0 +1,289 @@ +// Imperative chart cores — callers (the .svelte wrappers) can +// drive them via `bind:this` + lifecycle effects. + +type Series = { + key: string; + label?: string; + color: string; + area?: boolean; +}; + +type Padding = { top: number; right: number; bottom: number; left: number }; +type ChartData = Record; +type Formatter = (value: number) => string; + +function resolveColor(c: string | undefined, el: Element): string { + if (!c) return '#9ca3af'; + if (typeof c === 'string' && c.startsWith('var(')) { + const name = c.slice(4, -1).trim(); + const v = getComputedStyle(el).getPropertyValue(name).trim(); + return v || '#9ca3af'; + } + return c; +} + +export class SparklineCore { + cv: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + color: string; + fill?: string; + max: number; + data: number[] = []; + _ro: ResizeObserver; + + constructor(canvas: HTMLCanvasElement, { color = 'var(--acc)', fill, max = 60 }: { color?: string; fill?: string; max?: number } = {}) { + this.cv = canvas; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('2D canvas context is unavailable'); + this.ctx = ctx; + this.color = color; this.fill = fill; this.max = max; + this._resize(); + this._ro = new ResizeObserver(() => this._resize()); + this._ro.observe(canvas); + } + _resize(): void { + const dpr = devicePixelRatio || 1; + const { clientWidth: w, clientHeight: h } = this.cv; + this.cv.width = Math.max(1, Math.round(w * dpr)); + this.cv.height = Math.max(1, Math.round(h * dpr)); + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.draw(); + } + set(values: ArrayLike | null | undefined): void { + this.data = Array.from(values || []).slice(-this.max).map(v => Number(v) || 0); + this.draw(); + } + draw(): void { + const { ctx, cv, data } = this; + const w = cv.clientWidth, h = cv.clientHeight; + ctx.clearRect(0, 0, w, h); + if (data.length < 2) return; + const stroke = resolveColor(this.color, cv); + const fill = this.fill ? resolveColor(this.fill, cv) : (stroke + '33'); + const min = Math.min(...data), max = Math.max(...data); + const range = (max - min) || 1; + const stepX = w / (data.length - 1); + ctx.beginPath(); + data.forEach((v, i) => { + const x = i * stepX; + const y = h - ((v - min) / range) * (h - 4) - 2; + i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); + }); + ctx.strokeStyle = stroke; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; + ctx.stroke(); + if (fill && fill !== 'transparent') { + ctx.lineTo(w, h); ctx.lineTo(0, h); ctx.closePath(); + ctx.fillStyle = fill; ctx.fill(); + } + } + destroy(): void { this._ro?.disconnect(); } +} + +const SVG_NS = 'http://www.w3.org/2000/svg'; +const DEFAULT_PADDING = { top: 10, right: 14, bottom: 22, left: 44 }; + +export class ChartCore { + container: HTMLElement; + series: Series[]; + opts: { + yLabel: string; + yFormat: Formatter; + xFormat: Formatter; + padding: Padding; + gridX: number; + gridY: number; + showAxes: boolean; + }; + data: ChartData = {}; + xValues: Array | null = null; + svg: SVGSVGElement; + tip: HTMLDivElement; + legend?: HTMLDivElement; + _rafPending = false; + _ro: ResizeObserver; + _onHover: (e: MouseEvent) => void; + _hideTip: () => void; + _ih = 0; + _iw = 0; + _ix = 0; + _iy = 0; + _n = 0; + + constructor(container: HTMLElement, { + series, + yLabel, + yFormat, + xFormat, + padding, + gridX, + gridY, + showLegend, + showAxes, + }: { + series?: Series[]; + yLabel?: string; + yFormat?: Formatter; + xFormat?: Formatter; + padding?: Padding; + gridX?: number; + gridY?: number; + showLegend?: boolean; + showAxes?: boolean; + }) { + this.container = container; + container.classList.add('chart'); + this.series = series || []; + this.opts = { yLabel: yLabel || '', yFormat: yFormat || (v => String(Math.round(v))), + xFormat: xFormat || (i => String(i)), padding: padding || DEFAULT_PADDING, + gridX: gridX ?? 6, gridY: gridY ?? 4, showAxes: showAxes ?? true }; + container.innerHTML = ''; + this.svg = document.createElementNS(SVG_NS, 'svg'); + this.svg.setAttribute('preserveAspectRatio', 'none'); + container.appendChild(this.svg); + + this.tip = document.createElement('div'); + this.tip.className = 'chart-tip'; + container.appendChild(this.tip); + + if ((showLegend ?? true) && this.series.length > 1) { + this.legend = document.createElement('div'); + this.legend.className = 'chart-legend'; + this.legend.innerHTML = this.series.map(s => + `${s.label || s.key}` + ).join(''); + container.appendChild(this.legend); + } + + this._ro = new ResizeObserver(() => { + if (this._rafPending) return; + this._rafPending = true; + requestAnimationFrame(() => { this._rafPending = false; this.draw(); }); + }); + this._ro.observe(this.svg); + + this._onHover = this._handleHover.bind(this); + this._hideTip = this._handleHideTip.bind(this); + this.svg.addEventListener('mousemove', this._onHover); + this.svg.addEventListener('mouseleave', this._hideTip); + } + set(data: ChartData | null | undefined, xValues: Array | null = null): void { + this.data = data || {}; + this.xValues = xValues; + this.draw(); + } + _bounds(): { yMin: number; yMax: number; n: number } { + let yMin = Infinity, yMax = -Infinity, n = 0; + for (const s of this.series) { + const arr = this.data[s.key] || []; + n = Math.max(n, arr.length); + for (const v of arr) { + if (!Number.isFinite(v)) continue; + if (v < yMin) yMin = v; + if (v > yMax) yMax = v; + } + } + if (!Number.isFinite(yMin) || !Number.isFinite(yMax)) { yMin = 0; yMax = 1; } + if (yMin === yMax) { yMin = Math.max(0, yMin - 1); yMax = yMax + 1; } + const pad = (yMax - yMin) * 0.08; + return { yMin: Math.max(0, yMin - pad), yMax: yMax + pad, n }; + } + draw(): void { + const w = this.svg.clientWidth || this.container.clientWidth; + const h = this.svg.clientHeight || Math.max(120, this.container.clientHeight - (this.legend ? 28 : 0)); + if (w <= 0 || h <= 0) return; + const { padding, gridX, gridY, showAxes, yFormat, xFormat, yLabel } = this.opts; + const ix = padding.left, iy = padding.top; + const iw = w - padding.left - padding.right; + const ih = h - padding.top - padding.bottom; + this.svg.setAttribute('viewBox', `0 0 ${w} ${h}`); + const { yMin, yMax, n } = this._bounds(); + this._ih = ih; this._iw = iw; this._ix = ix; this._iy = iy; this._n = n; + const parts: string[] = []; + for (let i = 0; i <= gridY; i++) { + const y = iy + (i / gridY) * ih; + const yV = yMax - (i / gridY) * (yMax - yMin); + parts.push(``); + if (showAxes) parts.push(`${yFormat(yV)}`); + } + for (let i = 0; i <= gridX; i++) { + const x = ix + (i / gridX) * iw; + parts.push(``); + if (showAxes && n > 1) { + const sampleIdx = Math.round((i / gridX) * (n - 1)); + const lbl = this.xValues ? this._fmtTime(this.xValues[sampleIdx]) : xFormat(sampleIdx); + parts.push(`${lbl}`); + } + } + if (yLabel) parts.push(`${yLabel}`); + const range = (yMax - yMin) || 1; + for (const s of this.series) { + const arr = this.data[s.key] || []; + if (arr.length < 2 || n < 2) continue; + const pts: Array<[number, number]> = []; + for (let i = 0; i < arr.length; i++) { + const v = Number(arr[i]); + if (!Number.isFinite(v)) continue; + const x = ix + (i / (n - 1)) * iw; + const y = iy + ih - ((v - yMin) / range) * ih; + if (Number.isFinite(x) && Number.isFinite(y)) pts.push([x, y]); + } + if (pts.length < 2) continue; + const d = pts.map(([x, y], i) => (i === 0 ? 'M' : 'L') + x.toFixed(1) + ' ' + y.toFixed(1)).join(' '); + if (s.area) { + const last = pts.at(-1); + if (!last) continue; + const a = d + ` L ${last[0].toFixed(1)} ${iy + ih} L ${pts[0][0].toFixed(1)} ${iy + ih} Z`; + parts.push(``); + } + parts.push(``); + } + parts.push(``); + for (const s of this.series) { + parts.push(``); + } + this.svg.innerHTML = parts.join(''); + } + _handleHover(e: MouseEvent): void { + if (!this._n || this._n < 2) return; + const rect = this.svg.getBoundingClientRect(); + const xRel = Math.max(0, Math.min(1, (e.clientX - rect.left - this._ix) / this._iw)); + const idx = Math.round(xRel * (this._n - 1)); + const xPx = this._ix + (idx / (this._n - 1)) * this._iw; + const ch = this.svg.querySelector('.chart-crosshair'); + if (ch) { ch.setAttribute('x1', String(xPx)); ch.setAttribute('x2', String(xPx)); ch.setAttribute('visibility', 'visible'); } + const { yMin, yMax } = this._bounds(); + const range = (yMax - yMin) || 1; + const rows = this.series.map(s => { + const v = (this.data[s.key] || [])[idx]; + const marker = this.svg.querySelector(`.chart-marker-${s.key}`); + if (v == null || !Number.isFinite(v)) { if (marker) marker.setAttribute('visibility', 'hidden'); return ''; } + if (marker) { + const yPx = this._iy + this._ih - ((v - yMin) / range) * this._ih; + marker.setAttribute('cx', String(xPx)); marker.setAttribute('cy', String(yPx)); marker.setAttribute('visibility', 'visible'); + } + return `
    ${s.label || s.key}${this.opts.yFormat(v)}
    `; + }).join(''); + const tsLabel = this.xValues ? this._fmtTime(this.xValues[idx]) : '#' + idx; + this.tip.innerHTML = `
    ${tsLabel}
    ${rows}`; + this.tip.dataset.show = '1'; + const cw = this.container.clientWidth; + const left = Math.min(cw - this.tip.offsetWidth - 4, Math.max(4, e.clientX - rect.left + 12)); + this.tip.style.left = left + 'px'; this.tip.style.top = (this._iy + 4) + 'px'; + } + _handleHideTip(): void { + const ch = this.svg.querySelector('.chart-crosshair'); + ch?.setAttribute('visibility', 'hidden'); + this.svg.querySelectorAll('.chart-marker').forEach(m => m.setAttribute('visibility', 'hidden')); + this.tip.dataset.show = '0'; + } + _fmtTime(ts: string | number | undefined): string { + if (!ts) return ''; + if (typeof ts === 'number') return new Date(ts).toTimeString().slice(0, 8); + return String(ts); + } + destroy(): void { + this._ro?.disconnect(); + this.svg.removeEventListener('mousemove', this._onHover); + this.svg.removeEventListener('mouseleave', this._hideTip); + } +} diff --git a/web/frontend/src/lib/comboboxPopover.ts b/web/frontend/src/lib/comboboxPopover.ts new file mode 100644 index 00000000000..cd9d630d1bc --- /dev/null +++ b/web/frontend/src/lib/comboboxPopover.ts @@ -0,0 +1,107 @@ +type RenderItem = (item: T, index: number, selected: boolean) => string; +type Accept = (index: number) => void; + +export class ComboboxPopover { + readonly el: HTMLUListElement; + + items: T[] = []; + selected = 0; + open = false; + + constructor( + className: string, + private readonly renderItem: RenderItem, + private readonly accept: Accept, + ) { + this.el = document.createElement('ul'); + this.el.className = `combobox-pop ${className}`; + this.el.setAttribute('role', 'listbox'); + this.el.setAttribute('popover', 'manual'); + this.el.style.position = 'fixed'; + this.el.style.margin = '0'; + } + + mount(parent: Node = document.body): void { + parent.appendChild(this.el); + } + + destroy(): void { + this.el.remove(); + } + + contains(target: EventTarget | null): boolean { + return !!target && this.el.contains(target as Node); + } + + setItems(items: T[], selected = 0): void { + this.items = items; + this.selected = selected; + this.render(); + } + + show(): void { + this.open = true; + try { this.el.showPopover(); } catch {} + } + + hide(): void { + this.open = false; + try { this.el.hidePopover(); } catch {} + } + + setSelected(index: number): void { + this.selected = Math.max(0, Math.min(this.items.length - 1, index)); + this.reflectSelection(); + } + + move(delta: number): void { + this.setSelected(this.selected + delta); + this.scrollSelectedIntoView(); + } + + ensureParent(parent: Node): void { + if (this.el.parentNode !== parent) { + this.hide(); + parent.appendChild(this.el); + } + } + + position(left: number, top: number, minWidth?: number): void { + this.el.style.left = `${left}px`; + this.el.style.top = `${top}px`; + if (minWidth != null) this.el.style.minWidth = `${minWidth}px`; + } + + handleKey(e: KeyboardEvent): boolean { + if (!this.open) return false; + if (e.key === 'ArrowDown') this.move(1); + else if (e.key === 'ArrowUp') this.move(-1); + else if (e.key === 'Enter' || e.key === 'Tab') this.accept(this.selected); + else if (e.key === 'Escape') this.hide(); + else return false; + e.preventDefault(); + return true; + } + + render(): void { + this.el.innerHTML = this.items.map((item, i) => this.renderItem(item, i, i === this.selected)).join(''); + this.el.querySelectorAll('li').forEach(li => { + li.onmousedown = e => { + e.preventDefault(); + this.accept(Number((li as HTMLElement).dataset.i)); + }; + li.onmouseenter = () => this.setSelected(Number((li as HTMLElement).dataset.i)); + }); + } + + private reflectSelection(): void { + this.el.querySelectorAll('li').forEach((li, i) => { + li.setAttribute('aria-selected', String(i === this.selected)); + }); + } + + private scrollSelectedIntoView(): void { + const li = this.el.querySelectorAll('li')[this.selected] as HTMLElement | undefined; + li?.scrollIntoView({ block: 'nearest' }); + } +} diff --git a/web/frontend/src/lib/expression.ts b/web/frontend/src/lib/expression.ts new file mode 100644 index 00000000000..f949daa5097 --- /dev/null +++ b/web/frontend/src/lib/expression.ts @@ -0,0 +1,202 @@ +// Expression language tokenizer and completion helpers. Public metadata comes from +// the backend MQL constants endpoint. + +import { api } from './api.ts'; + +export type MqlField = { name: string; detail?: string }; +export type MqlFunction = { name: string; sig?: string; detail?: string; pipe?: boolean }; +export type MqlOperator = { name: string; detail?: string; kind?: string }; +export type MqlConstants = { + fields: MqlField[]; + functions: MqlFunction[]; + operators: MqlOperator[]; + literals: string[]; +}; + +const EMPTY_CONSTANTS: MqlConstants = { + fields: [], + functions: [], + operators: [], + literals: [], +}; + +let cached: MqlConstants | null = null; +let pending: Promise | null = null; + +export const schemaOrDefault = (schema?: MqlConstants | null) => schema ?? cached ?? EMPTY_CONSTANTS; + +const opDoc = (name: string, schema?: MqlConstants | null) => + operatorFor(name, schema)?.detail ?? ''; +export const operatorFor = (name: string, schema?: MqlConstants | null) => + schemaOrDefault(schema).operators.find(o => o.name === name); +export const operatorNames = (schema: MqlConstants | null | undefined, ...kinds: string[]) => + schemaOrDefault(schema).operators.filter(o => kinds.includes(o.kind ?? '')).map(o => o.name); + +export function appendArithAndPipeOps(out, schema?: MqlConstants | null) { + for (const o of operatorNames(schema, 'arithmetic')) { + out.push({ label: o, kind: 'op', insert: ' ' + o + ' ', detail: opDoc(o, schema) }); + } + const pipe = operatorFor('|', schema); + if (pipe) out.push({ label: '|', kind: 'op', insert: ' | ', detail: pipe.detail || '' }); +} + +export async function loadSchema() { + if (cached) return cached; + pending ??= api('/mql/constants') + .then(raw => cached = normalizeConstants(raw)) + .catch(error => { pending = null; throw error; }); + return pending; +} + +function normalizeConstants(raw: any): MqlConstants { + const functions = array(raw?.functions).map(fn => typeof fn === 'string' + ? { name: fn } + : { name: String(fn.name), sig: fn.sig, detail: fn.detail, pipe: !!fn.pipe }); + return { + fields: array(raw?.fields).map(field => typeof field === 'string' + ? { name: field } + : { name: String(field.name), detail: field.detail }), + functions, + operators: array(raw?.operators).map(op => typeof op === 'string' + ? { name: op } + : { name: String(op.name), detail: op.detail, kind: op.kind }), + literals: array(raw?.literals).map(String), + }; +} + +const array = (value: any) => Array.isArray(value) ? value : []; + +// ---- Tokenizer ---- + +const TOKEN_RULES: Array<[string, RegExp]> = [ + ['ws', /^\s+/], + ['literal', /^(true|false)\b/], + ['string', /^"([^"\\]|\\.)*"?/], + ['number', /^\d+(\.\d+)?/], + ['pipe', /^\|/], + ['op', /^(!=|<=|>=|=|<|>|~|\+|-|\*|\/|%)/], + ['paren', /^[()]/], + ['comma', /^,/], + ['dot', /^\./], + ['ident', /^[A-Za-z_][A-Za-z_0-9]*/], +]; + +export function tokenize(src) { + const tokens = []; + outer: for (let i = 0; i < src.length; ) { + const rest = src.slice(i); + for (const [kind, re] of TOKEN_RULES) { + const m = re.exec(rest); + if (!m || !m[0]) continue; + tokens.push({ kind, text: m[0], start: i, end: i + m[0].length }); + i += m[0].length; + continue outer; + } + tokens.push({ kind: 'error', text: src[i], start: i, end: i + 1 }); + i++; + } + promotePaths(tokens); + return tokens; +} + +function promotePaths(tokens) { + for (let j = 0; j < tokens.length; j++) { + if (tokens[j].kind !== 'ident') continue; + const next = tokens[j + 1]; + if (next?.kind === 'paren' && next.text === '(') { tokens[j].kind = 'function'; continue; } + tokens[j].kind = 'root'; + for (let k = j + 1; tokens[k]?.kind === 'dot' && tokens[k + 1]?.kind === 'ident'; k += 2) { + tokens[k + 1].kind = 'path'; + } + } +} + +// ---- Completion ---- + +const EDITING_KINDS = new Set(['root', 'path', 'ident', 'function', 'literal']); +const VALUE_END_KINDS = new Set(['root', 'path', 'literal', 'number', 'string']); +const EMPTY = new Set(); + +export function isInString(src, caret) { + let inStr = false; + for (let i = 0; i < caret && i < src.length; i++) { + const c = src[i]; + if (inStr && c === '\\' && i + 1 < caret) { i++; continue; } + if (c === '"') inStr = !inStr; + } + return inStr; +} + +export function complete(src, caret, schema) { + if (isInString(src, caret)) return []; + schema = schemaOrDefault(schema); + const ctx = contextAt(tokenize(src), caret); + const out = []; + + if (ctx.wants === 'value') { + for (const f of schema.fields) out.push({ label: f.name, kind: 'field', insert: f.name, detail: f.detail || '' }); + for (const f of schema.functions) out.push({ label: f.name, kind: 'function', insert: f.name + '(', detail: f.detail || 'function' }); + for (const l of schema.literals) out.push({ label: l, kind: 'literal', insert: l }); + } else if (ctx.wants === 'path') { + out.push({ label: '(any nbt key)', kind: 'hint', insert: '', detail: 'NBT / server-data sub-key' }); + } else if (ctx.wants === 'transform') { + for (const f of schema.functions.filter(f => f.pipe)) { + out.push({ label: f.name, kind: 'transform', insert: f.name, detail: f.detail || 'transform' }); + } + } else if (ctx.wants === 'op') appendArithAndPipeOps(out, schema); + + return finalize(out, ctx); +} + +export function finalize(items, ctx) { + const lo = ctx.partial.toLowerCase(); + // No partial = no suggestions. Showing the full catalog the moment the caret crosses + // whitespace is noisy and steals focus from typing. Users who want the full list can + // press Ctrl/Cmd+Space which calls openPop() again with a non-empty partial after they + // start typing. + if (!lo) return []; + + const scored = items + .map(c => { + const ll = c.label.toLowerCase(); + const score = ll.startsWith(lo) ? 0 : ll.includes(lo) ? 1 : -1; + return { ...c, score, range: ctx.range }; + }) + .filter(c => c.score >= 0); + + // If the only prefix-match is the partial itself, the user has already finished typing + // a valid term — surface nothing rather than re-suggesting what they just wrote. + const prefixHits = scored.filter(c => c.score === 0); + if (prefixHits.length === 1 && prefixHits[0].label.toLowerCase() === lo) return []; + + return scored + .sort((a, b) => a.score - b.score || a.label.localeCompare(b.label)) + .slice(0, 12); +} + +export function contextAt(tokens, caret, opts: any = {}) { + const { isKeyword = () => false, valueStartKw = EMPTY, cmpBoundaryKw = EMPTY } = opts; + let cur = null, here = -1; + for (let i = 0; i < tokens.length; i++) { + if (tokens[i].start <= caret && caret <= tokens[i].end) { cur = tokens[i]; here = i; break; } + } + const editing = !!(cur && cur.start < caret && cur.end >= caret && EDITING_KINDS.has(cur.kind)); + const partial = editing ? cur.text.slice(0, caret - cur.start) : ''; + const range = editing ? [cur.start, cur.end] : [caret, caret]; + let prevIdx = -1, prev = null; + const from = editing ? here : (cur ? here + 1 : tokens.length); + for (let i = from - 1; i >= 0; i--) if (tokens[i].kind !== 'ws') { prevIdx = i; prev = tokens[i]; break; } + const base = { partial, range, prev, prevIdx }; + if (!prev) return { ...base, wants: 'value' }; + if (prev.kind === 'dot') return { ...base, wants: 'path' }; + if (prev.kind === 'pipe') return { ...base, wants: 'transform' }; + if (prev.kind === 'op' || prev.kind === 'comma' + || (prev.kind === 'paren' && prev.text === '(') + || (isKeyword(prev) && (valueStartKw.has(prev.text) || cmpBoundaryKw.has(prev.text)))) { + return { ...base, wants: 'value' }; + } + if (VALUE_END_KINDS.has(prev.kind) || (prev.kind === 'paren' && prev.text === ')')) { + return { ...base, wants: partial ? 'value' : 'op' }; + } + return { ...base, wants: 'value' }; +} diff --git a/web/frontend/src/lib/floatingPopover.svelte.ts b/web/frontend/src/lib/floatingPopover.svelte.ts new file mode 100644 index 00000000000..155eeadf205 --- /dev/null +++ b/web/frontend/src/lib/floatingPopover.svelte.ts @@ -0,0 +1,101 @@ +/// Reactive helper for anchored floating popovers. Owns positioning, outside-click, +/// optional escape, and scroll/resize handling (scroll/resize closes the popover) so each +/// popover component only declares *where* it sits relative to its anchor. +/// +/// Must be called from a component ` + +{#snippet actionsCrumb()}Actions{/snippet} +{#snippet title()}{list.length} registered{/snippet} +{#snippet actions()}{/snippet} + + + +
    + {#if list.length === 0} + + {#snippet cta()} + + {/snippet} + + {:else} + {#each list as a (a.id)} + {@const kind = a.action?.type || 'unknown'} + {@const usedCount = a.usedBy?.length ?? 0} + + {#snippet icon()}{actionIcon(a.action)}{/snippet} + {#snippet badges()} + {kind} + {usedCount} routine{usedCount === 1 ? '' : 's'} + {/snippet} + {#snippet detail()} + + {actionSummary(a.action)} + + {/snippet} + {#snippet actions()} + + + {/snippet} + + {/each} + {/if} +
    + + +
    +

    {editing?.id ? 'Edit action' : 'New action'}

    +
    + + +
    +
    + {#if editing} +
    + +
    + Action + draftAction = v} /> +
    +
    + {/if} +
    diff --git a/web/frontend/src/views/Dashboard.svelte b/web/frontend/src/views/Dashboard.svelte new file mode 100644 index 00000000000..6d195ef19da --- /dev/null +++ b/web/frontend/src/views/Dashboard.svelte @@ -0,0 +1,184 @@ + + + + +{#snippet overview()}Overview{/snippet} + +
    +
    + +

    Live Operations

    +
    + {#if replayEnded} +
    + +
    + {replayStatus === 'error' ? 'Replay failed.' : 'Replay finished.'} + + {#if replayEndedAt}Frozen at {fmtTime(replayEndedAt)}.{/if} + Live counters have stopped updating. + {#if replayStatus === 'error' && replayError}
    {replayError}{/if} +
    +
    +
    + {/if} + {#if persistence?.enabled} +
    + + {#if exportErr} + {exportErr} + {:else} + protocol v{persistence.protocolVersion} + {/if} +
    + {/if} +
    + + + +
    + + + + + humanNumber(Math.round(v))} className="chart-md" /> + +
    + +
    + + {#snippet title()}Active Sessions{/snippet} + {#snippet actions()}View all →{/snippet} + + + + + + + {#each players as p (p.uuid)} + {@const offline = !!p.disconnectedAt} + {@const state = offline ? 'OFFLINE' : (p.serverConnectionState || '—')} + + + + + + + + + + + {/each} + +
    PlayerStateDimensionModeHealthPingSession
    {p.username || '—'}{#snippet children()}{state}{/snippet}{(p.dimension || '—').replace('minecraft:', '')}{#snippet children()}{p.gamemode || '—'}{/snippet}{(p.health ?? 0).toFixed(1)} / {(p.maxHealth ?? 20).toFixed(0)}{offline ? '—' : p.traffic.pingMs} {offline ? '' : 'ms'}{humanDuration(sessionDuration(p, now))} +
    + Open + Packets +
    +
    + {#if players.length === 0}
    No sessions. The proxy is listening; clients have yet to arrive.
    {/if} +
    +
    + + diff --git a/web/frontend/src/views/GlobalPackets.svelte b/web/frontend/src/views/GlobalPackets.svelte new file mode 100644 index 00000000000..cf283beb3b0 --- /dev/null +++ b/web/frontend/src/views/GlobalPackets.svelte @@ -0,0 +1,185 @@ + + +{#snippet pkCrumb()}Packets{/snippet} +{#snippet globalCrumb()}Global{/snippet} +{#snippet title()}Global packet analysis{/snippet} +{#snippet subtitle()} +

    + Aggregate across {players.length} sessions · {RATE_WINDOW}s rate window · click a swimlane for detail +

    +{/snippet} +{#snippet actions()} + +{/snippet} + + + +
    +
    +
    Throughput
    +
    {humanBytes(view.bps)}/s
    +
    {humanNumber(Math.round(view.pps))} pkt/s · {humanBytes(view.totalBytes)} in view
    +
    +
    +
    +
    +
    +
    +
    Sessions
    +
    {players.length}
    +
    tracking {view.streamCount} streams
    +
    +
    +
    Classes seen
    +
    {view.classCount}
    +
    + {#if view.topClass}top · {pktLabel(view.topClass.k)}{:else}—{/if} +
    +
    +
    +
    Total packets
    +
    {humanNumber(view.totalCount)}
    +
    {humanBytes(view.cbBytes)} ⬇ · {humanBytes(view.sbBytes)} ⬆
    +
    +
    + +
    + + {#if !view.lanes.length} +
    No active sessions.
    + {:else} +
    + {#each view.lanes as { p, lane } (p.uuid)} + navigate('/p/' + p.uuid + '/packets')} + /> + {/each} +
    + {/if} +
    + + {#if !feed.anomalies.length} +
    No anomalies detected.
    + {:else} +
    + {#each feed.anomalies as a, i (a.ts + ':' + i)} +
    + + {a.msg} + {fmtAge(feed.now - a.ts)} ago +
    + {/each} +
    + {/if} +
    +
    + +
    + sortBy = v} /> +
    + + diff --git a/web/frontend/src/views/Landing.svelte b/web/frontend/src/views/Landing.svelte new file mode 100644 index 00000000000..c2fb84f3c35 --- /dev/null +++ b/web/frontend/src/views/Landing.svelte @@ -0,0 +1,163 @@ + + +
    +
    +

    Replay a session

    +

    + Drop a sessions.sqlite file produced by a live proxy run. The dashboard + decodes its packet stream into players, lifecycle events, and minimaps — visible only + in this browser tab. +

    +
    + +
    { if (e.key === 'Enter' || e.key === ' ') onPick(); }}> +
    +
    + {uploading ? 'Uploading & decoding…' : 'Drop a .sqlite file, or click to pick'} +
    +
    + Protocol version must match this build (v{mode.protocolVersion ?? '?'}). +
    + +
    + + + + {#if error} +
    {error}
    + {/if} + + {#if mode.scope} +
    + Current scope: + {mode.scope.label} + Open dashboard → + +
    + {/if} +
    + + diff --git a/web/frontend/src/views/Players.svelte b/web/frontend/src/views/Players.svelte new file mode 100644 index 00000000000..286bfe73fb2 --- /dev/null +++ b/web/frontend/src/views/Players.svelte @@ -0,0 +1,127 @@ + + + + +{#snippet playersCrumb()}Players{/snippet} +{#snippet title()}{visible.length} connected{/snippet} +{#snippet actions()} + +{/snippet} + + + +
    + query = v} + rows={1} + compact + placeholder='filter — e.g. ping > 100 or gamemode = "SURVIVAL"' + status={statusToShow} + /> +
    + + + + + + + + + + + {#each visible as p (p.uuid)} + {@const pos = [p.posX ?? 0, p.posY ?? 0, p.posZ ?? 0]} + {@const offline = !!p.disconnectedAt} + + + + + + + + + + + + + + + + + + {/each} + +
    PlayerUUIDBackendStateDimensionModePosHealthFoodXPPingLatency 60sIn · OutSession
    {p.username || '—'}{shortUuid(p.uuid)}{p.backendAddress || '—'}{offline ? 'OFFLINE' : (p.serverConnectionState || '—')}{(p.dimension || '—').replace('minecraft:', '')}{p.gamemode || '—'}{pos.map(v => Number(v).toFixed(0)).join(', ')}{(p.health ?? 0).toFixed(1)}/{(p.maxHealth ?? 20).toFixed(0)}{p.food ?? 0}/20{p.xpLevel ?? 0}{pingOf(p)} ms + + + + {humanBytes(p.traffic.bytesIn)}·{humanBytes(p.traffic.bytesOut)}{humanDuration(sessionDuration(p, now))} +
    + Open + Packets +
    +
    + {#if visible.length === 0}
    No connected players.
    {/if} +
    diff --git a/web/frontend/src/views/Profile.svelte b/web/frontend/src/views/Profile.svelte new file mode 100644 index 00000000000..b289023baa2 --- /dev/null +++ b/web/frontend/src/views/Profile.svelte @@ -0,0 +1,267 @@ + + +{#snippet playersCrumb()}Players{/snippet} +{#snippet nameCrumb()}{player?.username || player?.uuid}{/snippet} + +{#snippet receivedChat(received)} + {#if received.length === 0} +
    No chat received.
    + {:else} + {#each received as line, i (i)} +
    + + {fmtClock(line.ts)} + {#if line.sender} · {shortUuid(line.sender)}{/if} + + +
    + {/each} + {/if} +{/snippet} + +{#snippet sentChat(sent)} + {#if sent.length === 0} +
    No outgoing chat captured yet.
    + {:else} + {#each sent as m, i (i)} +
    + {fmtClock(m.ts)} + + {#if m.kind === 'command'}/{/if} + {m.text} + +
    + {/each} + {/if} +{/snippet} + +{#if err} +
    +
    +

    Player not found

    +
    {err}
    +
    +
    +{:else if !player} +
    Loading…
    +{:else} +
    +
    +
    + +
    +
    + + + +
    +
    +
    +
    {(player.username || '?').slice(0, 2).toUpperCase()}
    +
    +
    {player.username || 'unknown'}
    +
    + {player.serverConnectionState || '—'} + UUID{shortUuid(player.uuid)} + Session{humanDuration(now - (player.connectedAt || now))} + {#if player.protocolVersion != null}Protocol{player.protocolVersion}{/if} + {#if player.locale}Locale{player.locale}{/if} +
    +
    +
    +
    + {player.traffic.pingMs} ms + Ping +
    +
    + {humanBytes(player.traffic.bytesIn + player.traffic.bytesOut)} + Total i/o +
    +
    +
    + {#if paused} +
    + Frozen · live state updates and packet streams are paused. Click Resume to continue. +
    + {/if} + + + + {#if tab === 'overview'} +
    + ⓘ Provenance + + Every traceable value carries a quiet dotted underline. + Hover to peek the source packet · click to pin the full history. + + +
    + +
    +
    + + + + + +
    +
    + + + +
    +
    +
    + + +
    +
    + {:else if tab === 'packets'} + + {:else if tab === 'lifecycle'} + + {:else if tab === 'inventory'} + + {:else if tab === 'world'} +
    + +
    + {:else if tab === 'entities'} + + {:else if tab === 'registries'} + + {:else if tab === 'action'} + + {:else if tab === 'chat'} +
    + + + {#snippet children()}{@render receivedChat((player.recentChat || []).slice(-100))}{/snippet} + + + + + {#snippet children()}{@render sentChat((player.sentChat || []).slice(-100))}{/snippet} + + +
    + {/if} + + {#if prov} + provenanceCurrentValue(player, f)} + sourceSeq={player?.provenance?.[prov.field]?.seq ?? null} + onClose={closeProv} + /> + {/if} + +
    +{/if} diff --git a/web/frontend/src/views/Query.svelte b/web/frontend/src/views/Query.svelte new file mode 100644 index 00000000000..b5d5c7b86f5 --- /dev/null +++ b/web/frontend/src/views/Query.svelte @@ -0,0 +1,160 @@ + + + + +{#snippet guideCrumb()}MQL guide{/snippet} +{#snippet title()}MQL guide & sandbox{/snippet} +{#snippet actions()} + + +{/snippet} + + + +
    +
    Minestom Query Language
    +

    A small, total expression language with comparisons, boolean logic, dotted paths, + regex matches, collection membership, and a tiny library of functions. Used by the trigger + page, routine filters, and the in-app evaluators. Browse the examples, grammar, and reference + below — or paste your own into the sandbox.

    +
    + Keyword + Field + Function + String + Number + Operator +
    +
    + +
    +
    + + {#snippet meta()}press cmd to run{/snippet} + ql = v} rows={3} big placeholder='health < 6 and gamemode = "SURVIVAL"' {status} onSubmit={() => runQuery(ql)} /> +
    + Press to accept · esc to dismiss + {matches.length === 0 ? '—' : `${matches.length} match${matches.length === 1 ? '' : 'es'}`} +
    +
    + {#if matches.length === 0 && status?.kind === 'error'} +
    {status.message}
    + {:else if matches.length > 0} +
    + {#each matches as u (u)} + {@const p = players.get(u)} + + {p?.username || u.slice(0, 8)} + {u} + {(p?.dimension || '—').replace('minecraft:', '')} + + {/each} +
    + {/if} +
    +
    + + +
    + {#each EXAMPLES as e, i (i)} + + {/each} +
    +
    +
    + + +
    diff --git a/web/frontend/src/views/Routines.svelte b/web/frontend/src/views/Routines.svelte new file mode 100644 index 00000000000..bbb31b6f5d8 --- /dev/null +++ b/web/frontend/src/views/Routines.svelte @@ -0,0 +1,157 @@ + + +{#snippet routinesCrumb()}Routines{/snippet} +{#snippet title()}{activeCount} / {routines.length} active{/snippet} +{#snippet actions()}{/snippet} + + + +
    + {#if routines.length === 0} + + {#snippet cta()} + + {/snippet} + + {:else} + {#each routines as r (r.id)} + {@const ax = r.action} + {@const kind = isActionRef(ax) ? 'ref' : (ax?.type || 'inline')} + {@const summary = isActionRef(ax) ? `(registered ${actionRefId(ax)})` : actionSummary(ax)} + + {#snippet icon()}{triggerIcon(r.trigger)}{/snippet} + {#snippet badges()} + {triggerLabel(r.trigger)} + {r.enabled ? 'enabled' : 'disabled'} + {/snippet} + {#snippet detail()} + + {#if r.ql}{:else}(empty){/if} + + + {kind}{summary} + {/snippet} + {#snippet actions()} + toggleEnabled(r)} /> + + + {/snippet} + + {/each} + {/if} +
    + + +
    +

    {editing?.id ? 'Edit routine' : 'New routine'}

    +
    + + +
    +
    + {#if draft} +
    + +
    + Match (MQL) + draft.ql = ql} rows={2} placeholder='health < 6 and gamemode = "SURVIVAL"' onSubmit={save} /> +
    +
    + Trigger + draft.trigger = trigger} /> +
    +
    + Action + draft.action = action} /> +
    + +
    + {/if} +
    diff --git a/web/frontend/src/views/Terminal.svelte b/web/frontend/src/views/Terminal.svelte new file mode 100644 index 00000000000..e1827c8afe9 --- /dev/null +++ b/web/frontend/src/views/Terminal.svelte @@ -0,0 +1,139 @@ + + + + +{#snippet terminalCrumb()}Terminal{/snippet} +{#snippet title()}Server terminal{/snippet} +{#snippet actions()}{lines.length} lines · live tail{/snippet} + + + +{#if metrics} + {@const m = metrics} +
    +
    CPU{(m.processCpu * 100).toFixed(1)}%
    +
    Heap{`${humanBytes(m.heapUsed)} / ${humanBytes(m.heapMax)}`}
    +
    TPS{m.tps.toFixed(1)}
    +
    MSPT{m.mspt.toFixed(2)} ms
    +
    Threads{m.threadCount}
    +
    Uptime{humanDuration(m.uptimeMs)}
    +
    Players{m.playerCount}
    +
    +{/if} +
    +
    + + {#snippet meta()}{lines.length} lines{/snippet} +
    + {#if lines.length === 0} +
    Waiting for output…
    + {:else} + {#each lines as l, i (i)} +
    + {fmtTime(l.ts).slice(0, 8)} + {l.level} + {l.message} +
    + {/each} + {/if} +
    +
    + +
    + {'>'} + + +
    +
    + + +
    + {#if global && Object.keys(global).length > 0} + + {:else} +
    No data pushed yet.
    Queryable via global.<path>.
    + {/if} +
    +
    +
    diff --git a/web/frontend/src/views/Throttle.svelte b/web/frontend/src/views/Throttle.svelte new file mode 100644 index 00000000000..05a4852b3ea --- /dev/null +++ b/web/frontend/src/views/Throttle.svelte @@ -0,0 +1,864 @@ + + +{#snippet throttleCrumb()}Throttle{/snippet} +{#snippet title()}Traffic shaper{/snippet} +{#snippet headActions()} + + + +{/snippet} +{#snippet tickMarks(ticks)} +
    + {#each ticks as t (t)}{/each} +
    +{/snippet} + +{#snippet faderScale(labels)} +
    + {#each labels as label (label)}{label}{/each} +
    +{/snippet} + + + +
    +
    +
    + {#each Array(6) as _, i (i)}{/each} +
    +
    + SHAPER + · + {statusLabel} +
    +
    + + Global: {summarize(liveGlobal)} + + + Targeted: {targetedCount} {targetedCount === 1 ? 'player' : 'players'} + +
    + +
    + +
    +
    + + +
    + + {#if mode === 'player'} +
    +
    + + {#if selectedUuid} + target → {labelFor(selectedUuid)} + {/if} +
    +
    + {#each filteredPlayers as p (p.uuid)} + {@const isSel = selectedUuid === p.uuid} + {@const live = perPlayer[p.uuid]} + + {/each} + {#if filteredPlayers.length === 0} +
    No matching connections.
    + {/if} +
    +
    + {/if} +
    + +
    +
    + Direction +
    + + + +
    +
    +
    + Draft + {summarize(active)} +
    + {#if mode === 'player' && selectedUuid && livePlayer} +
    + Live (target) + {summarize(livePlayer)} +
    + {/if} +
    + +
    +
    0}> +
    + 01 + Latency + ms +
    +
    + patch({ latencyMs: readInt(e, 0, 60_000) })} + /> + ms · base delay +
    +
    + {@render tickMarks(TICK20)} + patch({ latencyMs: +(e.currentTarget as HTMLInputElement).value })} + /> + {@render faderScale(['0', '500', '1k', '1.5k', '2k'])} +
    +
    fixed ms added per packet — both ends feel it
    +
    + +
    0}> +
    + 02 + Jitter + ± ms +
    +
    + ± + patch({ jitterMs: readInt(e, 0, 10_000) })} + /> + ms · random variance +
    +
    + {@render tickMarks(TICK10)} + patch({ jitterMs: +(e.currentTarget as HTMLInputElement).value })} + /> + {@render faderScale(['0', '125', '250', '375', '500'])} +
    +
    uniform [0…N) extra latency, picked per packet
    +
    + +
    0}> +
    + 03 + Bandwidth + cap +
    +
    + bwInputFocused = true} + onblur={() => bwInputFocused = false} + oninput={e => { + const raw = parseFloat((e.currentTarget as HTMLInputElement).value); + if (!Number.isFinite(raw) || raw < 0) return; + patch({ bandwidthBytesPerSec: Math.min(BW_MAX, Math.round(raw * bwUnitDiv(bwUnit))) }); + }} + /> + {bwUnit} + {#if !active.bandwidthBytesPerSec}· unlimited{/if} +
    +
    + {@render tickMarks(TICK20)} + patch({ bandwidthBytesPerSec: fracToBw(+(e.currentTarget as HTMLInputElement).value / 1000) })} + /> + {@render faderScale(['0', '1K', '32K', '1M', '16M'])} +
    +
    per-direction outgoing cap · log-scale
    +
    + +
    + + {#if Object.keys(perPlayer).length > 0} + + {#snippet meta()}{targetedCount} engaged{/snippet} + + + + {#each Object.entries(perPlayer) as [uuid, t] (uuid)} + + + + + + + {/each} + +
    PlayerUUIDThrottle
    {labelFor(uuid)}{shortUuid(uuid)}{summarize(t)} +
    + + +
    +
    +
    + {/if} +
    + + diff --git a/web/frontend/src/views/Trigger.svelte b/web/frontend/src/views/Trigger.svelte new file mode 100644 index 00000000000..e41381e6cb0 --- /dev/null +++ b/web/frontend/src/views/Trigger.svelte @@ -0,0 +1,143 @@ + + +{#snippet triggerCrumb()}Trigger{/snippet} +{#snippet title()}Ad-hoc trigger{/snippet} +{#snippet actions()} + + +{/snippet} + + + +
    +
    + + {#snippet title()}1 · Match · MQL{/snippet} + ql = v} rows={3} big placeholder='gamemode = "SURVIVAL" and ping < 100' {status} onSubmit={fire} /> + + + + {#snippet title()}2 · Then · action{/snippet} + action = v} /> + + + + {#snippet meta()}{history.length} runs this session{/snippet} + {#if history.length === 0} +
    No runs yet. Hit ▶ Run to fire against the live roster.
    + {:else} + + + + {#each history as h, i (i)} + + + + + + + + {/each} + +
    TimeActionMatchedFiredErrors
    {h.ts}{h.action}{h.matched}{h.fired}{h.errors.length}
    + {/if} +
    +
    + +
    + + {#snippet meta()}{preview.length} will fire{/snippet} + {#if preview.length === 0} +
    No matches.
    + {:else} + {#each preview as p (p.uuid)} + + +
    +
    {p.username || '—'}
    +
    {(p.dimension || '—').replace('minecraft:', '')} · HP {(p.health ?? 0).toFixed(1)}
    +
    + +
    + {/each} + {/if} +
    + + +
    +
    match all · leave blank to target every player
    +
    dry run · pick a chat action to preview what would fire
    +
    recurring · click "Save as routine" to fire it automatically
    +
    +
    +
    +
    diff --git a/web/frontend/tsconfig.json b/web/frontend/tsconfig.json new file mode 100644 index 00000000000..697be1d0b8d --- /dev/null +++ b/web/frontend/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": false, + "allowImportingTsExtensions": true, + "checkJs": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitAny": false, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": false, + "strict": false, + "target": "ES2022", + "useDefineForClassFields": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"], + "exclude": ["node_modules"] +} diff --git a/web/src/main/java/module-info.java b/web/src/main/java/module-info.java new file mode 100644 index 00000000000..5332704dbcf --- /dev/null +++ b/web/src/main/java/module-info.java @@ -0,0 +1,13 @@ +module net.minestom.web { + requires transitive net.minestom.server; + requires io.javalin; + requires org.slf4j; + requires java.desktop; + requires java.naming; + requires java.sql; + + requires net.kyori.adventure.text.serializer.gson; + requires net.kyori.adventure.text.serializer.legacy; + + exports net.minestom.web; +} diff --git a/web/src/main/java/net/minestom/web/Action.java b/web/src/main/java/net/minestom/web/Action.java new file mode 100644 index 00000000000..522af5169d1 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Action.java @@ -0,0 +1,35 @@ +package net.minestom.web; + +import java.util.List; +import java.util.Map; + +/// Declarative action discriminator for [Routine]. +public sealed interface Action { + record Inject(String className, Map fields) implements Action {} + + /// `component` is an expression ([String]) or literal [net.kyori.adventure.text.Component] JSON object. + record Chat(Object component) implements Action { + public Chat { + if (component == null) throw new IllegalArgumentException("component required"); + } + } + + record SetCustom(String key, String value) implements Action {} + + /// Transfer the player to another Minecraft server. Any reachable address works — the + /// proxy doesn't pre-register backends. + /// + /// `address` is an **expression source** ([net.minestom.web.internal.expression.ExpressionEngine]), + /// evaluated against the player on each fire and then handed to + /// [net.minestom.web.internal.AddressResolver#parseMinecraft]. The evaluated string accepts the same + /// shapes as the vanilla client connect dialog — `"play.example.com"` (SRV → fallback to + /// 25565), `"play.example.com:25577"` (explicit port), or `"[ipv6]:25565"`. Dynamic + /// targets can interpolate player/global state, e.g. `"\"region-\" + xpLevel + \".example.com\""`. + record Move(String address) implements Action { + public Move { + if (address == null || address.isBlank()) throw new IllegalArgumentException("address required"); + } + } + + record Sequence(List actions) implements Action {} +} diff --git a/web/src/main/java/net/minestom/web/BackendRouter.java b/web/src/main/java/net/minestom/web/BackendRouter.java new file mode 100644 index 00000000000..5d9cde04765 --- /dev/null +++ b/web/src/main/java/net/minestom/web/BackendRouter.java @@ -0,0 +1,46 @@ +package net.minestom.web; + +import org.jetbrains.annotations.Nullable; + +import java.net.InetSocketAddress; + +/// Decides which backend (`host:port`) a freshly accepted client connects to. Invoked after the +/// client handshake has been read, before any upstream socket is dialled. Embedders install a +/// custom router via [ProxyServer.Builder#router]; the default returns the configured default +/// backend address on LOGIN and honours the journey cookie's target on TRANSFER reconnects. +/// +/// **Transfer-aware.** The proxy passes a [Context] that flags whether this is a fresh `LOGIN` +/// connection or a `TRANSFER` reconnect carrying a cookie minted by an earlier +/// `movePlayer(...)`. For transfer reconnects [Context#targetFromCookie] is the address the +/// cookie was minted against — implementations can honour it directly or override. +@FunctionalInterface +public interface BackendRouter { + /// Pick a backend for this connection. Return `null` to refuse the connection entirely + /// (the proxy will close the socket without forwarding). + @Nullable BackendTarget route(Context ctx); + + /// Returns the configured default backend on LOGIN and the cookie's address on TRANSFER. + /// Suitable for the common case where every player starts on one server and only moves + /// via explicit `proxy.movePlayer(...)` calls. + static BackendRouter defaultRouter() { + return ctx -> { + final InetSocketAddress address = ctx.targetFromCookie() != null + ? ctx.targetFromCookie() : ctx.defaultBackend(); + return address == null ? null : new BackendTarget(address); + }; + } + + /// Read-only view of what the proxy knows when it has to choose a backend. + record Context( + InetSocketAddress defaultBackend, + String handshakeHostname, + int handshakePort, + int protocolVersion, + Intent intent, + /// On `TRANSFER` reconnects: the address the journey cookie was minted against. + /// `null` for `LOGIN` or for transfers that arrived without a matching cookie. + @Nullable InetSocketAddress targetFromCookie + ) { + public enum Intent { LOGIN, TRANSFER, STATUS } + } +} diff --git a/web/src/main/java/net/minestom/web/BackendTarget.java b/web/src/main/java/net/minestom/web/BackendTarget.java new file mode 100644 index 00000000000..0ca8447cb4a --- /dev/null +++ b/web/src/main/java/net/minestom/web/BackendTarget.java @@ -0,0 +1,32 @@ +package net.minestom.web; + +import org.jetbrains.annotations.Nullable; + +import java.net.InetSocketAddress; +import java.util.Objects; + +/// A Minecraft server the proxy can route a player to. The `address` doubles as the target's +/// identity for display and journey-tracking purposes — there is no pre-registration step. Any +/// reachable host:port works as a target the moment the router (or [ProxyServer#movePlayer]) +/// names it. +/// +/// `mojang` overrides the process-wide [ProxyConfig#mojang] when the target requires a +/// different bot identity. Most deployments leave it `null` and use the process-wide auth. +public record BackendTarget( + InetSocketAddress address, + @Nullable MojangAuth mojang +) { + public BackendTarget { + Objects.requireNonNull(address, "address is required"); + } + + public BackendTarget(InetSocketAddress address) { + this(address, null); + } + + /// Render the address as `host:port` — the canonical wire/display form used by the + /// dashboard, persistence, and MQL's `player.backend`. + public String label() { + return address.getHostString() + ":" + address.getPort(); + } +} diff --git a/web/src/main/java/net/minestom/web/ControlBridge.java b/web/src/main/java/net/minestom/web/ControlBridge.java new file mode 100644 index 00000000000..01041ba7447 --- /dev/null +++ b/web/src/main/java/net/minestom/web/ControlBridge.java @@ -0,0 +1,154 @@ +package net.minestom.web; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.function.Consumer; + +/// Two-way mailbox between the dashboard and the embedding game. Each direction has its own VT +/// and queue, so neither side blocks the other. +public final class ControlBridge implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(ControlBridge.class); + + public static final int HISTORY_LIMIT = 500; + private static final int OUTBOUND_CAPACITY = 1024; + + private final BlockingQueue inbound = new LinkedBlockingQueue<>(); + private final BlockingQueue outbound = new java.util.concurrent.ArrayBlockingQueue<>(OUTBOUND_CAPACITY); + private final Thread inboundWorker; + private final Thread outboundWorker; + + private final Deque recent = new ArrayDeque<>(); + private volatile ControlPacket.Metrics latestMetrics; + private volatile CompoundBinaryTag globalData = CompoundBinaryTag.empty(); + + private volatile Consumer onConsoleLine; + private volatile Consumer onMetrics; + private volatile Consumer onGlobalData; + private volatile Consumer onOutbound; + + public ControlBridge() { + this.inboundWorker = Thread.ofVirtual().name("Minestom-Web-Control-In").start(this::runInbound); + this.outboundWorker = Thread.ofVirtual().name("Minestom-Web-Control-Out").start(this::runOutbound); + } + + // ---- game → web -------------------------------------------------------------------- + + /// Enqueue a packet from the game side. Non-blocking. The worker thread picks it up, + /// updates caches, and fires the relevant dashboard sink. + public void receive(ControlPacket packet) { + inbound.offer(packet); + } + + /// Register the single outbound sink the dashboard pushes packets through. Replaces any + /// previous sink; pass `null` to detach. + public void setOnOutbound(Consumer sink) { + this.onOutbound = sink; + } + + // ---- web → game (dashboard's send-side) -------------------------------------------- + + public void send(ControlPacket packet) { + if (!outbound.offer(packet)) { + LOGGER.warn("control outbound queue full; dropping {}", packet.getClass().getSimpleName()); + } + } + public void sendCommand (String command) { send(new ControlPacket.Command(command)); } + public void sendBroadcast (Component message) { send(new ControlPacket.Broadcast(message)); } + public void sendKick (java.util.UUID t, String reason) { send(new ControlPacket.Kick(t, reason)); } + public void sendServerData(CompoundBinaryTag data) { send(new ControlPacket.ServerData(data)); } + + // ---- dashboard inbound sinks ------------------------------------------------------- + + public void setOnConsoleLine(Consumer sink) { this.onConsoleLine = sink; } + public void setOnMetrics (Consumer sink) { this.onMetrics = sink; } + public void setOnGlobalData (Consumer sink) { this.onGlobalData = sink; } + + // ---- cache snapshots (dashboard HTTP reads) --------------------------------------- + + /// Consistent snapshot for HTTP readers. `recent` is an [ArrayDeque] (not thread-safe), so the + /// copy is taken under the same lock the inbound worker holds while mutating it. + public List consoleHistory() { + synchronized (recent) { return new ArrayList<>(recent); } + } + public ControlPacket.Metrics latestMetrics() { return latestMetrics; } + public CompoundBinaryTag globalData() { return globalData; } + + // ---- worker ------------------------------------------------------------------------ + + private void runInbound() { + while (true) { + final ControlPacket packet; + try { packet = inbound.take(); } + catch (InterruptedException _) { return; } + try { dispatch(packet); } + catch (Throwable t) { LOGGER.warn("control inbound dispatch failed: {}", t.toString()); } + } + } + + private void runOutbound() { + while (true) { + final ControlPacket packet; + try { packet = outbound.take(); } + catch (InterruptedException _) { return; } + final Consumer sink = onOutbound; + if (sink == null) continue; + try { sink.accept(packet); } + catch (Throwable t) { LOGGER.debug("outbound sink failed: {}", t.toString()); } + } + } + + private void dispatch(ControlPacket packet) { + switch (packet) { + case ControlPacket.ConsoleLine line -> { + synchronized (recent) { + recent.addLast(line); + while (recent.size() > HISTORY_LIMIT) recent.removeFirst(); + } + deliver(onConsoleLine, line); + } + case ControlPacket.Metrics m -> { + latestMetrics = m; + deliver(onMetrics, m); + } + case ControlPacket.ServerData(CompoundBinaryTag data) -> { + globalData = data; + deliver(onGlobalData, data); + } + // Web→game packets that round-trip back here through `receive(...)` (by mistake or + // by design) are ignored — the dashboard doesn't consume them. + case ControlPacket.Command _, ControlPacket.Broadcast _, ControlPacket.Kick _ -> {} + } + } + + private static void deliver(Consumer sink, T value) { + if (sink == null) return; + try { sink.accept(value); } + catch (Throwable t) { LOGGER.debug("inbound sink failed: {}", t.toString()); } + } + + @Override + public void close() { + inboundWorker.interrupt(); + outboundWorker.interrupt(); + try { + inboundWorker.join(1_000); + outboundWorker.join(1_000); + } catch (InterruptedException _) { Thread.currentThread().interrupt(); } + onConsoleLine = null; + onMetrics = null; + onGlobalData = null; + onOutbound = null; + synchronized (recent) { recent.clear(); } + latestMetrics = null; + globalData = CompoundBinaryTag.empty(); + } +} diff --git a/web/src/main/java/net/minestom/web/ControlPacket.java b/web/src/main/java/net/minestom/web/ControlPacket.java new file mode 100644 index 00000000000..a70b4dd3285 --- /dev/null +++ b/web/src/main/java/net/minestom/web/ControlPacket.java @@ -0,0 +1,42 @@ +package net.minestom.web; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; + +/// Payload exchanged between the dashboard and the embedding (game) side of an in-JVM +/// Minestom deployment. The bridge is just a typed mailbox — there is no transport, no +/// serialisation, no id registry. +public sealed interface ControlPacket { + + /// Execute a command line on the game side. Web → game. + record Command(String command) implements ControlPacket {} + + /// One line of console output observed on the game side. Game → web. + /// `level` is free-form (`INFO`, `WARN`, `ERROR`, `STDOUT`, …). + record ConsoleLine(long ts, String level, String message) implements ControlPacket {} + + /// Periodic JVM + tick snapshot. Game → web. + /// + /// - `processCpu` / `heapUsed` / `heapMax` / `threadCount` / `uptimeMs` come from + /// `OperatingSystemMXBean` and friends. + /// - `mspt` is the most recent server-tick duration in milliseconds. + /// - `tps` is the effective ticks-per-second derived from `mspt` (capped at the target rate). + /// - `playerCount` is the live online roster size. + record Metrics(long ts, double processCpu, + long heapUsed, long heapMax, + int threadCount, long uptimeMs, + double mspt, double tps, + int playerCount) implements ControlPacket {} + + /// Send a chat message to every player. Web → game. + record Broadcast(Component message) implements ControlPacket {} + + /// Kick a player by uuid with a reason. Web → game. The reason is shown as the client-side + /// disconnect message. + record Kick(java.util.UUID target, String reason) implements ControlPacket {} + + /// Global server NBT — server-wide state that doesn't belong to any single player (event + /// id, season number, active modifiers, etc.). Bidirectional. Queryable through `global.*` + /// paths in MQL and expressions. + record ServerData(CompoundBinaryTag data) implements ControlPacket {} +} diff --git a/web/src/main/java/net/minestom/web/Direction.java b/web/src/main/java/net/minestom/web/Direction.java new file mode 100644 index 00000000000..28faf9d0bb2 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Direction.java @@ -0,0 +1,10 @@ +package net.minestom.web; + +/// Direction of a packet on the wire, from the player's perspective. +/// +/// `SERVERBOUND` flows client → server (player input). +/// `CLIENTBOUND` flows server → client (the player observes the result). +public enum Direction { + CLIENTBOUND, + SERVERBOUND +} diff --git a/web/src/main/java/net/minestom/web/LifecycleEvent.java b/web/src/main/java/net/minestom/web/LifecycleEvent.java new file mode 100644 index 00000000000..ec14dc99221 --- /dev/null +++ b/web/src/main/java/net/minestom/web/LifecycleEvent.java @@ -0,0 +1,55 @@ +package net.minestom.web; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; + +/// One step in the per-connection life of a player: TCP accept, handshake intent, login phase, +/// compression negotiation, configuration handover, play start, disconnect. Persisted in the +/// per-connection ring buffer alongside [PacketRecord]; the dashboard renders these as a +/// timeline. +/// +/// @param seq monotonically increasing per connection (independent of packet seq) +/// @param ts epoch millis +/// @param packetSeq the [PacketRecord#seq] this event was inferred from (or -1 if it was +/// emitted outside the packet stream, like CONNECT / DISCONNECT) +/// @param kind the lifecycle phase or signal — see [Kind] +/// @param data the event payload as JSON. For packet-derived events this is the full +/// decoded packet (same tree the `/api/connections/.../packets/{seq}` endpoint +/// returns); for CONNECT / DISCONNECT it's a small ad-hoc object with the +/// socket address. +public record LifecycleEvent( + long seq, + long ts, + long packetSeq, + Kind kind, + JsonElement data +) { + public LifecycleEvent { + if (data == null) data = JsonNull.INSTANCE; + } + + public enum Kind { + /// TCP socket accepted — emitted before any packet has flowed. + CONNECT, + /// `ClientHandshakePacket` observed. `data` is the serialised packet. + HANDSHAKE, + /// First LOGIN-state packet (e.g. `ClientLoginStartPacket`). + LOGIN_START, + /// `SetCompressionPacket` observed. + COMPRESSION_SET, + /// `LoginSuccessPacket` observed. + LOGIN_SUCCESS, + /// Direction entered CONFIGURATION state — typically from server `LoginAcknowledged`. + CONFIGURATION_START, + /// `FinishConfigurationPacket` observed — direction switches to PLAY. + CONFIGURATION_FINISH, + /// Direction entered PLAY state. + PLAY_START, + /// Player moved between proxy backends as part of an in-flight journey. `data` is + /// `{from: ""|null, to: ""}` — the previous backend is null + /// when the connection was minted by the journey tracker without prior state. + SERVER_SWITCH, + /// Socket closed (either side). + DISCONNECT + } +} diff --git a/web/src/main/java/net/minestom/web/MojangAuth.java b/web/src/main/java/net/minestom/web/MojangAuth.java new file mode 100644 index 00000000000..0584869d4a7 --- /dev/null +++ b/web/src/main/java/net/minestom/web/MojangAuth.java @@ -0,0 +1,34 @@ +package net.minestom.web; + +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; +import java.util.UUID; + +/// Credentials the proxy uses to log in to an online-mode upstream. The proxy holds **one** +/// Mojang account; every incoming player is forwarded to the upstream under this identity. +/// +/// `accessToken` is the `minecraftservices.com` access token (the one returned by +/// `POST /authentication/login_with_xbox`), **not** the Microsoft / XSTS token. Tokens are +/// short-lived (~24h) — refreshing them is out of scope for the proxy, restart with a fresh +/// token. +/// +/// `profileUuid` and `profileName` describe the bot and **must be non-null** by the time the +/// proxy uses this record. They may be left `null` here for convenience; resolve them yourself +/// from the access token (e.g. `GET https://api.minecraftservices.com/minecraft/profile`), or +/// let the bundled CLI's `--login` flow fill them in at startup. Providing them explicitly also +/// lets the proxy start without Mojang reachability. +public record MojangAuth( + String accessToken, + @Nullable UUID profileUuid, + @Nullable String profileName +) { + public MojangAuth { + Objects.requireNonNull(accessToken, "accessToken is required"); + if (accessToken.isBlank()) throw new IllegalArgumentException("accessToken is blank"); + } + + public MojangAuth(String accessToken) { + this(accessToken, null, null); + } +} diff --git a/web/src/main/java/net/minestom/web/PacketEvent.java b/web/src/main/java/net/minestom/web/PacketEvent.java new file mode 100644 index 00000000000..f9d72f43468 --- /dev/null +++ b/web/src/main/java/net/minestom/web/PacketEvent.java @@ -0,0 +1,18 @@ +package net.minestom.web; + +import net.minestom.server.network.ConnectionState; + +/// One packet on a connection timeline. This is the packet list/facet API shape and the +/// row persisted to SQLite; decoded packet objects are cached separately for inspector detail. +public record PacketEvent( + long seq, + long ts, + Direction direction, + ConnectionState state, + String className, + int sizeBytes, + String subject, + String subjectLabel, + String subjectGroup, + long ioEventSeq +) {} diff --git a/web/src/main/java/net/minestom/web/PacketRecord.java b/web/src/main/java/net/minestom/web/PacketRecord.java new file mode 100644 index 00000000000..fce7f070a8d --- /dev/null +++ b/web/src/main/java/net/minestom/web/PacketRecord.java @@ -0,0 +1,23 @@ +package net.minestom.web; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; + +/// Decoded packet detail cached for inspector reads. +/// +/// @param seq monotonically increasing per connection +/// @param ts epoch millis at capture time +/// @param direction CLIENTBOUND (server → client) or SERVERBOUND (client → server) +/// @param state the connection state at decode time +/// @param className simple record class name +/// @param sizeBytes on-wire size +/// @param record the decoded Java record reference (lazy-serialised to JSON on read) +public record PacketRecord( + long seq, + long ts, + Direction direction, + ConnectionState state, + String className, + int sizeBytes, + Packet record +) {} diff --git a/web/src/main/java/net/minestom/web/PlayerState.java b/web/src/main/java/net/minestom/web/PlayerState.java new file mode 100644 index 00000000000..4a870470e07 --- /dev/null +++ b/web/src/main/java/net/minestom/web/PlayerState.java @@ -0,0 +1,453 @@ +package net.minestom.web; + +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.kyori.adventure.text.Component; +import net.minestom.server.item.ItemStack; +import net.minestom.server.network.ConnectionState; + +import java.util.*; + +/// Per-connection observed state. +/// +/// **Owner-thread contract.** All access — read and write — must run on the owning session +/// worker virtual thread. Fields are plain Java primitives / collections on purpose: no +/// `volatile`, no atomics, no `Concurrent*`, and no object-monitor synchronization. +/// +/// **Patch model.** Every traceable mutation routes through [#set] (atomic value) or [#append] +/// (ring buffer). Each call records two things: the per-field provenance (for the dashboard's +/// dotted-underline affordance) and a pending entry on the patch accumulator. The observer +/// calls [#drainPatch] on a fixed cadence to ship the coalesced delta over WebSocket — clients +/// only ever receive what actually changed since the last drain. +/// +/// **Path scheme.** All path strings mirror the JSON layout exposed by +/// [net.minestom.web.internal.codec.WebJsonBuilders#playerStateJson] (top-level scalars, +/// nested objects/maps joined with `.`). The same path is used for both patch keys and +/// provenance — the frontend resolves it with one generic walker. +public final class PlayerState { + + // identity / session + public UUID connectionId; + public UUID uuid; + public String username; + /// Remote socket address, formatted as `host/ip:port`. String so both live (from a + /// `SocketAddress.toString()`) and replay (from the SQLite `connections.address` column) + /// can populate it without bringing transport types into the engine. + public String address; + public int protocolVersion; + public String clientBrand; + public String serverBrand; + public String locale; + /// `host:port` of the upstream this connection is currently bridged to. Set by the proxy + /// on connection open; carried across journey-stitched reconnects via the transfer cookie. + public String backendAddress; + /// Journey id — stable for the duration of a player's run through the proxy, even across + /// backend hops. `null` only on the brief window between TCP accept and the first packet + /// that reveals a player UUID. + public UUID journeyId; + public ConnectionState clientConnectionState = ConnectionState.HANDSHAKE; + public ConnectionState serverConnectionState = ConnectionState.HANDSHAKE; + public final long connectedAt = System.currentTimeMillis(); + /// 0 while the session is live; ms timestamp once the socket has closed. The session stays + /// in the registry for a while after disconnect so the dashboard can show profile + history + /// for players who already left. + public long disconnectedAt; + public final Traffic traffic = new Traffic(); + + // world / position + public String dimension; + public String gamemode; + public boolean hardcore; + public double posX, posY, posZ; + public float yaw, pitch; + public boolean onGround; + + // vitals + public float health = 20f; + public float maxHealth = 20f; + public int food = 20; + public float saturation; + public int xpLevel; + public float xpBar; + + // abilities + public boolean invulnerable; + public boolean flying; + public boolean allowFlying; + public boolean instantBreak; + public float flySpeed = 0.05f; + public float walkSpeed = 0.1f; + + // attributes / effects + public final Map attributes = new LinkedHashMap<>(); + public final Map activeEffects = new LinkedHashMap<>(); + + // inventory. `null` means "empty slot" — air stacks are normalised to null on the way in + // so the JSON output can omit them (the frontend treats falsy entries as empty). + public final ItemStack[] hotbar = new ItemStack[9]; + public final ItemStack[] mainInventory = new ItemStack[27]; + public final ItemStack[] armor = new ItemStack[4]; + public ItemStack offHand; + public ItemStack cursor; + public int selectedHotbar; + public OpenedWindow openedWindow; + + /// Ring buffer of slot clicks captured from `ClientClickWindowPacket`. Drives the + /// inventory tab's transient highlight animation — the frontend pulses the addressed slot + /// whenever a new entry lands here. Buffer is bounded; oldest entries fall off the front. + public final List recentClicks = new ArrayList<>(); + + public final List chatReceived = new ArrayList<>(); + public final List chatSent = new ArrayList<>(); + public Component lastActionBar; + + // HUD + public final Map bossBars = new LinkedHashMap<>(); + public ScoreboardSnapshot scoreboard; + public TabListSnapshot tabList = new TabListSnapshot(null, null); + /// Live `TeamsPacket` registry, read only to compose sidebar row displays. + public final Map teams = new LinkedHashMap<>(); + /// Reverse lookup `entityName → teamName` derived from [#teams] members. + public final Map teamByMember = new HashMap<>(); + + // combat + public DamageEvent lastDamage; + + // out-of-band + public CompoundBinaryTag serverData = CompoundBinaryTag.empty(); + public long serverDataUpdatedAt; + + // world mirror — chunk palettes, block entities, minimap columns; drained per-push by the dashboard. + public final PlayerWorld world = new PlayerWorld(); + + /// Entities currently in this player's view, keyed on the wire `entityId`. Maintained by + /// the spawn / position / destroy packet handlers. + public final Map visibleEntities = new LinkedHashMap<>(); + + // user extensions + public final Map custom = new HashMap<>(); + + // --- provenance --------------------------------------------------------------------------- + + /// The provenance of the packet currently being applied. Set by + /// [net.minestom.web.internal.state.StateApplier] before each dispatch; updaters read it via + /// [#set] and need not touch it directly. Transient working state — never part of the JSON + /// snapshot the dashboard emits. + public Provenance currentProvenance; + + /// Per-field provenance: `"health"` → packet that last set health. Cleared only on session + /// start. Serialised as a flat `{ field: {...} }` map under the profile snapshot. + public final Map provenance = new LinkedHashMap<>(); + + /// Per-field bounded history. Max [#PROVENANCE_HISTORY_DEPTH] entries; newest last. Each + /// entry pins the source packet and the before/after values so the popover can show the + /// `from → to` diff without re-querying the packet ring. + public final Map> provenanceHistory = new LinkedHashMap<>(); + + public static final int PROVENANCE_HISTORY_DEPTH = 20; + + // --- patch accumulator -------------------------------------------------------------------- + // Drain swaps each map for a fresh empty one and hands off the old reference to the + // outgoing [StatePatch]: no per-drain copies, no per-entry allocs. + + private Map pendingValues = new LinkedHashMap<>(); + private Map pendingAppends = new LinkedHashMap<>(); + private Map pendingProvenance = new LinkedHashMap<>(); + private Set pendingComputed = new LinkedHashSet<>(); + /// Previous totals shadowed by [#flushTrafficCounters] for delta detection. + private long lastFlushedBytesIn; + private long lastFlushedBytesOut; + /// Monotonic patch sequence, written under the lock. Bumped on every non-empty drain so + /// the frontend can detect gaps (e.g. after a WS reconnect). + public long patchSeq; + + /// Record + assign in one call; returns `next` so the assignment and the provenance record + /// can't drift (`s.health = s.set("health", s.health, p.health())`). + /// + /// The primitive overloads defer boxing of `prev`/`next` until the value actually changed, + /// so high-frequency redundant writes (unmoved position packets) never allocate. + public T set(String field, T prev, T next) { + if (currentProvenance != null && !Objects.equals(prev, next)) record(field, prev, next); + return next; + } + + public double set(String field, double prev, double next) { + if (currentProvenance != null && Double.compare(prev, next) != 0) record(field, prev, next); + return next; + } + + public float set(String field, float prev, float next) { + if (currentProvenance != null && Float.compare(prev, next) != 0) record(field, prev, next); + return next; + } + + public int set(String field, int prev, int next) { + if (currentProvenance != null && prev != next) record(field, prev, next); + return next; + } + + public long set(String field, long prev, long next) { + if (currentProvenance != null && prev != next) record(field, prev, next); + return next; + } + + public boolean set(String field, boolean prev, boolean next) { + if (currentProvenance != null && prev != next) record(field, prev, next); + return next; + } + + /// Apply one *changed* field: stash the value, stamp the source, append a `(prev, next)` + /// history entry. Only reached on a confirmed change, so the boxing the primitive overloads + /// deferred happens here and never on the no-op path. + private void record(String field, Object prev, Object next) { + stampProvenance(field); + pendingValues.put(field, next); + Deque deque = provenanceHistory.computeIfAbsent(field, k -> new ArrayDeque<>(PROVENANCE_HISTORY_DEPTH + 1)); + deque.addLast(new Provenance.Entry(currentProvenance, prev, next)); + while (deque.size() > PROVENANCE_HISTORY_DEPTH) deque.removeFirst(); + } + + /// Append one element to a caller-owned ring buffer and mirror it onto the patch. `max` + /// bounds the list and rides along in the patch so the frontend evicts in lock-step. + /// Recorded even without `currentProvenance` (replay seeds chat before any packet + /// provenance exists) — provenance is best-effort. + public void append(String path, List list, T item, int max) { + list.add(item); + while (list.size() > max) list.removeFirst(); + AppendAccumulator acc = pendingAppends.get(path); + if (acc == null) pendingAppends.put(path, acc = new AppendAccumulator()); + acc.add(item, max); + stampProvenance(path); + } + + /// Mark a path dirty without supplying the value; the drain looks up a computer and + /// serializes current state. Used for collections whose shape isn't a single value + /// (`visibleEntities`, `bossBars`, `attributes`, `hotbar`). Once `path` is already pending + /// subsequent calls early-return before touching the provenance maps — entity-move packets + /// hit this hundreds of times per drain window. + public void markDirty(String path) { + if (pendingComputed.add(path)) stampProvenance(path); + } + + /// Refresh the long-lived per-field provenance and the patch's provenance entry for `path`. + /// Only reached from the *changed* branch of [#set] / [#append] / [#markDirty], so the + /// per-field source pointer tracks the last *meaningful* change, not redundant touches. + private void stampProvenance(String path) { + if (currentProvenance == null) return; + provenance.put(path, currentProvenance); + pendingProvenance.put(path, currentProvenance); + } + + /// Push `traffic.bytesIn`/`bytesOut` onto the next patch if they've drifted. Provenance-less: + /// these are TCP totals, not packet-derived, and `ConnectionWorker` bumps them per read/write + /// — too noisy for `set`. Called from the cadence ticker before [#drainPatch]. + public void flushTrafficCounters() { + if (traffic.bytesIn != lastFlushedBytesIn) { + pendingValues.put("traffic.bytesIn", traffic.bytesIn); + lastFlushedBytesIn = traffic.bytesIn; + } + if (traffic.bytesOut != lastFlushedBytesOut) { + pendingValues.put("traffic.bytesOut", traffic.bytesOut); + lastFlushedBytesOut = traffic.bytesOut; + } + } + + /// True iff something has been recorded since the last drain. Cheap fast-path for the + /// observer so it can skip the lock entirely on quiet ticks. + public boolean hasPending() { + return !pendingValues.isEmpty() || !pendingAppends.isEmpty() || !pendingComputed.isEmpty(); + } + + /// Hand off the accumulator as an immutable [StatePatch] and rotate to fresh empty maps. + /// Returns `null` when nothing has changed (the observer will skip publishing). + /// + /// `computers` resolves the value for [#markDirty]ed paths: `path → serialized value`. The + /// observer owns this map because the JSON shape lives there, not in PlayerState. + public StatePatch drainPatch(java.util.function.Function computers) { + if (!hasPending()) return null; + final long seq = ++patchSeq; + final Map values = pendingValues; + final Map appendAcc = pendingAppends; + final Map prov = pendingProvenance; + final Set computed = pendingComputed; + pendingValues = new LinkedHashMap<>(); + pendingAppends = new LinkedHashMap<>(); + pendingProvenance = new LinkedHashMap<>(); + pendingComputed = new LinkedHashSet<>(); + + for (String path : computed) { + final Object computedValue = computers.apply(path); + if (computedValue != null) values.put(path, computedValue); + } + final Map appends; + if (appendAcc.isEmpty()) { + appends = Map.of(); + } else { + appends = new LinkedHashMap<>(appendAcc.size()); + for (Map.Entry e : appendAcc.entrySet()) { + appends.put(e.getKey(), e.getValue().toAppend()); + } + } + return new StatePatch(seq, System.currentTimeMillis(), values, appends, prov); + } + + /// Mutable holder collapsing the per-path append batch + ring bound into one entry so the + /// accumulator only carries one map. Handed off whole to [StatePatch.Append] on drain. + private static final class AppendAccumulator { + final List elements = new ArrayList<>(); + int max; + void add(Object item, int max) { + this.elements.add(item); + this.max = max; + } + StatePatch.Append toAppend() { + return new StatePatch.Append(elements, max); + } + } + + public record ActiveEffect(String id, int amplifier, int durationTicks, boolean ambient, boolean particles) { + } + + public record OpenedWindow(int id, String type, Component title, ItemStack[] slots, Map properties) { + } + + /// One slot-level interaction captured from `ClientClickWindowPacket`. `kind`/`localSlot` + /// resolves the wire slot index to its logical home (`hotbar`/`main`/`armor`/`offhand`/ + /// `container`/`craftingGrid`/`crafting`) so the inventory tab can target the matching slot + /// in the rendered grid without re-implementing the protocol mapping. `windowId` is the + /// window the click targeted — 0 for the player inventory, non-zero for an opened container. + public record ClickEvent(long seq, long ts, int windowId, int rawSlot, + String kind, int localSlot, int button, String clickType) { + } + + public record ChatLine(long ts, String sender, Component content, String style) {} + public record SentChatLine(long ts, String kind, String text) {} + + public record BossBarSnapshot(Component title, float progress, String color, String division, int flags) { + } + + public record ScoreboardSnapshot(String objectiveName, Component displayName, String slot, + Map rows) { + } + + /// `display` is pre-composed by [net.minestom.web.internal.state] so the frontend renders + /// it directly — no per-row team lookup needed on the wire. + public record ScoreboardRow(int score, Component display, NumberFormat numberFormat) { + } + + public record NumberFormat(String format, Component content) { + } + + public record TeamSnapshot(Component prefix, Component suffix, String teamColor) { + } + + public record TabListSnapshot(Component header, Component footer) { + } + + public record DamageEvent(long ts, double amount, String source, Integer attackerId) { + } + + /// Transport-derived state for this player connection. Mutated on the owning state worker. + public static final class Traffic { + public int compressionThreshold = -1; + public long bytesIn; + public long bytesOut; + public long packetsIn; + public long packetsOut; + public long pingMs; + public final List pingHistory = new ArrayList<>(); + + /// Transient bookkeeping for the proxied keep-alive RTT (not serialized). Written by the + /// keep-alive updaters in [net.minestom.web.internal.state.VitalsUpdaters]. + public long lastKeepAliveOutAt; + public long lastKeepAliveOutId; + + public Traffic() { + } + + public Traffic(int compressionThreshold, long pingMs, long bytesIn, long bytesOut, + long packetsIn, long packetsOut, List pingHistory) { + this.compressionThreshold = compressionThreshold; + this.pingMs = pingMs; + this.bytesIn = bytesIn; + this.bytesOut = bytesOut; + this.packetsIn = packetsIn; + this.packetsOut = packetsOut; + this.pingHistory.addAll(pingHistory); + } + } + + /// Short entity row for list snapshots and state patches. + public record VisibleEntityShort( + int id, + UUID uuid, + String type, + String group, + double x, + double y, + double z, + float yaw + ) { + public static VisibleEntityShort from(VisibleEntity e) { + return new VisibleEntityShort(e.id, e.uuid, e.type, e.group, e.x, e.y, e.z, e.yaw); + } + } + + /// Single tracked entity. Position is absolute world coords accumulated from spawn + delta + /// packets; `group` is the minimap UI bucket, fixed at spawn. + public static final class VisibleEntity { + public int id; + public UUID uuid; + public String type; // namespaced (e.g. "minecraft:zombie") + public String group; // minimap UI bucket + public double x, y, z; + public float yaw; + public long lastUpdate; + public long spawnSeq; // seq of the SpawnEntityPacket that introduced this entity + public long lastSeq; // seq of the most recent packet that touched this entity + public int packetCount; // total packets that touched this entity + + /// Per-field provenance: `"pos"`, `"yaw"`, … + public final Map provenance = new LinkedHashMap<>(); + + /// Bounded change log — newest last. Powers the entity drilldown view. + public final Deque changeLog = new ArrayDeque<>(); + + /// Record + return the new value in one call, mirroring [PlayerState#set]. Caller writes + /// `e.x = e.set(prov, "pos.x", e.x, p.x())` so the assignment can't drift from the trace. + public T set(Provenance prov, String field, T prev, T next) { + if (prov != null) recordEntity(field, prov, prev, next, Objects.equals(prev, next)); + return next; + } + + public double set(Provenance prov, String field, double prev, double next) { + if (prov != null) { + boolean same = Double.compare(prev, next) == 0; + recordEntity(field, prov, same ? null : prev, same ? null : next, same); + } + return next; + } + + public float set(Provenance prov, String field, float prev, float next) { + if (prov != null) { + boolean same = Float.compare(prev, next) == 0; + recordEntity(field, prov, same ? null : prev, same ? null : next, same); + } + return next; + } + + private void recordEntity(String field, Provenance prov, Object prev, Object next, boolean unchanged) { + provenance.put(field, prov); + lastSeq = prov.seq(); + lastUpdate = prov.ts(); + packetCount++; + if (!unchanged) { + changeLog.addLast(new EntityChange(prov, field, prev, next)); + while (changeLog.size() > 64) changeLog.removeFirst(); + } + } + } + + /// One mutation on a tracked entity — used by the entity drilldown's change log. + public record EntityChange(Provenance source, String field, Object prev, Object value) { + } +} diff --git a/web/src/main/java/net/minestom/web/PlayerWorld.java b/web/src/main/java/net/minestom/web/PlayerWorld.java new file mode 100644 index 00000000000..1504dc50530 --- /dev/null +++ b/web/src/main/java/net/minestom/web/PlayerWorld.java @@ -0,0 +1,137 @@ +package net.minestom.web; + +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.palette.Palette; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static net.minestom.server.coordinate.CoordConversion.*; + +/// Per-player world mirror built from observed chunk / block packets. +/// +/// Holds section block palettes, block entities, and a minimap height column per chunk. +/// Block lookups use the same palette layout as vanilla `ChunkData` — returns {@code null} +/// when the chunk or section is not loaded. +/// +/// Single-thread contract: every field is touched only on the owning session worker. +public final class PlayerWorld { + public static final int COLUMNS_PER_CHUNK = 256; + public static final short UNKNOWN = Short.MIN_VALUE; + public static final int UNKNOWN_COLOR = -1; + public static final int MAX_PENDING = 256; + + public final Map chunks = new HashMap<>(); + + public final Set dirtyChunks = new HashSet<>(); + public final Set unloadedChunks = new HashSet<>(); + + public final Map pendingChanges = new HashMap<>(); + + public int dimensionMinY = -64; + public int dimensionHeight = 384; + + public record PredictedBlockChange(int x, int y, int z, Kind kind) { + public enum Kind {PLACE, BREAK} + } + + /// One loaded chunk column bundle. + public static final class Chunk { + public final int chunkX; + public final int chunkZ; + public final int minSection; + /// Block palettes per section, indexed by `sectionY - minSection`. {@code null} when chunk + /// data was not parsed (heightmap-only seed). + public final Palette[] sections; + public final Map blockEntities; + public short[] heights; + public int[] columnColors; + + public Chunk(int chunkX, int chunkZ, int minSection, + Palette[] sections, Map blockEntities, + short[] heights, int[] columnColors) { + this.chunkX = chunkX; + this.chunkZ = chunkZ; + this.minSection = minSection; + this.sections = sections; + this.blockEntities = blockEntities == null || blockEntities.isEmpty() + ? new HashMap<>() : new HashMap<>(blockEntities); + this.heights = heights; + this.columnColors = columnColors; + } + + /// Heightmap-only chunk (tests or wire payload without section data). + public static Chunk heightsOnly(int chunkX, int chunkZ, int minSection, short[] heights) { + return new Chunk(chunkX, chunkZ, minSection, null, Map.of(), heights, null); + } + + public int columnIndex(int wx, int wz) { + return (globalToSectionRelative(wz) << 4) | globalToSectionRelative(wx); + } + + /// @return state id, or {@code -1} if the section is not present in this mirror. + public int getBlockStateId(int wx, int wy, int wz) { + if (sections == null) return -1; + final int sectionY = globalToChunk(wy); + final int rel = sectionY - minSection; + if (rel < 0 || rel >= sections.length) return -1; + final Palette palette = sections[rel]; + if (palette == null) return -1; + return palette.get( + globalToSectionRelative(wx), + globalToSectionRelative(wy), + globalToSectionRelative(wz)); + } + + public void setBlockState(int wx, int wy, int wz, int stateId) { + if (sections == null) return; + final int sectionY = globalToChunk(wy); + final int rel = sectionY - minSection; + if (rel < 0 || rel >= sections.length) return; + final Palette palette = sections[rel]; + if (palette == null) return; + palette.set( + globalToSectionRelative(wx), + globalToSectionRelative(wy), + globalToSectionRelative(wz), + stateId); + } + } + + public Chunk getChunk(int chunkX, int chunkZ) { + return chunks.get(chunkIndex(chunkX, chunkZ)); + } + + public Chunk getChunkAtBlock(int wx, int wz) { + return chunks.get(chunkIndex(globalToChunk(wx), globalToChunk(wz))); + } + + public void putChunk(Chunk chunk) { + chunks.put(chunkIndex(chunk.chunkX, chunk.chunkZ), chunk); + } + + public int getBlockStateId(int wx, int wy, int wz) { + final Chunk chunk = getChunkAtBlock(wx, wz); + return chunk == null ? -1 : chunk.getBlockStateId(wx, wy, wz); + } + + public void clear() { + chunks.clear(); + dirtyChunks.clear(); + unloadedChunks.clear(); + pendingChanges.clear(); + } + + /// Dimension switch: drop every chunk but record its key as an unload so the live minimap + /// frame tells the client to remove the now-stale tiles — vanilla sends no per-chunk + /// `UnloadChunkPacket` across a dimension change, so without this old tiles linger as ghosts. + /// A chunk reloaded at the same coords in the new dimension removes itself from + /// `unloadedChunks` on load, so it is never both unloaded and loaded in the same frame. + public void clearForDimensionChange() { + final Set previous = new HashSet<>(chunks.keySet()); + clear(); + unloadedChunks.addAll(previous); + } +} diff --git a/web/src/main/java/net/minestom/web/Provenance.java b/web/src/main/java/net/minestom/web/Provenance.java new file mode 100644 index 00000000000..69afd7c13f6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Provenance.java @@ -0,0 +1,19 @@ +package net.minestom.web; + +/// "Where did this value come from?" — pointer to the packet that last wrote a field. +/// +/// Carried alongside every traceable [PlayerState] field via [PlayerState#provenance]. The +/// dashboard's profile page renders this as a quiet dotted underline; clicking opens the full +/// change history (kept per-field in [PlayerState#provenanceHistory]). +/// +/// @param seq monotonic sequence of the source packet on the per-connection ring +/// @param ts epoch millis of the source packet +/// @param packetClass simple class name (e.g. `UpdateHealthPacket`) +/// @param direction `CLIENTBOUND` or `SERVERBOUND` +public record Provenance(long seq, long ts, String packetClass, Direction direction) { + + /// A single recorded mutation — `source` is the packet, `prev`/`value` are the before/after. + /// Stored in a bounded deque per field so the profile-page popover can show the recent + /// history without re-scanning the packet ring. + public record Entry(Provenance source, Object prev, Object value) {} +} diff --git a/web/src/main/java/net/minestom/web/ProxyConfig.java b/web/src/main/java/net/minestom/web/ProxyConfig.java new file mode 100644 index 00000000000..3d9c04289f5 --- /dev/null +++ b/web/src/main/java/net/minestom/web/ProxyConfig.java @@ -0,0 +1,50 @@ +package net.minestom.web; + +import org.jetbrains.annotations.Nullable; + +import java.net.InetSocketAddress; +import java.nio.file.Path; + +/// Run-time configuration for [ProxyServer]. In live mode the proxy needs a TCP bind, a default +/// backend address, and a dashboard. In replay mode only the dashboard is needed — set +/// [#replayMode] true and leave [#bind] / [#defaultBackend] `null`; per-browser SQLite replays +/// are wired up at runtime. +/// +/// There is no pre-registered backend roster: the proxy can move players to **any** reachable +/// address via [ProxyServer#movePlayer] `(uuid, addressSpec)`. `defaultBackend` is only the +/// landing target for fresh `LOGIN` connections. +public record ProxyConfig( + @Nullable InetSocketAddress bind, + @Nullable InetSocketAddress defaultBackend, + /// Hostname/port the proxy advertises to clients when issuing a `TransferPacket`. Falls + /// back to [#bind] when null. Set this explicitly when the proxy binds to a wildcard + /// address (`0.0.0.0` / `::`) — clients can't reconnect to a wildcard. + @Nullable InetSocketAddress publicAddress, + InetSocketAddress dashboard, + @Nullable String token, + int decodedPacketCacheSize, + String dataChannel, + @Nullable Path persistencePath, + @Nullable MojangAuth mojang, + boolean replayMode +) { + public static final String DEFAULT_DATA_CHANNEL = "minestom:web/data"; + + public ProxyConfig { + if (dashboard == null) throw new IllegalArgumentException("dashboard address is required"); + if (!replayMode) { + if (bind == null) throw new IllegalArgumentException("bind address is required in live mode"); + if (defaultBackend == null) { + throw new IllegalArgumentException("defaultBackend address is required in live mode"); + } + } + if (decodedPacketCacheSize < 0) throw new IllegalArgumentException("decodedPacketCacheSize < 0"); + } + + /// Reachable proxy address for `TransferPacket` — prefers [#publicAddress] but falls back + /// to [#bind]. Used by [net.minestom.web.internal.proxy.TcpAcceptor] when telling a client + /// where to reconnect. + public @Nullable InetSocketAddress reachableAddress() { + return publicAddress != null ? publicAddress : bind; + } +} diff --git a/web/src/main/java/net/minestom/web/ProxyServer.java b/web/src/main/java/net/minestom/web/ProxyServer.java new file mode 100644 index 00000000000..73a04ce4dd4 --- /dev/null +++ b/web/src/main/java/net/minestom/web/ProxyServer.java @@ -0,0 +1,261 @@ +package net.minestom.web; + +import com.google.gson.JsonObject; +import net.minestom.web.internal.AddressResolver; +import net.minestom.web.internal.expression.ExpressionEngine; +import net.minestom.web.internal.http.DashboardServer; +import net.minestom.web.internal.http.MetricsSampler; +import net.minestom.web.internal.persist.PersistentHistory; +import net.minestom.web.internal.persist.RunMetadata; +import net.minestom.web.internal.proxy.JourneyTracker; +import net.minestom.web.internal.proxy.TcpAcceptor; +import net.minestom.web.internal.expression.QueryEngine; +import net.minestom.web.internal.session.ActionRunner; +import net.minestom.web.internal.scope.DashboardScope; +import net.minestom.web.internal.session.PlayerView; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.UUID; + +/// Public entry point for the Minestom Web Interface. +/// +/// **Live mode** (default): owns a TCP proxy, a [BackendRouter], an optional persistence +/// writer, and a single "live" [DashboardScope] that the dashboard exposes. Embedders can drive +/// the [ControlBridge] returned by [#control()] to push console / metrics / global NBT, and +/// move players between backends via [TcpAcceptor#movePlayer]. +/// +/// **Replay mode** ([Builder#replayMode]): skips the TCP proxy entirely. No default scope is +/// created — each browser tab uploads a SQLite history via `POST /api/replay`, the dashboard +/// spins up an isolated scope for it (private registry, private WS subscribers), and replays +/// the file. Multiple uploads run concurrently without crossing data. +public final class ProxyServer implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(ProxyServer.class); + private static final String LIVE_SCOPE_ID = "live"; + + static { + // Item-icon compositing pulls in java.awt; force headless before any AWT class loads. + System.setProperty("java.awt.headless", "true"); + // Allow per-player registries. + System.setProperty("minestom.registry.unsafe-ops", "true"); + } + + private final ProxyConfig config; + private final DashboardServer dashboard; + private final @Nullable DashboardScope liveScope; + private final @Nullable TcpAcceptor proxy; + private final @Nullable PersistentHistory persistence; + private final ControlBridge liveControl; + + private ProxyServer(ProxyConfig config, BackendRouter router) { + this.config = config; + this.dashboard = new DashboardServer(config); + this.liveControl = new ControlBridge(); + if (config.replayMode()) { + this.liveScope = null; + this.proxy = null; + this.persistence = null; + } else { + this.persistence = openPersistence(config); + final ExpressionEngine expressions = new ExpressionEngine(liveControl); + final QueryEngine queries = new QueryEngine(expressions); + final SessionRegistry registry = new SessionRegistry(config.decodedPacketCacheSize(), queries); + final JourneyTracker journeys = new JourneyTracker(); + registry.attachJourneyTracker(journeys); + this.proxy = new TcpAcceptor(config, router, registry, journeys, persistence); + registry.attachActionRunner(new ActionRunner(proxy, expressions)); + final MetricsSampler metrics = new MetricsSampler(120); + this.liveScope = DashboardScope.live(LIVE_SCOPE_ID, registry, liveControl, queries, + expressions, metrics, persistence, proxy); + } + } + + private static @Nullable PersistentHistory openPersistence(ProxyConfig config) { + if (config.persistencePath() == null) return null; + final Path target = uniquePerRunPath(config.persistencePath()); + try { + return new PersistentHistory(target, runMetadata(config)); + } catch (Exception e) { + LOGGER.warn("persistence disabled — failed to open {}: {}", target, e.toString()); + return null; + } + } + + private static RunMetadata runMetadata(ProxyConfig config) { + return new RunMetadata( + formatAddr(config.bind()), + formatAddr(config.defaultBackend()), + config.mojang() != null ? RunMetadata.AuthMode.ONLINE : RunMetadata.AuthMode.OFFLINE, + config.dataChannel(), + RunMetadata.currentHostInfo()); + } + + private static @Nullable String formatAddr(@Nullable InetSocketAddress addr) { + return addr == null ? null : addr.getHostString() + ":" + addr.getPort(); + } + + /// Derive `/-yyyyMMdd-HHmmss` from the configured path so each run gets its + /// own file. Two runs starting in the same second get `-2`, `-3`, ... — defensive padding + /// since seconds-resolution timestamps collide once in a blue moon. + private static Path uniquePerRunPath(Path base) { + final String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + final String name = base.getFileName().toString(); + final int dot = name.lastIndexOf('.'); + final String stem = dot < 0 ? name : name.substring(0, dot); + final String ext = dot < 0 ? "" : name.substring(dot); + final Path dir = base.getParent() == null ? Path.of(".") : base.getParent(); + Path candidate = dir.resolve(stem + "-" + stamp + ext); + int suffix = 2; + while (java.nio.file.Files.exists(candidate)) { + candidate = dir.resolve(stem + "-" + stamp + "-" + suffix + ext); + suffix++; + } + return candidate; + } + + public static Builder builder() { return new Builder(); } + + public ProxyConfig config() { return config; } + + public void start() { + if (liveScope != null) dashboard.setLiveScope(liveScope); + if (proxy != null) { + try { proxy.start(); } + catch (IOException e) { throw new RuntimeException("proxy bind failed", e); } + } + dashboard.start(); + } + + @Override + public void close() { + try { dashboard.close(); } catch (Exception _) {} + if (proxy != null) try { proxy.close(); } catch (Exception _) {} + // The dashboard closes the live scope (which owns persistence + routines). The shared + // ControlBridge is owned here so embedders can hold a reference past server lifetime. + if (liveScope == null) try { liveControl.close(); } catch (Exception _) {} + } + + public Collection players() { + return liveScope == null ? List.of() + : liveScope.registry.players().stream().map(PlayerView::playerJson).toList(); + } + + public Optional player(UUID uuid) { + if (liveScope == null) return Optional.empty(); + final var player = liveScope.registry.player(uuid); + return player == null ? Optional.empty() : Optional.of(player.playerJson()); + } + + /// Live snapshot of the live-scope routines. Returns an empty collection in replay mode. + public Collection routines() { + return liveScope == null ? List.of() : liveScope.registry.routines(); + } + + public Routine removeRoutine(UUID id) { + return liveScope == null ? null : liveScope.registry.removeRoutine(id); + } + + /// Move `playerUuid` to a different backend. Mints a transfer cookie and injects a + /// `CookieStorePacket` + `TransferPacket` toward the client. The client disconnects and + /// reconnects with `Intent.TRANSFER`; the proxy recognises the cookie and dials the + /// requested address this time around. + /// + /// `addressSpec` accepts the same shapes as the vanilla connect dialog — + /// `"play.example.com"` (SRV → 25565 fallback), `"play.example.com:25577"`, + /// `"[ipv6]:25577"`. Resolved via [AddressResolver#parseMinecraft]; runs the (potentially + /// blocking) SRV lookup on the caller's thread. + /// + /// Returns `false` if the proxy isn't running, the player isn't currently online, the + /// inject was rejected, or `addressSpec` is malformed/unresolvable. + public boolean movePlayer(UUID playerUuid, String addressSpec) { + if (proxy == null) return false; + final InetSocketAddress target; + try { target = AddressResolver.parseMinecraft(addressSpec); } + catch (IllegalArgumentException _) { return false; } + return proxy.movePlayer(playerUuid, target); + } + + public boolean movePlayer(UUID playerUuid, InetSocketAddress target) { + return proxy != null && proxy.movePlayer(playerUuid, target); + } + + /// The live scope's control bridge — push console / metrics / global NBT in via + /// [ControlBridge#receive], and register a sink with [ControlBridge#setOnOutbound] to + /// receive Commands / Broadcasts / Kicks / ServerData from the dashboard. + /// + /// In replay mode this returns an inert bridge with no sinks attached; calls discard + /// silently so embedders don't need to null-check. + public ControlBridge control() { return liveControl; } + + public static final class Builder { + private InetSocketAddress bind = new InetSocketAddress("0.0.0.0", 25565); + private @Nullable InetSocketAddress defaultBackend; + private @Nullable InetSocketAddress publicAddress; + private InetSocketAddress dashboard = new InetSocketAddress("127.0.0.1", 8080); + private String token; + private int decodedPacketCacheSize = 5000; + private String dataChannel = ProxyConfig.DEFAULT_DATA_CHANNEL; + private @Nullable Path persistencePath = Path.of("sessions.db"); + private @Nullable MojangAuth mojang; + private @Nullable BackendRouter router; + private boolean replayMode; + + public Builder bindProxy(InetSocketAddress address) { this.bind = address; return this; } + public Builder bindDashboard(InetSocketAddress address) { this.dashboard = Objects.requireNonNull(address); return this; } + + /// Address fresh `LOGIN` connections are routed to. Required in live mode. Players can + /// still be moved to any other address at any time via [ProxyServer#movePlayer]; this + /// is just the landing target. + public Builder defaultBackend(InetSocketAddress address) { + this.defaultBackend = Objects.requireNonNull(address); + return this; + } + + /// Externally-reachable proxy address. Used as the `host:port` in `TransferPacket` when + /// moving a player — clients re-dial it on transfer. Defaults to [#bindProxy]; set this + /// explicitly when the bind is a wildcard (`0.0.0.0` / `::`), otherwise clients can't + /// reconnect. + public Builder publicAddress(InetSocketAddress address) { + this.publicAddress = address; + return this; + } + + public Builder token(String token) { this.token = token; return this; } + public Builder decodedPacketCacheSize(int n) { this.decodedPacketCacheSize = n; return this; } + public Builder dataChannel(String channel) { this.dataChannel = channel; return this; } + + public Builder persistence(@Nullable Path path) { this.persistencePath = path; return this; } + public Builder mojang(@Nullable MojangAuth mojang) { this.mojang = mojang; return this; } + + public Builder router(@Nullable BackendRouter router) { this.router = router; return this; } + + /// Switch the server into replay mode — no TCP proxy, no backends, no persistence + /// writer. The dashboard accepts SQLite uploads via `POST /api/replay` and scopes each + /// upload to the requesting browser tab. + public Builder replayMode(boolean enabled) { this.replayMode = enabled; return this; } + + public ProxyServer build() { + final InetSocketAddress effectiveBind = replayMode ? null : bind; + final InetSocketAddress effectiveDefault = replayMode ? null : defaultBackend; + final InetSocketAddress effectivePublic = replayMode ? null : publicAddress; + final Path effectivePersistence = replayMode ? null : persistencePath; + final MojangAuth effectiveMojang = replayMode ? null : mojang; + final BackendRouter effectiveRouter = router != null ? router : BackendRouter.defaultRouter(); + return new ProxyServer(new ProxyConfig( + effectiveBind, effectiveDefault, effectivePublic, dashboard, + token, decodedPacketCacheSize, dataChannel, + effectivePersistence, effectiveMojang, replayMode), effectiveRouter); + } + } +} diff --git a/web/src/main/java/net/minestom/web/Query.java b/web/src/main/java/net/minestom/web/Query.java new file mode 100644 index 00000000000..d4c70c0d917 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Query.java @@ -0,0 +1,8 @@ +package net.minestom.web; + +/// A compiled MQL query. Immutable and thread-safe; callers run [#matches] on the target +/// session's state worker. +public interface Query { + String source(); + boolean matches(PlayerState state); +} diff --git a/web/src/main/java/net/minestom/web/RegisteredAction.java b/web/src/main/java/net/minestom/web/RegisteredAction.java new file mode 100644 index 00000000000..fbfe8a716ed --- /dev/null +++ b/web/src/main/java/net/minestom/web/RegisteredAction.java @@ -0,0 +1,6 @@ +package net.minestom.web; + +import java.util.UUID; + +/// A named, reusable [Action] stored in the in-memory registry. +public record RegisteredAction(UUID id, String name, Action action) {} diff --git a/web/src/main/java/net/minestom/web/RegisteredRoutine.java b/web/src/main/java/net/minestom/web/RegisteredRoutine.java new file mode 100644 index 00000000000..63821f349e7 --- /dev/null +++ b/web/src/main/java/net/minestom/web/RegisteredRoutine.java @@ -0,0 +1,3 @@ +package net.minestom.web; + +public record RegisteredRoutine(Routine routine, boolean enabled) {} diff --git a/web/src/main/java/net/minestom/web/Routine.java b/web/src/main/java/net/minestom/web/Routine.java new file mode 100644 index 00000000000..f54dfc1d971 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Routine.java @@ -0,0 +1,30 @@ +package net.minestom.web; + +import net.minestom.server.network.packet.Packet; + +import java.util.UUID; + +/// A trigger → action automation. Routines live entirely in memory; the registry vanishes on +/// proxy restart. +public record Routine( + UUID id, + String name, + Query ql, + Trigger trigger, + Action action, + long debounceMs +) { + + /// Discriminator for when a [Routine] should fire. + public sealed interface Trigger { + /// Fires whenever a player starts matching the routine query. + record OnMatch() implements Trigger {} + /// Fires whenever a player stops matching the routine query. + record OnUnmatch() implements Trigger {} + /// Fires for every decoded packet whose class equals `packetClass`. + /// Subject to the routine's `debounceMs`. + record OnPacket(Class packetClass) implements Trigger {} + /// Fires every `millis` milliseconds for every matching player. + record Interval(long millis) implements Trigger {} + } +} diff --git a/web/src/main/java/net/minestom/web/StatePatch.java b/web/src/main/java/net/minestom/web/StatePatch.java new file mode 100644 index 00000000000..080cf946348 --- /dev/null +++ b/web/src/main/java/net/minestom/web/StatePatch.java @@ -0,0 +1,41 @@ +package net.minestom.web; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Delta over [PlayerState]. Built by [PlayerState#drainPatch] under the per-connection lock, +/// shipped on the `player::state` WebSocket topic; the frontend merges it on top of the +/// REST-loaded snapshot. +/// +/// Keys follow the dotted path scheme — @see PlayerState. +/// +/// **Two kinds of edit.** +/// - [#values] — replace the value at a path. The most recent write in the coalesce window +/// wins; only paths that actually changed appear. +/// - [#appends] — append a batch of elements to a bounded list (ring buffer). Used for +/// `recentChat`, `sentChat`, `traffic.pingHistory`, `recentClicks`. +/// +/// [#provenance] is the per-field source-of-truth pointer for paths whose source packet +/// changed in this window. Paths whose value flipped but whose source matches a previous patch +/// (rare) still appear here so the dashboard's provenance affordance stays in sync. +public record StatePatch(long seq, long ts, + Map values, + Map appends, + Map provenance) { + + /// Empty when nothing changed — the observer skips publishing in this case. + public boolean isEmpty() { + return values.isEmpty() && appends.isEmpty(); + } + + /// A batch of items appended to a ring buffer at `path` during the coalesce window. + /// [#max] is the bounded size so the frontend can mirror the same eviction. + public record Append(List elements, int max) {} + + /// Test-only convenience builder. Production patches always come from [PlayerState#drainPatch]. + public static StatePatch empty(long seq) { + return new StatePatch(seq, System.currentTimeMillis(), + new LinkedHashMap<>(), new LinkedHashMap<>(), new LinkedHashMap<>()); + } +} diff --git a/web/src/main/java/net/minestom/web/Throttle.java b/web/src/main/java/net/minestom/web/Throttle.java new file mode 100644 index 00000000000..03fd8527c62 --- /dev/null +++ b/web/src/main/java/net/minestom/web/Throttle.java @@ -0,0 +1,33 @@ +package net.minestom.web; + +import org.jetbrains.annotations.Nullable; + +/// Socket-level throttle profile applied to a TCP byte stream by +/// [net.minestom.web.internal.proxy.ThrottleManager] — nothing here is protocol-aware. A `null` +/// reference (rather than a throttle) is how callers disable throttling. +/// +/// - `latencyMs` — fixed delay bytes are held in transit. +/// - `jitterMs` — random extra delay in `[0, jitterMs]` on top of `latencyMs`, clamped +/// monotonic per direction so it can't reorder the stream. +/// - `bandwidthBytesPerSec` — outgoing byte-rate cap; 0 = unlimited. +/// - `direction` — if non-null, applies only to that direction; null = both. +public record Throttle( + int latencyMs, + int jitterMs, + long bandwidthBytesPerSec, + @Nullable Direction direction +) { + public Throttle { + if (latencyMs < 0) latencyMs = 0; + if (jitterMs < 0) jitterMs = 0; + if (bandwidthBytesPerSec < 0L) bandwidthBytesPerSec = 0L; + } + + public boolean isActive() { + return latencyMs > 0 || jitterMs > 0 || bandwidthBytesPerSec > 0L; + } + + public boolean appliesTo(Direction actual) { + return direction == null || direction == actual; + } +} diff --git a/web/src/main/java/net/minestom/web/cli/Main.java b/web/src/main/java/net/minestom/web/cli/Main.java new file mode 100644 index 00000000000..1b0f7da1fe9 --- /dev/null +++ b/web/src/main/java/net/minestom/web/cli/Main.java @@ -0,0 +1,343 @@ +package net.minestom.web.cli; + +import net.minestom.web.MojangAuth; +import net.minestom.web.ProxyConfig; +import net.minestom.web.ProxyServer; +import net.minestom.web.internal.AddressResolver; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; + +/// Standalone entry point. Run the proxy in front of one or more Minecraft servers, expose the +/// dashboard on a separate port, and block until SIGINT. Reads only CLI arguments — no env +/// vars, no config files — so behavior is fully reproducible from the command line. +/// +/// ``` +/// java -p libs -m net.minestom.web/net.minestom.web.cli.Main \ +/// --backend play.example.com:25565 \ +/// --bind 0.0.0.0:25577 \ +/// --dashboard 127.0.0.1:8080 \ +/// --token "$WEB_TOKEN" +/// ``` +public final class Main { + private static final Logger LOGGER = LoggerFactory.getLogger(Main.class); + + static void main(String[] args) { + final Options opts; + try { + opts = Options.parse(args); + } catch (IllegalArgumentException e) { + System.err.println("minestom-web: " + e.getMessage()); + System.err.println("Try 'minestom-web --help' for usage."); + System.exit(2); + return; + } + if (opts.help) { + System.out.println(USAGE); + return; + } + if (opts.login) { + System.exit(runLogin(opts)); + return; + } + + final ProxyServer.Builder builder = ProxyServer.builder() + .bindDashboard(opts.dashboard) + .token(opts.token) + .decodedPacketCacheSize(opts.decodedPacketCacheSize) + .dataChannel(opts.dataChannel); + + if (opts.replayMode) { + builder.replayMode(true); + } else { + final MojangAuth mojang; + try { + mojang = resolveMojang(opts.mojangTokenInline, opts.mojangTokenFile, + opts.mojangProfileUuid, opts.mojangProfileName); + } catch (IllegalArgumentException e) { + System.err.println("minestom-web: " + e.getMessage()); + System.exit(2); + return; + } + final MojangAuth resolved; + try { + resolved = resolveBotProfile(mojang); + } catch (IOException e) { + System.err.println("minestom-web: failed to resolve bot profile from access token: " + e.getMessage()); + System.err.println("Pass --mojang-profile-uuid + --mojang-profile-name to skip this lookup."); + System.exit(1); + return; + } + if (opts.backend == null) { + System.err.println("minestom-web: --backend is required"); + System.exit(2); + return; + } + builder.bindProxy(opts.bind) + .defaultBackend(opts.backend) + .persistence(opts.persistence) + .mojang(resolved); + } + + final ProxyServer server = builder.build(); + + final CountDownLatch shutdown = new CountDownLatch(1); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + LOGGER.info("Shutting down…"); + try { + server.close(); + } catch (Exception e) { + LOGGER.warn("error during shutdown", e); + } + shutdown.countDown(); + }, "Minestom-Web-Shutdown")); + + server.start(); + try { + shutdown.await(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + } + + private record Options( + InetSocketAddress bind, + InetSocketAddress backend, + InetSocketAddress dashboard, + String token, + int decodedPacketCacheSize, + String dataChannel, + Path persistence, + String mojangTokenInline, + Path mojangTokenFile, + UUID mojangProfileUuid, + String mojangProfileName, + boolean login, + String msClientId, + boolean replayMode, + boolean help + ) { + static Options parse(String[] args) { + InetSocketAddress bind = new InetSocketAddress("0.0.0.0", 25565); + InetSocketAddress backend = null; + InetSocketAddress dashboard = new InetSocketAddress("127.0.0.1", 8080); + String token = null; + int decodedPacketCacheSize = 5000; + String dataChannel = ProxyConfig.DEFAULT_DATA_CHANNEL; + Path persistence = Path.of("sessions.db"); + String mojangToken = null; + Path mojangTokenFile = null; + UUID mojangProfileUuid = null; + String mojangProfileName = null; + boolean login = false; + String msClientId = null; + boolean replayMode = false; + boolean help = false; + + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + switch (kind(arg)) { + case "-h", "--help" -> help = true; + case "-b", "--bind" -> bind = AddressResolver.parse(value(args, i++, arg), "0.0.0.0"); + case "--backend" -> backend = AddressResolver.parseMinecraft(value(args, i++, arg), "127.0.0.1"); + case "-d", "--dashboard" -> dashboard = AddressResolver.parse(value(args, i++, arg), "127.0.0.1"); + case "-t", "--token" -> token = value(args, i++, arg); + case "--decoded-packet-cache" -> + decodedPacketCacheSize = parseNonNegativeInt(value(args, i++, arg), "--decoded-packet-cache"); + case "--data-channel" -> dataChannel = value(args, i++, arg); + case "--persistence" -> { + String v = value(args, i++, arg); + persistence = v.equalsIgnoreCase("none") ? null : Path.of(v); + } + case "--mojang-token" -> mojangToken = value(args, i++, arg); + case "--mojang-token-file" -> mojangTokenFile = Path.of(value(args, i++, arg)); + case "--mojang-profile-uuid" -> mojangProfileUuid = parseUuid(value(args, i++, arg)); + case "--mojang-profile-name" -> mojangProfileName = value(args, i++, arg); + case "--login" -> login = true; + case "--ms-client-id" -> msClientId = value(args, i++, arg); + case "--replay-mode" -> replayMode = true; + default -> throw new IllegalArgumentException("unknown option: " + arg); + } + } + + return new Options(bind, backend, dashboard, token, decodedPacketCacheSize, + dataChannel, persistence, + mojangToken, mojangTokenFile, mojangProfileUuid, mojangProfileName, + login, msClientId, replayMode, help); + } + + /// Returns the flag name (`--foo` from `--foo=bar`); `value()` consumes the rest. + private static String kind(String arg) { + int eq = arg.indexOf('='); + return eq < 0 ? arg : arg.substring(0, eq); + } + + private static String value(String[] args, int i, String arg) { + int eq = arg.indexOf('='); + if (eq >= 0) return arg.substring(eq + 1); + if (i + 1 >= args.length) throw new IllegalArgumentException("missing value for " + arg); + return args[i + 1]; + } + } + + /// Sign in to Microsoft and write the resulting Mojang access_token to `--mojang-token-file`. + /// Returns a shell exit code — 0 on success, 1 on a flow-level failure (network, expired + /// code, no Minecraft entitlement, etc.). Required flags are validated here rather than at + /// parse time so the proxy mode is unaffected by their absence. + private static int runLogin(Options opts) { + if (opts.msClientId == null || opts.msClientId.isBlank()) { + System.err.println("minestom-web: --login requires --ms-client-id"); + return 2; + } + if (opts.mojangTokenFile == null) { + System.err.println("minestom-web: --login requires --mojang-token-file (output path)"); + return 2; + } + if (opts.mojangTokenInline != null) { + System.err.println("minestom-web: --login conflicts with --mojang-token (file output only)"); + return 2; + } + try { + final MicrosoftAuth.Result result = MicrosoftAuth.login(opts.msClientId); + Files.writeString(opts.mojangTokenFile, result.accessToken()); + // Best-effort tighten to rw-------. Windows / non-POSIX filesystems silently skip. + try { + Files.setPosixFilePermissions(opts.mojangTokenFile, + PosixFilePermissions.fromString("rw-------")); + } catch (UnsupportedOperationException | IOException _) {} + System.out.println("Token written to " + opts.mojangTokenFile); + System.out.println(); + System.out.println("Re-run with: --mojang-token-file " + opts.mojangTokenFile + + " \\"); + System.out.println(" --mojang-profile-uuid " + result.profileUuid() + " \\"); + System.out.println(" --mojang-profile-name " + result.profileName()); + return 0; + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + System.err.println("Login interrupted."); + return 1; + } catch (IOException e) { + System.err.println("Login failed: " + e.getMessage()); + return 1; + } + } + + private static UUID parseUuid(String value) { + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("invalid UUID: " + value); + } + } + + /// Fill in `profileUuid` / `profileName` via `GET /minecraft/profile` when the user only + /// supplied a token. No-op when both fields are already present or no token is configured. + /// Returns the same instance if nothing changed; throws if the lookup fails. + private static MojangAuth resolveBotProfile(MojangAuth mojang) throws IOException { + if (mojang == null) return null; + if (mojang.profileUuid() != null && mojang.profileName() != null) return mojang; + final MicrosoftAuth.Profile profile = MicrosoftAuth.fetchProfile(mojang.accessToken()); + LOGGER.info("Mojang bot identity resolved: {} ({})", profile.name(), profile.uuid()); + return new MojangAuth(mojang.accessToken(), + mojang.profileUuid() != null ? mojang.profileUuid() : profile.uuid(), + mojang.profileName() != null ? mojang.profileName() : profile.name()); + } + + /// Reconcile the four Mojang flags into a single optional [MojangAuth]. The token may come + /// inline (`--mojang-token`) or from a file (`--mojang-token-file`); the file form is + /// preferred because CLI args leak through `ps`. Profile overrides are accepted only when + /// a token is present; otherwise they are dead config and we flag it as a user error. + private static MojangAuth resolveMojang(String inlineToken, Path tokenFile, + UUID profileUuid, String profileName) { + if (inlineToken != null && tokenFile != null) { + throw new IllegalArgumentException("--mojang-token and --mojang-token-file are mutually exclusive"); + } + final String token; + if (tokenFile != null) { + try { + token = Files.readString(tokenFile).strip(); + } catch (IOException e) { + throw new IllegalArgumentException("failed to read --mojang-token-file " + tokenFile + ": " + e.getMessage()); + } + if (token.isEmpty()) throw new IllegalArgumentException("--mojang-token-file is empty: " + tokenFile); + } else { + token = inlineToken; + } + if (token == null) { + if (profileUuid != null || profileName != null) { + throw new IllegalArgumentException("--mojang-profile-* requires --mojang-token or --mojang-token-file"); + } + return null; + } + return new MojangAuth(token, profileUuid, profileName); + } + + private static int parseNonNegativeInt(String s, String flag) { + int n; + try { + n = Integer.parseInt(s); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("invalid integer for " + flag + ": " + s); + } + if (n < 0) throw new IllegalArgumentException(flag + " must be >= 0"); + return n; + } + + private static final String USAGE = """ + Usage: minestom-web [options] + + Run the Minestom web proxy + dashboard standalone, in front of a Minecraft + server. The proxy accepts player connections on --bind, sends fresh logins to + --backend, and the dashboard exposes the live view on --dashboard. Players can + be moved to any other reachable address at runtime via POST /api/players/{uuid} + /move — backends are not pre-registered. + + Options: + -b, --bind Public proxy bind address (default 0.0.0.0:25565) + --backend Landing target for fresh LOGIN connections. Required. + -d, --dashboard Dashboard HTTP/WebSocket bind (default 127.0.0.1:8080) + -t, --token Dashboard auth token (optional) + --decoded-packet-cache + Per-session decoded packet cache size (default 5000) + --data-channel Plugin channel for per-player NBT + (default %s) + --persistence Session SQLite path, or 'none' to disable + (default sessions.db) + --mojang-token Mojang minecraftservices access_token used to + authenticate the proxy to an online-mode upstream. + Leaks via 'ps' — prefer --mojang-token-file. + --mojang-token-file

    Read the Mojang access_token from a file. + --mojang-profile-uuid + Bot account UUID. Optional; auto-resolved from the + access_token at startup if omitted. + --mojang-profile-name + Bot account username. Optional; auto-resolved from + the access_token at startup if omitted. + --login Sign in to Microsoft via device-code flow, exchange + for a Mojang token, and write it to the path given + by --mojang-token-file. Then exit (does not start + the proxy). Requires --ms-client-id. + --ms-client-id Azure application ID used by --login. Register your + own at portal.azure.com (Microsoft Entra ID → App + registrations) with the XboxLive.signin permission. + --replay-mode Run the dashboard standalone without the TCP proxy. + The homepage becomes a drop zone — each browser tab + uploads a sessions.sqlite file produced by a prior + live run, and the dashboard replays it in isolation. + --bind / --backend / --mojang-* / --persistence are + ignored. + -h, --help Show this help and exit + + Addresses accept host:port, :port (with default host), or [ipv6]:port. + """.formatted(ProxyConfig.DEFAULT_DATA_CHANNEL); + + private Main() { + } +} diff --git a/web/src/main/java/net/minestom/web/cli/MicrosoftAuth.java b/web/src/main/java/net/minestom/web/cli/MicrosoftAuth.java new file mode 100644 index 00000000000..16a83ce5004 --- /dev/null +++ b/web/src/main/java/net/minestom/web/cli/MicrosoftAuth.java @@ -0,0 +1,258 @@ +package net.minestom.web.cli; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.UUID; + +/// Microsoft device-code OAuth flow that ends with a Mojang minecraftservices `access_token`. +/// The user gets a short code and a URL; once they confirm in any browser the flow walks the +/// Xbox Live → XSTS → Mojang chain and returns the final token + the bot's profile. +/// +/// Single public entry point: [#login(String)]. Returns synchronously after the user completes +/// (or times out). All HTTP calls use `HttpURLConnection` to avoid adding `java.net.http` to +/// the module graph. +/// +/// You must register an Azure application with the `XboxLive.signin` delegated permission and +/// pass its client ID. There is no shared / default ID — using someone else's would leak +/// telemetry to their tenant and may be revoked. Registration is free and takes ~5 minutes at +/// `https://portal.azure.com → Microsoft Entra ID → App registrations → New registration` +/// (account types: personal Microsoft accounts; redirect URI: not needed for device flow). +public final class MicrosoftAuth { + private static final String DEVICE_CODE_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; + private static final String TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; + private static final String XBL_AUTH_URL = "https://user.auth.xboxlive.com/user/authenticate"; + private static final String XSTS_AUTH_URL = "https://xsts.auth.xboxlive.com/xsts/authorize"; + private static final String MC_LOGIN_URL = "https://api.minecraftservices.com/authentication/login_with_xbox"; + private static final String MC_PROFILE_URL = "https://api.minecraftservices.com/minecraft/profile"; + + private static final String SCOPE = "XboxLive.signin offline_access"; + + public record Result(String accessToken, UUID profileUuid, String profileName) {} + + private MicrosoftAuth() {} + + /// Run the full sign-in flow. Prints user-facing instructions to stdout; blocks until the + /// user confirms in the browser (or the device code expires). + public static Result login(String clientId) throws IOException, InterruptedException { + final DeviceCode device = requestDeviceCode(clientId); + System.out.println(); + System.out.println("Open this URL in any browser:"); + System.out.println(" " + device.verificationUrl); + System.out.println("Enter the code:"); + System.out.println(" " + device.userCode); + System.out.println(); + System.out.printf("Waiting for confirmation (code expires in %d minutes)%n", + Math.max(1, device.expiresIn / 60)); + + final String msToken = pollForToken(clientId, device); + final XblToken xbl = xboxLiveAuth(msToken); + final XstsToken xsts = xstsAuthorize(xbl.token); + final String mcToken = mojangLogin(xsts.token, xsts.userHash); + final Profile profile = fetchProfile(mcToken); + System.out.println(); + System.out.println("Signed in as " + profile.name + " (" + profile.uuid + ")"); + return new Result(mcToken, profile.uuid, profile.name); + } + + // ---- step 1: device code request ---------------------------------------------------- + + private record DeviceCode(String deviceCode, String userCode, String verificationUrl, + int expiresIn, int interval) {} + + private static DeviceCode requestDeviceCode(String clientId) throws IOException { + final String form = "client_id=" + enc(clientId) + "&scope=" + enc(SCOPE); + final Response r = postForm(DEVICE_CODE_URL, form); + if (r.status != 200) throw apiError("device code request", r); + final JsonObject body = JsonParser.parseString(r.body).getAsJsonObject(); + return new DeviceCode( + body.get("device_code").getAsString(), + body.get("user_code").getAsString(), + body.get("verification_uri").getAsString(), + body.get("expires_in").getAsInt(), + body.has("interval") ? body.get("interval").getAsInt() : 5); + } + + // ---- step 2: poll until the user signs in ------------------------------------------ + + private static String pollForToken(String clientId, DeviceCode device) throws IOException, InterruptedException { + final long deadline = System.nanoTime() + Duration.ofSeconds(device.expiresIn).toNanos(); + int intervalSeconds = device.interval; + while (true) { + if (System.nanoTime() > deadline) { + throw new IOException("sign-in not completed in time — re-run --login"); + } + Thread.sleep(intervalSeconds * 1000L); + + final String form = "grant_type=urn:ietf:params:oauth:grant-type:device_code" + + "&client_id=" + enc(clientId) + + "&device_code=" + enc(device.deviceCode); + final Response r = postForm(TOKEN_URL, form); + if (r.status == 200) { + return JsonParser.parseString(r.body).getAsJsonObject() + .get("access_token").getAsString(); + } + // 400 with a JSON body carrying `error` is the documented continue-or-fail signal. + final JsonObject err; + try { err = JsonParser.parseString(r.body).getAsJsonObject(); } + catch (Exception _) { throw apiError("token poll", r); } + final String code = err.has("error") ? err.get("error").getAsString() : "unknown"; + switch (code) { + case "authorization_pending" -> { /* keep polling */ } + case "slow_down" -> intervalSeconds += 5; + case "expired_token" -> throw new IOException("sign-in code expired — re-run --login"); + case "authorization_declined" -> throw new IOException("sign-in declined by user"); + default -> { + final String desc = err.has("error_description") + ? err.get("error_description").getAsString() : ""; + throw new IOException("Microsoft sign-in failed: " + code + + (desc.isEmpty() ? "" : " — " + desc)); + } + } + } + } + + // ---- step 3: Xbox Live -------------------------------------------------------------- + + private record XblToken(String token, String userHash) {} + + private static XblToken xboxLiveAuth(String msToken) throws IOException { + final String json = """ + {"Properties":{"AuthMethod":"RPS","SiteName":"user.auth.xboxlive.com","RpsTicket":"d=%s"},"RelyingParty":"http://auth.xboxlive.com","TokenType":"JWT"}""" + .formatted(msToken); + final Response r = postJson(XBL_AUTH_URL, json); + if (r.status != 200) throw apiError("Xbox Live auth", r); + return parseXblOrXsts(r.body); + } + + // ---- step 4: XSTS authorize --------------------------------------------------------- + + private record XstsToken(String token, String userHash) {} + + private static XstsToken xstsAuthorize(String xblToken) throws IOException { + final String json = """ + {"Properties":{"SandboxId":"RETAIL","UserTokens":["%s"]},"RelyingParty":"rp://api.minecraftservices.com/","TokenType":"JWT"}""" + .formatted(xblToken); + final Response r = postJson(XSTS_AUTH_URL, json); + if (r.status == 401) { + // XSTS surfaces user-friendly failure modes as XErr codes; translate the common + // ones rather than dumping the raw JSON, which would only confuse the user. + JsonObject body; + try { body = JsonParser.parseString(r.body).getAsJsonObject(); } + catch (Exception _) { throw apiError("XSTS authorize", r); } + final long xerr = body.has("XErr") ? body.get("XErr").getAsLong() : 0L; + final String reason; + if (xerr == 2148916233L) reason = "this Microsoft account has no Xbox profile — visit xbox.com once to create one"; + else if (xerr == 2148916235L) reason = "Xbox Live is not available in this account's country/region"; + else if (xerr == 2148916236L || xerr == 2148916237L) reason = "this account requires adult verification"; + else if (xerr == 2148916238L) reason = "this is a child account; an adult must add it to a Microsoft family"; + else reason = "XErr=" + xerr; + throw new IOException("XSTS authorize failed: " + reason); + } + if (r.status != 200) throw apiError("XSTS authorize", r); + final XblToken parsed = parseXblOrXsts(r.body); + return new XstsToken(parsed.token, parsed.userHash); + } + + /// XBL and XSTS share a response shape: `{Token, DisplayClaims:{xui:[{uhs:"..."}]}}`. + private static XblToken parseXblOrXsts(String body) { + final JsonObject obj = JsonParser.parseString(body).getAsJsonObject(); + final String token = obj.get("Token").getAsString(); + final String userHash = obj.getAsJsonObject("DisplayClaims") + .getAsJsonArray("xui") + .get(0).getAsJsonObject() + .get("uhs").getAsString(); + return new XblToken(token, userHash); + } + + // ---- step 5: Mojang login ----------------------------------------------------------- + + private static String mojangLogin(String xstsToken, String userHash) throws IOException { + final String json = "{\"identityToken\":\"XBL3.0 x=" + userHash + ";" + xstsToken + "\"}"; + final Response r = postJson(MC_LOGIN_URL, json); + if (r.status != 200) throw apiError("Mojang login_with_xbox", r); + return JsonParser.parseString(r.body).getAsJsonObject().get("access_token").getAsString(); + } + + // ---- step 6: profile probe (also validates the token) ------------------------------- + + public record Profile(UUID uuid, String name) {} + + /// Resolve a Minecraft access_token to its UUID + username via + /// `GET /minecraft/profile`. Doubles as a token-validity check at startup. + public static Profile fetchProfile(String mcToken) throws IOException { + final Response r = get(MC_PROFILE_URL, "Bearer " + mcToken); + if (r.status == 404) { + // This Microsoft account doesn't own Minecraft, or no profile has been created yet. + throw new IOException("no Minecraft profile on this Microsoft account — buy / migrate Minecraft Java Edition first"); + } + if (r.status != 200) throw apiError("fetch profile", r); + final JsonObject body = JsonParser.parseString(r.body).getAsJsonObject(); + return new Profile( + parseUnhyphenatedUuid(body.get("id").getAsString()), + body.get("name").getAsString()); + } + + private static UUID parseUnhyphenatedUuid(String s) { + final String hyphenated = s.replaceFirst( + "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", + "$1-$2-$3-$4-$5"); + return UUID.fromString(hyphenated); + } + + // ---- HTTP helpers ------------------------------------------------------------------- + + private record Response(int status, String body) {} + + private static Response postForm(String url, String formBody) throws IOException { + return send(url, "POST", "application/x-www-form-urlencoded", null, formBody); + } + + private static Response postJson(String url, String json) throws IOException { + return send(url, "POST", "application/json", null, json); + } + + private static Response get(String url, @Nullable String authorization) throws IOException { + return send(url, "GET", null, authorization, null); + } + + private static Response send(String url, String method, @Nullable String contentType, + @Nullable String authorization, @Nullable String body) throws IOException { + final HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(15_000); + conn.setReadTimeout(30_000); + conn.setRequestProperty("Accept", "application/json"); + if (contentType != null) conn.setRequestProperty("Content-Type", contentType); + if (authorization != null) conn.setRequestProperty("Authorization", authorization); + if (body != null) { + conn.setDoOutput(true); + final byte[] payload = body.getBytes(StandardCharsets.UTF_8); + conn.setFixedLengthStreamingMode(payload.length); + try (OutputStream out = conn.getOutputStream()) { out.write(payload); } + } + final int status = conn.getResponseCode(); + final InputStream stream = (status >= 200 && status <= 299) + ? conn.getInputStream() : conn.getErrorStream(); + final String responseBody = stream == null ? "" : new String(stream.readAllBytes(), StandardCharsets.UTF_8); + return new Response(status, responseBody); + } + + private static IOException apiError(String stage, Response r) { + final String snippet = r.body.length() > 256 ? r.body.substring(0, 256) + "…" : r.body; + return new IOException(stage + " failed (HTTP " + r.status + "): " + snippet); + } + + private static String enc(String s) { + return URLEncoder.encode(s, StandardCharsets.UTF_8); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/AddressResolver.java b/web/src/main/java/net/minestom/web/internal/AddressResolver.java new file mode 100644 index 00000000000..52605b66c5c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/AddressResolver.java @@ -0,0 +1,168 @@ +package net.minestom.web.internal; + +import org.jetbrains.annotations.Nullable; + +import javax.naming.Context; +import javax.naming.NamingEnumeration; +import javax.naming.NamingException; +import javax.naming.directory.Attribute; +import javax.naming.directory.Attributes; +import javax.naming.directory.InitialDirContext; +import java.net.InetSocketAddress; +import java.util.Hashtable; + +/// Parse and resolve Minecraft server addresses. Two flavours: +/// +/// - **Plain.** [#parse] — accepts `host:port`, `[ipv6]:port`, or bare port; throws on DNS +/// failure. Use for `--bind` / `--dashboard` and anywhere the caller wants a literal socket +/// address. +/// - **Minecraft.** [#parseMinecraft] — additionally tries the `_minecraft._tcp.` SRV +/// record before resolving. When the SRV exists its target + port wins; otherwise falls back +/// to the supplied host/port (or to port 25565 for bare hostnames). Mirrors the vanilla +/// client's connect-by-name behaviour. +/// +/// Resolution is synchronous; the SRV lookup uses the JVM's bundled DNS provider via JNDI. On +/// timeout or no-record the lookup returns silently and the plain host:port is used. +public final class AddressResolver { + /// Default Minecraft port — used when the input is a bare hostname with no SRV record. + private static final int DEFAULT_PORT = 25565; + + private AddressResolver() {} + + /// Parse a `host:port` / `[ipv6]:port` / bare-port string. Bare-port form (e.g. `:8080` or + /// `8080`) uses `defaultHost` as the host. Throws [IllegalArgumentException] on malformed + /// input or unresolvable host. + public static InetSocketAddress parse(String spec, String defaultHost) { + return parse(spec, defaultHost, false); + } + + /// Parse a Minecraft address spec — same shape as [#parse], but also tries SRV. A bare + /// hostname (no port) is allowed and falls back to [#DEFAULT_PORT] when no SRV record is + /// found. + public static InetSocketAddress parseMinecraft(String spec, String defaultHost) { + return parse(spec, defaultHost, true); + } + + /// Convenience for runtime address strings (`movePlayer`, `Action.Move`). Unlike the + /// two-arg form there is no `defaultHost` fallback — bare-port input like `":25577"` or + /// `"25577"` throws, because runtime callers have no meaningful default and silently + /// rewriting to localhost is a footgun. + public static InetSocketAddress parseMinecraft(String spec) { + if (spec == null || spec.isBlank()) throw new IllegalArgumentException("empty address"); + if (spec.startsWith(":") || spec.chars().allMatch(Character::isDigit)) { + throw new IllegalArgumentException("missing host in address: " + spec); + } + return parse(spec, "", true); + } + + /// Resolve an already-split `host` + `port` pair. No SRV. + private static InetSocketAddress resolve(String host, int port) { + requirePort(port); + final InetSocketAddress addr = new InetSocketAddress(host, port); + if (addr.isUnresolved()) throw new IllegalArgumentException("could not resolve host: " + host); + return addr; + } + + /// Resolve `host` + `port` with SRV fallback. If `_minecraft._tcp.` resolves, its + /// target + port wins over `port`. Otherwise the supplied pair is used as-is. + private static InetSocketAddress resolveMinecraft(String host, int port) { + final SrvRecord srv = lookupMinecraftSrv(host); + if (srv != null) return resolve(srv.target(), srv.port()); + return resolve(host, port); + } + + private static InetSocketAddress parse(String spec, String defaultHost, boolean minecraftSrv) { + if (spec == null || spec.isBlank()) throw new IllegalArgumentException("empty address"); + if (spec.startsWith("[")) { + final int close = spec.indexOf(']'); + if (close < 0) throw new IllegalArgumentException("missing ']' in IPv6 address: " + spec); + final String host = spec.substring(1, close); + if (close + 1 >= spec.length() || spec.charAt(close + 1) != ':') { + throw new IllegalArgumentException("expected ':' after ']' in: " + spec); + } + final int port = parsePort(spec.substring(close + 2)); + return minecraftSrv ? resolveMinecraft(host, port) : resolve(host, port); + } + final int colon = spec.lastIndexOf(':'); + if (colon < 0) { + // Bare port (all digits) → use default host. Otherwise treat as a bare hostname + // (Minecraft mode only) and look up SRV / default port. + if (!minecraftSrv || spec.chars().allMatch(Character::isDigit)) { + return resolve(defaultHost, parsePort(spec)); + } + return resolveMinecraft(spec, DEFAULT_PORT); + } + final String host = spec.substring(0, colon); + final int port = parsePort(spec.substring(colon + 1)); + final String effectiveHost = host.isEmpty() ? defaultHost : host; + return minecraftSrv ? resolveMinecraft(effectiveHost, port) : resolve(effectiveHost, port); + } + + private static int parsePort(String s) { + final int p; + try { p = Integer.parseInt(s); } + catch (NumberFormatException _) { throw new IllegalArgumentException("invalid port: " + s); } + requirePort(p); + return p; + } + + private static void requirePort(int port) { + if (port < 1 || port > 65535) throw new IllegalArgumentException("port out of range: " + port); + } + + // ---- SRV --------------------------------------------------------------------------- + + private static @Nullable SrvRecord lookupMinecraftSrv(String host) { + final String query = "_minecraft._tcp." + host; + final Hashtable env = new Hashtable<>(); + env.put(Context.INITIAL_CONTEXT_FACTORY, "com.sun.jndi.dns.DnsContextFactory"); + InitialDirContext context = null; + try { + context = new InitialDirContext(env); + final Attributes attrs = context.getAttributes(query, new String[]{"SRV"}); + final Attribute records = attrs.get("SRV"); + if (records == null) return null; + SrvRecord best = null; + final NamingEnumeration values = records.getAll(); + while (values.hasMore()) { + final SrvRecord record = parseSrvRecord(values.next().toString()); + if (record != null && (best == null || record.compareTo(best) < 0)) best = record; + } + return best; + } catch (NamingException | RuntimeException _) { + return null; + } finally { + if (context != null) { + try { context.close(); } catch (NamingException _) {} + } + } + } + + private static @Nullable SrvRecord parseSrvRecord(String value) { + final String[] parts = value.trim().split("\\s+"); + if (parts.length != 4) return null; + try { + final int priority = Integer.parseInt(parts[0]); + final int weight = Integer.parseInt(parts[1]); + final int port = parsePort(parts[2]); + String target = parts[3]; + if (target.endsWith(".")) target = target.substring(0, target.length() - 1); + if (target.isBlank() || ".".equals(target)) return null; + return new SrvRecord(priority, weight, port, target); + } catch (IllegalArgumentException _) { + return null; + } + } + + private record SrvRecord(int priority, int weight, int port, String target) + implements Comparable { + @Override + public int compareTo(SrvRecord other) { + final int byPriority = Integer.compare(priority, other.priority); + if (byPriority != 0) return byPriority; + // Higher weight wins among equal-priority records. Deterministic; the proxy only + // needs one target per resolution. + return Integer.compare(other.weight, weight); + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/Uuids.java b/web/src/main/java/net/minestom/web/internal/Uuids.java new file mode 100644 index 00000000000..1d084a6cc7b --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/Uuids.java @@ -0,0 +1,26 @@ +package net.minestom.web.internal; + +import java.nio.ByteBuffer; +import java.util.UUID; + +/// UUID ↔ 16-byte big-endian (most-significant then least-significant long) conversion. Shared by +/// the SQLite archive (`BLOB(16)` columns) and the proxy's transfer cookies so the byte layout +/// lives in exactly one place. +public final class Uuids { + private Uuids() {} + + public static byte[] toBytes(UUID uuid) { + final ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + public static UUID fromBytes(byte[] bytes) { + if (bytes == null || bytes.length != 16) { + throw new IllegalArgumentException("not a 16-byte UUID: " + (bytes == null ? "null" : bytes.length)); + } + final ByteBuffer buf = ByteBuffer.wrap(bytes); + return new UUID(buf.getLong(), buf.getLong()); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/MinimapCodec.java b/web/src/main/java/net/minestom/web/internal/codec/MinimapCodec.java new file mode 100644 index 00000000000..dde52a44ac6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/MinimapCodec.java @@ -0,0 +1,111 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonObject; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; +import net.minestom.server.coordinate.CoordConversion; +import net.minestom.web.PlayerState; +import net.minestom.web.PlayerWorld; +import net.minestom.web.internal.renderer.MinimapRasterizer; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; + +/// Wire encoding for minimap v2: pre-rasterized 16×16 RGBA tiles (base64) plus unified pose +/// and entity markers on the same topic / HTTP snapshot. +public final class MinimapCodec { + public static final int VERSION = 2; + private static final Base64.Encoder BASE64 = Base64.getEncoder(); + + private MinimapCodec() { + } + + public static JsonObject snapshotJson(PlayerState player) { + final List tiles = new ArrayList<>(); + for (PlayerWorld.Chunk chunk : player.world.chunks.values()) { + if (chunk.heights == null) continue; + tiles.add(Tile.from(chunk)); + } + return WebJson.encodeAsObject(Snapshot.CODEC, + new Snapshot(Pose.from(player), PatchValue.visibleEntities(player), tiles)); + } + + /// Live frame: always carries pose + entity markers; terrain arrays only when dirty. + public static JsonObject frameJson(PlayerState player) { + final PlayerWorld world = player.world; + List loaded = null; + List unloaded = null; + if (!world.dirtyChunks.isEmpty()) { + loaded = new ArrayList<>(world.dirtyChunks.size()); + for (Long key : world.dirtyChunks) { + final PlayerWorld.Chunk chunk = world.chunks.get(key); + if (chunk != null && chunk.heights != null) loaded.add(Tile.from(chunk)); + } + world.dirtyChunks.clear(); + } + if (!world.unloadedChunks.isEmpty()) { + unloaded = new ArrayList<>(world.unloadedChunks.size()); + for (Long key : world.unloadedChunks) { + unloaded.add(new Coord(CoordConversion.chunkIndexGetX(key), CoordConversion.chunkIndexGetZ(key))); + } + world.unloadedChunks.clear(); + } + return WebJson.encodeAsObject(Frame.CODEC, + new Frame(Pose.from(player), PatchValue.visibleEntities(player), loaded, unloaded)); + } + + private record Pose(int v, double posX, double posY, double posZ, float yaw) { + static Pose from(PlayerState p) { + return new Pose(VERSION, p.posX, p.posY, p.posZ, p.yaw); + } + + static final StructCodec CODEC = StructCodec.struct( + "v", Codec.INT, Pose::v, + "posX", Codec.DOUBLE, Pose::posX, + "posY", Codec.DOUBLE, Pose::posY, + "posZ", Codec.DOUBLE, Pose::posZ, + "yaw", Codec.FLOAT, Pose::yaw, + Pose::new); + } + + private record Tile(int x, int z, String tile) { + static Tile from(PlayerWorld.Chunk chunk) { + // Owner-thread only + rasterizer is read-only → no defensive array copy. + return new Tile(chunk.chunkX, chunk.chunkZ, + BASE64.encodeToString(MinimapRasterizer.rasterize(chunk.heights, chunk.columnColors))); + } + + static final StructCodec CODEC = StructCodec.struct( + "x", Codec.INT, Tile::x, + "z", Codec.INT, Tile::z, + "tile", Codec.STRING, Tile::tile, + Tile::new); + } + + private record Coord(int x, int z) { + static final StructCodec CODEC = StructCodec.struct( + "x", Codec.INT, Coord::x, + "z", Codec.INT, Coord::z, + Coord::new); + } + + private record Snapshot(Pose pose, List entities, List chunks) { + static final StructCodec CODEC = StructCodec.struct( + StructCodec.INLINE, Pose.CODEC, Snapshot::pose, + "entities", WebCodecs.VISIBLE_ENTITY_SHORT.list(), Snapshot::entities, + "chunks", Tile.CODEC.list(), Snapshot::chunks, + Snapshot::new); + } + + private record Frame(Pose pose, List entities, + @Nullable List loaded, @Nullable List unloaded) { + static final StructCodec CODEC = StructCodec.struct( + StructCodec.INLINE, Pose.CODEC, Frame::pose, + "entities", WebCodecs.VISIBLE_ENTITY_SHORT.list(), Frame::entities, + "loaded", Tile.CODEC.list().optional(), Frame::loaded, + "unloaded", Coord.CODEC.list().optional(), Frame::unloaded, + Frame::new); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/PacketDecoder.java b/web/src/main/java/net/minestom/web/internal/codec/PacketDecoder.java new file mode 100644 index 00000000000..633ea8699f3 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/PacketDecoder.java @@ -0,0 +1,163 @@ +package net.minestom.web.internal.codec; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.PacketReading; +import net.minestom.server.network.packet.PacketVanilla; +import net.minestom.server.network.packet.PacketWriting; +import net.minestom.server.network.packet.client.ClientPacket; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.server.ServerPacket; +import net.minestom.server.network.packet.server.configuration.RegistryDataPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.server.registry.Registries; +import net.minestom.web.Direction; +import net.minestom.web.internal.session.Session; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import javax.crypto.Cipher; + +/// Wire framing for live proxy, replay, and blocking login. Decode updates [Session] states. +public final class PacketDecoder { + private static final Logger LOGGER = LoggerFactory.getLogger(PacketDecoder.class); + private static final int INITIAL_BUFFER = 8 * 1024; + public static final int MAX_BUFFER = 8 * 1024 * 1024; + + private PacketDecoder() {} + + public record EncryptionContext(Cipher encrypt, Cipher decrypt) {} + + public sealed interface Result { + Incomplete INCOMPLETE = new Incomplete(); + Error ERROR = new Error(); + + record Incomplete() implements Result {} + record Error() implements Result {} + record Frame(@Nullable byte[] wireBytes, Packet packet, + ConnectionState beforeState, ConnectionState nextState, int sizeBytes) implements Result {} + } + + public static Result drain(Session session, Direction direction, NetworkBuffer buffer) { + return drain(session, direction, buffer, true); + } + + public static Result drain(Session session, Direction direction, NetworkBuffer buffer, boolean captureWireBytes) { + final ConnectionState beforeState = direction == Direction.SERVERBOUND + ? session.clientToServerState : session.serverToClientState; + final int threshold = direction == Direction.SERVERBOUND + ? session.clientCompressionThreshold : session.upstreamCompressionThreshold; + final long start = buffer.readIndex(); + final PacketReading.Result result; + try { + result = direction == Direction.SERVERBOUND + ? PacketReading.readPacket(buffer, PacketVanilla.CLIENT_PACKET_PARSER, beforeState, + PacketVanilla::nextClientState, threshold > 0) + : PacketReading.readPacket(buffer, PacketVanilla.SERVER_PACKET_PARSER, beforeState, + PacketVanilla::nextServerState, threshold > 0); + } catch (Exception e) { + LOGGER.warn("decode error on {}", direction, e); + return Result.ERROR; + } + return switch (result) { + case PacketReading.Result.Empty _ -> Result.INCOMPLETE; + case PacketReading.Result.Failure failure -> { + prepareForMoreBytes(buffer, failure.requiredCapacity()); + yield Result.INCOMPLETE; + } + case PacketReading.Result.Success success -> { + final PacketReading.ParsedPacket parsed = success.packets().getFirst(); + final Packet packet = parsed.packet(); + final int sizeBytes = (int) (buffer.readIndex() - start); + final byte[] wireBytes; + if (captureWireBytes) { + wireBytes = new byte[sizeBytes]; + buffer.copyTo(start, wireBytes, 0, sizeBytes); + } else { + wireBytes = null; + } + advanceSessionState(session, direction, packet, parsed.nextState()); + reclaimReadHead(buffer); + yield new Result.Frame(wireBytes, packet, beforeState, parsed.nextState(), sizeBytes); + } + }; + } + + public static boolean encodeFramed(NetworkBuffer buffer, ConnectionState state, Packet packet, + int compressionThreshold) { + buffer.writeIndex(0); + buffer.readIndex(0); + while (true) { + try { + writeFramed(buffer, state, packet, compressionThreshold); + return true; + } catch (IndexOutOfBoundsException oob) { + if (buffer.capacity() >= MAX_BUFFER) return false; + buffer.resize(buffer.capacity() * 2L); + buffer.writeIndex(0); + buffer.readIndex(0); + } + } + } + + public static byte[] encodeToBytes(Registries registries, ConnectionState state, Packet packet, + int compressionThreshold) { + final NetworkBuffer buf = NetworkBuffer.resizableBuffer(INITIAL_BUFFER, registries); + if (!encodeFramed(buf, state, packet, compressionThreshold)) { + throw new IllegalStateException("packet exceeds " + MAX_BUFFER + " bytes"); + } + return buf.read(NetworkBuffer.RAW_BYTES); + } + + public static NetworkBuffer newCarry(Registries registries) { + return NetworkBuffer.resizableBuffer(INITIAL_BUFFER, registries); + } + + public static void decryptInPlace(NetworkBuffer buffer, long readStart, int nbytes, @Nullable Cipher decrypt) { + if (decrypt != null && nbytes > 0) buffer.cipher(decrypt, readStart, nbytes); + } + + public static void encryptInPlace(NetworkBuffer buffer, @Nullable Cipher encrypt) { + if (encrypt != null) buffer.cipher(encrypt, 0L, buffer.writeIndex()); + } + + private static void writeFramed(NetworkBuffer buffer, ConnectionState state, Packet packet, int threshold) { + switch (packet) { + case ServerPacket sp -> PacketWriting.writeFramedPacket(buffer, state, sp, threshold); + case ClientPacket cp -> PacketWriting.writeFramedPacket(buffer, state, cp, threshold); + } + } + + private static void prepareForMoreBytes(NetworkBuffer buffer, long frameBytes) { + if (frameBytes > buffer.capacity()) buffer.resize(frameBytes); + else reclaimReadHead(buffer); + } + + private static void reclaimReadHead(NetworkBuffer buffer) { + if (buffer.readIndex() > 0) buffer.compact(); + } + + private static void advanceSessionState(Session session, Direction direction, + Packet packet, ConnectionState nextState) { + if (packet instanceof ClientHandshakePacket handshake) { + final ConnectionState target = switch (handshake.intent()) { + case STATUS -> ConnectionState.STATUS; + case LOGIN, TRANSFER -> ConnectionState.LOGIN; + }; + session.clientToServerState = target; + session.serverToClientState = target; + return; + } + if (packet instanceof SetCompressionPacket(int t)) { + session.clientCompressionThreshold = t; + session.upstreamCompressionThreshold = t; + } + if (packet instanceof RegistryDataPacket registryData) { + Registries.applyRegistryDataPacket(session.registries, registryData); + } + if (direction == Direction.SERVERBOUND) session.clientToServerState = nextState; + else session.serverToClientState = nextState; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/PatchValue.java b/web/src/main/java/net/minestom/web/internal/codec/PatchValue.java new file mode 100644 index 00000000000..6599429d6e4 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/PatchValue.java @@ -0,0 +1,197 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.Result; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.codec.TranscoderProxy; +import net.minestom.server.codec.Codec.RawValue; +import net.minestom.server.item.ItemStack; +import net.minestom.web.PlayerState; +import net.minestom.web.Provenance; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/// Encodes heterogeneous `StatePatch` / snapshot values through typed [Codec]s, falling back +/// to the Java transcoder for plain collections and scalars Gson used to accept. +public final class PatchValue { + private static final Logger LOGGER = LoggerFactory.getLogger(PatchValue.class); + private static final Set> WARNED_RAW_TYPES = ConcurrentHashMap.newKeySet(); + public static final Codec> STRING_MAP = new Codec<>() { + @Override + public Result encode(Transcoder coder, Map value) { + return CODEC.encode(coder, value); + } + + @Override + public Result> decode(Transcoder coder, D value) { + Result> mapResult = coder.getMap(value); + if (!(mapResult instanceof Result.Ok>(Transcoder.MapLike map))) return mapResult.cast(); + Map out = new LinkedHashMap<>(map.size()); + for (String key : map.keys()) { + Result raw = map.getValue(key); + if (!(raw instanceof Result.Ok(D item))) return raw.cast(); + Result decoded = CODEC.decode(coder, item); + if (!(decoded instanceof Result.Ok(Object object))) return decoded.cast(); + out.put(key, object); + } + return new Result.Ok<>(out); + } + }; + + public static final Codec CODEC = new Codec<>() { + @Override + public Result encode(Transcoder coder, Object value) { + return switch (value) { + case null -> new Result.Ok<>(coder.createNull()); + case List list -> encodeList(coder, list); + case Map map when isStringKeyed(map) -> { + @SuppressWarnings("unchecked") + Map stringMap = (Map) map; + yield encodeStringMap(coder, stringMap); + } + default -> { + Codec typed = codecFor(value); + yield typed != null ? encodeTyped(coder, typed, value) : encodeRaw(coder, value); + } + }; + } + + @SuppressWarnings("unchecked") + private Result encodeTyped(Transcoder coder, Codec typed, Object value) { + return ((Codec) typed).encode(coder, (T) value); + } + + private Result encodeRaw(Transcoder coder, Object value) { + // Plain scalars/collections legitimately land here, but a web-owned type reaching the + // raw transcoder means a typed codec is missing from codecFor — it would ship a wrong + // (Java-shaped) value silently. Surface it once per offending class. + if (value != null && value.getClass().getName().startsWith("net.minestom.web") + && WARNED_RAW_TYPES.add(value.getClass())) { + LOGGER.warn("no typed codec for {}; falling back to raw transcoder (wire shape may be wrong)", + value.getClass().getName()); + } + return Codec.RAW_VALUE.encode(coder, RawValue.of(Transcoder.JAVA, value)) + .map(boxed -> boxed instanceof RawValue raw + ? raw.convertTo(coder) : new Result.Error<>("raw transcoder did not box a RawValue")); + } + + private Result encodeList(Transcoder coder, List list) { + Transcoder.ListBuilder builder = coder.createList(list.size()); + for (Object element : list) { + Result encoded = encode(coder, element); + if (!(encoded instanceof Result.Ok(D item))) return encoded; + builder.add(item); + } + return new Result.Ok<>(builder.build()); + } + + private Result encodeStringMap(Transcoder coder, Map map) { + if (TranscoderProxy.extractDelegate(coder) == Transcoder.JSON) { + JsonObject object = new JsonObject(); + for (Map.Entry entry : map.entrySet()) { + Result encoded = encode(coder, entry.getValue()); + if (!(encoded instanceof Result.Ok(D item))) return encoded; + object.add(entry.getKey(), (JsonElement) item); + } + @SuppressWarnings("unchecked") + D boxed = (D) object; + return new Result.Ok<>(boxed); + } + Transcoder.MapBuilder builder = coder.createMap(); + for (Map.Entry entry : map.entrySet()) { + Result encoded = encode(coder, entry.getValue()); + if (!(encoded instanceof Result.Ok(D item))) return encoded; + builder.put(entry.getKey(), item); + } + return new Result.Ok<>(builder.build()); + } + + @Override + public Result decode(Transcoder coder, D value) { + return Codec.RAW_VALUE.decode(coder, value) + .map(raw -> raw.convertTo(Transcoder.JAVA).mapResult(PatchValue::nullifyOptional)); + } + }; + + private PatchValue() {} + + private static boolean isStringKeyed(Map map) { + for (Object key : map.keySet()) { + if (key != null && !(key instanceof String)) return false; + } + return true; + } + + private static Object nullifyOptional(Object value) { + if (value instanceof java.util.Optional optional) return optional.orElse(null); + if (value instanceof List list) { + List out = new ArrayList<>(list.size()); + for (Object item : list) out.add(nullifyOptional(item)); + return out; + } + if (value instanceof Map map) { + Map out = new LinkedHashMap<>(map.size()); + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() instanceof String key) out.put(key, nullifyOptional(entry.getValue())); + } + return out; + } + return value; + } + + private static Codec codecFor(Object value) { + return switch (value) { + case Boolean b -> Codec.BOOLEAN; + case Byte b -> Codec.BYTE; + case Short s -> Codec.SHORT; + case Integer i -> Codec.INT; + case Long l -> Codec.LONG; + case Float f -> Codec.FLOAT; + case Double d -> Codec.DOUBLE; + case String s -> Codec.STRING; + case UUID u -> Codec.UUID_STRING; + case ItemStack stack -> ItemStack.CODEC; + case Component c -> Codec.COMPONENT; + case BinaryTag tag -> Codec.NBT; + case Provenance p -> WebCodecs.PROVENANCE; + case PlayerState.ActiveEffect e -> WebCodecs.ACTIVE_EFFECT; + case PlayerState.OpenedWindow w -> WebCodecs.OPENED_WINDOW; + case PlayerState.ClickEvent c -> WebCodecs.CLICK_EVENT; + case PlayerState.ChatLine c -> WebCodecs.CHAT_LINE; + case PlayerState.SentChatLine s -> WebCodecs.SENT_CHAT_LINE; + case PlayerState.BossBarSnapshot b -> WebCodecs.BOSS_BAR; + case PlayerState.ScoreboardSnapshot s -> WebCodecs.SCOREBOARD; + case PlayerState.TabListSnapshot t -> WebCodecs.TAB_LIST; + case PlayerState.DamageEvent d -> WebCodecs.DAMAGE_EVENT; + case PlayerState.VisibleEntityShort e -> WebCodecs.VISIBLE_ENTITY_SHORT; + default -> null; + }; + } + + public static List visibleEntities(PlayerState p) { + List out = new ArrayList<>(p.visibleEntities.size()); + for (PlayerState.VisibleEntity e : p.visibleEntities.values()) { + out.add(PlayerState.VisibleEntityShort.from(e)); + } + return out; + } + + static Map bossBars(PlayerState p) { + Map out = new LinkedHashMap<>(p.bossBars.size()); + for (var e : p.bossBars.entrySet()) out.put(String.valueOf(e.getKey()), e.getValue()); + return out; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/PlayerSnapshot.java b/web/src/main/java/net/minestom/web/internal/codec/PlayerSnapshot.java new file mode 100644 index 00000000000..0eba4907f10 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/PlayerSnapshot.java @@ -0,0 +1,305 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.item.ItemStack; + +import static net.minestom.web.internal.codec.WebCodecs.OPTIONAL_ITEM_STACK_LIST; +import static net.minestom.web.internal.codec.WebCodecs.itemStackList; +import static net.minestom.web.internal.codec.WebCodecs.nullIfAir; +import net.minestom.web.PlayerState; +import net.minestom.web.Provenance; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Wire snapshot of [PlayerState], split across nested [StructCodec]s (Minestom caps structs +/// at 19 fields — a full player snapshot is far larger). +final class PlayerSnapshot { + + private PlayerSnapshot() {} + + static JsonObject toJson(PlayerState p, Transcoder coder) { + return WebJson.encodeAsObject(Snapshot.CODEC, Snapshot.from(p), coder); + } + + private record Snapshot( + Core core, + PlayerState.Traffic traffic, + World world, + Vitals vitals, + Abilities abilities, + Inventory inventory, + Hud hud, + MetaFeed feed, + MetaSync sync, + MetaProvenance provenance + ) { + static Snapshot from(PlayerState p) { + return new Snapshot( + Core.from(p), + p.traffic, + World.from(p), + Vitals.from(p), + Abilities.from(p), + Inventory.from(p), + Hud.from(p), + MetaFeed.from(p), + MetaSync.from(p), + MetaProvenance.from(p)); + } + + static final StructCodec CODEC = StructCodec.struct( + StructCodec.INLINE, Core.CODEC, Snapshot::core, + "traffic", WebCodecs.TRAFFIC, Snapshot::traffic, + StructCodec.INLINE, World.CODEC, Snapshot::world, + StructCodec.INLINE, Vitals.CODEC, Snapshot::vitals, + StructCodec.INLINE, Abilities.CODEC, Snapshot::abilities, + StructCodec.INLINE, Inventory.CODEC, Snapshot::inventory, + StructCodec.INLINE, Hud.CODEC, Snapshot::hud, + StructCodec.INLINE, MetaFeed.CODEC, Snapshot::feed, + StructCodec.INLINE, MetaSync.CODEC, Snapshot::sync, + StructCodec.INLINE, MetaProvenance.CODEC, Snapshot::provenance, + Snapshot::new); + } + + private record Core( + String uuid, + String connectionId, + String journeyId, + String username, + String address, + String backendAddress, + int protocolVersion, + String clientBrand, + String serverBrand, + String locale, + long connectedAt, + long disconnectedAt, + String serverConnectionState, + String clientConnectionState + ) { + static Core from(PlayerState p) { + return new Core( + p.uuid == null ? null : p.uuid.toString(), + p.connectionId == null ? null : p.connectionId.toString(), + p.journeyId == null ? null : p.journeyId.toString(), + p.username, + String.valueOf(p.address), + p.backendAddress, + p.protocolVersion, + p.clientBrand, + p.serverBrand, + p.locale, + p.connectedAt, + p.disconnectedAt, + String.valueOf(p.serverConnectionState), + String.valueOf(p.clientConnectionState)); + } + + static final StructCodec CODEC = StructCodec.struct( + "uuid", Codec.STRING.optional(), Core::uuid, + "connectionId", Codec.STRING.optional(), Core::connectionId, + "journeyId", Codec.STRING.optional(), Core::journeyId, + "username", Codec.STRING.optional(), Core::username, + "address", Codec.STRING, Core::address, + "backendAddress", Codec.STRING.optional(), Core::backendAddress, + "protocolVersion", Codec.INT, Core::protocolVersion, + "clientBrand", Codec.STRING.optional(), Core::clientBrand, + "serverBrand", Codec.STRING.optional(), Core::serverBrand, + "locale", Codec.STRING.optional(), Core::locale, + "connectedAt", Codec.LONG, Core::connectedAt, + "disconnectedAt", Codec.LONG, Core::disconnectedAt, + "serverConnectionState", Codec.STRING, Core::serverConnectionState, + "clientConnectionState", Codec.STRING.optional(), Core::clientConnectionState, + Core::new); + } + + private record World( + String dimension, + String gamemode, + boolean hardcore, + double posX, + double posY, + double posZ, + float yaw, + float pitch, + boolean onGround + ) { + static World from(PlayerState p) { + return new World(p.dimension, p.gamemode, p.hardcore, p.posX, p.posY, p.posZ, p.yaw, p.pitch, p.onGround); + } + + static final StructCodec CODEC = StructCodec.struct( + "dimension", Codec.STRING.optional(), World::dimension, + "gamemode", Codec.STRING.optional(), World::gamemode, + "hardcore", Codec.BOOLEAN, World::hardcore, + "posX", Codec.DOUBLE, World::posX, + "posY", Codec.DOUBLE, World::posY, + "posZ", Codec.DOUBLE, World::posZ, + "yaw", Codec.FLOAT, World::yaw, + "pitch", Codec.FLOAT, World::pitch, + "onGround", Codec.BOOLEAN, World::onGround, + World::new); + } + + private record Vitals(float health, float maxHealth, int food, float saturation, int xpLevel, float xpBar) { + static Vitals from(PlayerState p) { + return new Vitals(p.health, p.maxHealth, p.food, p.saturation, p.xpLevel, p.xpBar); + } + + static final StructCodec CODEC = StructCodec.struct( + "health", Codec.FLOAT, Vitals::health, + "maxHealth", Codec.FLOAT, Vitals::maxHealth, + "food", Codec.INT, Vitals::food, + "saturation", Codec.FLOAT, Vitals::saturation, + "xpLevel", Codec.INT, Vitals::xpLevel, + "xpBar", Codec.FLOAT, Vitals::xpBar, + Vitals::new); + } + + private record Abilities( + boolean invulnerable, + boolean flying, + boolean allowFlying, + boolean instantBreak, + float flySpeed, + float walkSpeed + ) { + static Abilities from(PlayerState p) { + return new Abilities(p.invulnerable, p.flying, p.allowFlying, p.instantBreak, p.flySpeed, p.walkSpeed); + } + + static final StructCodec CODEC = StructCodec.struct( + "invulnerable", Codec.BOOLEAN, Abilities::invulnerable, + "flying", Codec.BOOLEAN, Abilities::flying, + "allowFlying", Codec.BOOLEAN, Abilities::allowFlying, + "instantBreak", Codec.BOOLEAN, Abilities::instantBreak, + "flySpeed", Codec.FLOAT, Abilities::flySpeed, + "walkSpeed", Codec.FLOAT, Abilities::walkSpeed, + Abilities::new); + } + + private record Inventory( + int selectedHotbar, + List hotbar, + List mainInventory, + List armor, + ItemStack offHand, + ItemStack cursor, + PlayerState.OpenedWindow openedWindow, + List recentClicks + ) { + static Inventory from(PlayerState p) { + return new Inventory( + p.selectedHotbar, + itemStackList(p.hotbar), + itemStackList(p.mainInventory), + itemStackList(p.armor), + nullIfAir(p.offHand), + nullIfAir(p.cursor), + p.openedWindow, + new ArrayList<>(p.recentClicks)); + } + + static final StructCodec CODEC = StructCodec.struct( + "selectedHotbar", Codec.INT, Inventory::selectedHotbar, + "hotbar", OPTIONAL_ITEM_STACK_LIST, Inventory::hotbar, + "mainInventory", OPTIONAL_ITEM_STACK_LIST, Inventory::mainInventory, + "armor", OPTIONAL_ITEM_STACK_LIST, Inventory::armor, + "offHand", ItemStack.CODEC.optional(), Inventory::offHand, + "cursor", ItemStack.CODEC.optional(), Inventory::cursor, + "openedWindow", WebCodecs.OPENED_WINDOW.optional(), Inventory::openedWindow, + "recentClicks", WebCodecs.CLICK_EVENT.list(), Inventory::recentClicks, + Inventory::new); + } + + private record Hud( + Map activeEffects, + Map attributes, + PlayerState.ScoreboardSnapshot scoreboard, + Map bossBars, + PlayerState.TabListSnapshot tabList, + Component lastActionBar + ) { + static Hud from(PlayerState p) { + return new Hud( + new LinkedHashMap<>(p.activeEffects), + new LinkedHashMap<>(p.attributes), + p.scoreboard, + PatchValue.bossBars(p), + p.tabList, + p.lastActionBar); + } + + static final StructCodec CODEC = StructCodec.struct( + "activeEffects", Codec.STRING.mapValue(WebCodecs.ACTIVE_EFFECT), Hud::activeEffects, + "attributes", Codec.STRING.mapValue(Codec.DOUBLE), Hud::attributes, + "scoreboard", WebCodecs.SCOREBOARD.optional(), Hud::scoreboard, + "bossBars", Codec.STRING.mapValue(WebCodecs.BOSS_BAR), Hud::bossBars, + "tabList", WebCodecs.TAB_LIST, Hud::tabList, + "lastActionBar", Codec.COMPONENT.optional(), Hud::lastActionBar, + Hud::new); + } + + private record MetaFeed( + List recentChat, + List sentChat, + Map custom + ) { + static MetaFeed from(PlayerState p) { + return new MetaFeed(tail(p.chatReceived, 24), tail(p.chatSent, 64), new LinkedHashMap<>(p.custom)); + } + + static final StructCodec CODEC = StructCodec.struct( + "recentChat", WebCodecs.CHAT_LINE.list(), MetaFeed::recentChat, + "sentChat", WebCodecs.SENT_CHAT_LINE.list(), MetaFeed::sentChat, + "custom", PatchValue.STRING_MAP, MetaFeed::custom, + MetaFeed::new); + } + + private record MetaSync( + long serverDataUpdatedAt, + net.kyori.adventure.nbt.BinaryTag serverData, + List visibleEntities, + long statePatchSeq + ) { + static MetaSync from(PlayerState p) { + return new MetaSync( + p.serverDataUpdatedAt, + p.serverData, + PatchValue.visibleEntities(p), + p.patchSeq); + } + + static final StructCodec CODEC = StructCodec.struct( + "serverDataUpdatedAt", Codec.LONG, MetaSync::serverDataUpdatedAt, + "serverData", Codec.NBT, MetaSync::serverData, + "visibleEntities", WebCodecs.VISIBLE_ENTITY_SHORT.list(), MetaSync::visibleEntities, + "statePatchSeq", Codec.LONG, MetaSync::statePatchSeq, + MetaSync::new); + } + + private record MetaProvenance(Map provenance) { + static MetaProvenance from(PlayerState p) { + return new MetaProvenance(new LinkedHashMap<>(p.provenance)); + } + + static final StructCodec CODEC = StructCodec.struct( + "provenance", Codec.STRING.mapValue(WebCodecs.PROVENANCE), MetaProvenance::provenance, + MetaProvenance::new); + } + + private static List tail(List source, int n) { + int size = source.size(); + if (size <= n) return new ArrayList<>(source); + return new ArrayList<>(source.subList(size - n, size)); + } + +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/RoutineCodecs.java b/web/src/main/java/net/minestom/web/internal/codec/RoutineCodecs.java new file mode 100644 index 00000000000..af3e178bcf7 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/RoutineCodecs.java @@ -0,0 +1,146 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonObject; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; +import net.minestom.web.Action; +import net.minestom.web.RegisteredRoutine; +import net.minestom.web.Routine; +import net.minestom.web.internal.http.PacketCatalog; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; + +/// [StructCodec] unions for the routine editor ([Routine.Trigger], [Action]). +/// +/// Wire shape (camelCase discriminators, shared {@code packet} field for class names): +///
    {@code
    +/// {"type":"onMatch"}
    +/// {"type":"onPacket","packet":"ClientChatPacket"}
    +/// {"type":"inject","packet":"...","fields":{...}}
    +/// {"type":"ref","id":""}
    +/// }
    +public final class RoutineCodecs { + + private static final StructCodec TRIGGER_ON_MATCH_CODEC = + StructCodec.struct(Routine.Trigger.OnMatch::new); + private static final StructCodec TRIGGER_ON_UNMATCH_CODEC = + StructCodec.struct(Routine.Trigger.OnUnmatch::new); + private static final StructCodec TRIGGER_ON_PACKET_CODEC = StructCodec.struct( + "packet", Codec.STRING, trigger -> trigger.packetClass().getSimpleName(), + RoutineCodecs::decodeOnPacket); + private static final StructCodec TRIGGER_INTERVAL_CODEC = StructCodec.struct( + "millis", Codec.LONG, Routine.Trigger.Interval::millis, + Routine.Trigger.Interval::new); + + public static final StructCodec TRIGGER = Codec.STRING.unionType("type", + type -> switch (type) { + case "onMatch" -> TRIGGER_ON_MATCH_CODEC; + case "onUnmatch" -> TRIGGER_ON_UNMATCH_CODEC; + case "onPacket" -> TRIGGER_ON_PACKET_CODEC; + case "interval" -> TRIGGER_INTERVAL_CODEC; + default -> null; + }, + trigger -> switch (trigger) { + case Routine.Trigger.OnMatch _ -> "onMatch"; + case Routine.Trigger.OnUnmatch _ -> "onUnmatch"; + case Routine.Trigger.OnPacket _ -> "onPacket"; + case Routine.Trigger.Interval _ -> "interval"; + }); + + private record ActionRef(UUID id) {} + + private static final StructCodec ACTION_REF_CODEC = StructCodec.struct( + "id", Codec.UUID_STRING, ActionRef::id, + ActionRef::new); + + @SuppressWarnings("unchecked") + private static final StructCodec[] ACTION_SLOT = (StructCodec[]) new StructCodec[1]; + + public static final StructCodec ACTION; + + static { + StructCodec inject = StructCodec.struct( + "packet", Codec.STRING, Action.Inject::className, + "fields", PatchValue.STRING_MAP.optional(Map.of()), Action.Inject::fields, + Action.Inject::new); + StructCodec chat = StructCodec.struct( + "component", WebCodecs.EXPRESSION_OR_COMPONENT, Action.Chat::component, + Action.Chat::new); + StructCodec setCustom = StructCodec.struct( + "key", Codec.STRING, Action.SetCustom::key, + "value", Codec.STRING, Action.SetCustom::value, + Action.SetCustom::new); + StructCodec move = StructCodec.struct( + "address", Codec.STRING, Action.Move::address, + Action.Move::new); + StructCodec sequence = StructCodec.struct( + "actions", Codec.ForwardRef(() -> ACTION_SLOT[0]).list(), Action.Sequence::actions, + Action.Sequence::new); + + ACTION_SLOT[0] = ACTION = Codec.STRING.unionType("type", + type -> switch (type) { + case "inject" -> inject; + case "chat" -> chat; + case "setCustom" -> setCustom; + case "move" -> move; + case "sequence" -> sequence; + default -> null; + }, + action -> switch (action) { + case Action.Inject _ -> "inject"; + case Action.Chat _ -> "chat"; + case Action.SetCustom _ -> "setCustom"; + case Action.Move _ -> "move"; + case Action.Sequence _ -> "sequence"; + }); + } + + public static Routine.Trigger decodeTrigger(JsonObject obj) { + if (obj == null) return new Routine.Trigger.OnMatch(); + return WebJson.decode(TRIGGER, obj); + } + + private static Routine.Trigger.OnPacket decodeOnPacket(String className) { + if (className == null || className.isBlank()) throw new IllegalArgumentException("packet required"); + try { + return new Routine.Trigger.OnPacket(PacketCatalog.packetClass(className.trim())); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("unknown packet class: " + className, e); + } + } + + public static Action decodeAction(JsonObject obj, Function resolveRef) { + if (obj == null) throw new IllegalArgumentException("missing action"); + if ("ref".equals(obj.has("type") ? obj.get("type").getAsString() : null)) { + ActionRef ref = WebJson.decode(ACTION_REF_CODEC, obj); + return resolveRef.apply(ref.id()); + } + return WebJson.decode(ACTION, obj); + } + + /// Wire shape for a single routine. Trigger / Action go through their registered Gson + /// hierarchy adapters in [net.minestom.web.internal.http.JsonSerialization] so callers can + /// hand this directly to Gson. + public static Map routineJson(RegisteredRoutine registered) { + Routine r = registered.routine(); + var out = new LinkedHashMap(); + out.put("id", r.id().toString()); + out.put("name", r.name()); + out.put("ql", r.ql().source()); + out.put("trigger", r.trigger()); + out.put("action", r.action()); + out.put("debounceMs", r.debounceMs()); + out.put("enabled", registered.enabled()); + return out; + } + + public static List> routinesJson(java.util.Collection routines) { + return routines.stream().map(RoutineCodecs::routineJson).toList(); + } + + private RoutineCodecs() {} +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/WebCodecs.java b/web/src/main/java/net/minestom/web/internal/codec/WebCodecs.java new file mode 100644 index 00000000000..086c7678a34 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/WebCodecs.java @@ -0,0 +1,429 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.Result; +import net.minestom.server.codec.StructCodec; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.item.ItemStack; +import net.minestom.web.internal.expression.ExprValue; +import net.minestom.server.network.ConnectionState; +import net.minestom.web.*; +import net.minestom.web.internal.http.MetricsSampler; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/// [StructCodec] definitions for dashboard REST + WebSocket payloads. Encode/decode through +/// [WebJson] and [Transcoder#JSON] — no hand-built Gson trees for these types. +public final class WebCodecs { + + public static final Codec EXPRESSION_OR_COMPONENT = new Codec<>() { + @Override + public Result encode(Transcoder coder, Object value) { + return value instanceof Component c + ? Codec.COMPONENT.encode(coder, c) + : Codec.STRING.encode(coder, String.valueOf(value)); + } + + @Override + public Result decode(Transcoder coder, D value) { + if (value instanceof JsonElement el && el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) { + return new Result.Ok<>(el.getAsString()); + } + Result component = Codec.COMPONENT.decode(coder, value); + if (component instanceof Result.Ok(Component c)) return new Result.Ok<>(c); + return Codec.STRING.decode(coder, value).mapResult(s -> (Object) s); + } + }; + + public static final Codec> OPTIONAL_ITEM_STACK_LIST = ItemStack.CODEC.optional().list(); + + public static final Codec ITEM_STACK_ARRAY = OPTIONAL_ITEM_STACK_LIST.transform( + list -> list.toArray(ItemStack[]::new), + WebCodecs::itemStackList); + + public static final Codec DIRECTION = enumName(Direction.class); + public static final Codec CONNECTION_STATE = enumName(ConnectionState.class); + public static final Codec LIFECYCLE_KIND = enumName(LifecycleEvent.Kind.class); + + public static final StructCodec TRAFFIC = StructCodec.struct( + "compressionThreshold", Codec.INT, traffic -> traffic.compressionThreshold, + "pingMs", Codec.LONG, traffic -> traffic.pingMs, + "bytesIn", Codec.LONG, traffic -> traffic.bytesIn, + "bytesOut", Codec.LONG, traffic -> traffic.bytesOut, + "packetsIn", Codec.LONG, traffic -> traffic.packetsIn, + "packetsOut", Codec.LONG, traffic -> traffic.packetsOut, + "pingHistory", Codec.LONG.list(), traffic -> traffic.pingHistory, + PlayerState.Traffic::new); + + public static final StructCodec PROVENANCE = StructCodec.struct( + "seq", Codec.LONG, Provenance::seq, + "ts", Codec.LONG, Provenance::ts, + "packetClass", Codec.STRING, Provenance::packetClass, + "direction", DIRECTION, Provenance::direction, + Provenance::new); + + public static final StructCodec PROVENANCE_ENTRY = StructCodec.struct( + "source", PROVENANCE, Provenance.Entry::source, + "prev", PatchValue.CODEC.optional(), Provenance.Entry::prev, + "value", PatchValue.CODEC.optional(), Provenance.Entry::value, + Provenance.Entry::new); + + public static final StructCodec STATE_APPEND = StructCodec.struct( + "elements", PatchValue.CODEC.list(), StatePatch.Append::elements, + "max", Codec.INT, StatePatch.Append::max, + StatePatch.Append::new); + + public static final StructCodec STATE_PATCH = StructCodec.struct( + "seq", Codec.LONG, StatePatch::seq, + "ts", Codec.LONG, StatePatch::ts, + "values", PatchValue.STRING_MAP, StatePatch::values, + "appends", Codec.STRING.mapValue(STATE_APPEND), StatePatch::appends, + "provenance", Codec.STRING.mapValue(PROVENANCE), StatePatch::provenance, + StatePatch::new); + + public static final StructCodec PACKET_EVENT = StructCodec.struct( + "seq", Codec.LONG, PacketEvent::seq, + "ts", Codec.LONG, PacketEvent::ts, + "direction", DIRECTION, PacketEvent::direction, + "state", CONNECTION_STATE, PacketEvent::state, + "className", Codec.STRING, PacketEvent::className, + "sizeBytes", Codec.INT, PacketEvent::sizeBytes, + "subject", Codec.STRING, PacketEvent::subject, + "subjectLabel", Codec.STRING, PacketEvent::subjectLabel, + "subjectGroup", Codec.STRING, PacketEvent::subjectGroup, + "ioEventSeq", Codec.LONG, PacketEvent::ioEventSeq, + PacketEvent::new); + + public static final StructCodec LIFECYCLE_EVENT = StructCodec.struct( + "seq", Codec.LONG, LifecycleEvent::seq, + "ts", Codec.LONG, LifecycleEvent::ts, + "packetSeq", Codec.LONG, LifecycleEvent::packetSeq, + "kind", LIFECYCLE_KIND, LifecycleEvent::kind, + "data", WebJson.ELEMENT, LifecycleEvent::data, + LifecycleEvent::new); + + public static final StructCodec CONSOLE_LINE = StructCodec.struct( + "ts", Codec.LONG, ControlPacket.ConsoleLine::ts, + "level", Codec.STRING, ControlPacket.ConsoleLine::level, + "message", Codec.STRING, ControlPacket.ConsoleLine::message, + ControlPacket.ConsoleLine::new); + + public static final StructCodec CONTROL_METRICS = StructCodec.struct( + "ts", Codec.LONG, ControlPacket.Metrics::ts, + "processCpu", Codec.DOUBLE, ControlPacket.Metrics::processCpu, + "heapUsed", Codec.LONG, ControlPacket.Metrics::heapUsed, + "heapMax", Codec.LONG, ControlPacket.Metrics::heapMax, + "threadCount", Codec.INT, ControlPacket.Metrics::threadCount, + "uptimeMs", Codec.LONG, ControlPacket.Metrics::uptimeMs, + "mspt", Codec.DOUBLE, ControlPacket.Metrics::mspt, + "tps", Codec.DOUBLE, ControlPacket.Metrics::tps, + "playerCount", Codec.INT, ControlPacket.Metrics::playerCount, + ControlPacket.Metrics::new); + + public static final StructCodec METRICS_SAMPLE = StructCodec.struct( + "ts", Codec.LONG, MetricsSampler.Sample::ts, + "bytesIn", Codec.LONG, MetricsSampler.Sample::bytesIn, + "bytesOut", Codec.LONG, MetricsSampler.Sample::bytesOut, + "packetsIn", Codec.LONG, MetricsSampler.Sample::packetsIn, + "packetsOut", Codec.LONG, MetricsSampler.Sample::packetsOut, + "connections", Codec.INT, MetricsSampler.Sample::connections, + MetricsSampler.Sample::new); + + public static final StructCodec VISIBLE_ENTITY_SHORT = + StructCodec.struct( + "id", Codec.INT, PlayerState.VisibleEntityShort::id, + "uuid", Codec.UUID_STRING.optional(), PlayerState.VisibleEntityShort::uuid, + "type", Codec.STRING, PlayerState.VisibleEntityShort::type, + "group", Codec.STRING, PlayerState.VisibleEntityShort::group, + "x", Codec.DOUBLE, PlayerState.VisibleEntityShort::x, + "y", Codec.DOUBLE, PlayerState.VisibleEntityShort::y, + "z", Codec.DOUBLE, PlayerState.VisibleEntityShort::z, + "yaw", Codec.FLOAT, PlayerState.VisibleEntityShort::yaw, + PlayerState.VisibleEntityShort::new); + + public static final StructCodec ACTIVE_EFFECT = StructCodec.struct( + "id", Codec.STRING, PlayerState.ActiveEffect::id, + "amplifier", Codec.INT, PlayerState.ActiveEffect::amplifier, + "durationTicks", Codec.INT, PlayerState.ActiveEffect::durationTicks, + "ambient", Codec.BOOLEAN, PlayerState.ActiveEffect::ambient, + "particles", Codec.BOOLEAN, PlayerState.ActiveEffect::particles, + PlayerState.ActiveEffect::new); + + public static final StructCodec CLICK_EVENT = StructCodec.struct( + "seq", Codec.LONG, PlayerState.ClickEvent::seq, + "ts", Codec.LONG, PlayerState.ClickEvent::ts, + "windowId", Codec.INT, PlayerState.ClickEvent::windowId, + "rawSlot", Codec.INT, PlayerState.ClickEvent::rawSlot, + "kind", Codec.STRING, PlayerState.ClickEvent::kind, + "localSlot", Codec.INT, PlayerState.ClickEvent::localSlot, + "button", Codec.INT, PlayerState.ClickEvent::button, + "clickType", Codec.STRING, PlayerState.ClickEvent::clickType, + PlayerState.ClickEvent::new); + + public static final StructCodec CHAT_LINE = StructCodec.struct( + "ts", Codec.LONG, PlayerState.ChatLine::ts, + "sender", Codec.STRING.optional(), PlayerState.ChatLine::sender, + "content", Codec.COMPONENT, PlayerState.ChatLine::content, + "style", Codec.STRING.optional(), PlayerState.ChatLine::style, + PlayerState.ChatLine::new); + + public static final StructCodec SENT_CHAT_LINE = StructCodec.struct( + "ts", Codec.LONG, PlayerState.SentChatLine::ts, + "kind", Codec.STRING, PlayerState.SentChatLine::kind, + "text", Codec.STRING, PlayerState.SentChatLine::text, + PlayerState.SentChatLine::new); + + public static final StructCodec BOSS_BAR = StructCodec.struct( + "title", Codec.COMPONENT.optional(), PlayerState.BossBarSnapshot::title, + "progress", Codec.FLOAT, PlayerState.BossBarSnapshot::progress, + "color", Codec.STRING, PlayerState.BossBarSnapshot::color, + "division", Codec.STRING, PlayerState.BossBarSnapshot::division, + "flags", Codec.INT, PlayerState.BossBarSnapshot::flags, + PlayerState.BossBarSnapshot::new); + + public static final StructCodec SCOREBOARD_NUMBER_FORMAT = StructCodec.struct( + "format", Codec.STRING, PlayerState.NumberFormat::format, + "content", Codec.COMPONENT.optional(), PlayerState.NumberFormat::content, + PlayerState.NumberFormat::new); + + public static final StructCodec SCOREBOARD_ROW = StructCodec.struct( + "score", Codec.INT, PlayerState.ScoreboardRow::score, + "display", Codec.COMPONENT.optional(), PlayerState.ScoreboardRow::display, + "numberFormat", SCOREBOARD_NUMBER_FORMAT.optional(), PlayerState.ScoreboardRow::numberFormat, + PlayerState.ScoreboardRow::new); + + public static final StructCodec SCOREBOARD = StructCodec.struct( + "objectiveName", Codec.STRING.optional(), PlayerState.ScoreboardSnapshot::objectiveName, + "displayName", Codec.COMPONENT.optional(), PlayerState.ScoreboardSnapshot::displayName, + "slot", Codec.STRING.optional(), PlayerState.ScoreboardSnapshot::slot, + "rows", Codec.STRING.mapValue(SCOREBOARD_ROW), PlayerState.ScoreboardSnapshot::rows, + PlayerState.ScoreboardSnapshot::new); + + public static final StructCodec TAB_LIST = StructCodec.struct( + "header", Codec.COMPONENT.optional(), PlayerState.TabListSnapshot::header, + "footer", Codec.COMPONENT.optional(), PlayerState.TabListSnapshot::footer, + PlayerState.TabListSnapshot::new); + + public static final StructCodec DAMAGE_EVENT = StructCodec.struct( + "ts", Codec.LONG, PlayerState.DamageEvent::ts, + "amount", Codec.DOUBLE, PlayerState.DamageEvent::amount, + "source", Codec.STRING.optional(), PlayerState.DamageEvent::source, + "attackerId", Codec.INT.optional(), PlayerState.DamageEvent::attackerId, + PlayerState.DamageEvent::new); + + public static final StructCodec OPENED_WINDOW = StructCodec.struct( + "id", Codec.INT, PlayerState.OpenedWindow::id, + "type", Codec.STRING, PlayerState.OpenedWindow::type, + "title", Codec.COMPONENT.optional(), PlayerState.OpenedWindow::title, + "slots", ITEM_STACK_ARRAY, PlayerState.OpenedWindow::slots, + "properties", Codec.STRING.mapValue(Codec.INT), PlayerState.OpenedWindow::properties, + PlayerState.OpenedWindow::new); + + public static final StructCodec THROTTLE = StructCodec.struct( + "latencyMs", Codec.INT.optional(0), Throttle::latencyMs, + "jitterMs", Codec.INT.optional(0), Throttle::jitterMs, + "bandwidthBytesPerSec", Codec.LONG.optional(0L), Throttle::bandwidthBytesPerSec, + "direction", DIRECTION.optional(), Throttle::direction, + Throttle::new); + + public static final Codec THROTTLE_OPTIONAL = THROTTLE.optional(); + + public static final StructCodec THROTTLES_SNAPSHOT = StructCodec.struct( + "global", THROTTLE_OPTIONAL, WebPayloads.ThrottlesSnapshot::global, + "players", Codec.UUID_STRING.mapValue(THROTTLE), WebPayloads.ThrottlesSnapshot::players, + WebPayloads.ThrottlesSnapshot::new); + + public static final StructCodec SCOPE_SUMMARY = StructCodec.struct( + "id", Codec.STRING, WebPayloads.ScopeSummary::id, + "label", Codec.STRING, WebPayloads.ScopeSummary::label, + "replay", Codec.BOOLEAN, WebPayloads.ScopeSummary::replay, + "createdAt", Codec.LONG, WebPayloads.ScopeSummary::createdAt, + "connectionCount", Codec.INT, WebPayloads.ScopeSummary::connectionCount, + "status", Codec.STRING.optional(), WebPayloads.ScopeSummary::status, + "error", Codec.STRING.optional(), WebPayloads.ScopeSummary::error, + "endedAt", Codec.LONG.optional(), WebPayloads.ScopeSummary::endedAt, + WebPayloads.ScopeSummary::new); + + public static final Codec> SCOPE_SUMMARY_LIST = SCOPE_SUMMARY.list(); + + public static final StructCodec SERVER_INFO = StructCodec.struct( + "startedAt", Codec.LONG, WebPayloads.ServerInfo::startedAt, + "connectionCount", Codec.INT, WebPayloads.ServerInfo::connectionCount, + "history", METRICS_SAMPLE.list(), WebPayloads.ServerInfo::history, + WebPayloads.ServerInfo::new); + + public static final StructCodec MODE_PAYLOAD = StructCodec.struct( + "mode", Codec.STRING, WebPayloads.ModePayload::mode, + "scope", SCOPE_SUMMARY.optional(), WebPayloads.ModePayload::scope, + "protocolVersion", Codec.INT, WebPayloads.ModePayload::protocolVersion, + WebPayloads.ModePayload::new); + + public static final StructCodec PERSISTENCE_INFO = StructCodec.struct( + "enabled", Codec.BOOLEAN, WebPayloads.PersistenceInfo::enabled, + "protocolVersion", Codec.INT.optional(), WebPayloads.PersistenceInfo::protocolVersion, + "sessionId", Codec.LONG.optional(), WebPayloads.PersistenceInfo::sessionId, + "path", Codec.STRING.optional(), WebPayloads.PersistenceInfo::path, + WebPayloads.PersistenceInfo::new); + + public static final StructCodec GLOBAL_DATA = StructCodec.struct( + "data", Codec.NBT.optional(), WebPayloads.GlobalData::data, + WebPayloads.GlobalData::new); + + public static final StructCodec MAILBOX_ROW = StructCodec.struct( + "sessionId", Codec.UUID_STRING, WebPayloads.MailboxRow::sessionId, + "playerUuid", Codec.UUID_STRING.optional(), WebPayloads.MailboxRow::playerUuid, + "inboxDepth", Codec.INT, WebPayloads.MailboxRow::inboxDepth, + "streamListeners", Codec.INT, WebPayloads.MailboxRow::streamListeners, + WebPayloads.MailboxRow::new); + + public static final Codec> MAILBOX_ROW_LIST = MAILBOX_ROW.list(); + + public static final StructCodec SUBJECT_AGGREGATE = StructCodec.struct( + "id", Codec.STRING, WebPayloads.SubjectAggregate::id, + "label", Codec.STRING, WebPayloads.SubjectAggregate::label, + "group", Codec.STRING, WebPayloads.SubjectAggregate::group, + "count", Codec.INT, WebPayloads.SubjectAggregate::count, + "lastTs", Codec.LONG, WebPayloads.SubjectAggregate::lastTs, + "rate", Codec.INT, WebPayloads.SubjectAggregate::rate, + WebPayloads.SubjectAggregate::new); + + public static final Codec> SUBJECT_AGGREGATE_LIST = SUBJECT_AGGREGATE.list(); + + public static final StructCodec QUERY_RESULT = StructCodec.struct( + "matches", Codec.STRING.list(), WebPayloads.QueryResult::matches, + WebPayloads.QueryResult::new); + + public static final StructCodec TRIGGER_RESULT = StructCodec.struct( + "matched", Codec.INT, WebPayloads.TriggerResult::matched, + "fired", Codec.INT, WebPayloads.TriggerResult::fired, + "errors", Codec.STRING.list(), WebPayloads.TriggerResult::errors, + WebPayloads.TriggerResult::new); + + public static final Codec> STRING_LIST = Codec.STRING.list(); + + public static final StructCodec PLAYERS_SUMMARY_TRAFFIC = StructCodec.struct( + "pingMs", Codec.LONG, WebPayloads.PlayersSummaryTraffic::pingMs, + WebPayloads.PlayersSummaryTraffic::new); + + public static final StructCodec PLAYERS_SUMMARY_ROW = StructCodec.struct( + "uuid", Codec.UUID_STRING, WebPayloads.PlayersSummaryRow::uuid, + "username", Codec.STRING.optional(), WebPayloads.PlayersSummaryRow::username, + "disconnectedAt", Codec.LONG, WebPayloads.PlayersSummaryRow::disconnectedAt, + "health", Codec.FLOAT, WebPayloads.PlayersSummaryRow::health, + "maxHealth", Codec.FLOAT, WebPayloads.PlayersSummaryRow::maxHealth, + "traffic", PLAYERS_SUMMARY_TRAFFIC, WebPayloads.PlayersSummaryRow::traffic, + "gamemode", Codec.STRING.optional(), WebPayloads.PlayersSummaryRow::gamemode, + "dimension", Codec.STRING.optional(), WebPayloads.PlayersSummaryRow::dimension, + "serverConnectionState", Codec.STRING, WebPayloads.PlayersSummaryRow::serverConnectionState, + "clientConnectionState", Codec.STRING, WebPayloads.PlayersSummaryRow::clientConnectionState, + WebPayloads.PlayersSummaryRow::new); + + public static final StructCodec PLAYERS_SUMMARY = StructCodec.struct( + "players", PLAYERS_SUMMARY_ROW.list(), WebPayloads.PlayersSummaryPayload::players, + WebPayloads.PlayersSummaryPayload::new); + + public static final StructCodec PLAYER_PACKET_EVENT = StructCodec.struct( + "uuid", Codec.UUID_STRING, WebPayloads.PlayerPacketEvent::uuid, + "connectionId", Codec.UUID_STRING.optional(), WebPayloads.PlayerPacketEvent::connectionId, + "username", Codec.STRING.optional(), WebPayloads.PlayerPacketEvent::username, + "seq", Codec.LONG, WebPayloads.PlayerPacketEvent::seq, + "ts", Codec.LONG, WebPayloads.PlayerPacketEvent::ts, + "direction", DIRECTION, WebPayloads.PlayerPacketEvent::direction, + "state", CONNECTION_STATE, WebPayloads.PlayerPacketEvent::state, + "className", Codec.STRING, WebPayloads.PlayerPacketEvent::className, + "sizeBytes", Codec.INT, WebPayloads.PlayerPacketEvent::sizeBytes, + "subject", Codec.STRING, WebPayloads.PlayerPacketEvent::subject, + "subjectLabel", Codec.STRING, WebPayloads.PlayerPacketEvent::subjectLabel, + "subjectGroup", Codec.STRING, WebPayloads.PlayerPacketEvent::subjectGroup, + "ioEventSeq", Codec.LONG, WebPayloads.PlayerPacketEvent::ioEventSeq, + WebPayloads.PlayerPacketEvent::new); + + public static final StructCodec PACKETS_AGGREGATE = StructCodec.struct( + "rows", PLAYER_PACKET_EVENT.list(), WebPayloads.PacketsAggregate::rows, + WebPayloads.PacketsAggregate::new); + + public static final StructCodec PLAYERS_ROSTER_EVENT = StructCodec.struct( + "event", Codec.STRING, WebPayloads.PlayersRosterEvent::event, + "uuid", Codec.UUID_STRING, WebPayloads.PlayersRosterEvent::uuid, + "player", WebJson.ELEMENT.optional(), WebPayloads.PlayersRosterEvent::player, + WebPayloads.PlayersRosterEvent::new); + + public static final Codec> PACKET_EVENT_LIST = PACKET_EVENT.list(); + + public static final Codec> LIFECYCLE_EVENT_LIST = LIFECYCLE_EVENT.list(); + + public static final Codec> CONSOLE_LINE_LIST = CONSOLE_LINE.list(); + + + public static final StructCodec ENTITY_CHANGE = StructCodec.struct( + "source", PROVENANCE, PlayerState.EntityChange::source, + "field", Codec.STRING, PlayerState.EntityChange::field, + "prev", PatchValue.CODEC.optional(), PlayerState.EntityChange::prev, + "value", PatchValue.CODEC, PlayerState.EntityChange::value, + PlayerState.EntityChange::new); + + public static final StructCodec VISIBLE_ENTITY_DETAIL = StructCodec.struct( + "id", Codec.INT, WebPayloads.VisibleEntityDetail::id, + "uuid", Codec.UUID_STRING.optional(), WebPayloads.VisibleEntityDetail::uuid, + "type", Codec.STRING, WebPayloads.VisibleEntityDetail::type, + "group", Codec.STRING, WebPayloads.VisibleEntityDetail::group, + "x", Codec.DOUBLE, WebPayloads.VisibleEntityDetail::x, + "y", Codec.DOUBLE, WebPayloads.VisibleEntityDetail::y, + "z", Codec.DOUBLE, WebPayloads.VisibleEntityDetail::z, + "yaw", Codec.FLOAT, WebPayloads.VisibleEntityDetail::yaw, + "lastUpdate", Codec.LONG, WebPayloads.VisibleEntityDetail::lastUpdate, + "spawnSeq", Codec.LONG, WebPayloads.VisibleEntityDetail::spawnSeq, + "lastSeq", Codec.LONG, WebPayloads.VisibleEntityDetail::lastSeq, + "packetCount", Codec.INT, WebPayloads.VisibleEntityDetail::packetCount, + "provenance", Codec.STRING.mapValue(PROVENANCE), WebPayloads.VisibleEntityDetail::provenance, + "changeLog", ENTITY_CHANGE.list(), WebPayloads.VisibleEntityDetail::changeLog, + WebPayloads.VisibleEntityDetail::new); + + public static final Codec>> PROVENANCE_HISTORY = + Codec.STRING.mapValue(PROVENANCE_ENTRY.list()); + + public static final StructCodec REGISTRY_ENTRY = StructCodec.struct( + "id", Codec.STRING, WebPayloads.RegistryEntryDto::id, + "vanilla", Codec.BOOLEAN, WebPayloads.RegistryEntryDto::vanilla, + WebPayloads.RegistryEntryDto::new); + + public static final StructCodec REGISTRY = StructCodec.struct( + "id", Codec.STRING, WebPayloads.RegistryDto::id, + "entries", REGISTRY_ENTRY.list(), WebPayloads.RegistryDto::entries, + WebPayloads.RegistryDto::new); + + public static final StructCodec REGISTRIES = StructCodec.struct( + "registries", REGISTRY.list(), WebPayloads.RegistriesPayload::registries, + WebPayloads.RegistriesPayload::new); + + private WebCodecs() {} + + static > Codec enumName(Class type) { + return Codec.STRING.transform(name -> Enum.valueOf(type, name), Enum::name); + } + + public static ItemStack nullIfAir(ItemStack stack) { + return stack == null || stack.isAir() ? null : stack; + } + + public static Component componentFromEval(ExprValue value) { + return switch (value) { + case ExprValue.Null _ -> Component.empty(); + case ExprValue.Opaque(var raw) when raw instanceof Component c -> c; + case ExprValue.Dict _, ExprValue.Coll _ -> + WebJson.decode(Codec.COMPONENT, WebJson.encode(PatchValue.CODEC, value.toObject())); + default -> Component.text(value.str()); + }; + } + + public static List itemStackList(ItemStack[] source) { + if (source == null) return null; + var out = new ArrayList(source.length); + for (ItemStack stack : source) out.add(nullIfAir(stack)); + return out; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/WebJson.java b/web/src/main/java/net/minestom/web/internal/codec/WebJson.java new file mode 100644 index 00000000000..b505066ba35 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/WebJson.java @@ -0,0 +1,44 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.registry.Registries; +import net.minestom.server.registry.RegistryTranscoder; + +/// Encode/decode helpers for dashboard wire types via [Transcoder#JSON]. +public final class WebJson { + public static final Transcoder CODER = coder(Registries.vanilla()); + + public static final Codec ELEMENT = Codec.RAW_VALUE.transform( + raw -> raw.convertTo(CODER).orElseThrow(), + value -> Codec.RawValue.of(CODER, value)); + + private WebJson() {} + + public static Transcoder coder(Registries registries) { + return new RegistryTranscoder<>(Transcoder.JSON, registries); + } + + public static JsonElement encode(Codec codec, T value) { + return encode(codec, value, CODER); + } + + public static JsonElement encode(Codec codec, T value, Transcoder coder) { + return codec.encode(coder, value).orElseThrow(); + } + + public static JsonObject encodeAsObject(Codec codec, T value) { + return encodeAsObject(codec, value, CODER); + } + + public static JsonObject encodeAsObject(Codec codec, T value, Transcoder coder) { + return encode(codec, value, coder).getAsJsonObject(); + } + + public static T decode(Codec codec, JsonElement json) { + return codec.decode(CODER, json).orElseThrow(); + } + +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/WebJsonBuilders.java b/web/src/main/java/net/minestom/web/internal/codec/WebJsonBuilders.java new file mode 100644 index 00000000000..a1e13322744 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/WebJsonBuilders.java @@ -0,0 +1,105 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.registry.DynamicRegistry; +import net.minestom.server.registry.Registries; +import net.minestom.server.registry.RegistryKey; +import net.minestom.web.PacketEvent; +import net.minestom.web.PacketRecord; +import net.minestom.web.PlayerState; +import net.minestom.web.Provenance; +import net.minestom.web.internal.codec.WebPayloads.RegistriesPayload; +import net.minestom.web.internal.codec.WebPayloads.RegistryDto; +import net.minestom.web.internal.codec.WebPayloads.RegistryEntryDto; +import net.minestom.web.internal.codec.WebPayloads.VisibleEntityDetail; +import net.minestom.web.internal.http.PacketCatalog; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/// Procedural JSON builders for dashboard payloads that don't map cleanly onto a single +/// [net.minestom.server.codec.StructCodec] (per-field filtering, registry walks, etc.). The DTO +/// records live in [WebPayloads]; the codec constants live in [WebCodecs]. +public final class WebJsonBuilders { + + private static final List>>> CLIENT_REGISTRIES = List.of( + Map.entry("minecraft:chat_type", Registries::chatType), + Map.entry("minecraft:worldgen/biome", Registries::biome), + Map.entry("minecraft:dialog", Registries::dialog), + Map.entry("minecraft:damage_type", Registries::damageType), + Map.entry("minecraft:trim_material", Registries::trimMaterial), + Map.entry("minecraft:trim_pattern", Registries::trimPattern), + Map.entry("minecraft:banner_pattern", Registries::bannerPattern), + Map.entry("minecraft:enchantment", Registries::enchantment), + Map.entry("minecraft:painting_variant", Registries::paintingVariant), + Map.entry("minecraft:jukebox_song", Registries::jukeboxSong), + Map.entry("minecraft:instrument", Registries::instrument), + Map.entry("minecraft:wolf_variant", Registries::wolfVariant), + Map.entry("minecraft:wolf_sound_variant", Registries::wolfSoundVariant), + Map.entry("minecraft:cat_variant", Registries::catVariant), + Map.entry("minecraft:cat_sound_variant", Registries::catSoundVariant), + Map.entry("minecraft:chicken_variant", Registries::chickenVariant), + Map.entry("minecraft:chicken_sound_variant", Registries::chickenSoundVariant), + Map.entry("minecraft:cow_variant", Registries::cowVariant), + Map.entry("minecraft:cow_sound_variant", Registries::cowSoundVariant), + Map.entry("minecraft:frog_variant", Registries::frogVariant), + Map.entry("minecraft:pig_variant", Registries::pigVariant), + Map.entry("minecraft:pig_sound_variant", Registries::pigSoundVariant), + Map.entry("minecraft:zombie_nautilus_variant", Registries::zombieNautilusVariant), + Map.entry("minecraft:world_clock", Registries::worldClock), + Map.entry("minecraft:timeline", Registries::timeline), + Map.entry("minecraft:dimension_type", Registries::dimensionType) + ); + + private static final Registries VANILLA = Registries.vanilla(); + + private WebJsonBuilders() {} + + public static JsonObject packetRecordJson(PacketRecord record, PacketCatalog.Subject subject) { + return WebJson.encodeAsObject(WebCodecs.PACKET_EVENT, new PacketEvent( + record.seq(), record.ts(), record.direction(), record.state(), + record.className(), record.sizeBytes(), + subject.id(), subject.label(), subject.groupId(), 0L)); + } + + public static JsonObject playerStateJson(PlayerState player, Transcoder coder) { + return PlayerSnapshot.toJson(player, coder); + } + + public static JsonObject provenanceHistoryJson(PlayerState player, String field) { + Map> out = new LinkedHashMap<>(); + for (var entry : player.provenanceHistory.entrySet()) { + if (field != null && !field.equals(entry.getKey())) continue; + out.put(entry.getKey(), new ArrayList<>(entry.getValue())); + } + return WebJson.encode(WebCodecs.PROVENANCE_HISTORY, out).getAsJsonObject(); + } + + public static JsonObject visibleEntityJson(PlayerState player, int entityId) { + PlayerState.VisibleEntity entity = player.visibleEntities.get(entityId); + if (entity == null) return null; + return WebJson.encodeAsObject(WebCodecs.VISIBLE_ENTITY_DETAIL, VisibleEntityDetail.from(entity)); + } + + public static JsonObject registriesJson(Registries registries) { + List rows = new ArrayList<>(CLIENT_REGISTRIES.size()); + for (var entry : CLIENT_REGISTRIES) { + String registryId = entry.getKey(); + DynamicRegistry reg = entry.getValue().apply(registries); + DynamicRegistry vanillaReg = entry.getValue().apply(VANILLA); + List entries = new ArrayList<>(); + for (RegistryKey key : reg.keys()) { + String entryId = key.key().asString(); + boolean vanilla = vanillaReg.getKey(key.key()) != null; + entries.add(new RegistryEntryDto(entryId, vanilla)); + } + rows.add(new RegistryDto(registryId, entries)); + } + return WebJson.encodeAsObject(WebCodecs.REGISTRIES, new RegistriesPayload(rows)); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/codec/WebPayloads.java b/web/src/main/java/net/minestom/web/internal/codec/WebPayloads.java new file mode 100644 index 00000000000..d11faf732d1 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/WebPayloads.java @@ -0,0 +1,162 @@ +package net.minestom.web.internal.codec; + +import com.google.gson.JsonElement; +import net.kyori.adventure.nbt.BinaryTag; +import net.minestom.server.network.ConnectionState; +import net.minestom.web.Direction; +import net.minestom.web.PacketEvent; +import net.minestom.web.PlayerState; +import net.minestom.web.Provenance; +import net.minestom.web.Throttle; +import net.minestom.web.internal.http.MetricsSampler; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/// Wire-DTO records for dashboard REST + WebSocket payloads. Their +/// [net.minestom.server.codec.StructCodec] definitions live in [WebCodecs]; the JSON builders that +/// materialize them live in [WebJsonBuilders]. +public final class WebPayloads { + + private WebPayloads() {} + + public record PlayersSummaryRow( + UUID uuid, + String username, + long disconnectedAt, + float health, + float maxHealth, + PlayersSummaryTraffic traffic, + String gamemode, + String dimension, + String serverConnectionState, + String clientConnectionState + ) { + public static PlayersSummaryRow from(PlayerState p) { + return new PlayersSummaryRow( + p.uuid, + p.username, + p.disconnectedAt, + p.health, + p.maxHealth, + new PlayersSummaryTraffic(p.traffic.pingMs), + p.gamemode, + p.dimension, + String.valueOf(p.serverConnectionState), + String.valueOf(p.clientConnectionState)); + } + } + + public record PlayersSummaryTraffic(long pingMs) { + } + + public record PlayersSummaryPayload(List players) {} + + public record PlayerPacketEvent( + UUID uuid, + UUID connectionId, + String username, + long seq, + long ts, + Direction direction, + ConnectionState state, + String className, + int sizeBytes, + String subject, + String subjectLabel, + String subjectGroup, + long ioEventSeq + ) { + public static PlayerPacketEvent from(PlayerState player, PacketEvent event) { + return new PlayerPacketEvent( + player.uuid, + player.connectionId, + player.username, + event.seq(), + event.ts(), + event.direction(), + event.state(), + event.className(), + event.sizeBytes(), + event.subject(), + event.subjectLabel(), + event.subjectGroup(), + event.ioEventSeq()); + } + } + + public record VisibleEntityDetail( + int id, + UUID uuid, + String type, + String group, + double x, + double y, + double z, + float yaw, + long lastUpdate, + long spawnSeq, + long lastSeq, + int packetCount, + Map provenance, + List changeLog + ) { + static VisibleEntityDetail from(PlayerState.VisibleEntity entity) { + return new VisibleEntityDetail( + entity.id, + entity.uuid, + entity.type, + entity.group, + entity.x, + entity.y, + entity.z, + entity.yaw, + entity.lastUpdate, + entity.spawnSeq, + entity.lastSeq, + entity.packetCount, + new LinkedHashMap<>(entity.provenance), + new ArrayList<>(entity.changeLog)); + } + } + + public record RegistryEntryDto(String id, boolean vanilla) {} + + public record RegistryDto(String id, List entries) {} + + public record RegistriesPayload(List registries) {} + + public record ScopeSummary(String id, String label, boolean replay, long createdAt, int connectionCount, + @org.jetbrains.annotations.Nullable String status, + @org.jetbrains.annotations.Nullable String error, + @org.jetbrains.annotations.Nullable Long endedAt) {} + + public record ServerInfo(long startedAt, int connectionCount, List history) {} + + public record ModePayload(String mode, @org.jetbrains.annotations.Nullable ScopeSummary scope, int protocolVersion) {} + + public record PersistenceInfo(boolean enabled, + @org.jetbrains.annotations.Nullable Integer protocolVersion, + @org.jetbrains.annotations.Nullable Long sessionId, + @org.jetbrains.annotations.Nullable String path) {} + + public record GlobalData(@org.jetbrains.annotations.Nullable BinaryTag data) {} + + public record ThrottlesSnapshot(@org.jetbrains.annotations.Nullable Throttle global, Map players) {} + + public record MailboxRow(UUID sessionId, @org.jetbrains.annotations.Nullable UUID playerUuid, + int inboxDepth, int streamListeners) {} + + public record SubjectAggregate(String id, String label, String group, int count, long lastTs, int rate) {} + + public record QueryResult(List matches) {} + + public record TriggerResult(int matched, int fired, List errors) {} + + public record PacketsAggregate(List rows) {} + + public record PlayersRosterEvent(String event, UUID uuid, @org.jetbrains.annotations.Nullable JsonElement player) {} +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/Builtins.java b/web/src/main/java/net/minestom/web/internal/expression/Builtins.java new file mode 100644 index 00000000000..d6ba03359e2 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/Builtins.java @@ -0,0 +1,139 @@ +package net.minestom.web.internal.expression; + +import net.minestom.server.instance.block.Block; +import net.minestom.web.PlayerState; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.function.DoubleBinaryOperator; + +final class Builtins { + + @FunctionalInterface + interface Unary { ExprValue apply(ExprValue v, PlayerState s); } + + @FunctionalInterface + interface Fn { ExprValue apply(List args, PlayerState s); } + + static record FunctionInfo(String name, String sig, String detail, boolean pipe) {} + + private interface Def { FunctionInfo info(); } + + private record UnaryDef(FunctionInfo info, Unary fn) implements Def {} + + private record FnDef(FunctionInfo info, Fn fn) implements Def {} + + private static final List UNARY_DEFS = List.of( + unary("blockKey(id)", "Block state id to namespaced block key (e.g. minecraft:stone).", (v, s) -> { + if (v instanceof ExprValue.Null) return ExprValue.NULL; + Block block = Block.fromStateId((int) v.num()); + return block == null ? ExprValue.NULL : new ExprValue.Str(block.key().asString()); + }), + unary("upper(s)", "Uppercase a string.", (v, s) -> new ExprValue.Str(v.str().toUpperCase(Locale.ROOT))), + unary("lower(s)", "Lowercase a string.", (v, s) -> new ExprValue.Str(v.str().toLowerCase(Locale.ROOT))), + unary("str(x)", "Coerce any value to its string form.", (v, s) -> new ExprValue.Str(v.str())), + unary("num(x)", "Coerce any value to a number.", (v, s) -> new ExprValue.Num(v.num())), + unary("len(s)", "Length of a string.", (v, s) -> new ExprValue.Num(v.str().length())), + unary("floor(n)", "Round down to integer.", (v, s) -> new ExprValue.Num(Math.floor(v.num()))), + unary("ceil(n)", "Round up to integer.", (v, s) -> new ExprValue.Num(Math.ceil(v.num()))), + unary("round(n)", "Round to nearest integer.", (v, s) -> new ExprValue.Num(Math.round(v.num()))), + unary("abs(n)", "Absolute value.", (v, s) -> new ExprValue.Num(Math.abs(v.num()))) + ); + + private static final List FUNCTION_DEFS = List.of( + fn("distance(a, b)", "Euclidean distance between two 3-tuples.", Builtins::distance), + fn("blockId(x, y, z)", "Block state id at world coordinates (null if chunk not loaded).", Builtins::blockId), + fn("concat(a, b, ...)", "Concatenate strings.", (args, s) -> { + var sb = new StringBuilder(); + for (Expr a : args) sb.append(a.eval(s).str()); + return new ExprValue.Str(sb.toString()); + }), + fn("substr(s, from[, to])", "Substring with clamped indices.", Builtins::substr), + fn("min(a, b, ...)", "Numeric minimum.", (args, s) -> new ExprValue.Num(reduce(args, s, Math::min))), + fn("max(a, b, ...)", "Numeric maximum.", (args, s) -> new ExprValue.Num(reduce(args, s, Math::max))) + ); + + static final Map UNARY = byName(UNARY_DEFS); + static final Map FUNCTIONS = byName(FUNCTION_DEFS); + + private Builtins() {} + + static List functionInfo() { + var out = new ArrayList(UNARY_DEFS.size() + FUNCTION_DEFS.size()); + for (UnaryDef def : UNARY_DEFS) out.add(def.info()); + for (FnDef def : FUNCTION_DEFS) out.add(def.info()); + out.sort(Comparator.comparing(FunctionInfo::name)); + return List.copyOf(out); + } + + static ExprValue applyUnary(String name, ExprValue value, PlayerState s) { + UnaryDef fn = UNARY.get(name); + if (fn == null) throw new IllegalArgumentException("Unknown transform: " + name); + return fn.fn().apply(value, s); + } + + static ExprValue apply(String name, List args, PlayerState s) { + UnaryDef unary = UNARY.get(name); + if (unary != null) { + if (args.size() != 1) throw new IllegalArgumentException(name + " expects 1 argument"); + return unary.fn().apply(args.getFirst().eval(s), s); + } + FnDef fn = FUNCTIONS.get(name); + if (fn == null) throw new IllegalArgumentException("Unknown function: " + name); + return fn.fn().apply(args, s); + } + + private static UnaryDef unary(String sig, String detail, Unary fn) { + return new UnaryDef(new FunctionInfo(name(sig), sig, detail, true), fn); + } + + private static FnDef fn(String sig, String detail, Fn fn) { + return new FnDef(new FunctionInfo(name(sig), sig, detail, false), fn); + } + + private static String name(String sig) { + return sig.substring(0, sig.indexOf('(')); + } + + private static Map byName(List defs) { + var out = new LinkedHashMap(); + for (T def : defs) out.put(def.info().name(), def); + return Map.copyOf(out); + } + + private static ExprValue blockId(List args, PlayerState s) { + if (args.size() != 3) return ExprValue.NULL; + int id = s.world.getBlockStateId( + (int) args.get(0).eval(s).num(), + (int) args.get(1).eval(s).num(), + (int) args.get(2).eval(s).num()); + return id < 0 ? ExprValue.NULL : new ExprValue.Num(id); + } + + private static ExprValue distance(List args, PlayerState s) { + if (args.size() != 2) return ExprValue.NULL; + ExprValue a = args.get(0).eval(s), b = args.get(1).eval(s); + if (!(a instanceof ExprValue.Vec3 x) || !(b instanceof ExprValue.Vec3 y)) return ExprValue.NULL; + double dx = x.x() - y.x(), dy = x.y() - y.y(), dz = x.z() - y.z(); + return new ExprValue.Num(Math.sqrt(dx * dx + dy * dy + dz * dz)); + } + + private static ExprValue substr(List args, PlayerState s) { + String v = args.getFirst().eval(s).str(); + int from = Math.clamp((int) args.get(1).eval(s).num(), 0, v.length()); + int to = args.size() > 2 ? (int) args.get(2).eval(s).num() : v.length(); + to = Math.clamp(to, from, v.length()); + return new ExprValue.Str(v.substring(from, to)); + } + + private static double reduce(List args, PlayerState s, DoubleBinaryOperator op) { + if (args.isEmpty()) return 0; + double acc = args.getFirst().eval(s).num(); + for (int i = 1; i < args.size(); i++) acc = op.applyAsDouble(acc, args.get(i).eval(s).num()); + return acc; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/Expr.java b/web/src/main/java/net/minestom/web/internal/expression/Expr.java new file mode 100644 index 00000000000..c6e06a86ca6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/Expr.java @@ -0,0 +1,139 @@ +package net.minestom.web.internal.expression; + +import net.minestom.web.PlayerState; + +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; +import java.util.regex.Pattern; + +public sealed interface Expr { + ExprValue eval(PlayerState s); + + record Literal(ExprValue value) implements Expr { + @Override public ExprValue eval(PlayerState s) { return value; } + } + + record Path(List segments, Function root) implements Expr { + @Override public ExprValue eval(PlayerState s) { + ExprValue cur = root.apply(s); + for (int i = 1; i < segments.size(); i++) { + if (cur instanceof ExprValue.Null) return ExprValue.NULL; + cur = step(cur, segments.get(i)); + } + return cur; + } + + private static ExprValue step(ExprValue cur, String seg) { + return switch (cur) { + case ExprValue.Dict d -> d.value().getOrDefault(seg, ExprValue.NULL); + case ExprValue.Tag t -> { + var child = t.value().get(seg); + yield child == null ? ExprValue.NULL : ExprValue.of(child); + } + default -> ExprValue.of(reflect(cur.toObject(), seg)); + }; + } + + private static Object reflect(Object cur, String seg) { + if (cur == null) return null; + try { return cur.getClass().getMethod(seg).invoke(cur); } + catch (Exception _) { + try { return cur.getClass().getField(seg).get(cur); } + catch (Exception _) { return null; } + } + } + } + + record Tuple(List parts) implements Expr { + @Override public ExprValue eval(PlayerState s) { + double x = 0, y = 0, z = 0; + int n = Math.min(3, parts.size()); + for (int i = 0; i < n; i++) { + double v = parts.get(i).eval(s).num(); + if (i == 0) x = v; else if (i == 1) y = v; else z = v; + } + return new ExprValue.Vec3(x, y, z); + } + } + + record Binary(String op, Expr left, Expr right) implements Expr { + /// Compiled-pattern cache for `matches`. The right-hand side is almost always a constant + /// literal, so distinct patterns are few; recompiling per call would recompile on every + /// per-player cadence tick. + private static final Map PATTERNS = new ConcurrentHashMap<>(); + + @Override public ExprValue eval(PlayerState s) { + ExprValue a = left.eval(s), b = right.eval(s); + return switch (op) { + case "=" -> bool(equals(a, b)); + case "!=" -> bool(!equals(a, b)); + case "<" -> bool(a.num() < b.num()); + case "<=" -> bool(a.num() <= b.num()); + case ">" -> bool(a.num() > b.num()); + case ">=" -> bool(a.num() >= b.num()); + case "matches" -> bool(present(a, b) && PATTERNS.computeIfAbsent(b.str(), Pattern::compile).matcher(a.str()).matches()); + case "contains" -> bool(present(a, b) && a.str().contains(b.str())); + case "~" -> bool(present(a, b) && a.str().toLowerCase(Locale.ROOT).contains(b.str().toLowerCase(Locale.ROOT))); + case "has", "in" -> bool(membership(op.equals("has") ? a : b, op.equals("has") ? b : a)); + case "and" -> bool(a.isTruthy() && b.isTruthy()); + case "or" -> bool(a.isTruthy() || b.isTruthy()); + case "+" -> a instanceof ExprValue.Num && b instanceof ExprValue.Num + ? new ExprValue.Num(a.num() + b.num()) : new ExprValue.Str(a.str() + b.str()); + case "-" -> new ExprValue.Num(a.num() - b.num()); + case "*" -> new ExprValue.Num(a.num() * b.num()); + case "/" -> new ExprValue.Num(b.num() == 0 ? 0 : a.num() / b.num()); + case "%" -> new ExprValue.Num(b.num() == 0 ? 0 : a.num() % b.num()); + default -> throw new IllegalArgumentException("Unknown operator: " + op); + }; + } + + private static ExprValue.Bool bool(boolean v) { return new ExprValue.Bool(v); } + + private static boolean present(ExprValue a, ExprValue b) { + return !(a instanceof ExprValue.Null) && !(b instanceof ExprValue.Null); + } + + private static boolean equals(ExprValue a, ExprValue b) { + if (a instanceof ExprValue.Null) return b instanceof ExprValue.Null; + if (b instanceof ExprValue.Null) return false; + if (a instanceof ExprValue.Num na && b instanceof ExprValue.Num nb) return na.value() == nb.value(); + return a.str().equals(b.str()); + } + + private static boolean membership(ExprValue container, ExprValue value) { + if (!present(container, value)) return false; + String v = value.str(); + return switch (container) { + case ExprValue.Coll c -> c.value().stream().anyMatch(x -> x.str().equals(v)); + case ExprValue.Dict d -> d.value().containsKey(v); + default -> { + Object raw = container.toObject(); + if (raw != null && raw.getClass().isArray()) { + int n = java.lang.reflect.Array.getLength(raw); + for (int i = 0; i < n; i++) { + Object item = java.lang.reflect.Array.get(raw, i); + if (item != null && item.toString().contains(v)) yield true; + } + yield false; + } + yield container.str().contains(v); + } + }; + } + } + + record Not(Expr inner) implements Expr { + @Override public ExprValue eval(PlayerState s) { return new ExprValue.Bool(!inner.eval(s).isTruthy()); } + } + + record Call(String name, List args) implements Expr { + @Override public ExprValue eval(PlayerState s) { return Builtins.apply(name, args, s); } + } + + record Pipe(Expr value, String name) implements Expr { + @Override public ExprValue eval(PlayerState s) { return Builtins.applyUnary(name, value.eval(s), s); } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/ExprValue.java b/web/src/main/java/net/minestom/web/internal/expression/ExprValue.java new file mode 100644 index 00000000000..53c7e00b487 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/ExprValue.java @@ -0,0 +1,98 @@ +package net.minestom.web.internal.expression; + +import net.kyori.adventure.nbt.CompoundBinaryTag; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public sealed interface ExprValue { + ExprValue NULL = new Null(); + + record Null() implements ExprValue {} + record Bool(boolean value) implements ExprValue {} + record Num(double value) implements ExprValue {} + record Str(String value) implements ExprValue {} + record Vec3(double x, double y, double z) implements ExprValue {} + record Tag(CompoundBinaryTag value) implements ExprValue {} + record Coll(List value) implements ExprValue {} + record Dict(Map value) implements ExprValue {} + record Opaque(Object value) implements ExprValue {} + + default String str() { + return switch (this) { + case Null _ -> ""; + case Str s -> s.value(); + default -> String.valueOf(toObject()); + }; + } + + default double num() { + return switch (this) { + case Null _ -> 0; + case Num n -> n.value(); + case Bool b -> b.value() ? 1 : 0; + default -> { + try { yield Double.parseDouble(str()); } + catch (NumberFormatException e) { yield 0; } + } + }; + } + + default boolean isTruthy() { + return switch (this) { + case Null _ -> false; + case Bool b -> b.value(); + case Num n -> n.value() != 0; + case Str s -> !s.value().isEmpty(); + case Coll c -> !c.value().isEmpty(); + case Dict d -> !d.value().isEmpty(); + default -> true; + }; + } + + static ExprValue of(Object o) { + return switch (o) { + case null -> NULL; + case Boolean b -> new Bool(b); + case Number n -> new Num(n.doubleValue()); + case String s -> new Str(s); + case CompoundBinaryTag tag -> new Tag(tag); + case Collection c -> { + var items = new ArrayList(c.size()); + for (Object item : c) items.add(of(item)); + yield new Coll(items); + } + case Map m -> { + var map = new LinkedHashMap(m.size()); + for (var e : m.entrySet()) map.put(String.valueOf(e.getKey()), of(e.getValue())); + yield new Dict(map); + } + default -> new Opaque(o); + }; + } + + default Object toObject() { + return switch (this) { + case Null _ -> null; + case Bool b -> b.value(); + case Num n -> n.value(); + case Str s -> s.value(); + case Vec3 v -> new double[]{v.x(), v.y(), v.z()}; + case Tag t -> t.value(); + case Coll c -> { + var list = new ArrayList<>(c.value().size()); + for (ExprValue v : c.value()) list.add(v.toObject()); + yield list; + } + case Dict d -> { + var map = new LinkedHashMap(d.value().size()); + for (var e : d.value().entrySet()) map.put(e.getKey(), e.getValue().toObject()); + yield map; + } + case Opaque o -> o.value(); + }; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/ExpressionEngine.java b/web/src/main/java/net/minestom/web/internal/expression/ExpressionEngine.java new file mode 100644 index 00000000000..594644a7e7d --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/ExpressionEngine.java @@ -0,0 +1,114 @@ +package net.minestom.web.internal.expression; + +import net.minestom.web.ControlBridge; +import net.minestom.web.PlayerState; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.function.Predicate; +import java.util.function.ToDoubleFunction; + +public final class ExpressionEngine { + + record FieldInfo(String name, String detail) {} + + private record FieldDef(FieldInfo info, BiFunction accessor) {} + + private static final List FIELDS = List.of( + field("backend", "Current upstream `host:port` this player is bridged to", str(s -> s.backendAddress)), + field("brand", "Reported client brand", str(s -> s.clientBrand)), + field("dimension", "Current dimension identifier", str(s -> s.dimension)), + field("flying", "true if currently flying", bool(s -> s.flying)), + field("food", "Hunger level, 0-20", num(s -> s.food)), + field("gamemode", "SURVIVAL, CREATIVE, ADVENTURE, or SPECTATOR", str(s -> s.gamemode)), + field("global", "Server-pushed global NBT data", (engine, _) -> new ExprValue.Tag(engine.control.globalData())), + field("hardcore", "true if the world is hardcore", bool(s -> s.hardcore)), + field("health", "Player health, 0-20", num(s -> s.health)), + field("locale", "Client locale, e.g. en_us", str(s -> s.locale)), + field("name", "Player username", str(s -> s.username)), + field("onGround", "true if on ground", bool(s -> s.onGround)), + field("ping", "Round-trip ping in milliseconds", num(s -> s.traffic.pingMs)), + field("pos", "Position 3-tuple, for example distance(pos, (0, 64, 0))", + (_, s) -> new ExprValue.Vec3(s.posX, s.posY, s.posZ)), + field("protocolVersion", "Numeric protocol version", num(s -> s.protocolVersion)), + field("server", "Server-pushed NBT data (alias of serverData.*)", (_, s) -> new ExprValue.Tag(s.serverData)), + field("serverData", "Server-pushed NBT data (dotted path)", (_, s) -> new ExprValue.Tag(s.serverData)), + field("traffic", "Connection traffic counters and transport state", s -> ExprValue.of(s.traffic)), + field("uuid", "Mojang UUID (string)", s -> s.uuid == null ? ExprValue.NULL : new ExprValue.Str(s.uuid.toString())), + field("xpLevel", "Experience level", num(s -> s.xpLevel)) + ); + + private static final Map FIELDS_BY_NAME = fieldsByName(); + /// Cap so the editor's per-keystroke `/api/expression/compile` validations can't grow the + /// cache without bound; the bounded set of routine/action sources fits comfortably below it. + private static final int COMPILE_CACHE_MAX = 1024; + + private final ControlBridge control; + /// Compiled-AST cache. The AST is immutable and resolves fields against this engine at + /// eval time, so one compile is reusable across every PlayerState — routine/action sources + /// otherwise re-tokenize + re-parse on every fire. + private final Map compileCache = new ConcurrentHashMap<>(); + + public ExpressionEngine(ControlBridge control) { this.control = control; } + + static List fieldInfo() { + return FIELDS.stream().map(FieldDef::info).toList(); + } + + public Function rootAccessor(String name) { + FieldDef field = FIELDS_BY_NAME.get(name); + return field == null ? reflect(name) : s -> field.accessor().apply(this, s); + } + + public Expr compile(String src) { + final Expr cached = compileCache.get(src); + if (cached != null) return cached; + final ValueParser p = newParser(src); + final Expr ast = p.parseExpr(); + if (p.peek().kind() != Lexer.Kind.EOF) + throw new IllegalArgumentException("Trailing tokens at " + p.peek()); + if (compileCache.size() < COMPILE_CACHE_MAX) compileCache.putIfAbsent(src, ast); + return ast; + } + + public ValueParser newParser(String src) { + return new ValueParser(Lexer.tokenize(src), this::rootAccessor); + } + + private static Function num(ToDoubleFunction f) { + return s -> new ExprValue.Num(f.applyAsDouble(s)); + } + + private static Function str(Function f) { + return s -> new ExprValue.Str(f.apply(s)); + } + + private static Function bool(Predicate f) { + return s -> new ExprValue.Bool(f.test(s)); + } + + private static FieldDef field(String name, String detail, Function accessor) { + return field(name, detail, (_, state) -> accessor.apply(state)); + } + + private static FieldDef field(String name, String detail, BiFunction accessor) { + return new FieldDef(new FieldInfo(name, detail), accessor); + } + + private static Map fieldsByName() { + var fields = new LinkedHashMap(); + for (FieldDef field : FIELDS) fields.put(field.info().name(), field); + return Map.copyOf(fields); + } + + private static Function reflect(String name) { + return s -> { + try { return ExprValue.of(s.getClass().getField(name).get(s)); } + catch (ReflectiveOperationException _) { return ExprValue.NULL; } + }; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/Lexer.java b/web/src/main/java/net/minestom/web/internal/expression/Lexer.java new file mode 100644 index 00000000000..c36a4858643 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/Lexer.java @@ -0,0 +1,86 @@ +package net.minestom.web.internal.expression; + +import java.util.ArrayList; +import java.util.List; + +public final class Lexer { + + public enum Kind { + NUMBER, STRING, IDENT, + DOT, COMMA, LPAREN, RPAREN, + PLUS, MINUS, STAR, SLASH, PERCENT, PIPE, + EQ, NE, LT, LE, GT, GE, TILDE, + EOF + } + + public record Token(Kind kind, String text) {} + + private Lexer() {} + + public static List tokenize(String src) { + List tokens = new ArrayList<>(); + int n = src.length(); + for (int i = 0; i < n; ) { + char c = src.charAt(i); + if (Character.isWhitespace(c)) { i++; continue; } + + if (Character.isLetter(c) || c == '_') { + int start = i++; + while (i < n && (Character.isLetterOrDigit(src.charAt(i)) || src.charAt(i) == '_')) i++; + tokens.add(new Token(Kind.IDENT, src.substring(start, i))); + } else if (Character.isDigit(c)) { + int start = i++; + while (i < n && (Character.isDigit(src.charAt(i)) || src.charAt(i) == '.')) i++; + tokens.add(new Token(Kind.NUMBER, src.substring(start, i))); + } else if (c == '"') { + i = readString(src, i + 1, n, tokens); + } else if ("<>=!".indexOf(c) >= 0) { + boolean two = i + 1 < n && src.charAt(i + 1) == '='; + Kind k = compareKind(c, two); + tokens.add(new Token(k, two ? src.substring(i, i + 2) : String.valueOf(c))); + i += two ? 2 : 1; + } else { + Kind k = singleCharKind(c, i); + tokens.add(new Token(k, String.valueOf(c))); + i++; + } + } + tokens.add(new Token(Kind.EOF, "")); + return tokens; + } + + private static int readString(String src, int from, int n, List out) { + var sb = new StringBuilder(); + int i = from; + while (i < n && src.charAt(i) != '"') { + if (src.charAt(i) == '\\' && i + 1 < n) { sb.append(src.charAt(i + 1)); i += 2; } + else { sb.append(src.charAt(i)); i++; } + } + if (i < n) i++; // closing " + out.add(new Token(Kind.STRING, sb.toString())); + return i; + } + + private static Kind compareKind(char c, boolean two) { + return switch (c) { + case '<' -> two ? Kind.LE : Kind.LT; + case '>' -> two ? Kind.GE : Kind.GT; + case '=' -> Kind.EQ; + case '!' -> Kind.NE; // tolerate bare '!' as '!=' + default -> throw new AssertionError(); + }; + } + + private static Kind singleCharKind(char c, int i) { + return switch (c) { + case '(' -> Kind.LPAREN; case ')' -> Kind.RPAREN; + case ',' -> Kind.COMMA; case '.' -> Kind.DOT; + case '+' -> Kind.PLUS; case '-' -> Kind.MINUS; + case '*' -> Kind.STAR; case '/' -> Kind.SLASH; + case '%' -> Kind.PERCENT; + case '|' -> Kind.PIPE; + case '~' -> Kind.TILDE; + default -> throw new IllegalArgumentException("Unexpected character '" + c + "' at " + i); + }; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/MqlConstants.java b/web/src/main/java/net/minestom/web/internal/expression/MqlConstants.java new file mode 100644 index 00000000000..42982c809ad --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/MqlConstants.java @@ -0,0 +1,76 @@ +package net.minestom.web.internal.expression; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class MqlConstants { + + public record OperatorInfo(String name, String detail, String kind) {} + + private static final List OPERATORS = List.of( + op("+", "Addition (numbers) or concatenation (any non-numeric operand becomes string)", "arithmetic"), + op("-", "Subtraction", "arithmetic"), + op("*", "Multiplication", "arithmetic"), + op("/", "Division (0 if divisor is 0)", "arithmetic"), + op("%", "Modulo (0 if divisor is 0)", "arithmetic"), + op("|", "Pipe into a unary function, for example blockKey or upper", "pipe"), + op("=", "Equality (numbers compared by value, others by string representation)", "comparison"), + op("!=", "Inequality", "comparison"), + op("<", "Less than (numeric)", "comparison"), + op("<=", "Less than or equal (numeric)", "comparison"), + op(">", "Greater than (numeric)", "comparison"), + op(">=", "Greater than or equal (numeric)", "comparison"), + op("~", "Case-insensitive substring match", "comparison"), + op("has", "Collection or map contains the right-hand value", "keyword"), + op("in", "Left value is contained in the right-hand collection", "keyword"), + op("contains", "Right-hand value is a substring of the left", "keyword"), + op("matches", "Left-hand string matches the Java regex on the right", "keyword"), + op("and", "Short-circuit conjunction", "logical"), + op("or", "Short-circuit disjunction", "logical"), + op("not", "Logical negation", "logical") + ); + + private static final List LITERALS = List.of("true", "false"); + private static final Set KEYWORD_OPERATORS = Set.copyOf(operatorNames("keyword")); + + private MqlConstants() {} + + public static Map payload() { + var out = new LinkedHashMap(); + out.put("fields", ExpressionEngine.fieldInfo().stream() + .map(field -> object("name", field.name(), "detail", field.detail())) + .toList()); + out.put("functions", Builtins.functionInfo().stream() + .map(fn -> object("name", fn.name(), "sig", fn.sig(), "detail", fn.detail(), "pipe", fn.pipe())) + .toList()); + out.put("operators", OPERATORS.stream() + .map(op -> object("name", op.name(), "detail", op.detail(), "kind", op.kind())) + .toList()); + out.put("literals", LITERALS); + return out; + } + + public static boolean isKeywordOperator(String name) { + return KEYWORD_OPERATORS.contains(name); + } + + private static OperatorInfo op(String name, String detail, String kind) { + return new OperatorInfo(name, detail, kind); + } + + private static List operatorNames(String... kinds) { + Set included = Set.of(kinds); + return OPERATORS.stream() + .filter(op -> included.contains(op.kind())) + .map(OperatorInfo::name) + .toList(); + } + + private static Map object(Object... kv) { + var out = new LinkedHashMap(kv.length / 2); + for (int i = 0; i < kv.length; i += 2) out.put((String) kv[i], kv[i + 1]); + return out; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/QueryEngine.java b/web/src/main/java/net/minestom/web/internal/expression/QueryEngine.java new file mode 100644 index 00000000000..cc6e069de12 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/QueryEngine.java @@ -0,0 +1,85 @@ +package net.minestom.web.internal.expression; + +import net.minestom.web.PlayerState; +import net.minestom.web.Query; + +/// The boolean/host layer over [ValueParser]: parses `and`/`or`/`not` and comparison/keyword +/// operators into an [Expr] tree, then wraps it as a [Query] that evaluates against a +/// [PlayerState]. +public record QueryEngine(ExpressionEngine expressions) { + + private static final Query MATCH_ALL = new Query() { + @Override public String source() { return ""; } + @Override public boolean matches(PlayerState state) { return true; } + }; + + public Query compile(String src) { + if (src == null || src.isBlank()) return MATCH_ALL; + ValueParser vp = expressions.newParser(src); + Parser p = new Parser(vp); + Expr ast = p.parseTop(); + return new Query() { + @Override public String source() { return src; } + @Override public boolean matches(PlayerState state) { + return ast.eval(state).isTruthy(); + } + @Override public String toString() { return "Query(" + src + ")"; } + }; + } + + private record Parser(ValueParser vp) { + Parser(ValueParser vp) { + this.vp = vp; + vp.groupParser(this::parseOr); + } + + Expr parseTop() { + Expr e = parseOr(); + if (vp.peek().kind() != Lexer.Kind.EOF) + throw new IllegalArgumentException("Trailing tokens at " + vp.peek()); + return e; + } + + private Expr parseOr() { + Expr left = parseAnd(); + while (vp.matchIdent("or")) left = new Expr.Binary("or", left, parseAnd()); + return left; + } + + private Expr parseAnd() { + Expr left = parseNot(); + while (vp.matchIdent("and")) left = new Expr.Binary("and", left, parseNot()); + return left; + } + + private Expr parseNot() { + if (vp.matchIdent("not")) return new Expr.Not(parseNot()); + return parseCmp(); + } + + private Expr parseCmp() { + Expr left = vp.parseExpr(); + String op = cmpOp(); + return op == null ? left : new Expr.Binary(op, left, vp.parseExpr()); + } + + private String cmpOp() { + Lexer.Token t = vp.peek(); + return switch (t.kind()) { + case EQ -> { vp.expect(Lexer.Kind.EQ); yield "="; } + case NE -> { vp.expect(Lexer.Kind.NE); yield "!="; } + case LT -> { vp.expect(Lexer.Kind.LT); yield "<"; } + case LE -> { vp.expect(Lexer.Kind.LE); yield "<="; } + case GT -> { vp.expect(Lexer.Kind.GT); yield ">"; } + case GE -> { vp.expect(Lexer.Kind.GE); yield ">="; } + case TILDE -> { vp.expect(Lexer.Kind.TILDE); yield "~"; } + case IDENT -> { + if (!MqlConstants.isKeywordOperator(t.text())) yield null; + vp.expect(Lexer.Kind.IDENT); + yield t.text(); + } + default -> null; + }; + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/expression/ValueParser.java b/web/src/main/java/net/minestom/web/internal/expression/ValueParser.java new file mode 100644 index 00000000000..ed5a96bd688 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/ValueParser.java @@ -0,0 +1,124 @@ +package net.minestom.web.internal.expression; + +import net.minestom.web.PlayerState; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; + +/// Value grammar: arithmetic, paths, calls, tuples, pipes. Comparisons/logicals stay for the host parser. +public final class ValueParser { + + private final List tokens; + private final Function> roots; + private int pos; + private Supplier groupParser; + + public ValueParser(List tokens, Function> roots) { + this.tokens = tokens; + this.roots = roots; + this.groupParser = this::parseExpr; + } + + public void groupParser(Supplier groupParser) { this.groupParser = groupParser; } + + public Lexer.Token peek() { return tokens.get(pos); } + + public boolean match(Lexer.Kind kind) { + if (peek().kind() == kind) { pos++; return true; } + return false; + } + + public boolean matchIdent(String text) { + Lexer.Token t = peek(); + if (t.kind() == Lexer.Kind.IDENT && t.text().equals(text)) { pos++; return true; } + return false; + } + + public Lexer.Token expect(Lexer.Kind kind) { + Lexer.Token t = peek(); + if (t.kind() != kind) throw new IllegalArgumentException("Expected " + kind + " got " + t); + pos++; + return t; + } + + public Expr parseExpr() { return parsePipe(); } + + private Expr parsePipe() { + Expr left = parseAdd(); + while (match(Lexer.Kind.PIPE)) left = new Expr.Pipe(left, expect(Lexer.Kind.IDENT).text()); + return left; + } + + private Expr parseAdd() { + Expr left = parseMul(); + while (peek().kind() == Lexer.Kind.PLUS || peek().kind() == Lexer.Kind.MINUS) { + String op = peek().text(); + pos++; + left = new Expr.Binary(op, left, parseMul()); + } + return left; + } + + private Expr parseMul() { + Expr left = parseUnary(); + while (peek().kind() == Lexer.Kind.STAR || peek().kind() == Lexer.Kind.SLASH || peek().kind() == Lexer.Kind.PERCENT) { + String op = peek().text(); + pos++; + left = new Expr.Binary(op, left, parseUnary()); + } + return left; + } + + private Expr parseUnary() { + if (match(Lexer.Kind.MINUS)) return new Expr.Binary("-", new Expr.Literal(new ExprValue.Num(0)), parseUnary()); + return parsePrimary(); + } + + private Expr parsePrimary() { + Lexer.Token t = peek(); + return switch (t.kind()) { + case NUMBER -> { pos++; yield new Expr.Literal(new ExprValue.Num(Double.parseDouble(t.text()))); } + case STRING -> { pos++; yield new Expr.Literal(new ExprValue.Str(t.text())); } + case LPAREN -> parseGroup(); + case IDENT -> parseIdent(t.text()); + default -> throw new IllegalArgumentException("Unexpected token: " + t); + }; + } + + private Expr parseGroup() { + expect(Lexer.Kind.LPAREN); + Expr first = groupParser.get(); + if (!match(Lexer.Kind.COMMA)) { + expect(Lexer.Kind.RPAREN); + return first; + } + List parts = new ArrayList<>(); + parts.add(first); + parts.add(groupParser.get()); + while (match(Lexer.Kind.COMMA)) parts.add(groupParser.get()); + expect(Lexer.Kind.RPAREN); + return new Expr.Tuple(parts); + } + + private Expr parseIdent(String name) { + pos++; + if (peek().kind() == Lexer.Kind.LPAREN) { + pos++; + List args = new ArrayList<>(); + if (peek().kind() != Lexer.Kind.RPAREN) { + args.add(groupParser.get()); + while (match(Lexer.Kind.COMMA)) args.add(groupParser.get()); + } + expect(Lexer.Kind.RPAREN); + return new Expr.Call(name, args); + } + if ("true".equals(name)) return new Expr.Literal(new ExprValue.Bool(true)); + if ("false".equals(name)) return new Expr.Literal(new ExprValue.Bool(false)); + List segments = new ArrayList<>(); + segments.add(name); + while (match(Lexer.Kind.DOT)) segments.add(expect(Lexer.Kind.IDENT).text()); + return new Expr.Path(segments, roots.apply(name)); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/DashboardServer.java b/web/src/main/java/net/minestom/web/internal/http/DashboardServer.java new file mode 100644 index 00000000000..bf04756206a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/DashboardServer.java @@ -0,0 +1,346 @@ +package net.minestom.web.internal.http; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.javalin.Javalin; +import io.javalin.compression.CompressionStrategy; +import io.javalin.config.RoutesConfig; +import io.javalin.http.Context; +import io.javalin.http.staticfiles.Location; +import io.javalin.plugin.bundled.CorsPluginConfig; +import net.minestom.web.*; +import net.minestom.web.internal.expression.ExpressionEngine; +import net.minestom.web.internal.http.routes.*; +import net.minestom.web.internal.expression.QueryEngine; +import net.minestom.web.internal.renderer.ItemIconRenderer; +import net.minestom.web.internal.replay.ReplaySource; +import net.minestom.web.internal.session.ActionRunner; +import net.minestom.web.internal.scope.DashboardScope; +import net.minestom.web.internal.scope.ScopeSessionBridge; +import net.minestom.web.internal.session.MailboxException; +import net.minestom.web.internal.session.PlayerView; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; +import java.util.concurrent.*; + +/// Javalin HTTP + WebSocket dashboard. Every request and every WS connection is bound to a +/// [DashboardScope] — live mode has a single default scope owning the proxy + persistence; replay +/// mode creates a fresh scope per uploaded SQLite file, isolated to the requesting browser +/// tab. Scope id travels on the `X-Replay-Id` header (REST) or `?replay=` query (WS). +public final class DashboardServer implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(DashboardServer.class); + private static final long REPLAY_IDLE_TTL_MS = 30 * 60 * 1000L; + private static final long DISCONNECTED_PLAYER_TTL_MS = 30 * 60 * 1000L; + private static final long MAX_REPLAY_BYTES = 512L * 1024 * 1024; + private static final long RATE_BUCKET_IDLE_NANOS = TimeUnit.MINUTES.toNanos(10); + + private final ProxyConfig config; + private final ConcurrentHashMap scopes = new ConcurrentHashMap<>(); + private final ConcurrentHashMap wsScope = new ConcurrentHashMap<>(); + private final ConcurrentHashMap>> scopeTickers = new ConcurrentHashMap<>(); + private final RateLimiter postLimiter = new RateLimiter(30, 30); + private final ItemIconRenderer itemIcons = new ItemIconRenderer(); + private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2, + r -> Thread.ofVirtual().name("web-scheduler").unstarted(r)); + private final ScopeRouter routeCtx; + + private Javalin app; + + public DashboardServer(ProxyConfig config) { + this.config = config; + this.routeCtx = new ScopeRouter(scopes); + this.routeCtx.setReplayLifecycle(this::createDashboardScope, this::removeScope); + } + + // ---- scope management ---------------------------------------------------------------- + + public void setLiveScope(DashboardScope scope) { + registerScope(scope); + routeCtx.setDefaultScopeId(scope.id); + } + + public void addDashboardScope(DashboardScope scope) { + registerScope(scope); + } + + private void registerScope(DashboardScope scope) { + scopes.put(scope.id, scope); + // Registers session listeners on `scope` that drive persistence + WS fan-out; the + // instance itself is not retained. + new ScopeSessionBridge(scope); + scope.wireControlSinks(); + final List> tickers = new ArrayList<>(); + tickers.add(scheduler.scheduleAtFixedRate(scope::sampleMetrics, 1, 1, TimeUnit.SECONDS)); + tickers.add(scheduler.scheduleAtFixedRate(scope::flushPacketAggregate, 250, 250, TimeUnit.MILLISECONDS)); + tickers.add(scheduler.scheduleAtFixedRate(scope::publishPlayersSummary, 500, 500, TimeUnit.MILLISECONDS)); + scopeTickers.put(scope.id, tickers); + LOGGER.info("Scope {} registered ({})", scope.id, scope.isReplay() ? "replay" : "live"); + } + + public void removeScope(String id) { + final DashboardScope scope = scopes.remove(id); + if (scope == null) return; + if (id.equals(routeCtx.defaultScopeId())) routeCtx.setDefaultScopeId(null); + stopScopeTickers(id); + try { scope.close(); } catch (Exception _) {} + LOGGER.info("Scope {} removed", id); + } + + private void stopScopeTickers(String id) { + final List> tickers = scopeTickers.remove(id); + if (tickers == null) return; + for (ScheduledFuture ticker : tickers) ticker.cancel(false); + } + + // ---- server lifecycle ---------------------------------------------------------------- + + public void start() { + final byte[] indexHtml = readResource("/web/index.html"); + app = Javalin.create(cfg -> { + cfg.staticFiles.add(staticFiles -> { + staticFiles.directory = "/web"; + staticFiles.location = Location.CLASSPATH; + staticFiles.hostedPath = "/"; + // Pre-compress + cache the static bundle (app.js ~500KB, style.css ~140KB) so + // they aren't re-gzipped per request. + staticFiles.precompressMaxSize = 8 * 1024 * 1024; + }); + cfg.spaRoot.addHandler("/", ctx -> ctx.contentType("text/html").result(indexHtml)); + cfg.bundledPlugins.enableCors(cors -> cors.addRule(CorsPluginConfig.CorsRule::anyHost)); + cfg.concurrency.useVirtualThreads = true; + cfg.startup.showJavalinBanner = false; + cfg.http.maxRequestSize = MAX_REPLAY_BYTES; + // gzip the JS/CSS bundle + all JSON responses (brotli4j native dep isn't on the + // classpath, so gzip-only). Cuts first-load JS+CSS transfer ~640KB → ~160KB. + cfg.http.compressionStrategy = CompressionStrategy.GZIP; + + cfg.routes.before("/api/*", this::checkAuth); + cfg.routes.before("/api/*", this::checkRateLimit); + cfg.routes.before("/api/*", routeCtx::resolveScopeMiddleware); + + cfg.routes.exception(MailboxException.class, (e, ctx) -> + ctx.status(e.httpStatus()).result(e.httpMessage())); + // Client-input failures are 400, not Javalin's default 500: a malformed JSON body, + // a bad path/enum/address value, and MQL compile errors all surface as these. Genuine + // server bugs (NPE, IllegalStateException) still fall through to 500. + cfg.routes.exception(com.google.gson.JsonParseException.class, (e, ctx) -> + ctx.status(400).result("malformed JSON body")); + cfg.routes.exception(NumberFormatException.class, (e, ctx) -> + ctx.status(400).result("invalid number")); + cfg.routes.exception(IllegalArgumentException.class, (e, ctx) -> + ctx.status(400).result(e.getMessage() == null ? "bad request" : e.getMessage())); + + registerRoutes(cfg.routes); + registerWebSockets(cfg.routes); + }); + + app.start(config.dashboard().getHostString(), config.dashboard().getPort()); + LOGGER.info("Dashboard listening on http://{} ({} mode)", + config.dashboard(), config.replayMode() ? "replay" : "live"); + Thread.ofVirtual().name("web-icons-warmup").start(itemIcons::warm); + scheduler.scheduleAtFixedRate(this::evictIdleScopes, 60, 60, TimeUnit.SECONDS); + scheduler.scheduleAtFixedRate(this::evictDisconnectedPlayers, 60, 60, TimeUnit.SECONDS); + scheduler.scheduleAtFixedRate(() -> postLimiter.sweepIdle(RATE_BUCKET_IDLE_NANOS), 5, 5, TimeUnit.MINUTES); + } + + private void evictDisconnectedPlayers() { + final long cutoff = System.currentTimeMillis() - DISCONNECTED_PLAYER_TTL_MS; + for (DashboardScope scope : scopes.values()) { + if (scope.isReplay()) continue; + for (PlayerView player : scope.registry.players()) { + if (!(player instanceof PlayerView.Retained retained)) continue; + if (retained.disconnectedAt() > cutoff) continue; + scope.registry.evict(retained); + } + } + } + + private void evictIdleScopes() { + final long now = System.currentTimeMillis(); + for (DashboardScope scope : scopes.values()) { + if (!scope.isReplay()) continue; + if (scope.hasSubscribers()) continue; + if (now - scope.lastActiveAt() < REPLAY_IDLE_TTL_MS) continue; + LOGGER.info("Evicting idle replay scope {} (inactive for >{}ms)", scope.id, REPLAY_IDLE_TTL_MS); + removeScope(scope.id); + } + } + + private boolean authorized(@Nullable String provided) { + final String token = config.token(); + if (token == null || token.isEmpty()) return true; + return token.equals(provided); + } + + private void checkRateLimit(Context ctx) { + String m = ctx.method().name(); + if (!"POST".equalsIgnoreCase(m) && !"DELETE".equalsIgnoreCase(m)) return; + // Key on client IP, never the auth token: the token gates auth, the IP gates rate. Keying + // on the (constant) token would lump every tab/user/machine into one shared bucket. + if (!postLimiter.tryAcquire(ctx.ip())) { + ctx.status(429).result("rate limit"); + ctx.skipRemainingHandlers(); + } + } + + private void checkAuth(Context ctx) { + String provided = ctx.header("X-Auth-Token"); + if (provided == null) provided = ctx.queryParam("token"); + if (!authorized(provided)) { + ctx.status(401).result("unauthorised"); + ctx.skipRemainingHandlers(); + } + } + + // ---- routes ------------------------------------------------------------------------- + + private void registerRoutes(RoutesConfig app) { + ModeRoutes.register(app, config, routeCtx); + PlayerRoutes.register(app); + PacketRoutes.register(app); + RoutineRoutes.register(app); + ConsoleRoutes.register(app); + ThrottleRoutes.register(app); + QueryRoutes.register(app); + InjectRoutes.register(app); + MiscRoutes.register(app, itemIcons); + } + + // ---- replay scope construction ----------------------------------------------------- + + private DashboardScope createDashboardScope(Context ctx) throws Exception { + final String id = UUID.randomUUID().toString(); + final String label = ctx.header("X-Replay-Label"); + final boolean respectTimestamps = replayRespectTimestamps(ctx); + final Path tempDir = Files.createTempDirectory("replay-" + id + "-"); + final Path dbPath = tempDir.resolve("history.sqlite"); + try (InputStream in = ctx.bodyInputStream()) { + Files.copy(in, dbPath, StandardCopyOption.REPLACE_EXISTING); + } + + final ControlBridge control = new ControlBridge(); + final ExpressionEngine expressions = new ExpressionEngine(control); + final QueryEngine queries = new QueryEngine(expressions); + final SessionRegistry registry = new SessionRegistry(config.decodedPacketCacheSize(), queries); + registry.attachActionRunner(new ActionRunner(null, expressions)); + final MetricsSampler metrics = new MetricsSampler(120); + + final ReplaySource source; + try { + source = new ReplaySource(dbPath, registry, respectTimestamps); + } catch (Throwable t) { + try { control.close(); } catch (Exception _) {} + try { Files.deleteIfExists(dbPath); Files.deleteIfExists(tempDir); } catch (Exception _) {} + throw t; + } + + final String resolvedLabel = label == null || label.isBlank() ? "replay-" + id.substring(0, 8) : label.trim(); + final DashboardScope scope = DashboardScope.replay(id, resolvedLabel, registry, control, + queries, expressions, metrics, dbPath); + scope.replaySource = source; + addDashboardScope(scope); + + scope.replayThread = Thread.ofVirtual().name("web-replay-" + id).start(() -> { + scope.replayStatus = DashboardScope.ReplayStatus.RUNNING; + scope.publishStatus(); + try { + source.runBlocking(); + scope.replayStatus = DashboardScope.ReplayStatus.DONE; + } catch (Throwable t) { + scope.replayStatus = DashboardScope.ReplayStatus.ERROR; + scope.replayError = t.toString(); + LOGGER.warn("replay scope {} failed: {}", id, t.toString()); + } finally { + scope.replayEndedAt = System.currentTimeMillis(); + stopScopeTickers(scope.id); + scope.publishStatus(); + } + }); + return scope; + } + + private static boolean replayRespectTimestamps(Context ctx) { + String value = ctx.queryParam("respectTimestamps"); + if (value == null) value = ctx.header("X-Replay-Respect-Timestamps"); + if (value == null) return true; + return switch (value.trim().toLowerCase(Locale.ROOT)) { + case "0", "false", "no", "off" -> false; + default -> true; + }; + } + + // ---- WebSockets --------------------------------------------------------------------- + + private void registerWebSockets(RoutesConfig app) { + app.ws("/ws", ws -> { + ws.onConnect(ctx -> { + if (!authorized(ctx.queryParam("token"))) { ctx.closeSession(); return; } + String replayId = ctx.queryParam("replay"); + final DashboardScope scope; + if (replayId == null || replayId.isEmpty()) { + String defId = routeCtx.defaultScopeId(); + scope = defId == null ? null : scopes.get(defId); + } else { + scope = scopes.get(replayId); + if (scope != null) scope.touch(); + } + if (scope == null) { ctx.closeSession(); return; } + wsScope.put(ctx, scope); + scope.addSubscriber(ctx); + }); + ws.onMessage(ctx -> { + final DashboardScope scope = wsScope.get(ctx); + if (scope == null) return; + final DashboardScope.Subscriber sub = scope.subscriber(ctx); + if (sub == null) return; + scope.touch(); + try { + JsonObject msg = JsonParser.parseString(ctx.message()).getAsJsonObject(); + if (msg.has("subscribe")) { + msg.get("subscribe").getAsJsonArray().forEach(e -> { + final String topic = e.getAsString(); + scope.subscribe(sub, topic); + if (Topics.SCOPE.equals(topic) && scope.isReplay()) scope.publishStatus(); + }); + } + if (msg.has("unsubscribe")) { + msg.get("unsubscribe").getAsJsonArray() + .forEach(e -> scope.unsubscribe(sub, e.getAsString())); + } + } catch (Exception _) {} + }); + ws.onClose(ctx -> { + final DashboardScope scope = wsScope.remove(ctx); + if (scope == null) return; + scope.removeSubscriber(ctx); + }); + }); + } + + @Override + public void close() { + scheduler.shutdownNow(); + for (DashboardScope scope : scopes.values()) + try { scope.close(); } catch (Exception _) {} + scopes.clear(); + wsScope.clear(); + if (app != null) app.stop(); + } + + private static byte[] readResource(String path) { + try (InputStream in = DashboardServer.class.getResourceAsStream(path)) { + if (in == null) throw new IllegalStateException("classpath resource " + path + " not found"); + return in.readAllBytes(); + } catch (java.io.IOException e) { + throw new IllegalStateException("failed to read " + path, e); + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/JsonSerialization.java b/web/src/main/java/net/minestom/web/internal/http/JsonSerialization.java new file mode 100644 index 00000000000..d9eb0f805f6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/JsonSerialization.java @@ -0,0 +1,36 @@ +package net.minestom.web.internal.http; + +import com.google.gson.*; +import net.kyori.adventure.nbt.BinaryTag; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.server.item.ItemStack; +import net.minestom.web.Action; +import net.minestom.web.Routine.Trigger; +import net.minestom.web.internal.codec.RoutineCodecs; +import net.minestom.web.internal.codec.WebJson; + +/// Shared [Gson] instance for types that still round-trip through Gson trees (decoded packets, +/// routine editor payloads, generic REST maps). Dashboard wire shapes are defined in +/// `WebCodecs` / `RoutineCodecs` and encoded via `WebJson`. +public final class JsonSerialization { + + public static final Gson GSON = new GsonBuilder() + .serializeNulls() + .registerTypeHierarchyAdapter(BinaryTag.class, + (JsonSerializer) (src, _, _) -> WebJson.encode(Codec.NBT, src)) + .registerTypeHierarchyAdapter(Component.class, + (JsonSerializer) (src, _, _) -> WebJson.encode(Codec.COMPONENT, src)) + .registerTypeHierarchyAdapter(Component.class, + (JsonDeserializer) (json, _, _) -> WebJson.decode(Codec.COMPONENT, json)) + .registerTypeHierarchyAdapter(ItemStack.class, + (JsonSerializer) (src, _, _) -> WebJson.encode(ItemStack.CODEC, src)) + .registerTypeHierarchyAdapter(Action.class, + (JsonSerializer) (src, _, _) -> WebJson.encodeAsObject(RoutineCodecs.ACTION, src)) + .registerTypeHierarchyAdapter(Trigger.class, + (JsonSerializer) (src, _, _) -> + WebJson.encodeAsObject(RoutineCodecs.TRIGGER, src)) + .create(); + + private JsonSerialization() {} +} diff --git a/web/src/main/java/net/minestom/web/internal/http/MetricsSampler.java b/web/src/main/java/net/minestom/web/internal/http/MetricsSampler.java new file mode 100644 index 00000000000..f38394894a0 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/MetricsSampler.java @@ -0,0 +1,55 @@ +package net.minestom.web.internal.http; + +/// Rolling buffer of per-second server samples — bytes/s and packets/s (in + out), plus the +/// current connection gauge. Tick at a steady cadence (1Hz) with the running totals; deltas +/// between successive totals become rates. Player disconnects can drop the global counters, so +/// negative deltas are clamped to zero. +public final class MetricsSampler { + public record Sample(long ts, + long bytesIn, long bytesOut, + long packetsIn, long packetsOut, + int connections) {} + + private final Sample[] buf; + private int head; + private int size; + private long lastTs; + private long lastBytesIn, lastBytesOut, lastPacketsIn, lastPacketsOut; + + public MetricsSampler(int capacity) { + this.buf = new Sample[capacity]; + } + + public synchronized Sample tick(long ts, long bytesIn, long bytesOut, + long packetsIn, long packetsOut, int connections) { + if (lastTs == 0) { + lastTs = ts; + lastBytesIn = bytesIn; lastBytesOut = bytesOut; + lastPacketsIn = packetsIn; lastPacketsOut = packetsOut; + return null; + } + double dt = Math.max(0.001, (ts - lastTs) / 1000.0); + Sample s = new Sample(ts, + rate(bytesIn, lastBytesIn, dt), rate(bytesOut, lastBytesOut, dt), + rate(packetsIn, lastPacketsIn, dt), rate(packetsOut, lastPacketsOut, dt), + connections); + lastTs = ts; + lastBytesIn = bytesIn; lastBytesOut = bytesOut; + lastPacketsIn = packetsIn; lastPacketsOut = packetsOut; + buf[head] = s; + head = (head + 1) % buf.length; + if (size < buf.length) size++; + return s; + } + + public synchronized Sample[] snapshot() { + Sample[] out = new Sample[size]; + int start = (head - size + buf.length) % buf.length; + for (int i = 0; i < size; i++) out[i] = buf[(start + i) % buf.length]; + return out; + } + + private static long rate(long now, long prev, double dt) { + return Math.round(Math.max(0, (now - prev) / dt)); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/PacketCatalog.java b/web/src/main/java/net/minestom/web/internal/http/PacketCatalog.java new file mode 100644 index 00000000000..7238de2c6a6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/PacketCatalog.java @@ -0,0 +1,366 @@ +package net.minestom.web.internal.http; + +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.PacketParser; +import net.minestom.server.network.packet.PacketVanilla; +import net.minestom.server.network.packet.client.ClientPacket; +import net.minestom.server.network.packet.client.common.ClientKeepAlivePacket; +import net.minestom.server.network.packet.client.common.ClientPluginMessagePacket; +import net.minestom.server.network.packet.client.common.ClientPongPacket; +import net.minestom.server.network.packet.client.common.ClientSettingsPacket; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.client.login.ClientLoginStartPacket; +import net.minestom.server.network.packet.client.play.*; +import net.minestom.server.network.packet.server.ServerPacket; +import net.minestom.server.network.packet.server.common.DisconnectPacket; +import net.minestom.server.network.packet.server.common.KeepAlivePacket; +import net.minestom.server.network.packet.server.common.PingPacket; +import net.minestom.server.network.packet.server.common.PluginMessagePacket; +import net.minestom.server.network.packet.server.login.LoginSuccessPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.web.Direction; +import net.minestom.web.PacketRecord; + +import java.util.*; +import java.util.function.Function; + +/// Static directory of every Minestom packet class plus the *subject* it mutates (Self, +/// Entities, World, HUD, Windows, Chat, Network). Used by: +/// - the dashboard packet picker (entries, name resolution, direction lookup), +/// - the routine action runner (resolve a short name → fully-qualified class), +/// - the packet stream view in the UI (per-row subject chip, drilldown filtering). +/// +/// One file because the two halves share `Packet`-class lookups. Classification is driven by a +/// single class→subject table ([#SUBJECT_BY_CLASS]) so a packet is registered exactly once. +public final class PacketCatalog { + + // --------------------------------------------------------------- catalog (every packet) + + public record Entry(String simple, String full, String side, String state) { + public static final Codec CODEC = StructCodec.struct( + "simple", Codec.STRING, Entry::simple, + "full", Codec.STRING, Entry::full, + "side", Codec.STRING, Entry::side, + "state", Codec.STRING, Entry::state, + Entry::new); + + public static final Codec> LIST_CODEC = CODEC.list(); + } + + private static final List ENTRIES = buildEntries(); + private static final List ANALYZABLE; + private static final Map SIMPLE_TO_FULL = new HashMap<>(); + + static { + // Last write wins; conflicts are extraordinarily rare since vanilla simple names are unique. + for (Entry e : ENTRIES) SIMPLE_TO_FULL.put(e.simple.toLowerCase(), e.full); + List analyzable = new ArrayList<>(); + for (Entry e : ENTRIES) { + // Catch Throwable so an individual packet whose class init throws + // (LinkageError, NoClassDefFoundError, ExceptionInInitializerError) only gets + // dropped from the analyzable subset rather than failing the whole catalog. + try { + if (PacketSchema.isAnalyzable(Class.forName(e.full))) analyzable.add(e); + } catch (Throwable ignored) { + } + } + ANALYZABLE = List.copyOf(analyzable); + } + + public static List entries() { + return ENTRIES; + } + + /// Subset of [#entries] whose record components fully resolve to known widget kinds. + /// The dashboard packet picker uses this when `?analyzable=true`; name resolution / + /// classification / direction lookups stay on the full catalog so non-analyzable + /// packets keep working everywhere else (kick, OnPacket triggers, replay). + public static List entriesAnalyzable() { + return ANALYZABLE; + } + + /// Resolve a user-typed simple name (case-insensitive) to its fully-qualified class name. + /// Returns the input untouched if it already contains a `.` or no match is found. + public static String resolve(String name) { + if (name == null || name.isBlank() || name.indexOf('.') >= 0) return name; + return SIMPLE_TO_FULL.getOrDefault(name.toLowerCase(), name); + } + + @SuppressWarnings("unchecked") + public static Class packetClass(String classNameOrSimple) throws ClassNotFoundException { + Class cls = Class.forName(resolve(classNameOrSimple)); + if (Packet.class.isAssignableFrom(cls)) return (Class) cls; + throw new IllegalArgumentException("Not a known packet class: " + cls.getName()); + } + + /// Derive the injection direction from a packet class: + /// {@link ClientPacket}s are sent by the client (serverbound), + /// {@link ServerPacket}s are sent by the server (clientbound). + public static Direction directionFor(String classNameOrSimple) throws ClassNotFoundException { + Class cls = packetClass(classNameOrSimple); + if (ServerPacket.class.isAssignableFrom(cls)) return Direction.CLIENTBOUND; + if (ClientPacket.class.isAssignableFrom(cls)) return Direction.SERVERBOUND; + throw new IllegalArgumentException("Not a known packet class: " + cls.getName()); + } + + private static List buildEntries() { + List out = new ArrayList<>(); + for (ConnectionState st : ConnectionState.values()) { + collect(out, PacketVanilla.CLIENT_PACKET_PARSER, st, "client"); + collect(out, PacketVanilla.SERVER_PACKET_PARSER, st, "server"); + } + out.sort((a, b) -> a.simple.compareToIgnoreCase(b.simple)); + return List.copyOf(out); + } + + private static void collect(List out, PacketParser parser, ConnectionState st, String side) { + for (var info : parser.stateRegistry(st).packets()) { + Class cls = info.packetClass(); + out.add(new Entry(cls.getSimpleName(), cls.getName(), side, st.name())); + } + } + + // --------------------------------------------------------------- subject classification + + public enum Group { + SELF, ENT, WORLD, HUD, WIN, CHAT, NET; + + public String id() { + return name().toLowerCase(); + } + } + + public record Subject(String id, String label, Group group) { + public String groupId() { + return group.id(); + } + } + + /// Reusable subjects so [#classify] is allocation-free on the common path. + private static final Subject SUBJ_ENT_ALL = new Subject("ent.all", "Entities", Group.ENT); + private static final Subject SUBJ_WIN_INV = new Subject("win.inv", "Inventory", Group.WIN); + private static final Subject SUBJ_HUD_SCOREBOARD = new Subject("hud.scoreboard", "Scoreboard", Group.HUD); + private static final Subject SUBJ_HUD_TAB = new Subject("hud.tab", "Tab list", Group.HUD); + private static final Subject SUBJ_HUD_ACTIONBAR = new Subject("hud.actionbar", "Action bar", Group.HUD); + private static final Subject SUBJ_HUD_TITLE = new Subject("hud.title", "Title", Group.HUD); + private static final Subject SUBJ_HUD_MISC = new Subject("hud.misc", "HUD", Group.HUD); + private static final Subject SUBJ_CHAT = new Subject("chat.system", "Chat", Group.CHAT); + private static final Subject SUBJ_NET = new Subject("net.io", "Network", Group.NET); + + private static final Subject SUBJ_SELF_VITALS = new Subject("self.vitals", "vitals", Group.SELF); + private static final Subject SUBJ_SELF_XP = new Subject("self.xp", "xp", Group.SELF); + private static final Subject SUBJ_SELF_ABILITIES = new Subject("self.abilities", "abilities", Group.SELF); + private static final Subject SUBJ_SELF_POSITION = new Subject("self.position", "position", Group.SELF); + private static final Subject SUBJ_SELF_EFFECTS = new Subject("self.effects", "effects", Group.SELF); + private static final Subject SUBJ_SELF_ATTRIBUTES = new Subject("self.attributes", "attributes", Group.SELF); + private static final Subject SUBJ_SELF_COMBAT = new Subject("self.combat", "combat", Group.SELF); + private static final Subject SUBJ_SELF_SESSION = new Subject("self.session", "session", Group.SELF); + private static final Subject SUBJ_SELF_MISC = new Subject("self.self", "self", Group.SELF); + + private static final Subject SUBJ_WORLD_CHUNK = new Subject("world.chunk", "chunk", Group.WORLD); + private static final Subject SUBJ_WORLD_BLOCK = new Subject("world.block", "block", Group.WORLD); + private static final Subject SUBJ_WORLD_LIGHTING = new Subject("world.lighting", "lighting", Group.WORLD); + private static final Subject SUBJ_WORLD_TIME = new Subject("world.time", "time", Group.WORLD); + private static final Subject SUBJ_WORLD_VIEWPORT = new Subject("world.viewport", "viewport", Group.WORLD); + private static final Subject SUBJ_WORLD_MISC = new Subject("world.world", "world", Group.WORLD); + + /// Entity- and window-scoped packets mapped to how their id is read off the instance. Single + /// source of truth: [#buildSubjectMap] registers these classes for classification and + /// [#entitySubject]/[#windowSubject] read the id from the same table, so the class list lives + /// in exactly one place. Declared before [#SUBJECT_BY_CLASS] so they initialise first. + private static final Map, Function> ENTITY_ID = entityIdExtractors(); + private static final Map, Function> WINDOW_ID = windowIdExtractors(); + + private static final Map, Function> SUBJECT_BY_CLASS = buildSubjectMap(); + + /// Static subjects keyed by id, for rehydrating a [Subject] from a persisted id string — + /// dynamic ids (`ent.42`, `win.5`, `hud.boss.xxxx`) fall through to [#subjectById]'s + /// prefix-derived path. + private static final Map STATIC_SUBJECTS_BY_ID = Map.ofEntries( + Map.entry(SUBJ_ENT_ALL.id(), SUBJ_ENT_ALL), + Map.entry(SUBJ_WIN_INV.id(), SUBJ_WIN_INV), + Map.entry(SUBJ_HUD_SCOREBOARD.id(), SUBJ_HUD_SCOREBOARD), + Map.entry(SUBJ_HUD_TAB.id(), SUBJ_HUD_TAB), + Map.entry(SUBJ_HUD_ACTIONBAR.id(), SUBJ_HUD_ACTIONBAR), + Map.entry(SUBJ_HUD_TITLE.id(), SUBJ_HUD_TITLE), + Map.entry(SUBJ_HUD_MISC.id(), SUBJ_HUD_MISC), + Map.entry(SUBJ_CHAT.id(), SUBJ_CHAT), + Map.entry(SUBJ_NET.id(), SUBJ_NET), + Map.entry(SUBJ_SELF_VITALS.id(), SUBJ_SELF_VITALS), + Map.entry(SUBJ_SELF_XP.id(), SUBJ_SELF_XP), + Map.entry(SUBJ_SELF_ABILITIES.id(), SUBJ_SELF_ABILITIES), + Map.entry(SUBJ_SELF_POSITION.id(), SUBJ_SELF_POSITION), + Map.entry(SUBJ_SELF_EFFECTS.id(), SUBJ_SELF_EFFECTS), + Map.entry(SUBJ_SELF_ATTRIBUTES.id(), SUBJ_SELF_ATTRIBUTES), + Map.entry(SUBJ_SELF_COMBAT.id(), SUBJ_SELF_COMBAT), + Map.entry(SUBJ_SELF_SESSION.id(), SUBJ_SELF_SESSION), + Map.entry(SUBJ_SELF_MISC.id(), SUBJ_SELF_MISC), + Map.entry(SUBJ_WORLD_CHUNK.id(), SUBJ_WORLD_CHUNK), + Map.entry(SUBJ_WORLD_BLOCK.id(), SUBJ_WORLD_BLOCK), + Map.entry(SUBJ_WORLD_LIGHTING.id(), SUBJ_WORLD_LIGHTING), + Map.entry(SUBJ_WORLD_TIME.id(), SUBJ_WORLD_TIME), + Map.entry(SUBJ_WORLD_VIEWPORT.id(), SUBJ_WORLD_VIEWPORT), + Map.entry(SUBJ_WORLD_MISC.id(), SUBJ_WORLD_MISC)); + + /// Rehydrate a [Subject] from a persisted id, so [net.minestom.web.internal.persist.PersistentHistory] + /// can drop `subject_label`/`subject_group` columns and derive them on read. Static subjects + /// hit the map directly; dynamic ids (`ent.N`, `win.N`, `hud.boss.UUID`) reconstruct a + /// synthetic label and group from the prefix. + public static Subject subjectById(String id) { + if (id == null || id.isEmpty()) return SUBJ_NET; + final Subject hit = STATIC_SUBJECTS_BY_ID.get(id); + if (hit != null) return hit; + final int dot = id.indexOf('.'); + final String prefix = dot < 0 ? id : id.substring(0, dot); + final Group group = switch (prefix) { + case "ent" -> Group.ENT; + case "win" -> Group.WIN; + case "hud" -> Group.HUD; + case "self" -> Group.SELF; + case "world" -> Group.WORLD; + case "chat" -> Group.CHAT; + default -> Group.NET; + }; + final String label = switch (group) { + case ENT -> "Entity #" + id.substring(dot + 1); + case WIN -> "Window #" + id.substring(dot + 1); + case HUD -> id.startsWith("hud.boss.") + ? "BossBar " + id.substring(9, Math.min(17, id.length())) + : id; + default -> id; + }; + return new Subject(id, label, group); + } + + /// The single source of truth for classification: each packet class maps to the function that + /// produces its [Subject]. Static subjects use [#constant]; entity/window/bossbar subjects carry + /// per-packet ids so they resolve against the packet instance. Anything unlisted is [#SUBJ_NET]. + private static Map, Function> buildSubjectMap() { + Map, Function> m = new HashMap<>(); + // self — vitals / xp / abilities / position / effects / attributes / combat / session + put(m, constant(SUBJ_SELF_VITALS), UpdateHealthPacket.class); + put(m, constant(SUBJ_SELF_XP), SetExperiencePacket.class); + put(m, constant(SUBJ_SELF_ABILITIES), PlayerAbilitiesPacket.class); + put(m, constant(SUBJ_SELF_POSITION), ClientPlayerPositionPacket.class, + ClientPlayerPositionAndRotationPacket.class, ClientPlayerRotationPacket.class, + PlayerPositionAndLookPacket.class); + put(m, constant(SUBJ_SELF_EFFECTS), EntityEffectPacket.class, RemoveEntityEffectPacket.class); + put(m, constant(SUBJ_SELF_ATTRIBUTES), EntityAttributesPacket.class); + put(m, constant(SUBJ_SELF_COMBAT), DamageEventPacket.class); + put(m, constant(SUBJ_SELF_SESSION), JoinGamePacket.class, RespawnPacket.class, ChangeGameStatePacket.class); + // entity-scoped — id/label drilldown via entitySubject (classes sourced from ENTITY_ID) + for (Class c : ENTITY_ID.keySet()) m.put(c, PacketCatalog::entitySubject); + // world — chunks, blocks, lighting, time, viewport + put(m, constant(SUBJ_WORLD_CHUNK), ChunkDataPacket.class, UnloadChunkPacket.class); + put(m, constant(SUBJ_WORLD_BLOCK), BlockChangePacket.class, MultiBlockChangePacket.class, + BlockBreakAnimationPacket.class, BlockEntityDataPacket.class); + put(m, constant(SUBJ_WORLD_LIGHTING), UpdateLightPacket.class); + put(m, constant(SUBJ_WORLD_TIME), SetTimePacket.class); + put(m, constant(SUBJ_WORLD_VIEWPORT), UpdateViewPositionPacket.class, UpdateViewDistancePacket.class); + put(m, constant(SUBJ_WORLD_MISC), ServerDifficultyPacket.class); + // HUD — dynamic bossbar + scoreboard / tab / action bar / title + put(m, PacketCatalog::bossSubject, BossBarPacket.class); + put(m, constant(SUBJ_HUD_SCOREBOARD), DisplayScoreboardPacket.class, + ScoreboardObjectivePacket.class, UpdateScorePacket.class); + put(m, constant(SUBJ_HUD_TAB), PlayerListHeaderAndFooterPacket.class, + PlayerInfoUpdatePacket.class, PlayerInfoRemovePacket.class); + put(m, constant(SUBJ_HUD_ACTIONBAR), ActionBarPacket.class); + put(m, constant(SUBJ_HUD_TITLE), SetTitleTextPacket.class, SetTitleSubTitlePacket.class, + SetTitleTimePacket.class, ClearTitlesPacket.class); + // windows / inventory — id/label drilldown via windowSubject (classes sourced from WINDOW_ID) + for (Class c : WINDOW_ID.keySet()) m.put(c, PacketCatalog::windowSubject); + // chat + put(m, constant(SUBJ_CHAT), SystemChatPacket.class, PlayerChatMessagePacket.class, + ClientChatMessagePacket.class, ClientCommandChatPacket.class, ClientSignedCommandChatPacket.class); + // network / common + put(m, constant(SUBJ_NET), KeepAlivePacket.class, ClientKeepAlivePacket.class, PingPacket.class, + ClientPongPacket.class, PluginMessagePacket.class, ClientPluginMessagePacket.class, + SetCompressionPacket.class, LoginSuccessPacket.class, ClientLoginStartPacket.class, + ClientHandshakePacket.class, ClientSettingsPacket.class, DisconnectPacket.class); + return Map.copyOf(m); + } + + @SafeVarargs + private static void put(Map, Function> m, + Function subject, Class... classes) { + for (Class c : classes) m.put(c, subject); + } + + private static Function constant(Subject subject) { + return packet -> subject; + } + + public static Subject classify(PacketRecord record) { + return classify(record.record()); + } + + public static Subject classify(Packet packet) { + if (packet == null) return SUBJ_NET; + final Function fn = SUBJECT_BY_CLASS.get(packet.getClass()); + return fn == null ? SUBJ_NET : fn.apply(packet); + } + + private static Subject entitySubject(Packet packet) { + final Function idOf = ENTITY_ID.get(packet.getClass()); + final Integer id = idOf == null ? null : idOf.apply(packet); + return id == null ? SUBJ_ENT_ALL : new Subject("ent." + id, "Entity #" + id, Group.ENT); + } + + private static Subject windowSubject(Packet packet) { + final Function idOf = WINDOW_ID.get(packet.getClass()); + final int id = idOf == null ? -1 : idOf.apply(packet); + if (id == 0 || id == -1) return SUBJ_WIN_INV; + return new Subject("win." + id, "Window #" + id, Group.WIN); + } + + /// Entity-scoped packet class → entity-id reader. `classify` only routes a packet here when its + /// exact class is a key, so the cast always matches. `null` (e.g. multi-target destroy) → the + /// "all entities" subject. + private static Map, Function> entityIdExtractors() { + Map, Function> m = new HashMap<>(); + m.put(SpawnEntityPacket.class, p -> ((SpawnEntityPacket) p).entityId()); + m.put(EntityPositionPacket.class, p -> ((EntityPositionPacket) p).entityId()); + m.put(EntityPositionAndRotationPacket.class, p -> ((EntityPositionAndRotationPacket) p).entityId()); + m.put(EntityRotationPacket.class, p -> ((EntityRotationPacket) p).entityId()); + m.put(EntityPositionSyncPacket.class, p -> ((EntityPositionSyncPacket) p).entityId()); + m.put(EntityTeleportPacket.class, p -> ((EntityTeleportPacket) p).entityId()); + m.put(EntityMetaDataPacket.class, p -> ((EntityMetaDataPacket) p).entityId()); + m.put(EntityHeadLookPacket.class, p -> ((EntityHeadLookPacket) p).entityId()); + m.put(EntityVelocityPacket.class, p -> ((EntityVelocityPacket) p).entityId()); + m.put(EntityAnimationPacket.class, p -> ((EntityAnimationPacket) p).entityId()); + m.put(EntityStatusPacket.class, p -> ((EntityStatusPacket) p).entityId()); + m.put(EntityEquipmentPacket.class, p -> ((EntityEquipmentPacket) p).entityId()); + m.put(DestroyEntitiesPacket.class, p -> { + final var ids = ((DestroyEntitiesPacket) p).entityIds(); + return ids.size() == 1 ? ids.getFirst() : null; + }); + return Map.copyOf(m); + } + + /// Window-scoped packet class → window-id reader. Packets that always target the player + /// inventory yield 0; `windowSubject` folds 0 (and the unreachable -1) into the inventory subject. + private static Map, Function> windowIdExtractors() { + Map, Function> m = new HashMap<>(); + m.put(OpenWindowPacket.class, p -> ((OpenWindowPacket) p).windowId()); + m.put(CloseWindowPacket.class, p -> ((CloseWindowPacket) p).windowId()); + m.put(SetSlotPacket.class, p -> ((SetSlotPacket) p).windowId()); + m.put(WindowItemsPacket.class, p -> ((WindowItemsPacket) p).windowId()); + m.put(WindowPropertyPacket.class, p -> ((WindowPropertyPacket) p).windowId()); + for (Class c : List.of(HeldItemChangePacket.class, ClientHeldItemChangePacket.class, + SetPlayerInventorySlotPacket.class, SetCursorItemPacket.class)) { + m.put(c, p -> 0); + } + return Map.copyOf(m); + } + + private static Subject bossSubject(Packet packet) { + final String s = ((BossBarPacket) packet).uuid().toString(); + return new Subject("hud.boss." + s, "BossBar " + s.substring(0, Math.min(8, s.length())), Group.HUD); + } + + private PacketCatalog() {} +} diff --git a/web/src/main/java/net/minestom/web/internal/http/PacketCodec.java b/web/src/main/java/net/minestom/web/internal/http/PacketCodec.java new file mode 100644 index 00000000000..437a2631362 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/PacketCodec.java @@ -0,0 +1,232 @@ +package net.minestom.web.internal.http; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.server.item.ItemStack; +import net.minestom.server.network.packet.Packet; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.http.PacketSchema.Field; +import net.minestom.web.internal.http.PacketSchema.Kind; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.Constructor; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Decodes the dashboard's JSON payload back into a `Packet`, driven by the widget schema +/// reflected in [PacketSchema]. Routine execution consumes [#decode] with an optional expression +/// [Evaluator] so MQL strings turn into typed JSON in one pass. +public final class PacketCodec { + + private PacketCodec() {} + + /// Resolves a typed expression at decode time. Returns the JSON value to use, or null + /// to leave the original element untouched. + @FunctionalInterface + public interface Evaluator { + @Nullable JsonElement evaluate(String source, Kind kind); + } + + public static Packet decode(String classNameOrSimple, JsonObject fields) throws Exception { + return decode(classNameOrSimple, fields, null); + } + + /// Decode a record packet from JSON. If `eval` is non-null and a field's kind is + /// expression-valued, string leaves are first run through the evaluator. + public static Packet decode(String classNameOrSimple, JsonObject fields, @Nullable Evaluator eval) throws Exception { + Class cls = PacketCatalog.packetClass(classNameOrSimple); + return (Packet) decodeRecord(cls, fields, PacketSchema.schema(cls).orElse(null), eval); + } + + private static Object decodeRecord(Class cls, JsonObject fields, @Nullable List schema, + @Nullable Evaluator eval) throws Exception { + if (!cls.isRecord()) throw new IllegalArgumentException("Class is not a record: " + cls.getName()); + RecordComponent[] components = cls.getRecordComponents(); + Object[] args = new Object[components.length]; + Class[] paramTypes = new Class[components.length]; + for (int i = 0; i < components.length; i++) { + RecordComponent c = components[i]; + paramTypes[i] = c.getType(); + JsonElement raw = fields == null ? null : fields.get(c.getName()); + Field f = schema == null ? null : schema.get(i); + args[i] = decodeValue(maybeEvaluate(raw, f, eval), c.getType(), c.getGenericType(), f, eval); + } + Constructor ctor = cls.getDeclaredConstructor(paramTypes); + ctor.setAccessible(true); + return ctor.newInstance(args); + } + + private static @Nullable JsonElement maybeEvaluate(@Nullable JsonElement raw, @Nullable Field f, + @Nullable Evaluator eval) { + if (eval == null || f == null) return raw; + if (!f.kind().isExpression()) return raw; + if (raw == null || !raw.isJsonPrimitive() || !raw.getAsJsonPrimitive().isString()) return raw; + JsonElement evaluated = eval.evaluate(raw.getAsString(), f.kind()); + return evaluated == null ? raw : evaluated; + } + + /// Map keys arrive as JSON object property strings. Evaluate them when the key field is + /// expression-valued so `health` or `name + "_id"` resolves before decoding to the key type. + private static String evaluateKey(String raw, @Nullable Field keyF, @Nullable Evaluator eval) { + if (eval == null || keyF == null || !keyF.kind().isExpression()) return raw; + JsonElement evaluated = eval.evaluate(raw, keyF.kind()); + if (evaluated == null || !evaluated.isJsonPrimitive()) return raw; + return evaluated.getAsString(); + } + + private static @Nullable Object decodeValue(@Nullable JsonElement value, Class type, Type generic, + @Nullable Field f, @Nullable Evaluator eval) throws Exception { + if (value == null || value.isJsonNull()) return structuralDefault(type); + if (type == ItemStack.class) return WebJson.decode(ItemStack.CODEC, value); + if (type == Component.class) return WebJson.decode(Codec.COMPONENT, evaluateComponentTree(value, eval)); + Kind k = PacketSchema.kindOf(type); + if (k != null) return decodeLeaf(value, k); + if (type.isEnum()) { + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) + throw new IllegalArgumentException("expected enum string for " + type.getSimpleName() + ", got " + value); + @SuppressWarnings({"unchecked", "rawtypes"}) + Object e = Enum.valueOf((Class) type, value.getAsString()); + return e; + } + if (List.class.isAssignableFrom(type) && value.isJsonArray() && generic instanceof ParameterizedType pt) { + Type elemT = pt.getActualTypeArguments()[0]; + Field elemF = f instanceof Field.ListF lf ? lf.element() : null; + List out = new ArrayList<>(); + for (JsonElement el : value.getAsJsonArray()) { + out.add(decodeValue(maybeEvaluate(el, elemF, eval), PacketSchema.rawClass(elemT), elemT, elemF, eval)); + } + return out; + } + if (Map.class.isAssignableFrom(type) && value.isJsonObject() && generic instanceof ParameterizedType pt) { + Type[] args = pt.getActualTypeArguments(); + Class keyCls = PacketSchema.rawClass(args[0]); + Field keyF = f instanceof Field.MapF mf ? mf.key() : null; + Field valF = f instanceof Field.MapF mf ? mf.value() : null; + Map out = new LinkedHashMap<>(); + for (Map.Entry e : value.getAsJsonObject().entrySet()) { + out.put(decodeMapKey(evaluateKey(e.getKey(), keyF, eval), keyCls), + decodeValue(maybeEvaluate(e.getValue(), valF, eval), PacketSchema.rawClass(args[1]), args[1], valF, eval)); + } + return out; + } + if (type.isRecord() && value.isJsonObject()) { + List nested = PacketSchema.schema(type).orElse(null); + return decodeRecord(type, value.getAsJsonObject(), nested, eval); + } + throw new IllegalArgumentException("Unsupported component type: " + type.getName()); + } + + private static Object decodeLeaf(JsonElement v, Kind k) { + return switch (k) { + case BYTE -> v.getAsByte(); + case SHORT -> v.getAsShort(); + case INT -> v.getAsInt(); + case LONG -> v.getAsLong(); + case FLOAT -> v.getAsFloat(); + case DOUBLE -> v.getAsDouble(); + case BOOLEAN -> v.getAsBoolean(); + case CHAR -> { + String s = v.isJsonPrimitive() && v.getAsJsonPrimitive().isString() ? v.getAsString() : ""; + yield s.isEmpty() ? '\0' : s.charAt(0); + } + case STRING -> v.getAsString(); + case UUID -> java.util.UUID.fromString(v.getAsString()); + default -> throw new IllegalStateException("not a leaf kind: " + k); + }; + } + + private static @Nullable Object defaultFor(Class type) { + if (!type.isPrimitive()) return null; + if (type == boolean.class) return false; + if (type == char.class) return '\0'; + if (type == byte.class) return (byte) 0; + if (type == short.class) return (short) 0; + if (type == int.class) return 0; + if (type == long.class) return 0L; + if (type == float.class) return 0f; + return 0.0; + } + + /// Sensible non-null default for omitted / null fields. Record canonical constructors + /// (and downstream packet handlers) frequently NPE on null component values, so a missing + /// `message` Component becomes [Component#empty], a missing `itemStack` becomes + /// [ItemStack#AIR], absent collections become empty, and enums fall back to the first constant. + private static @Nullable Object structuralDefault(Class type) { + if (type == Component.class) return Component.empty(); + if (type == ItemStack.class) return ItemStack.AIR; + if (List.class.isAssignableFrom(type)) return List.of(); + if (Map.class.isAssignableFrom(type)) return Map.of(); + if (type.isEnum()) { + Object[] consts = type.getEnumConstants(); + if (consts != null && consts.length > 0) return consts[0]; + } + return defaultFor(type); + } + + /// Walk a Component JSON tree and run every `text` leaf (and recursively any `extra` + /// children) through the evaluator with [Kind#STRING]. Bare identifiers like + /// `player.name` resolve to the live value; expressions that fail to compile or eval + /// (e.g. a plain word like `hello`) fall back to the original literal so casual text + /// still works without quoting. + private static JsonElement evaluateComponentTree(JsonElement v, @Nullable Evaluator eval) { + if (eval == null || v == null) return v; + if (v.isJsonArray()) { + JsonArray out = new JsonArray(); + for (JsonElement child : v.getAsJsonArray()) out.add(evaluateComponentTree(child, eval)); + return out; + } + if (!v.isJsonObject()) return v; + JsonObject in = v.getAsJsonObject(); + JsonObject out = new JsonObject(); + for (Map.Entry e : in.entrySet()) { + String key = e.getKey(); + JsonElement val = e.getValue(); + if (("text".equals(key) || "translate".equals(key) || "fallback".equals(key)) + && val.isJsonPrimitive() && val.getAsJsonPrimitive().isString()) { + out.add(key, tryEvalString(val.getAsString(), eval, val)); + } else if ("extra".equals(key) || "with".equals(key)) { + out.add(key, evaluateComponentTree(val, eval)); + } else { + out.add(key, val); + } + } + return out; + } + + private static JsonElement tryEvalString(String src, Evaluator eval, JsonElement fallback) { + if (src.isEmpty()) return fallback; + try { + JsonElement r = eval.evaluate(src, Kind.STRING); + return r != null ? r : fallback; + } catch (RuntimeException ignored) { + return fallback; + } + } + + private static Object decodeMapKey(String raw, Class keyCls) { + Kind k = PacketSchema.kindOf(keyCls); + if (k != null) return switch (k) { + case STRING -> raw; + case BYTE -> Byte.parseByte(raw); + case SHORT -> Short.parseShort(raw); + case INT -> Integer.parseInt(raw); + case LONG -> Long.parseLong(raw); + case UUID -> java.util.UUID.fromString(raw); + default -> raw; + }; + if (keyCls.isEnum()) { + @SuppressWarnings({"unchecked", "rawtypes"}) + Object e = Enum.valueOf((Class) keyCls, raw); + return e; + } + return raw; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/PacketSchema.java b/web/src/main/java/net/minestom/web/internal/http/PacketSchema.java new file mode 100644 index 00000000000..8e2c9b08311 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/PacketSchema.java @@ -0,0 +1,191 @@ +package net.minestom.web.internal.http; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import net.kyori.adventure.text.Component; +import net.minestom.server.item.ItemStack; +import org.jetbrains.annotations.Nullable; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.RecordComponent; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/// Reflects a packet record into a typed widget schema. A class is *analyzable* iff every record +/// component resolves to a known [Kind] (primitive, string, uuid, enum, item, component, record, +/// list-of-analyzable, or string-keyed map-of-analyzable). [PacketCodec] consumes this model to +/// decode the dashboard's JSON payload back into a `Packet`; `/api/packet/describe/{class}` +/// consumes [#describe]. +public final class PacketSchema { + + private PacketSchema() {} + + public enum Kind { + BYTE(byte.class, Byte.class), SHORT(short.class, Short.class), + INT(int.class, Integer.class), LONG(long.class, Long.class), + FLOAT(float.class, Float.class), DOUBLE(double.class, Double.class), + BOOLEAN(boolean.class, Boolean.class), CHAR(char.class, Character.class), + STRING(String.class), UUID(java.util.UUID.class), + ENUM, RECORD, LIST, MAP, ITEM, COMPONENT; + + final Class[] classes; + Kind(Class... classes) { this.classes = classes; } + + private boolean isNumeric() { + return this == BYTE || this == SHORT || this == INT || this == LONG + || this == FLOAT || this == DOUBLE; + } + + /// Kinds whose value comes from the user as a free-form expression string. Booleans, + /// enums, lists, maps, items, components, and records use structured widgets. + public boolean isExpression() { + return isNumeric() || this == CHAR || this == STRING || this == UUID; + } + + /// Map keys must round-trip through JSON object keys (which are strings), so only + /// scalar kinds qualify. + public boolean canBeMapKey() { + return this == STRING || this == UUID || this == ENUM + || this == BYTE || this == SHORT || this == INT || this == LONG; + } + } + + private static final Map, Kind> KIND_BY_CLASS = buildKindByClass(); + + private static Map, Kind> buildKindByClass() { + Map, Kind> m = new HashMap<>(); + for (Kind k : Kind.values()) for (Class c : k.classes) m.put(c, k); + m.put(ItemStack.class, Kind.ITEM); + m.put(Component.class, Kind.COMPONENT); + return Map.copyOf(m); + } + + /// The leaf [Kind] for a scalar class, or null for enums/records/lists/maps. + static @Nullable Kind kindOf(Class type) { + return KIND_BY_CLASS.get(type); + } + + /// One node in a packet's widget tree. The variant carries only the data its kind needs. + public sealed interface Field { + String name(); + Kind kind(); + + record Leaf(String name, Kind kind) implements Field {} + record EnumF(String name, List values) implements Field { + public Kind kind() { return Kind.ENUM; } + } + record RecordF(String name, List components) implements Field { + public Kind kind() { return Kind.RECORD; } + } + record ListF(String name, Field element) implements Field { + public Kind kind() { return Kind.LIST; } + } + record MapF(String name, Field key, Field value) implements Field { + public Kind kind() { return Kind.MAP; } + } + } + + private static final ConcurrentHashMap, Optional>> SCHEMA_CACHE = new ConcurrentHashMap<>(); + + /// Returns the typed schema for a record packet, or empty if any component resolves + /// to an unsupported type. Cached per class. + public static Optional> schema(Class cls) { + return SCHEMA_CACHE.computeIfAbsent(cls, c -> Optional.ofNullable(buildSchema(c, new HashSet<>()))); + } + + public static boolean isAnalyzable(Class cls) { return schema(cls).isPresent(); } + + private static @Nullable List buildSchema(Class cls, Set> visiting) { + if (!cls.isRecord()) return null; + if (!visiting.add(cls)) return null; + try { + List fields = new ArrayList<>(); + for (RecordComponent c : cls.getRecordComponents()) { + Field f = fieldFor(c.getName(), c.getType(), c.getGenericType(), visiting); + if (f == null) return null; + fields.add(f); + } + return List.copyOf(fields); + } finally { + visiting.remove(cls); + } + } + + private static @Nullable Field fieldFor(String name, Class type, Type generic, Set> visiting) { + Kind k = KIND_BY_CLASS.get(type); + if (k != null) return new Field.Leaf(name, k); + if (type.isEnum()) { + List values = new ArrayList<>(); + for (Object e : type.getEnumConstants()) values.add(((Enum) e).name()); + return new Field.EnumF(name, List.copyOf(values)); + } + if (List.class.isAssignableFrom(type) && generic instanceof ParameterizedType pt) { + Type arg = pt.getActualTypeArguments()[0]; + Field element = fieldFor("item", rawClass(arg), arg, visiting); + return element == null ? null : new Field.ListF(name, element); + } + if (Map.class.isAssignableFrom(type) && generic instanceof ParameterizedType pt) { + Type[] args = pt.getActualTypeArguments(); + Field key = fieldFor("key", rawClass(args[0]), args[0], visiting); + Field val = fieldFor("value", rawClass(args[1]), args[1], visiting); + if (key == null || val == null || !key.kind().canBeMapKey()) return null; + return new Field.MapF(name, key, val); + } + if (type.isRecord()) { + List nested = buildSchema(type, visiting); + return nested == null ? null : new Field.RecordF(name, nested); + } + return null; + } + + static Class rawClass(Type t) { + if (t instanceof Class c) return c; + if (t instanceof ParameterizedType pt) return (Class) pt.getRawType(); + return Object.class; + } + + /// Wire format for `/api/packet/describe/{class}`: `{class, analyzable, components?}`. + public static JsonObject describe(String classNameOrSimple) throws ClassNotFoundException { + Class cls = PacketCatalog.packetClass(classNameOrSimple); + JsonObject out = new JsonObject(); + out.addProperty("class", cls.getName()); + Optional> s = schema(cls); + out.addProperty("analyzable", s.isPresent()); + s.ifPresent(fields -> out.add("components", fieldsJson(fields))); + return out; + } + + private static JsonArray fieldsJson(List fields) { + JsonArray array = new JsonArray(); + for (Field f : fields) array.add(fieldJson(f)); + return array; + } + + private static JsonObject fieldJson(Field f) { + JsonObject o = new JsonObject(); + o.addProperty("name", f.name()); + o.addProperty("kind", f.kind().name().toLowerCase()); + switch (f) { + case Field.EnumF e -> { + JsonArray values = new JsonArray(); + for (String v : e.values()) values.add(v); + o.add("values", values); + } + case Field.RecordF r -> o.add("components", fieldsJson(r.components())); + case Field.ListF l -> o.add("element", fieldJson(l.element())); + case Field.MapF m -> { + o.add("key", fieldJson(m.key())); + o.add("value", fieldJson(m.value())); + } + case Field.Leaf _ -> {} + } + return o; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/RateLimiter.java b/web/src/main/java/net/minestom/web/internal/http/RateLimiter.java new file mode 100644 index 00000000000..4ac68620fb5 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/RateLimiter.java @@ -0,0 +1,59 @@ +package net.minestom.web.internal.http; + +import java.util.concurrent.ConcurrentHashMap; + +/// Simple per-key token bucket. All mutation happens under the bucket's monitor so the tokens +/// count is plain `long` — no need for {@link java.util.concurrent.atomic.AtomicLong} inside the +/// critical section. Used to gate POST endpoints per auth token / IP; the proxy itself is +/// unaffected by HTTP rate limits. +public final class RateLimiter { + private final ConcurrentHashMap buckets = new ConcurrentHashMap<>(); + private final long capacity; + private final long refillPerSecond; + + public RateLimiter(long capacity, long refillPerSecond) { + this.capacity = capacity; + this.refillPerSecond = refillPerSecond; + } + + public boolean tryAcquire(String key) { + Bucket b = buckets.computeIfAbsent(key, k -> new Bucket(capacity)); + synchronized (b) { + long now = System.nanoTime(); + b.lastAccessNanos = now; + long elapsed = now - b.lastRefillNanos; + if (elapsed > 0) { + long add = elapsed * refillPerSecond / 1_000_000_000L; + if (add > 0) { + b.tokens = Math.min(capacity, b.tokens + add); + b.lastRefillNanos = now; + } + } + if (b.tokens > 0) { + b.tokens--; + return true; + } + return false; + } + } + + /// Drop buckets untouched for longer than `idleNanos`. An idle bucket would refill to full + /// capacity anyway, so recreating it on the next request loses no meaningful rate state — + /// this just keeps the per-key map from growing without bound. Call periodically. + public void sweepIdle(long idleNanos) { + final long cutoff = System.nanoTime() - idleNanos; + buckets.values().removeIf(b -> { + synchronized (b) { return b.lastAccessNanos < cutoff; } + }); + } + + private static final class Bucket { + long tokens; + long lastRefillNanos = System.nanoTime(); + long lastAccessNanos = System.nanoTime(); + + Bucket(long initialTokens) { + this.tokens = initialTokens; + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/Topics.java b/web/src/main/java/net/minestom/web/internal/http/Topics.java new file mode 100644 index 00000000000..bcc516a79e6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/Topics.java @@ -0,0 +1,26 @@ +package net.minestom.web.internal.http; + +import java.util.UUID; + +/// Central catalog of WebSocket topic names published by [DashboardServer]. Kept in sync with +/// `web/frontend/src/lib/topics.ts` — adding a new topic on one side without the other is a bug. +public final class Topics { + private Topics() {} + + public static final String CONSOLE = "console"; + public static final String METRICS = "metrics"; + public static final String GLOBAL = "global"; + public static final String PLAYERS = "players"; + /// Replay scope status transitions (running → done/error). + public static final String SCOPE = "scope"; + /// Batched lightweight roster fields (ping, health, …) for list views — not full state patches. + public static final String PLAYERS_SUMMARY = "players:summary"; + /// Batched packet rows across all sessions for the global packet analysis view. + public static final String PACKETS_AGGREGATE = "packets:aggregate"; + public static final String SERVER_METRICS = "server:metrics"; + + public static String playerLifecycle(UUID uuid) { return "player:" + uuid + ":lifecycle"; } + public static String playerPackets(UUID uuid) { return "player:" + uuid + ":packets"; } + public static String playerMinimap(UUID uuid) { return "player:" + uuid + ":minimap"; } + public static String playerState(UUID uuid) { return "player:" + uuid + ":state"; } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/ConsoleRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/ConsoleRoutes.java new file mode 100644 index 00000000000..e11899bff7b --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/ConsoleRoutes.java @@ -0,0 +1,34 @@ +package net.minestom.web.internal.http.routes; + +import io.javalin.config.RoutesConfig; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.codec.WebJson; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Console and metrics REST endpoints: history, command, latest metrics, global data. +public final class ConsoleRoutes { + private ConsoleRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/console/history", scoped((ctx, scope) -> + encoded(ctx, WebCodecs.CONSOLE_LINE_LIST, scope.control.consoleHistory()))); + + app.post("/api/console/command", liveOnly((ctx, scope) -> { + String command = requiredString(ctx, parseJsonBody(ctx), "command"); + if (command == null) return; + scope.control.sendCommand(command); + jsonRaw(ctx, OK_JSON); + })); + + app.get("/api/metrics/latest", scoped((ctx, scope) -> { + var m = scope.control.latestMetrics(); + if (m == null) { jsonRaw(ctx, "null"); return; } + jsonRaw(ctx, WebJson.encodeAsObject(WebCodecs.CONTROL_METRICS, m).toString()); + })); + + app.get("/api/global", scoped((ctx, scope) -> + encoded(ctx, WebCodecs.GLOBAL_DATA, new WebPayloads.GlobalData(scope.control.globalData())))); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/InjectRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/InjectRoutes.java new file mode 100644 index 00000000000..cc18737a370 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/InjectRoutes.java @@ -0,0 +1,30 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonObject; +import io.javalin.config.RoutesConfig; +import net.minestom.web.internal.http.PacketCatalog; +import net.minestom.web.internal.http.PacketCodec; +import net.minestom.web.internal.session.Session; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Packet injection REST endpoint (live proxy only). +public final class InjectRoutes { + private InjectRoutes() {} + + public static void register(RoutesConfig app) { + app.post("/api/players/{uuid}/inject", liveOnly((ctx, scope) -> { + Session session = lookupLive(ctx, scope); + if (session == null) return; + JsonObject body = parseJsonBody(ctx); + String cls = requiredString(ctx, body, "class"); + if (cls == null) return; + JsonObject fields = body.has("fields") ? body.getAsJsonObject("fields") : new JsonObject(); + if (!scope.proxy.inject(session.playerUuid(), PacketCatalog.directionFor(cls), PacketCodec.decode(cls, fields))) { + notFound(ctx, "no live connection"); + return; + } + jsonRaw(ctx, OK_JSON); + })); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/MiscRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/MiscRoutes.java new file mode 100644 index 00000000000..7fe9162f9fe --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/MiscRoutes.java @@ -0,0 +1,107 @@ +package net.minestom.web.internal.http.routes; + +import io.javalin.config.RoutesConfig; +import net.minestom.server.item.Material; +import net.minestom.web.internal.AddressResolver; +import net.minestom.web.ControlPacket; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.persist.PersistentHistory; +import net.minestom.web.internal.proxy.ProxyMetrics; +import net.minestom.web.internal.renderer.ItemIconRenderer; +import net.minestom.web.internal.session.Session; + +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Miscellaneous REST endpoints: server info, materials, icons, persistence, export, mailbox, control packets, proxy metrics. +public final class MiscRoutes { + private MiscRoutes() {} + + public static void register(RoutesConfig app, ItemIconRenderer itemIcons) { + app.get("/api/server", scoped((ctx, scope) -> encoded(ctx, WebCodecs.SERVER_INFO, + new WebPayloads.ServerInfo(scope.createdAt, scope.registry.players().size(), + Arrays.asList(scope.metrics.snapshot()))))); + + app.get("/api/materials", ctx -> { + List keys = new ArrayList<>(); + for (Material m : Material.values()) keys.add(m.key().value()); + encoded(ctx, WebCodecs.STRING_LIST, keys); + }); + + app.get("/api/material-icon/{id}", ctx -> { + byte[] png = itemIcons.iconFor(ctx.pathParam("id")); + if (png == null) { ctx.status(404); return; } + ctx.contentType("image/png"); + ctx.header("Cache-Control", "public, max-age=86400, immutable"); + ctx.result(png); + }); + + app.get("/api/persistence", ctx -> { + var scope = scope(ctx); + PersistentHistory p = scope == null ? null : scope.persistence; + encoded(ctx, WebCodecs.PERSISTENCE_INFO, new WebPayloads.PersistenceInfo( + p != null, + p == null ? null : p.protocolVersion(), + p == null ? null : p.sessionId(), + p == null ? null : p.path().toString())); + }); + + app.get("/api/export.sqlite", scoped((ctx, scope) -> { + if (scope.persistence == null) { notFound(ctx, "persistence disabled"); return; } + Path tmp = Files.createTempFile("sessions-export-", ".sqlite"); + try { + scope.persistence.exportSnapshot(tmp); + ctx.contentType("application/vnd.sqlite3"); + ctx.header("Content-Disposition", + "attachment; filename=\"sessions-" + System.currentTimeMillis() + ".sqlite\""); + ctx.result(Files.newInputStream(tmp)); + } finally { + try { Files.deleteIfExists(tmp); } catch (Exception _) {} + } + })); + + app.get("/api/sessions/mailbox", scoped((ctx, scope) -> { + List rows = new ArrayList<>(); + for (Session session : scope.registry.sessions()) { + rows.add(new WebPayloads.MailboxRow(session.id, session.playerUuid(), + session.stateQueueDepth(), session.listenerCount())); + } + encoded(ctx, WebCodecs.MAILBOX_ROW_LIST, rows); + })); + + app.get("/api/control/packets", ctx -> { + List names = new ArrayList<>(); + for (Class permitted : ControlPacket.class.getPermittedSubclasses()) names.add(permitted.getSimpleName()); + encoded(ctx, WebCodecs.STRING_LIST, names); + }); + + app.get("/api/proxy/metrics", liveOnly((ctx, scope) -> + jsonRaw(ctx, WebJson.encodeAsObject(ProxyMetrics.CODEC, scope.proxy.metrics().snapshot()).toString()))); + + // Move a player to any reachable Minecraft server. Mints a transfer cookie + injects + // CookieStore + Transfer. 404 if the player isn't online, 400 if the address spec is + // invalid. Body shape: {"address": "play.example.com"} or {"address": "host:port"}. + app.post("/api/players/{uuid}/move", liveOnly((ctx, scope) -> { + final UUID uuid = pathUuid(ctx, "uuid"); + if (uuid == null) return; + final String address = requiredString(ctx, parseJsonBody(ctx), "address"); + if (address == null) return; + // A malformed address spec throws IllegalArgumentException → mapped to 400. + final InetSocketAddress target = AddressResolver.parseMinecraft(address); + if (!scope.proxy.movePlayer(uuid, target)) { + notFound(ctx, "no live connection or move rejected"); + return; + } + jsonRaw(ctx, OK_JSON); + })); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/ModeRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/ModeRoutes.java new file mode 100644 index 00000000000..b43eeceb9cd --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/ModeRoutes.java @@ -0,0 +1,53 @@ +package net.minestom.web.internal.http.routes; + +import io.javalin.config.RoutesConfig; +import net.minestom.server.MinecraftServer; +import net.minestom.web.ProxyConfig; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.scope.DashboardScope; + +import java.util.ArrayList; +import java.util.List; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Mode discovery and replay scope management REST endpoints. +public final class ModeRoutes { + private ModeRoutes() {} + + public static void register(RoutesConfig app, ProxyConfig config, ScopeRouter routeCtx) { + app.get("/api/mode", ctx -> { + DashboardScope s = scope(ctx); + encoded(ctx, WebCodecs.MODE_PAYLOAD, new WebPayloads.ModePayload( + config.replayMode() ? "replay" : "live", + s == null ? null : s.summary(), + MinecraftServer.PROTOCOL_VERSION)); + }); + + app.post("/api/replay", ctx -> wrap(ctx, () -> { + if (!config.replayMode()) { + ctx.status(405).result("not in replay mode"); + return; + } + DashboardScope scope = routeCtx.createReplayScope(ctx); + encoded(ctx, WebCodecs.SCOPE_SUMMARY, scope.summary()); + })); + + app.delete("/api/replay/{id}", ctx -> { + String id = ctx.pathParam("id"); + if (!routeCtx.scopeExists(id)) { + notFound(ctx, "unknown scope"); + return; + } + routeCtx.removeScope(id); + ctx.status(204); + }); + + app.get("/api/replay", ctx -> { + List summaries = new ArrayList<>(); + for (DashboardScope s : routeCtx.scopes()) if (s.isReplay()) summaries.add(s.summary()); + encoded(ctx, WebCodecs.SCOPE_SUMMARY_LIST, summaries); + }); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/PacketRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/PacketRoutes.java new file mode 100644 index 00000000000..40680986f8d --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/PacketRoutes.java @@ -0,0 +1,104 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonObject; +import io.javalin.config.RoutesConfig; +import net.minestom.web.PacketRecord; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebJsonBuilders; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.http.JsonSerialization; +import net.minestom.web.internal.http.PacketCatalog; +import net.minestom.web.internal.http.PacketSchema; +import net.minestom.web.internal.replay.PacketSeqResolver; +import net.minestom.web.internal.session.Session; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.util.*; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Packet-related REST endpoints: timeline, subjects, single packet, known packets, describe. +public final class PacketRoutes { + private static final Logger LOGGER = LoggerFactory.getLogger(PacketRoutes.class); + + private PacketRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/connections/{id}/packets", scoped((ctx, scope) -> { + Session session = lookupSession(ctx, scope); + if (session == null) return; + long since = parseLong(ctx.queryParam("since"), 0L); + int limit = parseLimit(ctx, 200, 5_000); + var recs = scope.packetEvents(session, since, limit, + parseDirection(ctx.queryParam("dir")), ctx.queryParam("class"), ctx.queryParam("subject")); + encoded(ctx, WebCodecs.PACKET_EVENT_LIST, recs); + })); + + app.get("/api/connections/{id}/packets/subjects", scoped((ctx, scope) -> { + Session session = lookupSession(ctx, scope); + if (session == null) return; + int limit = parseLimit(ctx, 5_000, 50_000); + var recs = scope.packetEvents(session, 0, limit, null, null, null); + long now = System.currentTimeMillis(); + Map recent = new HashMap<>(); + for (var s : recs) { + if (now - s.ts() <= 1_000L) recent.merge(s.subject(), 1, Integer::sum); + } + Map agg = new LinkedHashMap<>(); + for (var s : recs) { + agg.compute(s.subject(), (k, cur) -> new WebPayloads.SubjectAggregate( + s.subject(), s.subjectLabel(), s.subjectGroup(), + cur == null ? 1 : cur.count() + 1, + cur == null ? s.ts() : Math.max(cur.lastTs(), s.ts()), + recent.getOrDefault(s.subject(), 0))); + } + encoded(ctx, WebCodecs.SUBJECT_AGGREGATE_LIST, new ArrayList<>(agg.values())); + })); + + app.get("/api/connections/{id}/packets/{seq}", scoped((ctx, scope) -> { + Session session = lookupSession(ctx, scope); + if (session == null) return; + Long seqNum = pathLong(ctx, "seq"); + if (seqNum == null) return; + + PacketRecord rec = session.packets.decoded(seqNum); + if (rec == null) { + final Path archive = scope.archivePath(); + if (archive != null) { + try { + rec = PacketSeqResolver.resolve(archive, session.id, seqNum); + } catch (Exception e) { + LOGGER.debug("packet {} resolve from {} failed: {}", seqNum, archive, e.toString()); + } + } + } + if (rec == null) { + notFound(ctx, "packet seq not in memory or archive"); + return; + } + JsonObject o = WebJsonBuilders.packetRecordJson(rec, PacketCatalog.classify(rec)); + try { + o.add("record", JsonSerialization.GSON.toJsonTree(rec.record())); + } catch (Exception e) { + o.addProperty("recordError", e.toString()); + } + json(ctx, o); + })); + + app.get("/api/packets/known", ctx -> { + boolean analyzable = "true".equalsIgnoreCase(ctx.queryParam("analyzable")); + encoded(ctx, PacketCatalog.Entry.LIST_CODEC, + analyzable ? PacketCatalog.entriesAnalyzable() : PacketCatalog.entries()); + }); + + app.get("/api/packet/describe/{class}", ctx -> { + try { + json(ctx, PacketSchema.describe(ctx.pathParam("class"))); + } catch (Exception e) { + notFound(ctx, e.getMessage()); + } + }); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/PlayerRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/PlayerRoutes.java new file mode 100644 index 00000000000..73a195dbb5a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/PlayerRoutes.java @@ -0,0 +1,68 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonArray; +import io.javalin.config.RoutesConfig; +import net.minestom.web.internal.codec.MinimapCodec; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebJsonBuilders; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.session.PlayerView; +import net.minestom.web.internal.session.Session; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Player-related REST endpoints: list, single player, minimap, entities, registries, provenance, lifecycle. +public final class PlayerRoutes { + private PlayerRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/players", scoped((ctx, scope) -> { + JsonArray array = new JsonArray(); + for (PlayerView player : scope.registry.players()) array.add(player.playerJson()); + json(ctx, array); + })); + + app.get("/api/players/{uuid}", scoped((ctx, scope) -> { + PlayerView player = lookupPlayer(ctx, scope); + jsonOrNotFound(ctx, player != null ? player.playerJson() : null, "not found"); + })); + + app.get("/api/players/{uuid}/minimap", livePlayerJson(MinimapCodec::snapshotJson)); + + app.get("/api/players/{uuid}/entities/{eid}", scoped((ctx, scope) -> { + Session session = lookupLive(ctx, scope); + if (session == null) return; + Integer eid = pathInt(ctx, "eid"); + if (eid == null) return; + var snap = httpRead(session, player -> WebJsonBuilders.visibleEntityJson(player, eid)); + if (snap == null) { notFound(ctx, "not visible"); return; } + int limit = parseLimit(ctx, 200, 5_000); + var packets = scope.packetEvents(session, 0, limit, null, null, "ent." + eid); + snap.add("packets", WebJson.encode(WebCodecs.PACKET_EVENT_LIST, packets)); + json(ctx, snap); + })); + + app.get("/api/players/{uuid}/registries", scoped((ctx, scope) -> { + Session session = lookupPlayerSession(ctx, scope); + if (session == null) return; + json(ctx, WebJsonBuilders.registriesJson(session.registries)); + })); + + app.get("/api/players/{uuid}/provenance", scoped((ctx, scope) -> { + PlayerView player = lookupPlayer(ctx, scope); + if (player == null) return; + if (player instanceof PlayerView.Retained retained) { + json(ctx, retained.provenanceHistoryJson(ctx.queryParam("field"))); + return; + } + Session session = ((PlayerView.Live) player).session(); + json(ctx, httpRead(session, state -> WebJsonBuilders.provenanceHistoryJson(state, ctx.queryParam("field")))); + })); + + app.get("/api/players/{uuid}/lifecycle", scoped((ctx, scope) -> { + Session session = lookupPlayerSession(ctx, scope); + if (session == null) return; + encoded(ctx, WebCodecs.LIFECYCLE_EVENT_LIST, session.lifecycle.snapshot()); + })); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/QueryRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/QueryRoutes.java new file mode 100644 index 00000000000..938391d9f6a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/QueryRoutes.java @@ -0,0 +1,37 @@ +package net.minestom.web.internal.http.routes; + +import io.javalin.config.RoutesConfig; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.expression.MqlConstants; +import net.minestom.web.internal.session.Session; + +import java.util.ArrayList; +import java.util.List; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// MQL expression and query REST endpoints: compile expression, run query, get constants. +public final class QueryRoutes { + private QueryRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/mql/constants", ctx -> json(ctx, MqlConstants.payload())); + + app.post("/api/expression/compile", scoped((ctx, scope) -> { + String src = stringField(ctx, parseJsonBody(ctx), "src"); + if (src == null) return; + scope.expressions.compile(src); + jsonRaw(ctx, OK_JSON); + })); + + app.post("/api/query", scoped((ctx, scope) -> { + String ql = stringField(ctx, parseJsonBody(ctx), "ql"); + if (ql == null) return; + var q = scope.queries.compile(ql); + List matches = new ArrayList<>(); + for (Session session : scope.registry.sessionsMatching(q)) matches.add(String.valueOf(session.playerUuid())); + encoded(ctx, WebCodecs.QUERY_RESULT, new WebPayloads.QueryResult(matches)); + })); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/RouteResponses.java b/web/src/main/java/net/minestom/web/internal/http/routes/RouteResponses.java new file mode 100644 index 00000000000..1ad7a2afb31 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/RouteResponses.java @@ -0,0 +1,268 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.javalin.http.Context; +import io.javalin.http.Handler; +import net.minestom.server.codec.Codec; +import net.minestom.web.Direction; +import net.minestom.web.PlayerState; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.http.JsonSerialization; +import net.minestom.web.internal.scope.DashboardScope; +import net.minestom.web.internal.session.PlayerView; +import net.minestom.web.internal.session.Session; +import org.jetbrains.annotations.Nullable; + +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Function; + +/// Stateless helpers shared by every route handler: scope-attribute lookup + handler wrappers, +/// JSON response writers, common entity lookups, and path/query parsers. The stateful scope +/// registry + replay lifecycle live in [ScopeRouter]. +public final class RouteResponses { + public static final String OK_JSON = "{\"ok\":true}"; + static final String SCOPE_ATTR = "dashboard.scope"; + + private RouteResponses() {} + + // ---- Scope attribute + handler wrappers ---------------------------------------------- + + public static @Nullable DashboardScope scope(Context ctx) { + return ctx.attribute(SCOPE_ATTR); + } + + @FunctionalInterface + public interface ScopedHandler { + void handle(Context ctx, DashboardScope scope) throws Exception; + } + + /// Wraps a handler that needs the scope. 404s when no scope can be resolved. + public static Handler scoped(ScopedHandler inner) { + return ctx -> { + DashboardScope scope = scope(ctx); + if (scope == null) { notFound(ctx, "unknown scope"); return; } + inner.handle(ctx, scope); + }; + } + + /// Wraps a handler that requires a live proxy attached to the scope. 405s in replay mode. + public static Handler liveOnly(ScopedHandler inner) { + return ctx -> { + DashboardScope scope = scope(ctx); + if (scope == null) { notFound(ctx, "unknown scope"); return; } + if (scope.proxy == null) { + ctx.status(405).result("not supported in replay mode"); + return; + } + inner.handle(ctx, scope); + }; + } + + /// Run `extractor` on a live player's state worker and send the result as JSON. + public static Handler livePlayerJson(Function extractor) { + return scoped((ctx, scope) -> { + Session session = lookupLive(ctx, scope); + if (session == null) return; + json(ctx, httpRead(session, extractor)); + }); + } + + /// Read player state on its owner thread, bounded by the shared HTTP timeout. A wedged or + /// slow owner surfaces as a 503/504 (`MailboxException`, mapped in DashboardServer) rather + /// than pinning the request thread on an unbounded wait. Request handlers must use this in + /// preference to the unbounded `Session#readState`. + public static T httpRead(Session session, Function body) { + return session.tryReadState(body, Session.HTTP_READ_TIMEOUT_MS); + } + + // ---- JSON helpers -------------------------------------------------------------------- + + public static void json(Context ctx, Object o) { + ctx.contentType("application/json").result(JsonSerialization.GSON.toJson(o)); + } + + public static void jsonRaw(Context ctx, String body) { + ctx.contentType("application/json").result(body); + } + + /// Encode `value` via `codec` and write as JSON — replaces `json(ctx, WebJson.encode(codec, value))`. + public static void encoded(Context ctx, Codec codec, T value) { + jsonRaw(ctx, WebJson.encode(codec, value).toString()); + } + + public static void jsonOrNotFound(Context ctx, @Nullable T value, String message) { + if (value == null) { notFound(ctx, message); return; } + json(ctx, value); + } + + public static void jsonOrNotFound(Context ctx, Optional value, String message) { + if (value.isEmpty()) { notFound(ctx, message); return; } + json(ctx, value.get()); + } + + public static void notFound(Context ctx, String message) { + ctx.status(404).result(message); + } + + public static void badRequest(Context ctx, Throwable e) { + ctx.status(400).contentType("application/json") + .result(JsonSerialization.GSON.toJson(Map.of("error", String.valueOf(e.getMessage())))); + } + + public static void wrap(Context ctx, ThrowingRunnable body) { + try { + body.run(); + } catch (Exception e) { + badRequest(ctx, e); + } + } + + @FunctionalInterface + public interface ThrowingRunnable { + void run() throws Exception; + } + + // ---- Lookup helpers ------------------------------------------------------------------ + + public static @Nullable PlayerView lookupPlayer(Context ctx, DashboardScope scope) { + UUID uuid = pathUuid(ctx, "uuid"); + if (uuid == null) return null; + PlayerView player = scope.registry.player(uuid); + if (player == null) { notFound(ctx, "not found"); return null; } + return player; + } + + public static @Nullable Session lookupSession(Context ctx, DashboardScope scope) { + UUID id = pathUuid(ctx, "id"); + if (id == null) return null; + Session session = scope.registry.sessionById(id); + if (session == null) { notFound(ctx, "connection not found"); return null; } + return session; + } + + public static @Nullable Session lookupLive(Context ctx, DashboardScope scope) { + PlayerView player = lookupPlayer(ctx, scope); + if (player == null) return null; + if (!(player instanceof PlayerView.Live live)) { + notFound(ctx, "not live"); + return null; + } + return live.session(); + } + + /// Resolve the `Session` backing a player (live or retained). 404s if either lookup fails. + public static @Nullable Session lookupPlayerSession(Context ctx, DashboardScope scope) { + PlayerView player = lookupPlayer(ctx, scope); + if (player == null) return null; + Session session = scope.registry.sessionById(player.sessionId()); + if (session == null) { notFound(ctx, "connection gone"); return null; } + return session; + } + + // ---- Parsing helpers ----------------------------------------------------------------- + + public static long parseLong(String s, long def) { + if (s == null) return def; + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return def; + } + } + + /// Parse the `limit` query param, clamped to [1, max] so a caller can't force an unbounded read. + public static int parseLimit(Context ctx, int def, int max) { + final long raw = parseLong(ctx.queryParam("limit"), def); + return (int) Math.clamp(raw, 1, max); + } + + /// Parse a path param as long; on failure sets 400 status and returns null. + public static @Nullable Long pathLong(Context ctx, String name) { + try { + return Long.parseLong(ctx.pathParam(name)); + } catch (NumberFormatException e) { + ctx.status(400).result("bad " + name); + return null; + } + } + + public static @Nullable Integer pathInt(Context ctx, String name) { + try { + return Integer.parseInt(ctx.pathParam(name)); + } catch (NumberFormatException e) { + ctx.status(400).result("bad " + name); + return null; + } + } + + public static @Nullable UUID pathUuid(Context ctx, String name) { + try { + return UUID.fromString(ctx.pathParam(name)); + } catch (IllegalArgumentException e) { + ctx.status(400).result("invalid " + name); + return null; + } + } + + public static @Nullable Direction parseDirection(String dir) { + if (dir == null) return null; + return switch (dir.toLowerCase()) { + case "client", "clientbound", "cb" -> Direction.CLIENTBOUND; + case "server", "serverbound", "sb" -> Direction.SERVERBOUND; + default -> null; + }; + } + + public static JsonObject parseJsonBody(Context ctx) { + final var el = JsonParser.parseString(ctx.body()); + if (!el.isJsonObject()) throw new IllegalArgumentException("expected a JSON object body"); + return el.getAsJsonObject(); + } + + // ---- Body-field helpers: 400 + null on missing/invalid (mirrors pathUuid's contract) ----- + + /// Required string field that may be empty (e.g. a match-all query). 400 + null when the + /// field is absent, null, or not a string. + public static @Nullable String stringField(Context ctx, JsonObject body, String field) { + final var el = body.get(field); + if (el != null && el.isJsonPrimitive() && el.getAsJsonPrimitive().isString()) return el.getAsString(); + ctx.status(400).result("missing or invalid '" + field + "'"); + return null; + } + + /// Required, non-blank string field. 400 + null when absent, null, not a string, or blank. + public static @Nullable String requiredString(Context ctx, JsonObject body, String field) { + final String v = stringField(ctx, body, field); + if (v != null && v.isBlank()) { + ctx.status(400).result("'" + field + "' must not be blank"); + return null; + } + return v; + } + + /// Required boolean field. 400 + null (not `false`!) when absent or not a boolean — callers + /// must null-check before unboxing. + public static @Nullable Boolean requiredBoolean(Context ctx, JsonObject body, String field) { + final var el = body.get(field); + if (el != null && el.isJsonPrimitive() && el.getAsJsonPrimitive().isBoolean()) return el.getAsBoolean(); + ctx.status(400).result("missing or invalid '" + field + "'"); + return null; + } + + /// Required nested object field. 400 + null when absent or not an object. + public static @Nullable JsonObject requiredObject(Context ctx, JsonObject body, String field) { + final var el = body.get(field); + if (el != null && el.isJsonObject()) return el.getAsJsonObject(); + ctx.status(400).result("missing or invalid '" + field + "'"); + return null; + } + + public static @Nullable String queryOrHeader(Context ctx, String queryName, String headerName) { + String v = ctx.header(headerName); + if (v == null || v.isEmpty()) v = ctx.queryParam(queryName); + return v == null || v.isEmpty() ? null : v; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/RoutineRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/RoutineRoutes.java new file mode 100644 index 00000000000..a9c35cdfee7 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/RoutineRoutes.java @@ -0,0 +1,83 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonObject; +import io.javalin.config.RoutesConfig; +import net.minestom.web.Action; +import net.minestom.web.Query; +import net.minestom.web.RegisteredRoutine; +import net.minestom.web.internal.codec.RoutineCodecs; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.session.ActionRunner; +import net.minestom.web.internal.session.Session; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Routine and action REST endpoints: CRUD for routines/actions, trigger execution. +public final class RoutineRoutes { + private RoutineRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/routines", scoped((ctx, scope) -> + json(ctx, RoutineCodecs.routinesJson(scope.registry.listRoutines())))); + + app.post("/api/routines", scoped((ctx, scope) -> + json(ctx, RoutineCodecs.routineJson(scope.registry.upsertRoutine(ctx.body()))))); + + app.put("/api/routines/{id}/enabled", scoped((ctx, scope) -> { + UUID id = pathUuid(ctx, "id"); + if (id == null) return; + Boolean enabled = requiredBoolean(ctx, parseJsonBody(ctx), "enabled"); + if (enabled == null) return; + RegisteredRoutine routine = scope.registry.setRoutineEnabled(id, enabled); + jsonOrNotFound(ctx, routine != null ? RoutineCodecs.routineJson(routine) : null, "unknown routine"); + })); + + app.delete("/api/routines/{id}", scoped((ctx, scope) -> { + UUID id = pathUuid(ctx, "id"); + if (id == null) return; + scope.registry.removeRoutine(id); + ctx.status(204); + })); + + app.get("/api/actions", scoped((ctx, scope) -> json(ctx, scope.registry.listActions()))); + + app.post("/api/actions", scoped((ctx, scope) -> json(ctx, scope.registry.upsertAction(ctx.body())))); + + app.delete("/api/actions/{id}", scoped((ctx, scope) -> { + UUID id = pathUuid(ctx, "id"); + if (id == null) return; + scope.registry.removeAction(id); + ctx.status(204); + })); + + app.post("/api/trigger", scoped((ctx, scope) -> { + JsonObject body = parseJsonBody(ctx); + String qSrc = body.has("query") && !body.get("query").isJsonNull() ? body.get("query").getAsString() : null; + Query q = scope.queries.compile(qSrc); + JsonObject actionObj = requiredObject(ctx, body, "action"); + if (actionObj == null) return; + Action action = scope.registry.resolveAction(actionObj); + ActionRunner runner = scope.registry.actionRunner(); + int matched = 0, fired = 0; + List errors = new ArrayList<>(); + for (Session session : scope.registry.sessionsMatching(q)) { + matched++; + try { + session.callState(player -> { + if (runner != null) runner.execute(action, player); + return null; + }); + fired++; + } catch (Exception e) { + errors.add(session.playerUuid() + ": " + e.getMessage()); + } + } + encoded(ctx, WebCodecs.TRIGGER_RESULT, new WebPayloads.TriggerResult(matched, fired, errors)); + })); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/ScopeRouter.java b/web/src/main/java/net/minestom/web/internal/http/routes/ScopeRouter.java new file mode 100644 index 00000000000..46f2c8c9f6c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/ScopeRouter.java @@ -0,0 +1,76 @@ +package net.minestom.web.internal.http.routes; + +import io.javalin.http.Context; +import net.minestom.web.internal.scope.DashboardScope; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Consumer; + +/// Stateful per-server scope registry + replay lifecycle: resolves the [DashboardScope] for each +/// request from the `X-Replay-Id` header / `?replay=` query, tracks the default (live) scope id, +/// and owns the replay create/remove callbacks. The stateless response/lookup helpers live in +/// [RouteResponses]. +public final class ScopeRouter { + private final ConcurrentHashMap scopes; + private volatile @Nullable String defaultScopeId; + private volatile @Nullable ScopeCreator scopeCreator; + private volatile @Nullable Consumer scopeRemover; + + public ScopeRouter(ConcurrentHashMap scopes) { + this.scopes = scopes; + } + + public void setDefaultScopeId(@Nullable String id) { + this.defaultScopeId = id; + } + + public @Nullable String defaultScopeId() { + return defaultScopeId; + } + + public void setReplayLifecycle(ScopeCreator creator, Consumer remover) { + this.scopeCreator = creator; + this.scopeRemover = remover; + } + + public Collection scopes() { + return scopes.values(); + } + + DashboardScope createReplayScope(Context ctx) throws Exception { + return scopeCreator.create(ctx); + } + + void removeScope(String id) { + scopeRemover.accept(id); + } + + boolean scopeExists(String id) { + return scopes.containsKey(id); + } + + /// Before-middleware that resolves scope from `X-Replay-Id` header or `?replay=` query + /// and stores it in context attribute. Call this in `before("/api/*", ...)`. + public void resolveScopeMiddleware(Context ctx) { + String id = RouteResponses.queryOrHeader(ctx, "replay", "X-Replay-Id"); + DashboardScope scope = scopeOrDefault(id); + if (scope != null) ctx.attribute(RouteResponses.SCOPE_ATTR, scope); + } + + private @Nullable DashboardScope scopeOrDefault(@Nullable String explicitId) { + if (explicitId == null || explicitId.isEmpty()) { + final String def = defaultScopeId; + return def == null ? null : scopes.get(def); + } + DashboardScope s = scopes.get(explicitId); + if (s != null) s.touch(); + return s; + } + + @FunctionalInterface + public interface ScopeCreator { + DashboardScope create(Context ctx) throws Exception; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/http/routes/ThrottleRoutes.java b/web/src/main/java/net/minestom/web/internal/http/routes/ThrottleRoutes.java new file mode 100644 index 00000000000..54a9f8898f9 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/ThrottleRoutes.java @@ -0,0 +1,57 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonParser; +import io.javalin.config.RoutesConfig; +import net.minestom.web.Throttle; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.proxy.ThrottleManager; + +import java.util.UUID; + +import static net.minestom.web.internal.http.routes.RouteResponses.*; + +/// Throttle REST endpoints (live proxy only): get/set global and per-player throttles. +public final class ThrottleRoutes { + private ThrottleRoutes() {} + + public static void register(RoutesConfig app) { + app.get("/api/throttle", liveOnly((ctx, scope) -> { + ThrottleManager tm = scope.proxy.throttles(); + encoded(ctx, WebCodecs.THROTTLES_SNAPSHOT, + new WebPayloads.ThrottlesSnapshot(tm.global(), tm.perPlayer())); + })); + + app.put("/api/throttle/global", liveOnly((ctx, scope) -> { + ThrottleManager tm = scope.proxy.throttles(); + tm.setGlobal(decodeThrottle(ctx.body())); + encoded(ctx, WebCodecs.THROTTLE_OPTIONAL, tm.global()); + })); + + app.delete("/api/throttle/global", liveOnly((ctx, scope) -> { + scope.proxy.throttles().setGlobal(null); + ctx.status(204); + })); + + app.put("/api/throttle/players/{uuid}", liveOnly((ctx, scope) -> { + UUID uuid = pathUuid(ctx, "uuid"); + if (uuid == null) return; + ThrottleManager tm = scope.proxy.throttles(); + tm.setForPlayer(uuid, decodeThrottle(ctx.body())); + encoded(ctx, WebCodecs.THROTTLE_OPTIONAL, tm.perPlayer().get(uuid)); + })); + + app.delete("/api/throttle/players/{uuid}", liveOnly((ctx, scope) -> { + UUID uuid = pathUuid(ctx, "uuid"); + if (uuid == null) return; + scope.proxy.throttles().setForPlayer(uuid, null); + ctx.status(204); + })); + } + + private static Throttle decodeThrottle(String body) { + if (body == null || body.isBlank()) return null; + return WebJson.decode(WebCodecs.THROTTLE_OPTIONAL, JsonParser.parseString(body)); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/persist/HistoryFile.java b/web/src/main/java/net/minestom/web/internal/persist/HistoryFile.java new file mode 100644 index 00000000000..5c8eb65daf6 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/HistoryFile.java @@ -0,0 +1,207 @@ +package net.minestom.web.internal.persist; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.network.ConnectionState; +import net.minestom.web.Direction; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.Properties; +import java.util.UUID; + +/// Single entry point for opening a recorded SQLite history — writer, replay reader and seq +/// resolver all route through here so pragmas, schema, and the version check stay in one place. +/// +/// The single-row `format` table is stamped with the [MinecraftServer#PROTOCOL_VERSION] that +/// produced the bytes; reopening a file built by a different protocol throws, since frames are +/// only decodable by their original codec. +/// +/// Schema notes: UUIDs as 16-byte BLOBs, [Direction] / [ConnectionState] as ordinal INTEGERs, +/// timestamps as epoch ms. The three hot tables are `WITHOUT ROWID` so the composite primary key +/// is the storage order. +public final class HistoryFile { + private HistoryFile() {} + + /// Open a history file for read/write. Creates the file and schema if missing, then stamps + /// or verifies the protocol version. The returned connection is configured with WAL, + /// `synchronous = NORMAL`, foreign keys on, and an 8 MB page cache. + public static Connection openWritable(Path path) throws SQLException, IOException { + Files.createDirectories(path.getParent() == null ? Path.of(".") : path.getParent()); + final Connection db = DriverManager.getConnection("jdbc:sqlite:" + path.toAbsolutePath()); + applyPragmas(db); + ensureSchema(db); + stampProtocolVersion(db); + verifyProtocolVersion(db); + return db; + } + + /// Open a history file for read-only consumers (replay, resolver, archived `packet_events` + /// scans). Read-only mode skips the write-pragmas, which would otherwise fail on the file or + /// race the live writer's WAL state. + public static Connection openReadOnly(Path path) throws SQLException { + final Properties props = new Properties(); + // sqlite-jdbc reads open_mode as SQLite's OPEN flags bitfield; 1 == SQLITE_OPEN_READONLY. + props.setProperty("open_mode", "1"); + final Connection db = DriverManager.getConnection( + "jdbc:sqlite:" + path.toAbsolutePath(), props); + verifyProtocolVersion(db); + return db; + } + + private static void applyPragmas(Connection db) throws SQLException { + try (Statement s = db.createStatement()) { + s.execute("PRAGMA journal_mode = WAL"); + s.execute("PRAGMA synchronous = NORMAL"); + s.execute("PRAGMA temp_store = MEMORY"); + s.execute("PRAGMA cache_size = -8000"); // ~8 MB page cache + s.execute("PRAGMA foreign_keys = ON"); + } + } + + private static void ensureSchema(Connection db) throws SQLException { + try (Statement s = db.createStatement()) { + s.execute(""" + CREATE TABLE IF NOT EXISTS format ( + id INTEGER PRIMARY KEY CHECK(id = 1), + protocol_version INTEGER NOT NULL, + created_at_ms INTEGER NOT NULL + )"""); + s.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at_ms INTEGER NOT NULL, + ended_at_ms INTEGER, + bind_address TEXT, + upstream_address TEXT, + auth_mode TEXT, + data_channel TEXT, + host_info TEXT + )"""); + s.execute(""" + CREATE TABLE IF NOT EXISTS player_journeys ( + id BLOB NOT NULL PRIMARY KEY, + player_uuid BLOB, + started_at_ms INTEGER NOT NULL, + ended_at_ms INTEGER + ) WITHOUT ROWID"""); + s.execute("CREATE INDEX IF NOT EXISTS idx_journey_player ON player_journeys(player_uuid)"); + s.execute(""" + CREATE TABLE IF NOT EXISTS connections ( + id BLOB NOT NULL PRIMARY KEY, + session_id INTEGER NOT NULL REFERENCES sessions(id), + journey_id BLOB, + upstream_address TEXT, + address TEXT, + connect_ms INTEGER NOT NULL, + disconnect_ms INTEGER, + init_state_sb INTEGER, + init_state_cb INTEGER, + init_compression INTEGER + ) WITHOUT ROWID"""); + s.execute("CREATE INDEX IF NOT EXISTS idx_conn_journey ON connections(journey_id)"); + s.execute("CREATE INDEX IF NOT EXISTS idx_conn_upstream ON connections(upstream_address)"); + s.execute(""" + CREATE TABLE IF NOT EXISTS io_events ( + connection_id BLOB NOT NULL REFERENCES connections(id), + seq INTEGER NOT NULL, + ts_ms INTEGER NOT NULL, + direction INTEGER NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY (connection_id, seq) + ) WITHOUT ROWID"""); + s.execute("CREATE INDEX IF NOT EXISTS idx_io_conn_ts ON io_events(connection_id, ts_ms)"); + s.execute(""" + CREATE TABLE IF NOT EXISTS packet_checkpoints ( + connection_id BLOB NOT NULL REFERENCES connections(id), + packet_seq INTEGER NOT NULL, + io_event_seq INTEGER NOT NULL, + state_sb INTEGER, + state_cb INTEGER, + compression INTEGER, + PRIMARY KEY (connection_id, packet_seq) + ) WITHOUT ROWID"""); + s.execute(""" + CREATE TABLE IF NOT EXISTS packet_events ( + connection_id BLOB NOT NULL REFERENCES connections(id), + seq INTEGER NOT NULL, + ts_ms INTEGER NOT NULL, + direction INTEGER NOT NULL, + state INTEGER NOT NULL, + class_name TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + subject TEXT NOT NULL, + io_event_seq INTEGER, + PRIMARY KEY (connection_id, seq) + ) WITHOUT ROWID"""); + s.execute("CREATE INDEX IF NOT EXISTS idx_pkt_events_conn_ts ON packet_events(connection_id, ts_ms)"); + } + } + + private static void stampProtocolVersion(Connection db) throws SQLException { + // INSERT OR IGNORE: stamps on a fresh file, no-op on an existing one (CHECK(id=1) keeps + // the row a singleton). Mismatches are caught by verifyProtocolVersion. + try (PreparedStatement ps = db.prepareStatement( + "INSERT OR IGNORE INTO format(id, protocol_version, created_at_ms) VALUES(1, ?, ?)")) { + ps.setInt(1, MinecraftServer.PROTOCOL_VERSION); + ps.setLong(2, nowMs()); + ps.executeUpdate(); + } + } + + private static void verifyProtocolVersion(Connection db) throws SQLException { + try (Statement s = db.createStatement(); + ResultSet rs = s.executeQuery("SELECT protocol_version FROM format WHERE id = 1")) { + if (!rs.next()) { + throw new SQLException("not a Proxy history (missing format row)"); + } + final int found = rs.getInt(1); + if (found != MinecraftServer.PROTOCOL_VERSION) { + throw new SQLException("incompatible Minecraft protocol: file is v" + found + + ", this build speaks v" + MinecraftServer.PROTOCOL_VERSION); + } + } + } + + /// Wall-clock epoch milliseconds. Used everywhere a timestamp lands on disk so cross-thread + /// and cross-connection ordering is well-defined (in contrast to `System.nanoTime`, whose + /// epoch is unspecified and whose values cannot be compared to wall-clock anchors). Matches + /// the unit of in-memory [net.minestom.web.PacketEvent#ts] and the dashboard wire format — + /// no conversion at the persist/read boundary. + public static long nowMs() { + return System.currentTimeMillis(); + } + + // ---------------------------------------------------------------- enum <-> ordinal + + private static final Direction[] DIRECTIONS = Direction.values(); + private static final ConnectionState[] STATES = ConnectionState.values(); + + public static int directionId(Direction direction) { + return direction.ordinal(); + } + + public static Direction directionFromId(int id) { + if (id < 0 || id >= DIRECTIONS.length) { + throw new IllegalArgumentException("bad direction ordinal: " + id); + } + return DIRECTIONS[id]; + } + + public static int stateId(@Nullable ConnectionState state) { + return state == null ? -1 : state.ordinal(); + } + + public static @Nullable ConnectionState stateFromId(int id) { + if (id < 0) return null; + if (id >= STATES.length) throw new IllegalArgumentException("bad state ordinal: " + id); + return STATES[id]; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/persist/Op.java b/web/src/main/java/net/minestom/web/internal/persist/Op.java new file mode 100644 index 00000000000..5f07bac035c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/Op.java @@ -0,0 +1,68 @@ +package net.minestom.web.internal.persist; + +import net.minestom.server.network.ConnectionState; +import net.minestom.web.Direction; +import net.minestom.web.PacketEvent; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; + +/// Producer messages enqueued from [PersistentHistory] record methods onto the writer thread's +/// queue. The writer drains a batch and dispatches each op via pattern match — every JDBC bind +/// stays on one thread. +sealed interface Op { + + record OpenConnection(UUID id, long sessionId, @Nullable UUID journeyId, + @Nullable String upstreamAddress, + String address, long tsMs) implements Op {} + + record OpenJourney(UUID journeyId, @Nullable UUID playerUuid, long tsMs) implements Op {} + + record JourneyPlayerUuid(UUID journeyId, UUID playerUuid) implements Op {} + + record InitConnection(UUID id, + @Nullable ConnectionState stateSb, + @Nullable ConnectionState stateCb, + int compression) implements Op {} + + record CloseConnection(UUID id, long tsMs) implements Op {} + + record Io(UUID id, long seq, long tsMs, Direction direction, byte[] payload) implements Op {} + + record Checkpoint(UUID id, + long packetSeq, + long ioEventSeq, + @Nullable ConnectionState stateSb, + @Nullable ConnectionState stateCb, + int compression) implements Op {} + + record PacketRow(UUID id, PacketEvent event) implements Op {} + + /// Inline barrier — `complete(null)` releases on a successful commit, `complete(error)` on + /// rollback or writer death. The waiter rethrows the error so flushSync callers can't + /// mistake a rolled-back batch for a durable one. + final class Sync implements Op { + private final CountDownLatch latch = new CountDownLatch(1); + private volatile @Nullable Throwable error; + + void complete(@Nullable Throwable err) { + this.error = err; + latch.countDown(); + } + + void await() throws InterruptedException { + latch.await(); + } + + @Nullable Throwable error() { + return error; + } + } + + /// Sentinel that tells the writer thread to drain remaining ops, close its connection, and + /// exit. Enqueued by [PersistentHistory#close]. + enum Shutdown implements Op { + INSTANCE + } +} diff --git a/web/src/main/java/net/minestom/web/internal/persist/PersistentHistory.java b/web/src/main/java/net/minestom/web/internal/persist/PersistentHistory.java new file mode 100644 index 00000000000..2c5c0f8f353 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/PersistentHistory.java @@ -0,0 +1,516 @@ +package net.minestom.web.internal.persist; + +import net.minestom.server.MinecraftServer; +import net.minestom.server.network.ConnectionState; +import net.minestom.web.Direction; +import net.minestom.web.PacketEvent; +import net.minestom.web.internal.Uuids; +import net.minestom.web.internal.http.PacketCatalog; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Types; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +/// Append-only recorder for replay and packet timeline queries. Public `record…` methods enqueue +/// a typed [Op] and return; a single writer thread owns the JDBC [Connection], the +/// [PreparedStatement]s, and every batch commit (flushed per [#FLUSH_INTERVAL_NS] window or +/// [#FLUSH_THRESHOLD] ops). +/// +/// **Shutdown.** [#close] enqueues [Op.Shutdown]; any [Op.Sync] still pending after the writer +/// exits is completed with the captured error so blocked [#flushSync] callers fail fast instead +/// of deadlocking. +public final class PersistentHistory implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(PersistentHistory.class); + + private static final int FLUSH_THRESHOLD = 256; + private static final long FLUSH_INTERVAL_NS = 100_000_000L; + private static final long SHUTDOWN_JOIN_MS = 5_000L; + + private final Path path; + private final long sessionId; + private final BlockingQueue queue = new LinkedBlockingQueue<>(); + private final Thread writerThread; + private volatile boolean writerExited; + private volatile @Nullable Throwable writerError; + + public PersistentHistory(Path path) throws SQLException, IOException { + this(path, RunMetadata.EMPTY); + } + + public PersistentHistory(Path path, RunMetadata metadata) throws SQLException, IOException { + this.path = path; + // Open + insert the session row on the calling thread so callers see a valid sessionId + // before any record* call. After this point the writer thread takes exclusive ownership + // of the connection. + try (Connection bootstrap = HistoryFile.openWritable(path)) { + this.sessionId = insertSession(bootstrap, metadata); + } + this.writerThread = Thread.ofVirtual() + .name("Minestom-Web-Persist") + .unstarted(this::runWriter); + this.writerThread.start(); + LOGGER.info("Persistent history opened at {} (session id {}, protocol v{})", + path, sessionId, MinecraftServer.PROTOCOL_VERSION); + } + + private static long insertSession(Connection db, RunMetadata m) throws SQLException { + try (PreparedStatement ps = db.prepareStatement(""" + INSERT INTO sessions(started_at_ms, bind_address, upstream_address, auth_mode, data_channel, host_info) + VALUES(?, ?, ?, ?, ?, ?) + """, Statement.RETURN_GENERATED_KEYS)) { + ps.setLong(1, HistoryFile.nowMs()); + ps.setString(2, m.bindAddress()); + ps.setString(3, m.upstreamAddress()); + ps.setString(4, m.authMode() == null ? null : m.authMode().name().toLowerCase()); + ps.setString(5, m.dataChannel()); + ps.setString(6, m.hostInfo()); + ps.executeUpdate(); + try (ResultSet keys = ps.getGeneratedKeys()) { + return keys.next() ? keys.getLong(1) : 1L; + } + } + } + + public long sessionId() { + return sessionId; + } + + public Path path() { + return path; + } + + public int protocolVersion() { + return MinecraftServer.PROTOCOL_VERSION; + } + + // ---------------------------------------------------------------- producer API + + /// Open a connection row. `journeyId` / `upstreamAddress` may be null for status pings. + public void recordConnect(UUID connectionId, @Nullable UUID journeyId, + @Nullable String upstreamAddress, + String address, long connectMs) { + if (connectionId == null || writerExited) return; + queue.add(new Op.OpenConnection(connectionId, sessionId, journeyId, upstreamAddress, + address, connectMs)); + } + + /// Record a fresh player journey. The first connection on a journey calls this; subsequent + /// transfer-stitched connections only call `recordConnect` again pointing at the same + /// journey id. + public void recordJourneyOpen(UUID journeyId, @Nullable UUID playerUuid, long tsMs) { + if (journeyId == null || writerExited) return; + queue.add(new Op.OpenJourney(journeyId, playerUuid, tsMs)); + } + + /// Backfill a player UUID onto a journey row once the upstream's LoginSuccess reveals it. + public void recordJourneyPlayerUuid(UUID journeyId, UUID playerUuid) { + if (journeyId == null || playerUuid == null || writerExited) return; + queue.add(new Op.JourneyPlayerUuid(journeyId, playerUuid)); + } + + /// Stamp the post-login session state onto an already-recorded connection (online-mode only, + /// where the login pipeline consumes handshake + LoginStart + SetCompression before the + /// worker records). Offline-mode leaves these columns NULL and replay starts at HANDSHAKE. + public void recordConnectInit(UUID connectionId, + @Nullable ConnectionState sb, @Nullable ConnectionState cb, int compression) { + if (connectionId == null || writerExited) return; + queue.add(new Op.InitConnection(connectionId, sb, cb, compression)); + } + + public void recordDisconnect(UUID connectionId, long disconnectMs) { + if (connectionId == null || writerExited) return; + queue.add(new Op.CloseConnection(connectionId, disconnectMs)); + } + + /// Persist one wire frame. `payload` is referenced, not copied — the writer never mutates it. + public void recordIo(UUID connectionId, long seq, long tsMs, Direction direction, byte[] payload) { + if (connectionId == null || payload == null || payload.length == 0 || writerExited) return; + queue.add(new Op.Io(connectionId, seq, tsMs, direction, payload)); + } + + /// Snapshot decode state after `packetSeq` was assigned (paired with the current + /// `ioEventSeq`). Producer is [net.minestom.web.internal.proxy.ConnectionWorker], which + /// always supplies positive seqs. + public void recordCheckpoint(UUID connectionId, long packetSeq, long ioEventSeq, + @Nullable ConnectionState stateSb, @Nullable ConnectionState stateCb, + int compression) { + if (connectionId == null || writerExited) return; + queue.add(new Op.Checkpoint(connectionId, packetSeq, ioEventSeq, stateSb, stateCb, compression)); + } + + public void recordPacketEvent(UUID connectionId, PacketEvent event) { + if (connectionId == null || event == null || writerExited) return; + queue.add(new Op.PacketRow(connectionId, event)); + } + + // ---------------------------------------------------------------- reader API + + /// Read packet events for a connection from the live writer's file. Blocks on a synchronous + /// flush so the caller sees rows enqueued up to this moment; rethrows the batch's failure + /// if the flush rolled back. + public List packetEvents(UUID connectionId, long sinceSeq, int limit, + @Nullable Direction dirFilter, + @Nullable String classFilter, + @Nullable String subjectFilter) throws SQLException { + flushSync(); + return readPacketEvents(path, connectionId, sinceSeq, limit, dirFilter, classFilter, subjectFilter); + } + + /// Read packet events for a connection from an arbitrary archived file. Static so the + /// dashboard can serve uploaded `sessions.sqlite` files without instantiating a writer. + public static List readPacketEvents(Path sqlitePath, UUID connectionId, long sinceSeq, int limit, + @Nullable Direction dirFilter, + @Nullable String classFilter, + @Nullable String subjectFilter) throws SQLException { + if (sqlitePath == null || connectionId == null || limit <= 0) return List.of(); + try (Connection db = HistoryFile.openReadOnly(sqlitePath)) { + final StringBuilder sql = new StringBuilder(""" + SELECT seq, ts_ms, direction, state, class_name, size_bytes, subject, io_event_seq + FROM packet_events + WHERE connection_id = ? AND seq > ? + """); + if (dirFilter != null) sql.append(" AND direction = ?"); + if (classFilter != null && !classFilter.isEmpty()) sql.append(" AND lower(class_name) = lower(?)"); + if (subjectFilter != null && !subjectFilter.isEmpty()) sql.append(" AND subject = ?"); + sql.append(" ORDER BY seq ASC LIMIT ?"); + try (PreparedStatement ps = db.prepareStatement(sql.toString())) { + int i = 1; + ps.setBytes(i++, Uuids.toBytes(connectionId)); + ps.setLong(i++, sinceSeq); + if (dirFilter != null) ps.setInt(i++, HistoryFile.directionId(dirFilter)); + if (classFilter != null && !classFilter.isEmpty()) ps.setString(i++, classFilter); + if (subjectFilter != null && !subjectFilter.isEmpty()) ps.setString(i++, subjectFilter); + ps.setInt(i, limit); + try (ResultSet rs = ps.executeQuery()) { + final List out = new ArrayList<>(); + while (rs.next()) out.add(readPacketEvent(rs)); + return out; + } + } + } + } + + private static PacketEvent readPacketEvent(ResultSet rs) throws SQLException { + final long seq = rs.getLong(1); + final long tsMs = rs.getLong(2); + final Direction direction = HistoryFile.directionFromId(rs.getInt(3)); + final ConnectionState state = HistoryFile.stateFromId(rs.getInt(4)); + final String className = rs.getString(5); + final int sizeBytes = rs.getInt(6); + final String subjectId = rs.getString(7); + final long ioEventSeq = rs.getLong(8); + final boolean ioEventSeqNull = rs.wasNull(); + final PacketCatalog.Subject subject = PacketCatalog.subjectById(subjectId); + return new PacketEvent(seq, tsMs, direction, state, className, sizeBytes, + subject.id(), subject.label(), subject.groupId(), + ioEventSeqNull ? 0 : ioEventSeq); + } + + /// Write a self-contained snapshot to `target` (no WAL side files). Runs `VACUUM INTO` on + /// an independent read-only connection so the writer keeps draining the proxy's recording + /// queue while the export is in flight. On VACUUM failure the partial target is removed. + public void exportSnapshot(Path target) throws SQLException, IOException { + Files.createDirectories(target.getParent() == null ? Path.of(".") : target.getParent()); + Files.deleteIfExists(target); + // Make sure every queued op is on disk before the snapshot connection reads. + flushSync(); + // VACUUM doesn't bind a target-path parameter, so single-quote escape and inline. + final String dstSql = target.toAbsolutePath().toString().replace("'", "''"); + try (Connection snap = HistoryFile.openReadOnly(path); + Statement s = snap.createStatement()) { + s.execute("VACUUM INTO '" + dstSql + "'"); + } catch (SQLException e) { + try { Files.deleteIfExists(target); } catch (IOException _) {} + throw e; + } + } + + /// Block until the writer has drained the queue up to this moment, then rethrow if the + /// batch the barrier rode in on rolled back. + private void flushSync() throws SQLException { + if (writerExited) throw writerExitedException(); + final Op.Sync sync = new Op.Sync(); + queue.add(sync); + // Race: writer could have exited (and drained the queue, marking remaining Syncs as + // failed) between our writerExited check and the add. Re-check and self-complete to + // avoid an indefinite await. + if (writerExited) sync.complete(writerError); + try { + sync.await(); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + throw new SQLException("interrupted waiting for flush"); + } + final Throwable err = sync.error(); + if (err != null) { + throw err instanceof SQLException se ? se : new SQLException(err); + } + } + + private SQLException writerExitedException() { + return writerError instanceof SQLException se + ? se + : new SQLException("persistence writer is not running", writerError); + } + + @Override + public void close() { + if (writerExited) return; + queue.add(Op.Shutdown.INSTANCE); + try { + writerThread.join(SHUTDOWN_JOIN_MS); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + } + if (writerThread.isAlive()) { + LOGGER.warn("persistence writer did not shut down within {} ms ({} ops still queued)", + SHUTDOWN_JOIN_MS, queue.size()); + } + } + + // ---------------------------------------------------------------- writer thread + + private void runWriter() { + try (Connection db = HistoryFile.openWritable(path); + Writer writer = new Writer(db, sessionId)) { + final List drain = new ArrayList<>(FLUSH_THRESHOLD); + boolean shutdown = false; + while (!shutdown) { + drain.clear(); + final Op head = queue.poll(FLUSH_INTERVAL_NS, TimeUnit.NANOSECONDS); + if (head != null) drain.add(head); + queue.drainTo(drain, FLUSH_THRESHOLD - drain.size()); + for (Op op : drain) { + if (op instanceof Op.Shutdown) { + shutdown = true; + // Catch ops queued concurrently with close() so disconnect/ts updates + // racing the sentinel still land. + queue.drainTo(drain); + break; + } + } + writer.process(drain); + } + } catch (Throwable t) { + writerError = t; + LOGGER.error("persistence writer terminated: {}", t.toString(), t); + } finally { + writerExited = true; + failPendingBarriers(); + } + } + + /// Release every still-queued [Op.Sync] with the writer error so callers blocked on + /// `flushSync` fail rather than deadlock. + private void failPendingBarriers() { + final Throwable err = writerError != null ? writerError : new SQLException("persistence writer closed"); + Op op; + while ((op = queue.poll()) != null) { + if (op instanceof Op.Sync sync) sync.complete(err); + } + } + + /// Sole owner of the [Connection] and [PreparedStatement]s once construction finishes. + /// Batches every commit so one transaction lands per flush window or per + /// [#FLUSH_THRESHOLD] ops. + private static final class Writer implements AutoCloseable { + private final Connection db; + private final long sessionId; + private final PreparedStatement insertConnect; + private final PreparedStatement updateConnectInit; + private final PreparedStatement updateDisconnect; + private final PreparedStatement insertIo; + private final PreparedStatement insertCheckpoint; + private final PreparedStatement insertPacketEvent; + private final PreparedStatement updateSessionEnd; + private final List batched; + private final List all; + + private final PreparedStatement insertJourney; + private final PreparedStatement updateJourneyPlayer; + + Writer(Connection db, long sessionId) throws SQLException { + this.db = db; + this.sessionId = sessionId; + this.insertConnect = db.prepareStatement( + "INSERT OR REPLACE INTO connections(id, session_id, journey_id, upstream_address, address, connect_ms, disconnect_ms) VALUES(?,?,?,?,?,?,?)"); + this.updateConnectInit = db.prepareStatement( + "UPDATE connections SET init_state_sb = ?, init_state_cb = ?, init_compression = ? WHERE id = ?"); + this.updateDisconnect = db.prepareStatement( + "UPDATE connections SET disconnect_ms = ? WHERE id = ?"); + this.insertIo = db.prepareStatement( + "INSERT INTO io_events(connection_id, seq, ts_ms, direction, payload) VALUES(?,?,?,?,?)"); + this.insertCheckpoint = db.prepareStatement( + "INSERT OR REPLACE INTO packet_checkpoints(connection_id, packet_seq, io_event_seq, state_sb, state_cb, compression) VALUES(?,?,?,?,?,?)"); + this.insertPacketEvent = db.prepareStatement(""" + INSERT INTO packet_events( + connection_id, seq, ts_ms, direction, state, class_name, size_bytes, subject, io_event_seq + ) VALUES(?,?,?,?,?,?,?,?,?) + """); + this.insertJourney = db.prepareStatement( + "INSERT OR IGNORE INTO player_journeys(id, player_uuid, started_at_ms) VALUES(?,?,?)"); + this.updateJourneyPlayer = db.prepareStatement( + "UPDATE player_journeys SET player_uuid = ? WHERE id = ?"); + this.updateSessionEnd = db.prepareStatement("UPDATE sessions SET ended_at_ms = ? WHERE id = ?"); + // FK enforcement runs per-statement, so the batch order must follow the FK graph: + // journeys before connections (FK target — we don't enforce it on the column but + // logical order matters for queries reading both), connections before any table + // that references them. + this.batched = List.of(insertJourney, updateJourneyPlayer, insertConnect, + updateConnectInit, updateDisconnect, + insertIo, insertCheckpoint, insertPacketEvent); + this.all = List.of(insertJourney, updateJourneyPlayer, insertConnect, + updateConnectInit, updateDisconnect, + insertIo, insertCheckpoint, insertPacketEvent, updateSessionEnd); + } + + void process(List ops) { + if (ops.isEmpty()) return; + Throwable batchError = null; + if (hasBatchable(ops)) { + try { + db.setAutoCommit(false); + for (Op op : ops) bind(op); + for (PreparedStatement ps : batched) ps.executeBatch(); + db.commit(); + } catch (Throwable t) { + batchError = t; + LOGGER.warn("persistence flush failed: {}", t.toString()); + try { db.rollback(); } catch (Throwable _) {} + clearBatches(); + } finally { + try { db.setAutoCommit(true); } catch (SQLException _) {} + } + } + // Sync barriers ride alongside the data ops; complete them with the batch outcome + // so flushSync callers can't mistake a rolled-back batch for a durable commit. + for (Op op : ops) { + if (op instanceof Op.Sync sync) sync.complete(batchError); + } + } + + private static boolean hasBatchable(List ops) { + for (Op op : ops) { + if (!(op instanceof Op.Sync) && !(op instanceof Op.Shutdown)) return true; + } + return false; + } + + private void bind(Op op) throws SQLException { + switch (op) { + case Op.OpenConnection(UUID id, long sid, UUID journeyId, + String upstreamAddress, String addr, long ts) -> { + insertConnect.setBytes(1, Uuids.toBytes(id)); + insertConnect.setLong(2, sid); + if (journeyId == null) insertConnect.setNull(3, Types.BLOB); + else insertConnect.setBytes(3, Uuids.toBytes(journeyId)); + if (upstreamAddress == null) insertConnect.setNull(4, Types.VARCHAR); + else insertConnect.setString(4, upstreamAddress); + insertConnect.setString(5, addr); + insertConnect.setLong(6, ts); + insertConnect.setNull(7, Types.INTEGER); + insertConnect.addBatch(); + } + case Op.OpenJourney(UUID journeyId, UUID playerUuid, long ts) -> { + insertJourney.setBytes(1, Uuids.toBytes(journeyId)); + if (playerUuid == null) insertJourney.setNull(2, Types.BLOB); + else insertJourney.setBytes(2, Uuids.toBytes(playerUuid)); + insertJourney.setLong(3, ts); + insertJourney.addBatch(); + } + case Op.JourneyPlayerUuid(UUID journeyId, UUID playerUuid) -> { + updateJourneyPlayer.setBytes(1, Uuids.toBytes(playerUuid)); + updateJourneyPlayer.setBytes(2, Uuids.toBytes(journeyId)); + updateJourneyPlayer.addBatch(); + } + case Op.InitConnection(UUID id, var sb, var cb, int compression) -> { + setNullableInt(updateConnectInit, 1, HistoryFile.stateId(sb)); + setNullableInt(updateConnectInit, 2, HistoryFile.stateId(cb)); + setNullableInt(updateConnectInit, 3, compression > 0 ? compression : -1); + updateConnectInit.setBytes(4, Uuids.toBytes(id)); + updateConnectInit.addBatch(); + } + case Op.CloseConnection(UUID id, long ts) -> { + updateDisconnect.setLong(1, ts); + updateDisconnect.setBytes(2, Uuids.toBytes(id)); + updateDisconnect.addBatch(); + } + case Op.Io(UUID id, long seq, long ts, Direction dir, byte[] payload) -> { + insertIo.setBytes(1, Uuids.toBytes(id)); + insertIo.setLong(2, seq); + insertIo.setLong(3, ts); + insertIo.setInt(4, HistoryFile.directionId(dir)); + insertIo.setBytes(5, payload); + insertIo.addBatch(); + } + case Op.Checkpoint(UUID id, long packetSeq, long ioEventSeq, var sb, var cb, int compression) -> { + insertCheckpoint.setBytes(1, Uuids.toBytes(id)); + insertCheckpoint.setLong(2, packetSeq); + insertCheckpoint.setLong(3, ioEventSeq); + setNullableInt(insertCheckpoint, 4, HistoryFile.stateId(sb)); + setNullableInt(insertCheckpoint, 5, HistoryFile.stateId(cb)); + setNullableInt(insertCheckpoint, 6, compression > 0 ? compression : -1); + insertCheckpoint.addBatch(); + } + case Op.PacketRow(UUID id, PacketEvent ev) -> { + insertPacketEvent.setBytes(1, Uuids.toBytes(id)); + insertPacketEvent.setLong(2, ev.seq()); + insertPacketEvent.setLong(3, ev.ts()); + insertPacketEvent.setInt(4, HistoryFile.directionId(ev.direction())); + insertPacketEvent.setInt(5, HistoryFile.stateId(ev.state())); + insertPacketEvent.setString(6, ev.className()); + insertPacketEvent.setInt(7, ev.sizeBytes()); + insertPacketEvent.setString(8, ev.subject()); + setNullableLong(insertPacketEvent, 9, ev.ioEventSeq() > 0 ? ev.ioEventSeq() : -1); + insertPacketEvent.addBatch(); + } + case Op.Sync _, Op.Shutdown _ -> { /* completed in process() after commit */ } + } + } + + private static void setNullableInt(PreparedStatement ps, int idx, int value) throws SQLException { + if (value < 0) ps.setNull(idx, Types.INTEGER); + else ps.setInt(idx, value); + } + + private static void setNullableLong(PreparedStatement ps, int idx, long value) throws SQLException { + if (value < 0) ps.setNull(idx, Types.INTEGER); + else ps.setLong(idx, value); + } + + private void clearBatches() { + for (PreparedStatement ps : batched) { + try { ps.clearBatch(); } catch (SQLException _) {} + } + } + + @Override + public void close() { + try { + updateSessionEnd.setLong(1, HistoryFile.nowMs()); + updateSessionEnd.setLong(2, sessionId); + updateSessionEnd.executeUpdate(); + } catch (SQLException e) { + LOGGER.debug("session close update failed: {}", e.toString()); + } + for (PreparedStatement ps : all) { + try { ps.close(); } catch (SQLException _) {} + } + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/persist/RunMetadata.java b/web/src/main/java/net/minestom/web/internal/persist/RunMetadata.java new file mode 100644 index 00000000000..c021358355c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/RunMetadata.java @@ -0,0 +1,23 @@ +package net.minestom.web.internal.persist; + +import org.jetbrains.annotations.Nullable; + +/// Per-run metadata stamped onto the `sessions` row at open time. Lets a recorded file describe +/// the proxy that produced it: where it listened, where it forwarded, whether it ran in online +/// mode, the plugin channel that carries per-player NBT, and the host's OS/JVM info. +public record RunMetadata( + @Nullable String bindAddress, + @Nullable String upstreamAddress, + @Nullable AuthMode authMode, + @Nullable String dataChannel, + @Nullable String hostInfo +) { + public enum AuthMode { ONLINE, OFFLINE } + + public static final RunMetadata EMPTY = new RunMetadata(null, null, null, null, null); + + public static String currentHostInfo() { + return System.getProperty("os.name") + "/" + System.getProperty("os.arch") + + " jdk-" + System.getProperty("java.version"); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/ConnectionWorker.java b/web/src/main/java/net/minestom/web/internal/proxy/ConnectionWorker.java new file mode 100644 index 00000000000..af41d23aed5 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/ConnectionWorker.java @@ -0,0 +1,422 @@ +package net.minestom.web.internal.proxy; + +import net.kyori.adventure.nbt.BinaryTagIO; +import net.kyori.adventure.nbt.CompoundBinaryTag; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.server.common.PluginMessagePacket; +import net.minestom.web.Direction; +import net.minestom.web.PlayerState; +import net.minestom.web.ProxyConfig; +import net.minestom.web.internal.codec.PacketDecoder; +import net.minestom.web.internal.codec.PacketDecoder.Result; +import net.minestom.web.internal.persist.HistoryFile; +import net.minestom.web.internal.persist.PersistentHistory; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.StandardSocketOptions; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.channels.SocketChannel; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.atomic.AtomicInteger; + +/// One VT per connection: selector + sockets + ciphers + the [Session]'s owner-thread mutations +/// and cadence ticks. Decoded packets apply to state on the same iteration they were read. +public final class ConnectionWorker implements Runnable { + private static final Logger LOGGER = LoggerFactory.getLogger(ConnectionWorker.class); + private static final int INITIAL_BUFFER = 64 * 1024; + private static final long SELECT_TIMEOUT_MS = 50L; + private static final int CHECKPOINT_EVERY_PACKETS = 250; + /// Bound on packets queued for injection toward this connection from off-worker threads. + private static final int INJECT_QUEUE_CAPACITY = 256; + + private final SessionRegistry registry; + private final Session session; + private final SocketChannel clientChannel; + private final SocketChannel upstreamChannel; + private final ArrayBlockingQueue tasks; + private final Selector selector; + private final ThrottleManager throttles; + private final ThrottleManager.WorkerState cbThrottle = new ThrottleManager.WorkerState(); + private final ThrottleManager.WorkerState sbThrottle = new ThrottleManager.WorkerState(); + /// Frames deferred (throttled) but not yet written, per direction. While > 0, a same-direction + /// frame must NOT be encrypted+written inline — that would advance the stateful AES-CFB8 cipher + /// ahead of the still-pending frames and desync the receiver. See [#encryptAndDispatch]. + private final AtomicInteger cbPendingDeferred = new AtomicInteger(); + private final AtomicInteger sbPendingDeferred = new AtomicInteger(); + private final @Nullable PersistentHistory persistence; + private final ProxyMetrics.Live metrics; + private long ioSeq; + private final String dataChannel; + + private volatile @Nullable PacketDecoder.EncryptionContext clientCipher; + private volatile @Nullable PacketDecoder.EncryptionContext upstreamCipher; + + private final NetworkBuffer clientReadBuffer; + private final NetworkBuffer upstreamReadBuffer; + private final NetworkBuffer writeBuffer; + + public ConnectionWorker(SessionRegistry registry, Session session, + SocketChannel clientChannel, SocketChannel upstreamChannel, + ProxyConfig config, ThrottleManager throttles, + @Nullable PersistentHistory persistence, ProxyMetrics.Live metrics, + @Nullable byte[] initialClientBytes, @Nullable byte[] initialUpstreamBytes, + long initialIoSeq) throws IOException { + this.registry = registry; + this.session = session; + this.clientChannel = clientChannel; + this.upstreamChannel = upstreamChannel; + this.tasks = new ArrayBlockingQueue<>(INJECT_QUEUE_CAPACITY); + tuneTcp(clientChannel); + tuneTcp(upstreamChannel); + this.selector = Selector.open(); + clientChannel.register(selector, SelectionKey.OP_READ); + upstreamChannel.register(selector, SelectionKey.OP_READ); + this.throttles = throttles; + this.persistence = persistence; + this.metrics = metrics; + this.dataChannel = config.dataChannel(); + this.ioSeq = initialIoSeq; + this.clientReadBuffer = NetworkBuffer.resizableBuffer(INITIAL_BUFFER, session.registries); + this.upstreamReadBuffer = NetworkBuffer.resizableBuffer(INITIAL_BUFFER, session.registries); + this.writeBuffer = NetworkBuffer.resizableBuffer(INITIAL_BUFFER, session.registries); + seedBuffer(clientReadBuffer, initialClientBytes); + seedBuffer(upstreamReadBuffer, initialUpstreamBytes); + } + + public void installClientCipher(PacketDecoder.EncryptionContext ctx) { + if (clientCipher != null) throw new IllegalStateException("client cipher already installed"); + clientCipher = ctx; + } + + public void installUpstreamCipher(PacketDecoder.EncryptionContext ctx) { + if (upstreamCipher != null) throw new IllegalStateException("upstream cipher already installed"); + upstreamCipher = ctx; + } + + public boolean inject(Direction direction, Packet packet) { + // Honest contract: a closed/closing worker (run() already past its isOpen() loop) would + // never drain the task, so report the drop rather than a phantom success to movePlayer. + if (!isOpen() || !tasks.offer(() -> writeInjected(direction, packet))) { + metrics.injectDropped().increment(); + return false; + } + selector.wakeup(); + return true; + } + + public boolean isOpen() { + return session.isOpen(); + } + + public boolean close() { + try { + selector.close(); + } catch (IOException _) { + } + TcpAcceptor.closeQuiet(clientChannel); + TcpAcceptor.closeQuiet(upstreamChannel); + return session.close(); + } + + @Override + public void run() { + session.bindOwner(); + try { + // Drain pre-queued synthetic-login mutations before touching the wire. + session.drainMailbox(); + while (isOpen()) { + drainTasks(); + session.drainMailbox(); + if (!drainBuffered()) break; + try { + if (selector.select(SELECT_TIMEOUT_MS) > 0 && !handleReady()) break; + } catch (IOException _) { + break; + } + session.tickCadence(System.currentTimeMillis()); + } + } catch (Throwable t) { + LOGGER.debug("connection {} terminated: {}", session.id, t); + } finally { + close(); + } + } + + private void drainTasks() { + for (Runnable t; (t = tasks.poll()) != null; ) { + try { + t.run(); + } catch (Throwable th) { + LOGGER.warn("task failed on {}: {}", session.id, th); + } + } + } + + private boolean drainBuffered() { + if (clientReadBuffer.readableBytes() > 0) { + if (!decodeAvailable(Direction.SERVERBOUND, upstreamChannel, clientReadBuffer)) return false; + clientReadBuffer.compact(); + } + if (upstreamReadBuffer.readableBytes() > 0) { + if (!decodeAvailable(Direction.CLIENTBOUND, clientChannel, upstreamReadBuffer)) return false; + upstreamReadBuffer.compact(); + } + return true; + } + + private boolean handleReady() { + for (var it = selector.selectedKeys().iterator(); it.hasNext(); ) { + final var key = it.next(); + it.remove(); + if (!key.isValid() || !key.isReadable()) continue; + final boolean fromClient = key.channel() == clientChannel; + if (!pumpSide( + fromClient ? clientChannel : upstreamChannel, + fromClient ? upstreamChannel : clientChannel, + fromClient ? Direction.SERVERBOUND : Direction.CLIENTBOUND, + fromClient ? clientReadBuffer : upstreamReadBuffer)) { + return false; + } + } + return true; + } + + private boolean pumpSide(SocketChannel source, SocketChannel sink, Direction direction, NetworkBuffer readBuffer) { + final long readStart = readBuffer.writeIndex(); + final int n; + try { + n = readBuffer.readChannel(source); + } catch (IOException _) { + return false; + } + if (n < 0) return false; + if (n > 0) { + var cipher = readCipher(direction); + PacketDecoder.decryptInPlace(readBuffer, readStart, n, cipher == null ? null : cipher.decrypt()); + if (direction == Direction.SERVERBOUND) { + session.playerForOwnerThread().traffic.bytesIn += n; + } + } + if (!decodeAvailable(direction, sink, readBuffer)) return false; + readBuffer.compact(); + return true; + } + + private void maybeCheckpoint(long ioEventSeq) { + if (persistence == null) return; + final long pktSeq = session.packets.latestSeq(); + if (pktSeq <= 0 || pktSeq % CHECKPOINT_EVERY_PACKETS != 0) return; + final int compression = session.upstreamCompressionThreshold > 0 + ? session.upstreamCompressionThreshold : session.clientCompressionThreshold; + persistence.recordCheckpoint(session.id, pktSeq, ioEventSeq, + session.clientToServerState, session.serverToClientState, compression); + } + + private boolean decodeAvailable(Direction direction, SocketChannel sink, NetworkBuffer readBuffer) { + while (true) switch (PacketDecoder.drain(session, direction, readBuffer, persistence != null)) { + case Result.Incomplete _ -> { + return true; + } + case Result.Error _ -> { + metrics.decodeErrors().increment(); + return false; + } + case Result.Frame(var wire, var packet, var beforeState, var _, var size) -> { + final long packetIoSeq; + if (persistence != null) { + persistence.recordIo(session.id, ++ioSeq, HistoryFile.nowMs(), direction, wire); + packetIoSeq = ioSeq; + } else { + packetIoSeq = 0; + } + registry.applier().apply(session, direction, beforeState, packet, size, packetIoSeq); + maybeCheckpoint(ioSeq); + if (!shouldForward(packet)) continue; + if (!encodeAndFlush(direction, sink, packet, beforeState)) return false; + } + } + } + + private boolean encodeAndFlush(Direction direction, SocketChannel sink, Packet packet, ConnectionState beforeState) { + final int frameBytes = encodeIntoWriteBuffer(packet, beforeState, direction); + if (frameBytes < 0) { + LOGGER.warn("re-encode dropped (session {}, {})", session.id, packet.getClass().getSimpleName()); + return true; + } + return encryptAndDispatch(direction, sink, frameBytes); + } + + private int encodeIntoWriteBuffer(Packet packet, ConnectionState beforeState, Direction direction) { + writeBuffer.writeIndex(0); + writeBuffer.readIndex(0); + if (!PacketDecoder.encodeFramed(writeBuffer, beforeState, packet, threshold(direction))) { + metrics.encodeDrops().increment(); + return -1; + } + return (int) writeBuffer.writeIndex(); + } + + private boolean encryptAndDispatch(Direction direction, SocketChannel sink, int frameBytes) { + final var throttle = direction == Direction.CLIENTBOUND ? cbThrottle : sbThrottle; + final long delayNanos = throttles.delayFor(throttle, session.playerUuid(), direction, frameBytes); + final AtomicInteger pending = pendingDeferred(direction); + // AES-CFB8 is stateful — frames on a direction must be encrypted in submission order. + // Encrypt+write inline ONLY when nothing is delayed AND no earlier frame on this direction + // is still pending. Otherwise defer: encrypting inline now would advance the cipher ahead + // of the pending frame(s) and corrupt the receiver's decrypt stream. The shared + // single-threaded DELAY executor then releases all deferred frames in submission order + // (frames scheduled for the same instant run in submission order), so this invariant holds + // locally regardless of ThrottleManager.delayFor's internal scheduling. + if (delayNanos == 0L && pending.get() == 0) { + var cipher = writeCipher(direction); + PacketDecoder.encryptInPlace(writeBuffer, cipher == null ? null : cipher.encrypt()); + return writeFully(sink, writeBuffer, direction); + } + final byte[] frame = new byte[frameBytes]; + writeBuffer.copyTo(0L, frame, 0L, frameBytes); + pending.incrementAndGet(); + ThrottleManager.schedule(delayNanos, () -> { + if (!isOpen()) { pending.decrementAndGet(); return; } + if (tasks.offer(() -> writeDelayed(sink, frame, direction))) selector.wakeup(); + else { pending.decrementAndGet(); metrics.injectDropped().increment(); } + }); + return true; + } + + private boolean writeFully(SocketChannel sink, NetworkBuffer buffer, Direction direction) { + if (buffer.readableBytes() == 0) return true; + final long bytes = buffer.readableBytes(); + try { + flushToSink(sink, buffer); + if (direction == Direction.CLIENTBOUND) { + session.playerForOwnerThread().traffic.bytesOut += bytes; + } + return true; + } catch (IOException _) { + return false; + } + } + + /// Blocking, level-triggered write of all of `buffer` to `sink`. When the kernel send buffer + /// fills this parks the worker (cheap on a virtual thread) until `sink` is writable again — + /// intentionally pausing this connection's other-direction reads, inject queue, and cadence + /// ticks for the duration. That backpressure is per-connection only: a slow peer can stall its + /// own session but not the server. Non-sink ready keys are dropped here but not lost — the + /// selector is level-triggered, so they re-select on the next outer loop. + private void flushToSink(SocketChannel sink, NetworkBuffer buffer) throws IOException { + final var key = sink.keyFor(selector); + if (key == null) throw new IOException("channel not registered"); + writeLoop: + while (buffer.readableBytes() > 0) { + if (buffer.writeChannel(sink)) continue; + key.interestOps(SelectionKey.OP_READ | SelectionKey.OP_WRITE); + try { + while (isOpen()) { + if (selector.select(SELECT_TIMEOUT_MS) == 0) continue; + for (var it = selector.selectedKeys().iterator(); it.hasNext(); ) { + final var ready = it.next(); + it.remove(); + if (ready.isValid() && ready.channel() == sink && ready.isWritable()) continue writeLoop; + } + } + throw new IOException("closed"); + } finally { + key.interestOps(SelectionKey.OP_READ); + } + } + } + + private void writeDelayed(SocketChannel sink, byte[] frame, Direction direction) { + try { + if (!isOpen()) return; + final var buffer = NetworkBuffer.wrap(frame, 0, frame.length, session.registries); + var cipher = writeCipher(direction); + PacketDecoder.encryptInPlace(buffer, cipher == null ? null : cipher.encrypt()); + if (!writeFully(sink, buffer, direction)) close(); + } finally { + pendingDeferred(direction).decrementAndGet(); + } + } + + private void writeInjected(Direction direction, Packet packet) { + final boolean clientbound = direction == Direction.CLIENTBOUND; + final ConnectionState beforeState = clientbound ? session.serverToClientState : session.clientToServerState; + final SocketChannel sink = clientbound ? clientChannel : upstreamChannel; + final int frameBytes = encodeIntoWriteBuffer(packet, beforeState, direction); + if (frameBytes < 0) { + LOGGER.warn("inject encode dropped (session {}, {})", session.id, packet.getClass().getSimpleName()); + return; + } + long packetIoSeq = 0; + if (persistence != null) { + final byte[] frame = new byte[frameBytes]; + writeBuffer.copyTo(0L, frame, 0L, frameBytes); + persistence.recordIo(session.id, ++ioSeq, HistoryFile.nowMs(), direction, frame); + packetIoSeq = ioSeq; + } + registry.applier().apply(session, direction, beforeState, packet, frameBytes, packetIoSeq); + if (!encryptAndDispatch(direction, sink, frameBytes)) { + LOGGER.warn("inject write failed (session {})", session.id); + close(); + } + } + + private int threshold(Direction direction) { + return direction == Direction.CLIENTBOUND + ? session.clientCompressionThreshold : session.upstreamCompressionThreshold; + } + + private @Nullable PacketDecoder.EncryptionContext readCipher(Direction direction) { + return direction == Direction.SERVERBOUND ? clientCipher : upstreamCipher; + } + + private @Nullable PacketDecoder.EncryptionContext writeCipher(Direction direction) { + return direction == Direction.CLIENTBOUND ? clientCipher : upstreamCipher; + } + + private AtomicInteger pendingDeferred(Direction direction) { + return direction == Direction.CLIENTBOUND ? cbPendingDeferred : sbPendingDeferred; + } + + private boolean shouldForward(Packet packet) { + if (!(packet instanceof PluginMessagePacket(String channel, byte[] data)) || !dataChannel.equals(channel)) { + return true; + } + final var nbt = parseNbt(data); + final PlayerState player = session.playerForOwnerThread(); + player.serverData = nbt; + player.serverDataUpdatedAt = System.currentTimeMillis(); + return false; + } + + private static CompoundBinaryTag parseNbt(byte[] data) { + try { + return BinaryTagIO.unlimitedReader().read(new ByteArrayInputStream(data), BinaryTagIO.Compression.NONE); + } catch (Exception _) { + return CompoundBinaryTag.empty(); + } + } + + private static void seedBuffer(NetworkBuffer buffer, @Nullable byte[] bytes) { + if (bytes == null || bytes.length == 0) return; + buffer.ensureWritable(bytes.length); + var source = NetworkBuffer.wrap(bytes, 0, bytes.length, buffer.registries()); + NetworkBuffer.copy(source, 0L, buffer, buffer.writeIndex(), bytes.length); + buffer.advanceWrite(bytes.length); + } + + private static void tuneTcp(SocketChannel channel) throws IOException { + channel.configureBlocking(false); + channel.setOption(StandardSocketOptions.TCP_NODELAY, true); + } + +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/JourneyTracker.java b/web/src/main/java/net/minestom/web/internal/proxy/JourneyTracker.java new file mode 100644 index 00000000000..d23b095f953 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/JourneyTracker.java @@ -0,0 +1,86 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.web.internal.Uuids; +import org.jetbrains.annotations.Nullable; + +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/// Short-lived transfer cookies + per-player backend assignments. +/// +/// A **journey** is one player's continuous run through the proxy: the chain of TCP sessions +/// they spawn as they move between backends. The journey id lives on the [net.minestom.web.internal.session.Session] +/// — this tracker just carries it across the disconnect/reconnect gap via a one-shot +/// `CookieStorePacket` payload. On `Intent.TRANSFER` reconnect the proxy asks for the cookie +/// via `CookieRequestPacket`, looks it up here, and the new TCP session adopts the prior +/// journey id + lands on the cookie's target address. +public final class JourneyTracker { + public static final String COOKIE_KEY = "minestom-web:journey"; + + /// Short window after a TransferPacket is sent during which the matching reconnect must + /// arrive. Anything older is treated as a stale cookie (the client closed and came back + /// via the front door); routes the connection through the default backend. + private static final long PENDING_TTL_MS = 30_000L; + + private final Map pendingByCookie = new ConcurrentHashMap<>(); + private final Map assignmentsByPlayer = new ConcurrentHashMap<>(); + + /// Outstanding transfer: client received `TransferPacket` carrying [#cookieId], expected + /// to reconnect within [#PENDING_TTL_MS] with the same value as a `ClientCookieResponse`. + public record Pending(UUID cookieId, UUID journeyId, UUID playerUuid, + InetSocketAddress targetAddress, + @Nullable InetSocketAddress fromAddress, long mintedAt) {} + + /// The backend a player is currently assigned to. Updated when a session reveals its + /// player UUID (see `SessionRegistry.markLive`). + public record Assignment(InetSocketAddress address) {} + + /// Mint a one-shot transfer cookie. `journeyId` comes from the caller (typically + /// `session.journeyId()`) so two concurrent moves for the same player can't diverge. + /// Returns the pending record — the 16-byte payload that goes on the wire is + /// `Uuids.toBytes(pending.cookieId())`. + public Pending mintTransfer(UUID playerUuid, UUID journeyId, + @Nullable InetSocketAddress from, InetSocketAddress target) { + Objects.requireNonNull(playerUuid, "playerUuid"); + Objects.requireNonNull(journeyId, "journeyId"); + Objects.requireNonNull(target, "target"); + final UUID cookieId = UUID.randomUUID(); + final Pending pending = new Pending(cookieId, journeyId, playerUuid, target, from, + System.currentTimeMillis()); + pendingByCookie.put(cookieId, pending); + sweepStale(); + return pending; + } + + /// Look up a cookie value carried by a TRANSFER reconnect. Returns the matching pending + /// record and removes it (cookies are one-shot). Returns `null` if the bytes don't decode + /// to a known cookie or the cookie expired. + public @Nullable Pending consume(byte @Nullable [] cookieBytes) { + if (cookieBytes == null || cookieBytes.length != 16) return null; + final UUID id = Uuids.fromBytes(cookieBytes); + final Pending pending = pendingByCookie.remove(id); + if (pending == null) return null; + if (System.currentTimeMillis() - pending.mintedAt() > PENDING_TTL_MS) return null; + return pending; + } + + /// Stamp a player as currently assigned to `address`. Called when a new connection (LOGIN or + /// post-TRANSFER) finishes login and is about to flow PLAY traffic. + public void recordAssignment(UUID playerUuid, InetSocketAddress address) { + if (playerUuid == null || address == null) return; + assignmentsByPlayer.put(playerUuid, new Assignment(address)); + } + + /// The currently assigned backend for a player, or `null` if no journey is on file. + public @Nullable Assignment current(UUID playerUuid) { + return playerUuid == null ? null : assignmentsByPlayer.get(playerUuid); + } + + private void sweepStale() { + final long cutoff = System.currentTimeMillis() - PENDING_TTL_MS; + pendingByCookie.values().removeIf(p -> p.mintedAt() < cutoff); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/LoginIo.java b/web/src/main/java/net/minestom/web/internal/proxy/LoginIo.java new file mode 100644 index 00000000000..cd58d40e16c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/LoginIo.java @@ -0,0 +1,105 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.PacketParser; +import net.minestom.server.network.packet.PacketReading; +import net.minestom.server.network.packet.PacketVanilla; +import net.minestom.server.network.packet.client.ClientPacket; +import net.minestom.server.network.packet.server.ServerPacket; +import net.minestom.server.registry.Registries; +import net.minestom.web.internal.codec.PacketDecoder; +import org.jetbrains.annotations.Nullable; + +import javax.crypto.Cipher; +import java.io.EOFException; +import java.io.IOException; +import java.nio.channels.SocketChannel; +import java.util.function.BiFunction; +import java.util.zip.DataFormatException; + +/// Blocking, one-packet-at-a-time wire I/O for the synchronous login handshake driven by +/// [LoginPipeline]. The streaming (non-blocking) drain/encode path lives in [PacketDecoder] and +/// is shared by the proxy + replay; this is the back-and-forth request/response leg. +final class LoginIo { + + private LoginIo() {} + + static T readClient(SocketChannel channel, NetworkBuffer carry, + ConnectionState state, @Nullable Cipher decrypt, + int compressionThreshold, Class expected) throws IOException { + return cast(readOneBlocking(channel, carry, state, decrypt, compressionThreshold, + PacketVanilla.CLIENT_PACKET_PARSER, PacketVanilla::nextClientState), expected); + } + + static T readServer(SocketChannel channel, NetworkBuffer carry, + ConnectionState state, @Nullable Cipher decrypt, + int compressionThreshold, Class expected) throws IOException { + return cast(readOneBlocking(channel, carry, state, decrypt, compressionThreshold, + PacketVanilla.SERVER_PACKET_PARSER, PacketVanilla::nextServerState), expected); + } + + static void writeClient(SocketChannel channel, ConnectionState state, ClientPacket packet, + @Nullable Cipher encrypt, int compressionThreshold, Registries registries) throws IOException { + writeOneBlocking(channel, registries, encrypt, state, packet, compressionThreshold); + } + + static void writeServer(SocketChannel channel, ConnectionState state, ServerPacket packet, + @Nullable Cipher encrypt, int compressionThreshold, Registries registries) throws IOException { + writeOneBlocking(channel, registries, encrypt, state, packet, compressionThreshold); + } + + private static Object readOneBlocking(SocketChannel channel, NetworkBuffer carry, ConnectionState state, + @Nullable Cipher decrypt, int compressionThreshold, + PacketParser parser, + BiFunction stateUpdater) throws IOException { + final boolean compressed = compressionThreshold > 0; + while (true) { + final PacketReading.Result result; + try { + result = PacketReading.readPacket(carry, parser, state, stateUpdater, compressed); + } catch (DataFormatException e) { + throw new IOException("packet decode failed", e); + } + switch (result) { + case PacketReading.Result.Success success -> { + carry.compact(); + return success.packets().getFirst().packet(); + } + case PacketReading.Result.Failure failure -> { + if (failure.requiredCapacity() > PacketDecoder.MAX_BUFFER) { + throw new IOException("packet exceeds " + PacketDecoder.MAX_BUFFER + " bytes"); + } + carry.resize(failure.requiredCapacity()); + } + case PacketReading.Result.Empty _ -> { } + } + final long readStart = carry.writeIndex(); + final int n = carry.readChannel(channel); + if (n < 0) throw new EOFException("connection closed during login"); + PacketDecoder.decryptInPlace(carry, readStart, n, decrypt); + } + } + + private static void writeOneBlocking(SocketChannel channel, Registries registries, + @Nullable Cipher encrypt, ConnectionState state, + Packet packet, int compressionThreshold) throws IOException { + final NetworkBuffer buf = PacketDecoder.newCarry(registries); + if (!PacketDecoder.encodeFramed(buf, state, packet, compressionThreshold)) { + throw new IOException("login packet exceeds " + PacketDecoder.MAX_BUFFER + " bytes"); + } + PacketDecoder.encryptInPlace(buf, encrypt); + while (buf.readableBytes() > 0) { + if (!buf.writeChannel(channel)) Thread.yield(); + } + } + + private static T cast(Object obj, Class expected) throws IOException { + if (!expected.isInstance(obj)) { + throw new IOException("expected " + expected.getSimpleName() + + " but got " + obj.getClass().getSimpleName()); + } + return expected.cast(obj); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/LoginPipeline.java b/web/src/main/java/net/minestom/web/internal/proxy/LoginPipeline.java new file mode 100644 index 00000000000..394b197cfff --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/LoginPipeline.java @@ -0,0 +1,364 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.server.extras.mojangAuth.MojangCrypt; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.client.common.ClientCookieResponsePacket; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.client.login.ClientEncryptionResponsePacket; +import net.minestom.server.network.packet.client.login.ClientLoginStartPacket; +import net.minestom.server.network.packet.server.ServerPacket; +import net.kyori.adventure.text.Component; +import net.minestom.server.network.packet.server.common.CookieRequestPacket; +import net.minestom.server.network.packet.server.login.EncryptionRequestPacket; +import net.minestom.server.network.packet.server.login.LoginDisconnectPacket; +import net.minestom.server.network.packet.server.login.LoginSuccessPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.server.network.player.GameProfile; +import net.minestom.server.registry.Registries; +import net.minestom.server.utils.mojang.MojangUtils; +import net.minestom.web.BackendRouter; +import net.minestom.web.BackendTarget; +import net.minestom.web.MojangAuth; +import net.minestom.web.ProxyConfig; +import net.minestom.web.internal.codec.PacketDecoder; +import org.jetbrains.annotations.Nullable; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import java.io.IOException; +import java.math.BigInteger; +import java.net.SocketAddress; +import java.nio.channels.SocketChannel; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.PublicKey; +import java.security.SecureRandom; +import java.security.spec.X509EncodedKeySpec; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/// Login termination for both legs of the proxy. +/// +/// The proxy plays "server" to the client (Mojang `hasJoined` runs against the player's +/// session) and "client" to the upstream (Mojang `join` runs against the bot's session). +/// Each leg ends with its own AES key; once both are installed, the proxy can decrypt +/// + inspect + re-encrypt every byte in either direction. +/// +/// **Address-driven routing.** After reading the client handshake the pipeline asks the +/// [BackendRouter] which address to dial. On `Intent.TRANSFER` reconnects the pipeline first +/// requests the journey cookie via [CookieRequestPacket] / [ClientCookieResponsePacket] and +/// feeds the resolved target address back into the router context — so cookie-driven transfers +/// always re-land on the address the original `movePlayer` minted them for. +public final class LoginPipeline { + private static final SecureRandom RANDOM = new SecureRandom(); + + /// Upstream sent a [LoginDisconnectPacket] during the bot's login handshake. + public static final class UpstreamRejected extends IOException { + private final Component reason; + UpstreamRejected(Component reason) { this.reason = reason; } + public Component reason() { return reason; } + } + + public record ClientLegResult(ClientLoginStartPacket loginStart, + EncryptionRequestPacket encryptionRequest, + ClientEncryptionResponsePacket encryptionResponse, + GameProfile playerProfile, + SecretKey aesKey) {} + + private record UpstreamLegResult(@Nullable PacketDecoder.EncryptionContext cipher, + int compressionThreshold) {} + + public record Result(SocketChannel upstream, + BackendTarget backend, + @Nullable JourneyTracker.Pending consumedCookie, + @Nullable PacketDecoder.EncryptionContext clientCipher, + @Nullable PacketDecoder.EncryptionContext upstreamCipher, + int compressionThreshold, + ClientHandshakePacket handshake, + @Nullable ClientLegResult clientLeg, + byte[] initialClientBytes, + byte[] initialUpstreamBytes) {} + + private LoginPipeline() {} + + /// Read the client's handshake, decide whether this is a STATUS ping (no auth, route to + /// default), a fresh LOGIN, or a TRANSFER reconnect (cookie-driven route). Then run both + /// auth legs against the chosen address and the appropriate bot identity, and forge a + /// LoginSuccess carrying the real player profile so the client never sees the bot identity. + public static Result run(SocketChannel client, ProxyConfig config, BackendRouter router, + JourneyTracker journeys, Registries registries) throws IOException { + final NetworkBuffer clientCarry = PacketDecoder.newCarry(registries); + final NetworkBuffer upstreamCarry = PacketDecoder.newCarry(registries); + + final ClientHandshakePacket handshake = LoginIo.readClient( + client, clientCarry, ConnectionState.HANDSHAKE, null, -1, ClientHandshakePacket.class); + + // Status pings AND offline-mode proxies (no Mojang bot) flow through transparently: + // pick a backend, dial it, forward the handshake, and let the byte pump take over. + // The client-side encryption dance is online-mode only. + if (handshake.intent() == ClientHandshakePacket.Intent.STATUS || config.mojang() == null) { + final BackendRouter.Context.Intent intent = mapIntent(handshake.intent()); + final BackendTarget chosen = chooseBackend(handshake, null, config, router, intent); + final SocketChannel upstream = openUpstream(chosen); + try { + LoginIo.writeClient(upstream, ConnectionState.HANDSHAKE, handshake, null, -1, registries); + return new Result(upstream, chosen, null, null, null, -1, + handshake, null, + unreadBytes(clientCarry), unreadBytes(upstreamCarry)); + } catch (Throwable t) { + try { upstream.close(); } catch (IOException _) {} + throw t; + } + } + + // LOGIN or TRANSFER, online-mode: read LoginStart immediately so we can also consume + // the journey cookie before opening any upstream socket. + final ClientLoginStartPacket loginStart = LoginIo.readClient( + client, clientCarry, ConnectionState.LOGIN, null, -1, ClientLoginStartPacket.class); + + JourneyTracker.Pending consumedCookie = null; + if (handshake.intent() == ClientHandshakePacket.Intent.TRANSFER) { + LoginIo.writeServer(client, ConnectionState.LOGIN, + new CookieRequestPacket(JourneyTracker.COOKIE_KEY), null, -1, registries); + final ClientCookieResponsePacket cookieResp = LoginIo.readClient( + client, clientCarry, ConnectionState.LOGIN, null, -1, ClientCookieResponsePacket.class); + if (JourneyTracker.COOKIE_KEY.equals(cookieResp.key())) { + consumedCookie = journeys.consume(cookieResp.value()); + } + } + + final BackendTarget chosen = chooseBackend(handshake, consumedCookie, + config, router, mapIntent(handshake.intent())); + + // Phase 2: open the upstream socket NOW that we know where to dial. + final SocketChannel upstream = openUpstream(chosen); + try { + LoginIo.writeClient(upstream, ConnectionState.HANDSHAKE, handshake, null, -1, registries); + + final ClientLegResult clientLeg = authenticateClientLeg(client, clientCarry, loginStart, + client.getRemoteAddress(), registries); + + // Client is AES from EncryptionResponse onward, including any forwarded disconnect. + final PacketDecoder.EncryptionContext clientCipher = makeCipher(clientLeg.aesKey()); + + // Per-target bot identity falls back to the process-wide MojangAuth. + final MojangAuth bot = chosen.mojang() != null ? chosen.mojang() : config.mojang(); + final UpstreamLegResult upstreamLeg; + try { + upstreamLeg = bot == null + ? new UpstreamLegResult(null, -1) + : authenticateUpstreamLeg(upstream, upstreamCarry, bot, registries); + } catch (UpstreamRejected rejected) { + try { + LoginIo.writeServer(client, ConnectionState.LOGIN, + new LoginDisconnectPacket(rejected.reason()), + clientCipher.encrypt(), -1, registries); + } catch (IOException _) {} + throw rejected; + } + final int compression = upstreamLeg.compressionThreshold(); + if (compression > 0) { + LoginIo.writeServer(client, ConnectionState.LOGIN, new SetCompressionPacket(compression), + clientCipher.encrypt(), -1, registries); + } + LoginIo.writeServer(client, ConnectionState.LOGIN, new LoginSuccessPacket(clientLeg.playerProfile()), + clientCipher.encrypt(), compression, registries); + + return new Result(upstream, chosen, consumedCookie, clientCipher, upstreamLeg.cipher(), + compression, + handshake, clientLeg, + unreadBytes(clientCarry), unreadBytes(upstreamCarry)); + } catch (Throwable t) { + try { upstream.close(); } catch (IOException _) {} + throw t; + } + } + + private static BackendTarget chooseBackend(ClientHandshakePacket handshake, + @Nullable JourneyTracker.Pending cookie, + ProxyConfig config, BackendRouter router, + BackendRouter.Context.Intent intent) throws IOException { + final BackendRouter.Context ctx = new BackendRouter.Context( + config.defaultBackend(), + handshake.serverAddress(), + handshake.serverPort(), + handshake.protocolVersion(), + intent, + cookie == null ? null : cookie.targetAddress()); + final BackendTarget chosen = router.route(ctx); + if (chosen == null) throw new IOException("router refused connection"); + return chosen; + } + + private static BackendRouter.Context.Intent mapIntent(ClientHandshakePacket.Intent intent) { + return switch (intent) { + case STATUS -> BackendRouter.Context.Intent.STATUS; + case TRANSFER -> BackendRouter.Context.Intent.TRANSFER; + case LOGIN -> BackendRouter.Context.Intent.LOGIN; + }; + } + + private static SocketChannel openUpstream(BackendTarget chosen) throws IOException { + // Sockets from `SocketChannel.open(address)` are blocking by default — what we need + // for the auth dance — and they get switched to non-blocking by the connection worker. + return SocketChannel.open(chosen.address()); + } + + /// Run the proxy-as-server handshake against a freshly accepted client whose + /// `ClientLoginStartPacket` has already been consumed by the caller. Runs the RSA + Mojang + /// `hasJoined` round-trip and returns the verified player profile + the AES key shared + /// with the client. The cipher is NOT installed on the socket — the caller does that once + /// it's ready to also send encrypted frames back. + private static ClientLegResult authenticateClientLeg(SocketChannel client, NetworkBuffer carry, + ClientLoginStartPacket loginStart, + SocketAddress clientAddress, + Registries registries) throws IOException { + final KeyPair keyPair = MojangCrypt.generateKeyPair(); + if (keyPair == null) throw new IOException("RSA keypair generation failed"); + + final byte[] nonce = new byte[4]; + RANDOM.nextBytes(nonce); + final EncryptionRequestPacket encryptionRequest = + new EncryptionRequestPacket("", keyPair.getPublic().getEncoded(), nonce, true); + LoginIo.writeServer(client, ConnectionState.LOGIN, encryptionRequest, null, -1, registries); + + final ClientEncryptionResponsePacket response = LoginIo.readClient( + client, carry, ConnectionState.LOGIN, null, -1, ClientEncryptionResponsePacket.class); + + final byte[] verifyToken = MojangCrypt.decryptUsingKey(keyPair.getPrivate(), response.encryptedVerifyToken()); + if (!Arrays.equals(verifyToken, nonce)) { + throw new IOException("client encryption nonce mismatch"); + } + final SecretKey sharedSecret = MojangCrypt.decryptByteToSecretKey(keyPair.getPrivate(), response.sharedSecret()); + + final byte[] digest = MojangCrypt.digestData("", keyPair.getPublic(), sharedSecret); + if (digest == null) throw new IOException("server-hash digest failed"); + final String serverId = new BigInteger(digest).toString(16); + + final GameProfile playerProfile = profileFromHasJoined(loginStart.username(), serverId, clientAddress); + return new ClientLegResult(loginStart, encryptionRequest, response, playerProfile, sharedSecret); + } + + /// Run the proxy-as-client handshake against an already-connected upstream socket. Sends + /// the forwarded handshake + a `LoginStart` carrying the bot identity, then if the + /// upstream is in online mode performs the RSA + Mojang `join` round-trip and installs + /// AES. Stops at (and consumes) the upstream's `LoginSuccess`. Returns the negotiated + /// cipher context (null when the upstream is offline-mode) plus the compression threshold. + private static UpstreamLegResult authenticateUpstreamLeg(SocketChannel upstream, NetworkBuffer carry, + MojangAuth bot, Registries registries) throws IOException { + if (bot.profileUuid() == null || bot.profileName() == null) { + throw new IllegalArgumentException( + "MojangAuth must have profileUuid and profileName resolved before reaching the pipeline"); + } + LoginIo.writeClient(upstream, ConnectionState.LOGIN, + new ClientLoginStartPacket(bot.profileName(), bot.profileUuid()), + null, -1, registries); + + PacketDecoder.EncryptionContext cipher = null; + int compressionThreshold = -1; + while (true) { + final ServerPacket.Login packet = LoginIo.readServer(upstream, carry, ConnectionState.LOGIN, + cipher == null ? null : cipher.decrypt(), compressionThreshold, ServerPacket.Login.class); + switch (packet) { + case EncryptionRequestPacket req -> { + if (cipher != null) throw new IOException("upstream sent EncryptionRequest twice"); + cipher = makeCipher(exchangeUpstreamEncryption(upstream, req, bot, registries)); + } + case SetCompressionPacket(int threshold) -> { + compressionThreshold = threshold; + } + case LoginSuccessPacket _ -> { + return new UpstreamLegResult(cipher, compressionThreshold); + } + case LoginDisconnectPacket(Component reason) -> throw new UpstreamRejected(reason); + default -> throw new IOException("unexpected upstream login packet: " + + packet.getClass().getSimpleName()); + } + } + } + + private static SecretKey exchangeUpstreamEncryption(SocketChannel upstream, EncryptionRequestPacket req, + MojangAuth bot, Registries registries) throws IOException { + final PublicKey upstreamPubKey = parseRsaPublicKey(req.publicKey()); + final SecretKey sharedSecret = generateAesKey(); + + final byte[] digest = MojangCrypt.digestData(req.serverId(), upstreamPubKey, sharedSecret); + if (digest == null) throw new IOException("server-hash digest failed"); + final String serverHash = new BigInteger(digest).toString(16); + + MojangUtils.joinSession(bot.accessToken(), bot.profileUuid(), serverHash); + + final byte[] encryptedSecret = rsaEncrypt(upstreamPubKey, sharedSecret.getEncoded()); + final byte[] encryptedNonce = rsaEncrypt(upstreamPubKey, req.verifyToken()); + LoginIo.writeClient(upstream, ConnectionState.LOGIN, + new ClientEncryptionResponsePacket(encryptedSecret, encryptedNonce), + null, -1, registries); + return sharedSecret; + } + + // ---- helpers ------------------------------------------------------------------------ + + private static PacketDecoder.EncryptionContext makeCipher(SecretKey key) { + return new PacketDecoder.EncryptionContext( + MojangCrypt.getCipher(Cipher.ENCRYPT_MODE, key), + MojangCrypt.getCipher(Cipher.DECRYPT_MODE, key)); + } + + private static byte[] unreadBytes(NetworkBuffer buffer) { + return buffer.read(NetworkBuffer.RAW_BYTES); + } + + private static SecretKey generateAesKey() { + try { + final KeyGenerator kg = KeyGenerator.getInstance("AES"); + kg.init(128, RANDOM); + return kg.generateKey(); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("AES key generation failed", e); + } + } + + private static PublicKey parseRsaPublicKey(byte[] encoded) throws IOException { + try { + return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(encoded)); + } catch (GeneralSecurityException e) { + throw new IOException("upstream public key invalid", e); + } + } + + private static byte[] rsaEncrypt(PublicKey key, byte[] data) throws IOException { + try { + final Cipher c = Cipher.getInstance("RSA"); + c.init(Cipher.ENCRYPT_MODE, key); + return c.doFinal(data); + } catch (GeneralSecurityException e) { + throw new IOException("RSA encryption failed", e); + } + } + + private static GameProfile profileFromHasJoined(String username, String serverId, SocketAddress clientAddress) throws IOException { + final var json = MojangUtils.authenticateSession(username, serverId, clientAddress); + // A 200 with an unexpected shape would NPE here and surface as a generic setup failure; + // turn it into a clean login failure instead so it lands in the loginFailures() metric. + if (json == null || !json.has("id") || !json.has("name") || !json.has("properties")) { + throw new IOException("malformed hasJoined response for " + username); + } + final UUID uuid = UUID.fromString(json.get("id").getAsString() + .replaceFirst("(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", "$1-$2-$3-$4-$5")); + final String name = json.get("name").getAsString(); + final List properties = new ArrayList<>(); + for (var element : json.get("properties").getAsJsonArray()) { + final var obj = element.getAsJsonObject(); + properties.add(new GameProfile.Property( + obj.get("name").getAsString(), + obj.get("value").getAsString(), + obj.has("signature") ? obj.get("signature").getAsString() : null)); + } + return new GameProfile(uuid, name, properties); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/ProxyMetrics.java b/web/src/main/java/net/minestom/web/internal/proxy/ProxyMetrics.java new file mode 100644 index 00000000000..23b6a204b5e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/ProxyMetrics.java @@ -0,0 +1,48 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; + +import java.util.concurrent.atomic.LongAdder; + +/// Proxy lifecycle counters. Inject failures are split by stage: [#injectRejected] counts +/// requests the acceptor couldn't route to a worker at all (no live session / worker for the +/// player); [#injectDropped] counts requests that reached a worker but were lost there (worker +/// closing, task queue full, or a deferred throttled frame that couldn't be re-queued). +public record ProxyMetrics( + long connectionsAccepted, + long loginFailures, + long decodeErrors, + long encodeDrops, + long injectRejected, + long injectDropped +) { + public static final StructCodec CODEC = StructCodec.struct( + "connectionsAccepted", Codec.LONG, ProxyMetrics::connectionsAccepted, + "loginFailures", Codec.LONG, ProxyMetrics::loginFailures, + "decodeErrors", Codec.LONG, ProxyMetrics::decodeErrors, + "encodeDrops", Codec.LONG, ProxyMetrics::encodeDrops, + "injectRejected", Codec.LONG, ProxyMetrics::injectRejected, + "injectDropped", Codec.LONG, ProxyMetrics::injectDropped, + ProxyMetrics::new); + + public record Live( + LongAdder connectionsAccepted, + LongAdder loginFailures, + LongAdder decodeErrors, + LongAdder encodeDrops, + LongAdder injectRejected, + LongAdder injectDropped + ) { + public static Live create() { + return new Live(new LongAdder(), new LongAdder(), new LongAdder(), new LongAdder(), + new LongAdder(), new LongAdder()); + } + + public ProxyMetrics snapshot() { + return new ProxyMetrics( + connectionsAccepted.sum(), loginFailures.sum(), decodeErrors.sum(), + encodeDrops.sum(), injectRejected.sum(), injectDropped.sum()); + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/TcpAcceptor.java b/web/src/main/java/net/minestom/web/internal/proxy/TcpAcceptor.java new file mode 100644 index 00000000000..cf7998e5b09 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/TcpAcceptor.java @@ -0,0 +1,308 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.server.common.CookieStorePacket; +import net.minestom.server.network.packet.server.common.TransferPacket; +import net.minestom.server.network.packet.server.login.LoginSuccessPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.web.BackendRouter; +import net.minestom.web.Direction; +import net.minestom.web.LifecycleEvent; +import net.minestom.web.ProxyConfig; +import net.minestom.web.internal.Uuids; +import net.minestom.web.internal.codec.PacketDecoder; +import net.minestom.web.internal.persist.HistoryFile; +import net.minestom.web.internal.persist.PersistentHistory; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionEvent; +import net.minestom.web.internal.session.SessionMessage; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.nio.channels.ServerSocketChannel; +import java.nio.channels.SocketChannel; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +public final class TcpAcceptor implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(TcpAcceptor.class); + + private final ProxyConfig config; + private final BackendRouter router; + private final SessionRegistry registry; + private final JourneyTracker journeys; + private final ThrottleManager throttles; + private final @Nullable PersistentHistory persistence; + private final ProxyMetrics.Live metrics = ProxyMetrics.Live.create(); + private final Map workersBySession = new ConcurrentHashMap<>(); + private final Executor connectionSetup = virtualExecutor("Minestom-Web-Setup-"); + private final Executor workers = virtualExecutor("Minestom-Web-Conn-"); + + private ServerSocketChannel server; + private volatile boolean running; + + public TcpAcceptor(ProxyConfig config, BackendRouter router, SessionRegistry registry, + JourneyTracker journeys, @Nullable PersistentHistory persistence) { + this.config = config; + this.router = router; + this.registry = registry; + this.journeys = journeys; + this.throttles = new ThrottleManager(); + this.persistence = persistence; + } + + public ThrottleManager throttles() { return throttles; } + public ProxyMetrics.Live metrics() { return metrics; } + + public boolean inject(UUID playerUuid, Direction direction, Packet packet) { + final Session session = registry.sessionFor(playerUuid); + final ConnectionWorker worker = session == null ? null : workersBySession.get(session.id); + if (worker != null && worker.inject(direction, packet)) return true; + metrics.injectRejected().increment(); + return false; + } + + /// Move `playerUuid` to `target` by minting a transfer cookie, injecting a + /// [CookieStorePacket] and a [TransferPacket] toward the client. The client will disconnect + /// and reconnect with `Intent.TRANSFER`; the journey tracker recognises the cookie and the + /// new TCP session lands on `target`. + /// + /// Returns `true` on a successful inject, `false` if the player isn't currently online or + /// the inject was rejected (worker queue full / closed). + public boolean movePlayer(UUID playerUuid, InetSocketAddress target) { + if (playerUuid == null || target == null) return false; + final Session session = registry.sessionFor(playerUuid); + if (session == null || session.journeyId() == null) return false; + final JourneyTracker.Assignment current = journeys.current(playerUuid); + final JourneyTracker.Pending pending = journeys.mintTransfer(playerUuid, session.journeyId(), + current == null ? null : current.address(), target); + final InetSocketAddress reachable = config.reachableAddress(); + final boolean a = inject(playerUuid, Direction.CLIENTBOUND, + new CookieStorePacket(JourneyTracker.COOKIE_KEY, + Uuids.toBytes(pending.cookieId()))); + final boolean b = inject(playerUuid, Direction.CLIENTBOUND, + new TransferPacket(reachable.getHostString(), reachable.getPort())); + return a && b; + } + + public void start() throws IOException { + server = ServerSocketChannel.open(); + server.bind(config.bind()); + running = true; + Thread.ofPlatform().name("Minestom-Web-Proxy-Accept").daemon(true).start(this::acceptLoop); + LOGGER.info("Proxy listening on {} → default backend {}", config.bind(), config.defaultBackend()); + // TransferPacket must carry a host clients can actually dial — bare 0.0.0.0/:: don't + // round-trip through a client. Embedders should set publicAddress(...) explicitly. + if (config.publicAddress() == null && isWildcard(config.bind().getAddress())) { + LOGGER.warn("Proxy bind is wildcard {} and no publicAddress is configured — " + + "movePlayer's TransferPacket will tell clients to reconnect to that " + + "wildcard. Set ProxyServer.Builder#publicAddress for production.", + config.bind()); + } + } + + private static boolean isWildcard(java.net.InetAddress addr) { + return addr != null && addr.isAnyLocalAddress(); + } + + private void acceptLoop() { + while (running) { + try { + final SocketChannel client = server.accept(); + metrics.connectionsAccepted().increment(); + connectionSetup.execute(() -> spawnConnection(client)); + } catch (IOException e) { + if (running) LOGGER.warn("accept failed", e); + break; + } + } + } + + private void spawnConnection(SocketChannel client) { + SocketAddress remote = null; + try { remote = client.getRemoteAddress(); } catch (IOException _) {} + // Defer firing onSessionOpen until backend/journey are stamped so subscribers (e.g. + // ScopeSessionBridge → persistence.recordConnect) see the full routing context. + final Session session = registry.createSession(UUID.randomUUID(), + remote == null ? "?" : remote.toString()); + + final LoginPipeline.Result login; + try { + login = LoginPipeline.run(client, config, router, journeys, session.registries); + } catch (LoginPipeline.UpstreamRejected rejected) { + LOGGER.info("upstream rejected login for {}: {}", remote, rejected.reason()); + metrics.loginFailures().increment(); + closeQuiet(client); + session.close(); + return; + } catch (IOException io) { + LOGGER.warn("login failed for {}", remote, io); + metrics.loginFailures().increment(); + closeQuiet(client); + session.close(); + return; + } + + try { + stampAndRun(client, session, login); + } catch (Throwable t) { + LOGGER.warn("connection setup failed for {}: {}", remote, t.toString()); + closeQuiet(client); + closeQuiet(login.upstream()); + session.close(); + } + } + + /// Runs after a successful [LoginPipeline] — stamps routing data, fires open listeners, + /// queues the synthetic login + (optional) SERVER_SWITCH, and submits the worker. Any + /// throw here is caught by the caller, which closes both sockets. + private void stampAndRun(SocketChannel client, Session session, LoginPipeline.Result login) throws IOException { + session.setBackendAddress(login.backend().address()); + + final boolean isStatus = login.handshake().intent() == ClientHandshakePacket.Intent.STATUS; + if (!isStatus) { + session.setJourneyId(login.consumedCookie() != null + ? login.consumedCookie().journeyId() : UUID.randomUUID()); + } + + // Listeners (ScopeSessionBridge.onSessionOpen → persistence.recordConnect) read the + // stamped backendAddress + journeyId, so this must run AFTER the setters above and + // BEFORE the SERVER_SWITCH mutate enqueue (so the lifecycle listener is registered). + registry.notifyOpened(session); + + // Transfer reconnect: adopt the cookie's player UUID + journey + publish SERVER_SWITCH. + // Bundle into a single Mutate so all three observations happen on the owner thread, + // after which session.playerUuid() resolves correctly for the lifecycle listener. + if (!isStatus && login.consumedCookie() != null) { + final JourneyTracker.Pending cookie = login.consumedCookie(); + final InetSocketAddress toAddress = login.backend().address(); + session.send(new SessionMessage.Mutate(p -> { + p.uuid = cookie.playerUuid(); + session.refreshPlayerUuid(); + registry.markLive(session); + session.publish(new SessionEvent.Lifecycle(session.lifecycle.record( + LifecycleEvent.Kind.SERVER_SWITCH, -1, + serverSwitchJson(cookie.fromAddress(), toAddress)))); + })); + } + + final long initialIoSeq = seedSyntheticLogin(session, login); + + final ConnectionWorker worker = new ConnectionWorker(registry, session, client, + login.upstream(), config, throttles, persistence, metrics, + login.initialClientBytes(), login.initialUpstreamBytes(), initialIoSeq); + + if (login.clientCipher() != null) worker.installClientCipher(login.clientCipher()); + if (login.upstreamCipher() != null) worker.installUpstreamCipher(login.upstreamCipher()); + + workersBySession.put(session.id, worker); + session.onClosed(() -> workersBySession.remove(session.id)); + workers.execute(worker); + } + + private static com.google.gson.JsonObject serverSwitchJson(@Nullable InetSocketAddress from, InetSocketAddress to) { + final com.google.gson.JsonObject o = new com.google.gson.JsonObject(); + if (from != null) o.addProperty("from", from.getHostString() + ":" + from.getPort()); + o.addProperty("to", to.getHostString() + ":" + to.getPort()); + return o; + } + + /// Inject everything [LoginPipeline] consumed from the wire as synthetic packets so the + /// session state machine, packet ring and persistence all match the on-wire reality the + /// worker is about to resume from. + /// + /// Always seeds the client handshake (every login flow consumes it). For online-mode + /// connections additionally seeds the LoginStart / EncryptionRequest / EncryptionResponse + /// [/ SetCompression] / LoginSuccess chain; for STATUS pings and offline-mode LOGIN / + /// TRANSFER the handshake alone is enough. + private long seedSyntheticLogin(Session session, LoginPipeline.Result result) { + session.clientCompressionThreshold = result.compressionThreshold(); + session.upstreamCompressionThreshold = result.compressionThreshold(); + + long ioSeq = recordSynthetic(session, 0, + new Synthetic(Direction.SERVERBOUND, ConnectionState.HANDSHAKE, result.handshake(), -1)); + + final var leg = result.clientLeg(); + if (leg == null) { + // STATUS or offline-mode LOGIN/TRANSFER — applySynthetic advanced the state via the + // handshake's intent; nothing else was consumed by the pipeline. + if (persistence != null) { + persistence.recordConnectInit(session.id, + session.clientToServerState, session.serverToClientState, -1); + } + return ioSeq; + } + + if (persistence != null) { + persistence.recordConnectInit(session.id, ConnectionState.HANDSHAKE, ConnectionState.HANDSHAKE, -1); + } + final int compression = result.compressionThreshold(); + ioSeq = recordSynthetic(session, ioSeq, + new Synthetic(Direction.SERVERBOUND, ConnectionState.LOGIN, leg.loginStart(), -1)); + ioSeq = recordSynthetic(session, ioSeq, + new Synthetic(Direction.CLIENTBOUND, ConnectionState.LOGIN, leg.encryptionRequest(), -1)); + ioSeq = recordSynthetic(session, ioSeq, + new Synthetic(Direction.SERVERBOUND, ConnectionState.LOGIN, leg.encryptionResponse(), -1)); + if (compression > 0) { + ioSeq = recordSynthetic(session, ioSeq, + new Synthetic(Direction.CLIENTBOUND, ConnectionState.LOGIN, new SetCompressionPacket(compression), -1)); + } + return recordSynthetic(session, ioSeq, + new Synthetic(Direction.CLIENTBOUND, ConnectionState.LOGIN, + new LoginSuccessPacket(leg.playerProfile()), compression > 0 ? compression : -1)); + } + + private long recordSynthetic(Session session, long ioSeq, Synthetic s) { + final long nextSeq = ioSeq + 1; + applySynthetic(session, s, nextSeq); + if (persistence != null) { + persistence.recordIo(session.id, nextSeq, HistoryFile.nowMs(), s.direction(), + PacketDecoder.encodeToBytes(session.registries, s.state(), s.packet(), s.threshold())); + } + return nextSeq; + } + + private void applySynthetic(Session session, Synthetic s, long ioEventSeq) { + switch (s.packet()) { + case ClientHandshakePacket handshake -> { + final var target = switch (handshake.intent()) { + case STATUS -> ConnectionState.STATUS; + case LOGIN, TRANSFER -> ConnectionState.LOGIN; + }; + session.clientToServerState = session.serverToClientState = target; + } + case LoginSuccessPacket _ -> session.serverToClientState = ConnectionState.CONFIGURATION; + default -> { } + } + // Queued; the worker's run() drains synthetics on its first iteration before wire I/O. + session.send(new SessionMessage.Mutate( + _ -> registry.applier().apply(session, s.direction(), s.state(), s.packet(), 0, ioEventSeq))); + } + + private record Synthetic(Direction direction, ConnectionState state, Packet packet, int threshold) {} + + private static Executor virtualExecutor(String prefix) { + return Executors.newThreadPerTaskExecutor(Thread.ofVirtual().name(prefix, 0).factory()); + } + + static void closeQuiet(SocketChannel c) { + try { c.close(); } catch (IOException _) {} + } + + @Override + public void close() { + running = false; + try { if (server != null) server.close(); } catch (IOException _) {} + registry.closeAll(); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/proxy/ThrottleManager.java b/web/src/main/java/net/minestom/web/internal/proxy/ThrottleManager.java new file mode 100644 index 00000000000..d54f1b171dd --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/ThrottleManager.java @@ -0,0 +1,102 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.web.Direction; +import net.minestom.web.Throttle; +import org.jetbrains.annotations.Nullable; + +import java.util.Collections; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +/// Per-process throttle policy store: one global profile plus a per-player overlay, consulted on +/// every byte chunk via [#delayFor]. +/// +/// Stream-level, never packet-level — it only sees a byte count, not contents. A null reference +/// at either layer is bypass; setters canonicalise no-op throttles (all knobs zero) to `null` so +/// the per-packet path never re-checks `isActive`. +public final class ThrottleManager { + + private volatile @Nullable Throttle global; + private final Map perPlayer = new ConcurrentHashMap<>(); + + public @Nullable Throttle global() { + return global; + } + + public void setGlobal(@Nullable Throttle throttle) { + this.global = (throttle != null && throttle.isActive()) ? throttle : null; + } + + public Map perPlayer() { + return Collections.unmodifiableMap(perPlayer); + } + + /// `null` or a no-op throttle clears any existing entry for `uuid`. + public void setForPlayer(UUID uuid, @Nullable Throttle throttle) { + if (uuid == null) return; + if (throttle == null || !throttle.isActive()) perPlayer.remove(uuid); + else perPlayer.put(uuid, throttle); + } + + /// Effective throttle for a connection. Per-player overrides global; global is the fallback; + /// returns `null` if neither applies. Setters canonicalise no-op throttles to `null`, so a + /// non-null map entry is always active. + public @Nullable Throttle resolve(@Nullable UUID playerUuid) { + final Throttle g = global; + // Hot path: zero connections throttled. Single volatile read + cheap sumCount on CHM. + if (g == null && perPlayer.isEmpty()) return null; + if (playerUuid != null) { + final Throttle t = perPlayer.get(playerUuid); + if (t != null) return t; + } + return g; + } + + /// Per-direction outgoing bookkeeping. Tracks the latest scheduled send time so jitter and + /// bandwidth spacing can't reorder bytes on the wire. + public static final class WorkerState { + private long nextSendNanos; + } + + /// How many nanoseconds the worker should hold this chunk of `bytes` before letting it leave + /// on `direction`. Returns 0 for "send now". Mutates `state.nextSendNanos` so subsequent + /// chunks on the same direction can't be scheduled to leave earlier than this one. + public long delayFor(WorkerState state, @Nullable UUID playerUuid, Direction direction, int bytes) { + final Throttle t = resolve(playerUuid); + if (t == null || !t.appliesTo(direction)) return 0L; + + final long now = System.nanoTime(); + long sendAt = now; + if (t.latencyMs() > 0 || t.jitterMs() > 0) { + int extra = t.jitterMs() > 0 ? ThreadLocalRandom.current().nextInt(t.jitterMs() + 1) : 0; + sendAt += (long) (t.latencyMs() + extra) * 1_000_000L; + } + sendAt = Math.max(sendAt, state.nextSendNanos); + + final long bps = t.bandwidthBytesPerSec(); + if (bps > 0L && bytes > 0) { + final long spacing = (long) bytes * 1_000_000_000L / bps; + state.nextSendNanos = sendAt + spacing; + } else { + state.nextSendNanos = sendAt; + } + + final long delay = sendAt - now; + return delay <= 0L ? 0L : delay; + } + + /// Shared scheduler that fires deferred writes back onto each connection's worker queue. + /// Single-threaded so tasks scheduled for the same instant run in submission order + /// (preserves per-connection FIFO when many connections all hit the same `sendAt`). + private static final ScheduledExecutorService DELAY = Executors.newSingleThreadScheduledExecutor( + Thread.ofVirtual().name("Minestom-Web-Throttle-Delay").factory()); + + public static void schedule(long delayNanos, Runnable task) { + DELAY.schedule(task, Math.max(0L, delayNanos), TimeUnit.NANOSECONDS); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/BlockModelResolver.java b/web/src/main/java/net/minestom/web/internal/renderer/BlockModelResolver.java new file mode 100644 index 00000000000..b0470629267 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/BlockModelResolver.java @@ -0,0 +1,84 @@ +package net.minestom.web.internal.renderer; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.Nullable; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/// Resolves `minecraft:block/` models into top / left / right face texture names. +final class BlockModelResolver { + private static final String MODELS = "/web/assets/models/block/"; + private static final int MAX_PARENT_DEPTH = 12; + + private final Map cache = new HashMap<>(); + + @Nullable IconRecipe resolve(String blockId) { + JsonObject model = load(blockId); + if (model == null) return null; + Map textures = new HashMap<>(); + mergeModel(model, textures, new HashSet<>(), 0); + if (textures.isEmpty()) return null; + String top = face(textures, "up", "top", "all", "particle"); + String left = face(textures, "west", "side", "north", "all", "particle"); + String right = face(textures, "east", "side", "south", "all", "particle"); + if (top == null && left == null && right == null) return null; + if (top == null) top = left != null ? left : right; + if (left == null) left = top; + if (right == null) right = left; + return IconRecipe.cube(top, left, right); + } + + private void mergeModel(JsonObject model, Map out, Set visiting, int depth) { + if (depth > MAX_PARENT_DEPTH) return; + String parent = IconResourceIds.stringOrNull(model.get("parent")); + if (parent != null) { + String parentPath = IconResourceIds.modelPath(parent); + if (parentPath != null && visiting.add(parentPath)) { + JsonObject parentModel = load(parentPath); + if (parentModel != null) mergeModel(parentModel, out, visiting, depth + 1); + visiting.remove(parentPath); + } + } + JsonObject tex = model.getAsJsonObject("textures"); + if (tex != null) { + for (Map.Entry e : tex.entrySet()) { + String resolved = resolveTextureRef(e.getValue().getAsString(), out); + if (resolved != null) out.put(e.getKey(), resolved); + } + } + } + + private static String face(Map textures, String... keys) { + for (String key : keys) { + String v = textures.get(key); + if (v != null) return IconResourceIds.bareTexture(v); + } + return null; + } + + private static @Nullable String resolveTextureRef(String raw, Map ctx) { + if (raw.startsWith("#")) { + return ctx.get(raw.substring(1)); + } + return IconResourceIds.bareTexture(raw); + } + + private @Nullable JsonObject load(String path) { + return cache.computeIfAbsent(path, p -> { + try (InputStream in = BlockModelResolver.class.getResourceAsStream(MODELS + p + ".json")) { + if (in == null) return null; + return JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject(); + } catch (Exception e) { + return null; + } + }); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/IconCanvas.java b/web/src/main/java/net/minestom/web/internal/renderer/IconCanvas.java new file mode 100644 index 00000000000..0e60febf9a8 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconCanvas.java @@ -0,0 +1,115 @@ +package net.minestom.web.internal.renderer; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/// Shared high-resolution nearest-neighbour canvas for item and block-entity icon renderers. +/// Quads rasterize via barycentric interpolation (p0→uv00, p1→uv10, p2→uv11, p3→uv01). +final class IconCanvas { + static final int OUT = 32; + static final int RENDER = 128; + + private final int[] pixels = new int[RENDER * RENDER]; + + void quad(BufferedImage texture, + double x0, double y0, double x1, double y1, double x2, double y2, double x3, double y3, + int u0, int v0, int u1, int v1, float brightness) { + int minX = Math.max(0, (int) Math.floor(Math.min(Math.min(x0, x1), Math.min(x2, x3)))); + int maxX = Math.min(RENDER - 1, (int) Math.ceil(Math.max(Math.max(x0, x1), Math.max(x2, x3)))); + int minY = Math.max(0, (int) Math.floor(Math.min(Math.min(y0, y1), Math.min(y2, y3)))); + int maxY = Math.min(RENDER - 1, (int) Math.ceil(Math.max(Math.max(y0, y1), Math.max(y2, y3)))); + + int tw = texture.getWidth(), th = texture.getHeight(); + float uScale = (u1 - u0) / (float) tw; + float vScale = (v1 - v0) / (float) th; + float uOff = u0 / (float) tw; + float vOff = v0 / (float) th; + + final double[] uv = new double[2]; // reused across every pixel in this quad + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + if (!barycentric(uv, x + 0.5, y + 0.5, x0, y0, x1, y1, x2, y2, x3, y3)) continue; + int tx = Math.clamp((int) ((uOff + uv[0] * uScale) * tw), 0, tw - 1); + int ty = Math.clamp((int) ((vOff + uv[1] * vScale) * th), 0, th - 1); + int argb = texture.getRGB(tx, ty); + if (((argb >>> 24) & 0xFF) == 0) continue; + int i = y * RENDER + x; + pixels[i] = brightness >= 0.999f ? argb : shade(argb, brightness, pixels[i]); + } + } + } + + static byte[] cube(BufferedImage top, BufferedImage left, BufferedImage right) throws IOException { + IconCanvas c = new IconCanvas(); + c.quad(top, 8, 40, 64, 8, 120, 40, 64, 72, + 0, 0, top.getWidth(), top.getHeight(), 0.74f); + c.quad(left, 8, 40, 64, 72, 64, 128, 8, 96, + 0, 0, left.getWidth(), left.getHeight(), 0.52f); + c.quad(right, 64, 72, 120, 40, 120, 96, 64, 128, + 0, 0, right.getWidth(), right.getHeight(), 0.64f); + return c.png(); + } + + byte[] png() throws IOException { + // Nearest-neighbour downsample straight from the int[] — no intermediate full-res image. + BufferedImage out = new BufferedImage(OUT, OUT, BufferedImage.TYPE_INT_ARGB); + for (int y = 0; y < OUT; y++) { + for (int x = 0; x < OUT; x++) { + out.setRGB(x, y, pixels[(y * RENDER / OUT) * RENDER + (x * RENDER / OUT)]); + } + } + ByteArrayOutputStream baos = new ByteArrayOutputStream(2048); + ImageIO.write(out, "png", baos); + return baos.toByteArray(); + } + + private static int shade(int argb, float brightness, int under) { + int a = (argb >>> 24) & 0xFF; + if (a == 0) return under; + int r = (int) (((argb >>> 16) & 0xFF) * brightness); + int g = (int) (((argb >>> 8) & 0xFF) * brightness); + int b = (int) ((argb & 0xFF) * brightness); + int out = (a << 24) | (r << 16) | (g << 8) | b; + if (under == 0) return out; + int ua = (under >>> 24) & 0xFF; + if (ua == 0) return out; + int inv = 255 - a; + int or = (under >>> 16) & 0xFF, og = (under >>> 8) & 0xFF, ob = under & 0xFF; + return (Math.min(255, a + inv * ua / 255) << 24) + | ((r * a + or * inv) / 255 << 16) + | ((g * a + og * inv) / 255 << 8) + | ((b * a + ob * inv) / 255); + } + + /// Writes the (u, v) weights into `uv` and returns true on a hit; false (uv untouched) for a + /// degenerate quad or a point outside both triangles. + private static boolean barycentric(double[] uv, double px, double py, + double x0, double y0, double x1, double y1, + double x2, double y2, double x3, double y3) { + if (tri(uv, px, py, x0, y0, x1, y1, x3, y3)) return true; + double d = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); + if (Math.abs(d) < 1e-6) return false; + double w0 = ((y2 - y3) * (px - x3) + (x3 - x2) * (py - y3)) / d; + double w1 = ((y3 - y1) * (px - x3) + (x1 - x3) * (py - y3)) / d; + double w2 = 1.0 - w0 - w1; + if (w0 < -0.001 || w1 < -0.001 || w2 < -0.001) return false; + uv[0] = w0 + w1; + uv[1] = w1 + w2; + return true; + } + + private static boolean tri(double[] uv, double px, double py, + double x0, double y0, double x1, double y1, double x2, double y2) { + double d = (y1 - y2) * (x0 - x2) + (x2 - x1) * (y0 - y2); + if (Math.abs(d) < 1e-6) return false; + double w0 = ((y1 - y2) * (px - x2) + (x2 - x1) * (py - y2)) / d; + double w1 = ((y2 - y0) * (px - x2) + (x0 - x2) * (py - y2)) / d; + double w2 = 1.0 - w0 - w1; + if (w0 < -0.001 || w1 < -0.001 || w2 < -0.001) return false; + uv[0] = w1; + uv[1] = w2; + return true; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/IconCatalog.java b/web/src/main/java/net/minestom/web/internal/renderer/IconCatalog.java new file mode 100644 index 00000000000..13a8c27268e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconCatalog.java @@ -0,0 +1,235 @@ +package net.minestom.web.internal.renderer; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.JarURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Enumeration; +import java.util.HashMap; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/// Builds a material-id → [IconRecipe] map from extracted vanilla `items/*.json` definitions. +final class IconCatalog { + private static final Logger LOGGER = LoggerFactory.getLogger(IconCatalog.class); + private static final String ITEMS = "/web/assets/items/"; + private static final String ITEM_MODELS = "/web/assets/models/item/"; + + private final Map recipes = new HashMap<>(); + private final BlockModelResolver blockModels = new BlockModelResolver(); + + private IconCatalog() {} + + static IconCatalog load() { + IconCatalog catalog = new IconCatalog(); + catalog.scanItems(); + LOGGER.info("Icon catalog: {} recipes from item definitions", catalog.recipes.size()); + return catalog; + } + + @Nullable IconRecipe recipe(String bareId) { + return recipes.get(bareId); + } + + private void scanItems() { + try { + URL itemsRoot = IconCatalog.class.getResource(ITEMS); + if (itemsRoot == null) return; + if ("jar".equals(itemsRoot.getProtocol())) { + scanJarItems(itemsRoot); + } else { + scanFileItems(itemsRoot); + } + } catch (Exception e) { + LOGGER.warn("Icon catalog: failed to scan item definitions: {}", e.toString()); + } + } + + private void scanJarItems(URL jarUrl) throws Exception { + JarURLConnection conn = (JarURLConnection) jarUrl.openConnection(); + try (JarFile jar = conn.getJarFile()) { + String prefix = conn.getEntryName(); + if (prefix == null) return; + if (!prefix.endsWith("/")) prefix += "/"; + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + String name = entries.nextElement().getName(); + if (!name.startsWith(prefix) || !name.endsWith(".json")) continue; + parseItem(name.substring(prefix.length(), name.length() - 5)); + } + } + } + + private void scanFileItems(URL dirUrl) throws Exception { + Path root = Path.of(dirUrl.toURI()); + try (var stream = Files.walk(root)) { + stream.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + String rel = root.relativize(p).toString().replace('\\', '/'); + parseItem(rel.substring(0, rel.length() - 5)); + }); + } + } + + private void parseItem(String bareId) { + try (InputStream in = IconCatalog.class.getResourceAsStream(ITEMS + bareId + ".json")) { + if (in == null) return; + JsonObject root = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject model = root.getAsJsonObject("model"); + if (model == null) return; + IconRecipe recipe = resolveModel(model); + if (recipe != null) recipes.put(bareId, recipe); + } catch (Exception ignored) { + } + } + + private @Nullable IconRecipe resolveModel(JsonObject model) { + String type = IconResourceIds.stringOrNull(model.get("type")); + if (type == null) return null; + return switch (type) { + case "minecraft:model" -> resolvePathModel(IconResourceIds.stringOrNull(model.get("model"))); + case "minecraft:bed" -> IconRecipe.bed(IconResourceIds.stripNamespace(IconResourceIds.stringOrNull(model.get("texture")))); + case "minecraft:banner" -> IconRecipe.banner(IconResourceIds.stringOrNull(model.get("color"))); + case "minecraft:chest" -> chestRecipe(IconResourceIds.stripNamespace(IconResourceIds.stringOrNull(model.get("texture")))); + case "minecraft:shulker_box" -> shulkerRecipe(IconResourceIds.stripNamespace(IconResourceIds.stringOrNull(model.get("texture")))); + case "minecraft:copper_golem_statue" -> IconRecipe.copperGolemStatue(texturePath(IconResourceIds.stringOrNull(model.get("texture")))); + case "minecraft:boat", "minecraft:chest_boat" -> boatRecipe(type, IconResourceIds.stripNamespace(IconResourceIds.stringOrNull(model.get("texture")))); + case "minecraft:head" -> headRecipe(model); + case "minecraft:player_head" -> resolvePathModel(IconResourceIds.stringOrNull(model.get("base"))); + case "minecraft:shield" -> IconRecipe.entitySprite("entity/shield/shield_base_nopattern"); + case "minecraft:conduit" -> IconRecipe.entitySprite("entity/conduit/wind"); + case "minecraft:decorated_pot" -> IconRecipe.decoratedPot(); + case "minecraft:bell" -> IconRecipe.entitySprite("entity/bell/bell_body"); + case "minecraft:composite" -> resolveComposite(model.getAsJsonArray("models")); + case "minecraft:select" -> resolveModel(model.getAsJsonObject("fallback")); + case "minecraft:condition" -> { + JsonObject whenTrue = model.getAsJsonObject("on_true"); + yield whenTrue != null ? resolveModel(whenTrue) : resolveModel(model.getAsJsonObject("on_false")); + } + case "minecraft:range_dispatch" -> resolveModel(model.getAsJsonObject("fallback")); + case "minecraft:constant" -> resolveModel(model.getAsJsonObject("value")); + case "minecraft:dye", "minecraft:grass", "minecraft:map_color", "minecraft:potion", "minecraft:trident" -> + resolvePathModel(IconResourceIds.stringOrNull(model.get("base"))); + case "minecraft:special" -> { + JsonObject inner = model.getAsJsonObject("model"); + yield inner != null ? resolveModel(inner) : resolvePathModel(IconResourceIds.stringOrNull(model.get("base"))); + } + default -> null; + }; + } + + private @Nullable IconRecipe resolveComposite(@Nullable JsonArray models) { + if (models == null) return null; + for (JsonElement el : models) { + if (!el.isJsonObject()) continue; + IconRecipe r = resolveModel(el.getAsJsonObject()); + if (r != null) return r; + } + return null; + } + + private @Nullable IconRecipe resolvePathModel(@Nullable String path) { + if (path == null) return null; + String p = path.startsWith("minecraft:") ? path.substring("minecraft:".length()) : path; + if (p.startsWith("block/")) { + String blockId = p.substring("block/".length()); + IconRecipe fromModel = blockModels.resolve(blockId); + if (fromModel != null) return fromModel; + return IconRecipe.cube(blockId, blockId, blockId); + } + if (p.startsWith("item/")) { + return resolveItemModel(p.substring("item/".length())); + } + return null; + } + + private @Nullable IconRecipe resolveItemModel(String itemModelId) { + try (InputStream in = IconCatalog.class.getResourceAsStream(ITEM_MODELS + itemModelId + ".json")) { + if (in == null) return null; + JsonObject model = JsonParser.parseReader(new InputStreamReader(in, StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject textures = model.getAsJsonObject("textures"); + if (textures != null) { + String layer0 = IconResourceIds.stringOrNull(textures.get("layer0")); + if (layer0 != null) { + String tex = IconResourceIds.bareTexture(layer0); + if (tex != null && IconCatalog.class.getResource("/web/assets/textures/item/" + tex + ".png") != null) { + return IconRecipe.flatItem(tex); + } + } + } + String parent = IconResourceIds.stringOrNull(model.get("parent")); + if (parent != null && parent.contains("template_bed")) { + String color = colorFromId(itemModelId); + if (color != null) return IconRecipe.bed(color); + } + if (parent != null && parent.contains("template_banner")) { + String color = colorFromId(itemModelId); + if (color != null) return IconRecipe.banner(color); + } + if (parent != null) return resolvePathModel(parent); + } catch (Exception ignored) { + } + return null; + } + + private static @Nullable IconRecipe chestRecipe(@Nullable String variant) { + if (variant == null) variant = "normal"; + return IconRecipe.chest("entity/chest/" + variant); + } + + private static @Nullable IconRecipe shulkerRecipe(@Nullable String color) { + if (color == null || color.equals("shulker")) { + return IconRecipe.shulkerBox("entity/shulker/shulker"); + } + return IconRecipe.shulkerBox("entity/shulker/" + color); + } + + private static @Nullable IconRecipe boatRecipe(String type, @Nullable String wood) { + if (wood == null) return null; + String path = type.equals("minecraft:chest_boat") + ? "entity/chest_boat/" + wood + : "entity/boat/" + wood; + return IconRecipe.entitySprite(path); + } + + private static @Nullable IconRecipe headRecipe(JsonObject model) { + String kind = IconResourceIds.stringOrNull(model.get("kind")); + return switch (kind == null ? "skeleton" : kind) { + case "skeleton" -> IconRecipe.head("entity/skeleton/skeleton"); + case "wither_skeleton" -> IconRecipe.head("entity/skeleton/wither_skeleton"); + case "zombie" -> IconRecipe.head("entity/zombie/zombie"); + case "creeper" -> IconRecipe.head("entity/creeper/creeper"); + case "piglin" -> IconRecipe.head("entity/piglin/piglin"); + case "dragon" -> IconRecipe.head("entity/enderdragon/dragon"); + default -> IconRecipe.head("entity/skeleton/skeleton"); + }; + } + + private static @Nullable String colorFromId(String itemModelId) { + for (String c : IconConstants.COLOURS) { + if (itemModelId.startsWith(c + "_")) return c; + } + return null; + } + + private static @Nullable String texturePath(@Nullable String raw) { + String s = IconResourceIds.stripNamespace(raw); + if (s == null) return null; + if (s.startsWith("textures/")) s = s.substring("textures/".length()); + if (s.endsWith(".png")) s = s.substring(0, s.length() - 4); + return s; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/IconConstants.java b/web/src/main/java/net/minestom/web/internal/renderer/IconConstants.java new file mode 100644 index 00000000000..861f907fb70 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconConstants.java @@ -0,0 +1,11 @@ +package net.minestom.web.internal.renderer; + +final class IconConstants { + static final String[] COLOURS = { + "white", "light_gray", "gray", "black", "brown", "red", "orange", "yellow", + "lime", "green", "cyan", "light_blue", "blue", "purple", "magenta", "pink", + }; + + private IconConstants() { + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/IconRecipe.java b/web/src/main/java/net/minestom/web/internal/renderer/IconRecipe.java new file mode 100644 index 00000000000..ee22854daa9 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconRecipe.java @@ -0,0 +1,59 @@ +package net.minestom.web.internal.renderer; + +import org.jetbrains.annotations.Nullable; + +/// Resolved render strategy for a single material id. +record IconRecipe(Kind kind, @Nullable String a, @Nullable String b, @Nullable String c) { + public enum Kind { + FLAT, + CUBE, + ENTITY_SPRITE, + BANNER, + BED, + CHEST, + HEAD, + SHULKER_BOX, + DECORATED_POT, + COPPER_GOLEM_STATUE, + } + + public static IconRecipe flatItem(String itemTexture) { + return new IconRecipe(Kind.FLAT, itemTexture, null, null); + } + + public static IconRecipe cube(String top, String left, String right) { + return new IconRecipe(Kind.CUBE, top, left, right); + } + + public static IconRecipe entitySprite(String entityPath) { + return new IconRecipe(Kind.ENTITY_SPRITE, entityPath, null, null); + } + + public static IconRecipe banner(String color) { + return new IconRecipe(Kind.BANNER, color, null, null); + } + + public static IconRecipe bed(String color) { + return new IconRecipe(Kind.BED, color, null, null); + } + + public static IconRecipe chest(String texture) { + return new IconRecipe(Kind.CHEST, texture, null, null); + } + + public static IconRecipe head(String texture) { + return new IconRecipe(Kind.HEAD, texture, null, null); + } + + public static IconRecipe shulkerBox(String texture) { + return new IconRecipe(Kind.SHULKER_BOX, texture, null, null); + } + + public static IconRecipe decoratedPot() { + return new IconRecipe(Kind.DECORATED_POT, null, null, null); + } + + public static IconRecipe copperGolemStatue(String texture) { + return new IconRecipe(Kind.COPPER_GOLEM_STATUE, texture, null, null); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/IconResourceIds.java b/web/src/main/java/net/minestom/web/internal/renderer/IconResourceIds.java new file mode 100644 index 00000000000..93f8f3ff138 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconResourceIds.java @@ -0,0 +1,39 @@ +package net.minestom.web.internal.renderer; + +import com.google.gson.JsonElement; +import org.jetbrains.annotations.Nullable; + +/// Shared id/texture-reference cleanup helpers for the icon resolvers ([IconCatalog], +/// [BlockModelResolver]): strip the `minecraft:` namespace and `block/`/`item/` prefixes off +/// resource ids and read string JSON values defensively. +final class IconResourceIds { + + private IconResourceIds() {} + + /// Strip `minecraft:` and a leading `block/` or `item/` segment off a texture reference. + static @Nullable String bareTexture(String raw) { + return strip(raw, "block/", "item/"); + } + + /// Strip the `minecraft:` namespace off an id, leaving any path prefix intact. + static @Nullable String stripNamespace(@Nullable String raw) { + if (raw == null) return null; + return raw.startsWith("minecraft:") ? raw.substring("minecraft:".length()) : raw; + } + + /// Strip `minecraft:` and a leading `block/` segment off a model id (model paths keep `item/`). + static @Nullable String modelPath(String id) { + return strip(id, "block/"); + } + + /// Strip the `minecraft:` namespace, then each of `prefixes` (in order) once if present. + private static @Nullable String strip(String raw, String... prefixes) { + String s = raw.startsWith("minecraft:") ? raw.substring("minecraft:".length()) : raw; + for (String prefix : prefixes) if (s.startsWith(prefix)) s = s.substring(prefix.length()); + return s.isEmpty() ? null : s; + } + + static @Nullable String stringOrNull(@Nullable JsonElement el) { + return el == null || el.isJsonNull() ? null : el.getAsString(); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/ItemIconRenderer.java b/web/src/main/java/net/minestom/web/internal/renderer/ItemIconRenderer.java new file mode 100644 index 00000000000..9c1d109f182 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/ItemIconRenderer.java @@ -0,0 +1,258 @@ +package net.minestom.web.internal.renderer; + +import net.minestom.server.item.Material; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; + +/// Renders 32×32 isometric PNG icons for any [Material]. Block-entity items (beds, chests, +/// shulker boxes, heads, decorated pots, copper-golem statues) get bespoke quad projections; +/// everything else either reads a flat sprite or composes a 3-face cube via [IconCanvas]. +public final class ItemIconRenderer { + private static final Logger LOGGER = LoggerFactory.getLogger(ItemIconRenderer.class); + private static final byte[] MISSING = new byte[0]; + private static final java.util.regex.Pattern SAFE_ID = java.util.regex.Pattern.compile("[a-z0-9_]+"); + + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + private final IconCatalog catalog; + + public ItemIconRenderer() { + this.catalog = IconCatalog.load(); + } + + public byte[] iconFor(String id) { + if (id == null) return null; + final String bare = bareId(id); + if (!SAFE_ID.matcher(bare).matches()) return null; + final byte[] cached = cache.computeIfAbsent(bare, this::render); + return cached.length == 0 ? null : cached; + } + + public void warm() { + long t0 = System.currentTimeMillis(); + int n = 0; + for (Material m : Material.values()) { + try { + iconFor(m.key().value()); + n++; + } catch (Exception _) { + } + } + LOGGER.info("Item icons warmed: {} materials in {} ms", n, System.currentTimeMillis() - t0); + } + + private byte[] render(String bare) { + try { + byte[] flat = TextureResources.readBytes(TextureResources.ROOT + "/item/" + bare + ".png"); + if (flat != null) return flat; + + IconRecipe recipe = catalog.recipe(bare); + if (recipe != null) { + byte[] fromRecipe = renderRecipe(recipe); + if (fromRecipe != null) return fromRecipe; + } + + BufferedImage top = loadBlockFace(bare, "top", "up", "end", "front"); + BufferedImage side = loadBlockFace(bare, "side", "north", "west"); + BufferedImage all = TextureResources.load("block/" + bare); + if (top == null) top = all != null ? all : side; + if (side == null) side = all != null ? all : top; + if (top == null) top = coloredWoolFallback(bare); + if (top == null) return MISSING; + if (side == null) side = top; + return IconCanvas.cube(top, side, side); + } catch (Exception e) { + LOGGER.debug("Icon render failed for {}: {}", bare, e.toString()); + return MISSING; + } + } + + private byte @Nullable [] renderRecipe(IconRecipe recipe) throws IOException { + return switch (recipe.kind()) { + case FLAT -> TextureResources.readBytes(TextureResources.ROOT + "/item/" + recipe.a() + ".png"); + case CUBE -> { + BufferedImage top = TextureResources.load("block/" + recipe.a()); + BufferedImage left = TextureResources.load("block/" + recipe.b()); + BufferedImage right = TextureResources.load("block/" + recipe.c()); + if (top == null && left == null && right == null) yield null; + if (top == null) top = left != null ? left : right; + if (left == null) left = top; + if (right == null) right = left; + yield IconCanvas.cube(top, left, right); + } + case ENTITY_SPRITE -> { + BufferedImage img = TextureResources.load(recipe.a()); + yield img == null ? null : SpriteIcons.scale(img); + } + case BANNER -> TextureResources.readBytes(TextureResources.ROOT + "/map/decorations/" + recipe.a() + "_banner.png"); + case BED -> { + BufferedImage img = TextureResources.load("entity/bed/" + recipe.a()); + yield img == null ? null : renderBed(img); + } + case CHEST -> { + BufferedImage img = TextureResources.load(recipe.a()); + yield img == null ? null : renderChest(img); + } + case HEAD -> { + BufferedImage img = TextureResources.load(recipe.a()); + yield img == null ? null : renderHead(img); + } + case SHULKER_BOX -> { + BufferedImage img = TextureResources.load(recipe.a()); + yield img == null ? null : renderShulkerBox(img); + } + case DECORATED_POT -> { + BufferedImage tex = TextureResources.load("block/terracotta"); + yield tex == null ? null : renderDecoratedPot(tex); + } + case COPPER_GOLEM_STATUE -> { + BufferedImage img = TextureResources.load(recipe.a()); + yield img == null ? null : renderCopperGolemStatue(img); + } + }; + } + + private @Nullable BufferedImage coloredWoolFallback(String bare) { + for (String c : IconConstants.COLOURS) { + if (bare.startsWith(c + "_")) return TextureResources.load("block/" + c + "_wool"); + } + return null; + } + + private @Nullable BufferedImage loadBlockFace(String bare, String... suffixes) { + for (String suffix : suffixes) { + BufferedImage img = TextureResources.load("block/" + bare + "_" + suffix); + if (img != null) return img; + } + return TextureResources.load("block/" + bare); + } + + private static String bareId(String id) { + Objects.requireNonNull(id, "id"); + int colon = id.indexOf(':'); + return colon >= 0 ? id.substring(colon + 1) : id; + } + + // ---- block-entity quad projections --------------------------------------------------- + // Compact stand-ins for the vanilla block-entity models — each projects a handful of + // textured quads into a 128px canvas, then [IconCanvas] downsamples to 32px. + + private static byte[] renderBed(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + + c.quad(texture, 12, 58, 68, 88, 68, 104, 12, 74, + 16, 0, 22, 16, 0.55f); + c.quad(texture, 68, 88, 124, 58, 124, 74, 68, 104, + 16, 22, 22, 38, 0.68f); + + // Mojang bed model pieces: head texOffs(0, 0), foot texOffs(0, 22). + c.quad(texture, 52, 38, 80, 24, 124, 58, 94, 74, + 0, 22, 16, 38, 0.88f); + c.quad(texture, 30, 50, 52, 38, 94, 74, 68, 88, + 0, 6, 16, 16, 0.88f); + c.quad(texture, 12, 58, 30, 50, 68, 88, 48, 99, + 0, 0, 16, 6, 0.98f); + + c.quad(texture, 12, 74, 68, 104, 68, 112, 12, 82, + 16, 0, 22, 16, 0.50f); + c.quad(texture, 77, 96, 89, 89, 89, 105, 77, 112, + 50, 0, 53, 3, 0.62f); + c.quad(texture, 109, 72, 121, 66, 121, 82, 109, 88, + 50, 12, 53, 15, 0.66f); + + return c.png(); + } + + private static byte[] renderChest(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + int w = texture.getWidth(), h = texture.getHeight(); + int u0 = w / 4, u1 = w / 2, u2 = Math.min(w, w * 3 / 4); + int top0 = 0, top1 = Math.max(1, h / 4); + int side0 = Math.max(1, h * 5 / 16), side1 = Math.max(side0 + 1, h * 9 / 16); + int front0 = Math.max(1, h * 33 / 64), front1 = Math.max(front0 + 1, h * 45 / 64); + + c.quad(texture, 20, 42, 64, 17, 108, 42, 64, 68, u0, top0, u1, top1, 1f); + c.quad(texture, 20, 42, 64, 68, 64, 112, 20, 88, 0, side0, u0, side1, 0.74f); + c.quad(texture, 64, 68, 108, 42, 108, 88, 64, 112, u0, front0, u2, front1, 0.88f); + c.quad(texture, 58, 68, 71, 61, 71, 77, 58, 84, u1, side0, u2, side1, 0.68f); + return c.png(); + } + + private static byte[] renderShulkerBox(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + BufferedImage cropped = SpriteIcons.tightCrop(texture, 0.02f); + box(c, cropped, 24, 25, 104, 105, 1f, 0.72f, 0.86f); + // Slightly raised lid line, like the in-game model, so shulkers do not read as wool cubes. + c.quad(cropped, 22, 42, 64, 18, 106, 42, 64, 66, + 0, 0, cropped.getWidth(), Math.max(1, cropped.getHeight() / 3), 1f); + return c.png(); + } + + private static byte[] renderHead(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + int u0 = Math.min(texture.getWidth() - 1, 8); + int v0 = Math.min(texture.getHeight() - 1, 8); + int u1 = Math.min(texture.getWidth(), 16); + int v1 = Math.min(texture.getHeight(), 16); + if (texture.getWidth() >= 128) { + u0 = texture.getWidth() * 3 / 8; + v0 = texture.getHeight() / 8; + u1 = texture.getWidth() * 5 / 8; + v1 = texture.getHeight() * 3 / 8; + } + if (u1 <= u0 || v1 <= v0) { + u0 = v0 = 0; + u1 = texture.getWidth(); + v1 = texture.getHeight(); + } + c.quad(texture, 32, 44, 64, 26, 96, 44, 64, 62, u0, v0, u1, v1, 1f); + c.quad(texture, 32, 44, 64, 62, 64, 96, 32, 78, u0, v0, u1, v1, 0.72f); + c.quad(texture, 64, 62, 96, 44, 96, 78, 64, 96, u0, v0, u1, v1, 0.86f); + return c.png(); + } + + private static byte[] renderDecoratedPot(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + c.quad(texture, 37, 47, 64, 32, 91, 47, 64, 63, + 0, 0, texture.getWidth(), texture.getHeight(), 1f); + c.quad(texture, 31, 52, 64, 71, 64, 112, 31, 92, + 0, 0, texture.getWidth(), texture.getHeight(), 0.74f); + c.quad(texture, 64, 71, 97, 52, 97, 92, 64, 112, + 0, 0, texture.getWidth(), texture.getHeight(), 0.88f); + return c.png(); + } + + private static byte[] renderCopperGolemStatue(BufferedImage texture) throws IOException { + IconCanvas c = new IconCanvas(); + // Body and head use visible texture atlas regions; exact pose animation is irrelevant for + // inventory but the silhouette follows the block-entity renderer's upright statue framing. + box(c, texture, 38, 48, 90, 106, 0.96f, 0.70f, 0.84f); + c.quad(texture, 38, 31, 64, 16, 90, 31, 64, 46, 0, 0, 16, 16, 1f); + c.quad(texture, 38, 31, 64, 46, 64, 64, 38, 49, 16, 16, 32, 32, 0.72f); + c.quad(texture, 64, 46, 90, 31, 90, 49, 64, 64, 16, 16, 32, 32, 0.86f); + return c.png(); + } + + private static void box(IconCanvas c, BufferedImage texture, + int left, int top, int right, int bottom, + float topBrightness, float leftBrightness, float rightBrightness) { + int midX = (left + right) / 2; + int shoulderY = top + (bottom - top) / 4; + int centerY = top + (bottom - top) / 2; + int footY = bottom - (bottom - top) / 4; + int uMax = texture.getWidth(); + int vMax = texture.getHeight(); + + c.quad(texture, left, shoulderY, midX, top, right, shoulderY, midX, centerY, + 0, 0, uMax, Math.max(1, vMax / 3), topBrightness); + c.quad(texture, left, shoulderY, midX, centerY, midX, bottom, left, footY, + 0, vMax / 3, Math.max(1, uMax / 2), vMax, leftBrightness); + c.quad(texture, midX, centerY, right, shoulderY, right, footY, midX, bottom, + uMax / 2, vMax / 3, uMax, vMax, rightBrightness); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/MinimapRasterizer.java b/web/src/main/java/net/minestom/web/internal/renderer/MinimapRasterizer.java new file mode 100644 index 00000000000..2fdc33d227f --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/MinimapRasterizer.java @@ -0,0 +1,34 @@ +package net.minestom.web.internal.renderer; + +import net.minestom.web.internal.state.BlockColors; + +import static net.minestom.web.PlayerWorld.COLUMNS_PER_CHUNK; +import static net.minestom.web.PlayerWorld.UNKNOWN; +import static net.minestom.web.PlayerWorld.UNKNOWN_COLOR; + +/// Rasterizes a chunk column bundle into a 16×16 RGBA tile (one pixel per block column). +public final class MinimapRasterizer { + static final int TILE = 16; + static final int BYTES = TILE * TILE * 4; + + private MinimapRasterizer() { + } + + public static byte[] rasterize(short[] heights, int[] colors) { + final byte[] out = new byte[BYTES]; + if (heights == null) return out; + for (int z = 0; z < TILE; z++) { + for (int x = 0; x < TILE; x++) { + final int idx = (z << 4) | x; + final int o = idx * 4; + final int packed = heights[idx] == UNKNOWN ? BlockColors.VOID + : (colors == null || colors[idx] == UNKNOWN_COLOR ? BlockColors.UNKNOWN : colors[idx]); + out[o] = (byte) ((packed >> 16) & 0xFF); + out[o + 1] = (byte) ((packed >> 8) & 0xFF); + out[o + 2] = (byte) (packed & 0xFF); + out[o + 3] = (byte) 255; + } + } + return out; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/SpriteIcons.java b/web/src/main/java/net/minestom/web/internal/renderer/SpriteIcons.java new file mode 100644 index 00000000000..337d2162143 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/SpriteIcons.java @@ -0,0 +1,52 @@ +package net.minestom.web.internal.renderer; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +/// Scales entity / atlas sprites into square inventory icons. +final class SpriteIcons { + private static final int OUT = 32; + + private SpriteIcons() { + } + + static byte[] scale(BufferedImage src) throws IOException { + BufferedImage crop = tightCrop(src, 0.02f); + int sw = crop.getWidth(), sh = crop.getHeight(); + if (sw <= 0 || sh <= 0) return new byte[0]; + double scale = Math.min((OUT - 2.0) / sw, (OUT - 2.0) / sh); + int dw = Math.max(1, (int) Math.round(sw * scale)); + int dh = Math.max(1, (int) Math.round(sh * scale)); + BufferedImage out = new BufferedImage(OUT, OUT, BufferedImage.TYPE_INT_ARGB); + Graphics2D g = out.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF); + int ox = (OUT - dw) / 2; + int oy = (OUT - dh) / 2; + g.drawImage(crop, ox, oy, ox + dw, oy + dh, 0, 0, sw, sh, null); + g.dispose(); + ByteArrayOutputStream baos = new ByteArrayOutputStream(2048); + ImageIO.write(out, "png", baos); + return baos.toByteArray(); + } + + static BufferedImage tightCrop(BufferedImage src, float alphaThreshold) { + int w = src.getWidth(), h = src.getHeight(); + int minX = w, minY = h, maxX = 0, maxY = 0; + for (int y = 0; y < h; y++) { + for (int x = 0; x < w; x++) { + int a = (src.getRGB(x, y) >>> 24) & 0xFF; + if (a <= (int) (alphaThreshold * 255)) continue; + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + } + if (maxX < minX || maxY < minY) return src; + return src.getSubimage(minX, minY, maxX - minX + 1, maxY - minY + 1); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/renderer/TextureResources.java b/web/src/main/java/net/minestom/web/internal/renderer/TextureResources.java new file mode 100644 index 00000000000..c02790a4684 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/TextureResources.java @@ -0,0 +1,32 @@ +package net.minestom.web.internal.renderer; + +import org.jetbrains.annotations.Nullable; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; + +final class TextureResources { + static final String ROOT = "/web/assets/textures"; + + private TextureResources() {} + + @Nullable + static BufferedImage load(String path) { + try (InputStream in = TextureResources.class.getResourceAsStream(ROOT + "/" + path + ".png")) { + return in == null ? null : ImageIO.read(in); + } catch (IOException e) { + return null; + } + } + + static byte @Nullable [] readBytes(String path) { + try (InputStream in = TextureResources.class.getResourceAsStream(path)) { + if (in == null) return null; + return in.readAllBytes(); + } catch (IOException e) { + return null; + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/replay/PacketSeqResolver.java b/web/src/main/java/net/minestom/web/internal/replay/PacketSeqResolver.java new file mode 100644 index 00000000000..f7ec432dd08 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/replay/PacketSeqResolver.java @@ -0,0 +1,236 @@ +package net.minestom.web.internal.replay; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.PacketReading; +import net.minestom.web.Direction; +import net.minestom.web.PacketRecord; +import net.minestom.web.internal.Uuids; +import net.minestom.web.internal.codec.PacketDecoder; +import net.minestom.web.internal.persist.HistoryFile; +import net.minestom.web.internal.session.Session; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +import static net.minestom.server.network.NetworkBuffer.VAR_INT; + +/// Resolves a [PacketRecord] by [PacketRecord#seq] from SQLite. Replays `io_events` from the +/// nearest `packet_checkpoints` row when present; otherwise from the start. +public final class PacketSeqResolver { + private static final int CACHE_MAX = 512; + private static final Map CACHE = new LinkedHashMap<>(CACHE_MAX, 0.75f, true) { + @Override protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > CACHE_MAX; + } + }; + + private PacketSeqResolver() {} + + public static @Nullable PacketRecord resolve(Path sqlitePath, UUID connectionId, long packetSeq) + throws SQLException { + if (sqlitePath == null || connectionId == null || packetSeq <= 0) return null; + + // Cache key includes mtime + size so a path reused across uploads (replay tempfile + // recycling, JVM rerun) doesn't return stale frames from the previous file. + final String key = cacheKey(sqlitePath, connectionId, packetSeq); + if (key != null) { + synchronized (CACHE) { + final PacketRecord hit = CACHE.get(key); + if (hit != null) return hit; + } + } + + final int capacity = (int) Math.clamp(packetSeq + 256, 1024, 500_000); + try (Connection db = HistoryFile.openReadOnly(sqlitePath)) { + final Init init = loadInit(db, connectionId); + if (init == null) return null; + + final Checkpoint cp = loadCheckpoint(db, connectionId, packetSeq); + + final Session session = new Session(connectionId, capacity); + final long afterIo = applyInit(session, init, cp); + + final NetworkBuffer sb = NetworkBuffer.resizableBuffer(8 * 1024, session.registries); + final NetworkBuffer cb = NetworkBuffer.resizableBuffer(8 * 1024, session.registries); + final String sql = afterIo > 0 + ? "SELECT direction, payload FROM io_events WHERE connection_id = ? AND seq > ? ORDER BY seq ASC" + : "SELECT direction, payload FROM io_events WHERE connection_id = ? ORDER BY seq ASC"; + try (PreparedStatement ps = db.prepareStatement(sql)) { + ps.setBytes(1, Uuids.toBytes(connectionId)); + if (afterIo > 0) ps.setLong(2, afterIo); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final Direction dir = HistoryFile.directionFromId(rs.getInt(1)); + final byte[] payload = rs.getBytes(2); + if (payload == null || payload.length == 0) continue; + + final PacketRecord hit = feed(session, dir, + dir == Direction.SERVERBOUND ? sb : cb, payload, packetSeq); + if (hit != null) { + if (key != null) synchronized (CACHE) { CACHE.put(key, hit); } + return hit; + } + if (session.packets.latestSeq() > packetSeq) return null; + } + } + } + final PacketRecord tail = session.packets.decoded(packetSeq); + if (tail != null && key != null) synchronized (CACHE) { CACHE.put(key, tail); } + return tail; + } + } + + private static @Nullable String cacheKey(Path path, UUID connectionId, long packetSeq) { + try { + return path.toAbsolutePath() + + "|" + Files.size(path) + + "|" + Files.getLastModifiedTime(path).toMillis() + + "|" + connectionId + + "|" + packetSeq; + } catch (IOException _) { + return null; + } + } + + /// @return `io_events.seq` cursor — replay rows with `seq >` this value + private static long applyInit(Session session, Init init, @Nullable Checkpoint cp) { + if (init.stateSb != null) session.clientToServerState = init.stateSb; + if (init.stateCb != null) session.serverToClientState = init.stateCb; + int compression = startsAtHandshake(init) ? -1 : init.compression; + long afterIo = 0L; + if (cp != null) { + if (cp.stateSb != null) session.clientToServerState = cp.stateSb; + if (cp.stateCb != null) session.serverToClientState = cp.stateCb; + if (cp.compression > 0) compression = cp.compression; + // Resume one before the checkpoint so decoding its io_event produces cp.packetSeq. + session.packets.seedAfter(Math.max(0, cp.packetSeq() - 1)); + afterIo = Math.max(0, cp.ioEventSeq() - 1); + } + if (compression > 0) { + session.clientCompressionThreshold = compression; + session.upstreamCompressionThreshold = compression; + } + return afterIo; + } + + private static boolean startsAtHandshake(Init init) { + return init.stateSb == ConnectionState.HANDSHAKE || init.stateCb == ConnectionState.HANDSHAKE; + } + + private static @Nullable PacketRecord feed(Session session, Direction dir, NetworkBuffer buffer, + byte[] payload, long targetSeq) { + buffer.write(NetworkBuffer.RAW_BYTES, payload); + while (true) { + final long next = session.packets.latestSeq() + 1; + if (next != targetSeq && inPlay(session)) { + final int skipped = skipFrame(session, dir, buffer); + if (skipped > 0) { + session.packets.bumpSeq(); + continue; + } + if (skipped < 0) return null; + } + switch (PacketDecoder.drain(session, dir, buffer)) { + case PacketDecoder.Result.Incomplete _ -> { return null; } + case PacketDecoder.Result.Error _ -> { return null; } + case PacketDecoder.Result.Frame frame -> { + final PacketRecord rec = session.packets.recordDecoded(dir, frame.beforeState(), + frame.packet(), frame.sizeBytes(), 0); + if (rec.seq() >= targetSeq) return rec.seq() == targetSeq ? rec : null; + } + } + } + } + + private static int skipFrame(Session session, Direction dir, NetworkBuffer buffer) { + final ConnectionState state = dir == Direction.SERVERBOUND + ? session.clientToServerState : session.serverToClientState; + final long mark = buffer.readIndex(); + final int packetLength; + try { + packetLength = buffer.read(VAR_INT); + } catch (IndexOutOfBoundsException e) { + return 0; + } + if (packetLength > PacketReading.maxPacketSize(state)) return -1; + if (buffer.readableBytes() < packetLength) { + buffer.readIndex(mark); + return 0; + } + buffer.readIndex(buffer.readIndex() + packetLength); + return (int) (buffer.readIndex() - mark); + } + + private static boolean inPlay(Session session) { + return session.clientToServerState == ConnectionState.PLAY + && session.serverToClientState == ConnectionState.PLAY; + } + + private static @Nullable Init loadInit(Connection db, UUID connectionId) throws SQLException { + try (PreparedStatement ps = db.prepareStatement( + "SELECT init_state_sb, init_state_cb, init_compression FROM connections WHERE id = ?")) { + ps.setBytes(1, Uuids.toBytes(connectionId)); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + final int sbId = rs.getInt(1); + final boolean sbNull = rs.wasNull(); + final int cbId = rs.getInt(2); + final boolean cbNull = rs.wasNull(); + final int compression = rs.getInt(3); + final boolean compressionNull = rs.wasNull(); + return new Init( + sbNull ? null : HistoryFile.stateFromId(sbId), + cbNull ? null : HistoryFile.stateFromId(cbId), + compressionNull ? -1 : compression); + } + } + } + + private static @Nullable Checkpoint loadCheckpoint(Connection db, UUID connectionId, long packetSeq) + throws SQLException { + try (PreparedStatement ps = db.prepareStatement(""" + SELECT packet_seq, io_event_seq, state_sb, state_cb, compression + FROM packet_checkpoints + WHERE connection_id = ? AND packet_seq <= ? + ORDER BY packet_seq DESC + LIMIT 1 + """)) { + ps.setBytes(1, Uuids.toBytes(connectionId)); + ps.setLong(2, packetSeq); + try (ResultSet rs = ps.executeQuery()) { + if (!rs.next()) return null; + final int sbId = rs.getInt(3); + final boolean sbNull = rs.wasNull(); + final int cbId = rs.getInt(4); + final boolean cbNull = rs.wasNull(); + final int compression = rs.getInt(5); + final boolean compressionNull = rs.wasNull(); + return new Checkpoint( + rs.getLong(1), + rs.getLong(2), + sbNull ? null : HistoryFile.stateFromId(sbId), + cbNull ? null : HistoryFile.stateFromId(cbId), + compressionNull ? -1 : compression); + } + } + } + + private record Init(@Nullable ConnectionState stateSb, + @Nullable ConnectionState stateCb, + int compression) {} + + private record Checkpoint(long packetSeq, long ioEventSeq, + @Nullable ConnectionState stateSb, + @Nullable ConnectionState stateCb, + int compression) {} +} diff --git a/web/src/main/java/net/minestom/web/internal/replay/ReplaySource.java b/web/src/main/java/net/minestom/web/internal/replay/ReplaySource.java new file mode 100644 index 00000000000..18bbcab44ee --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/replay/ReplaySource.java @@ -0,0 +1,182 @@ +package net.minestom.web.internal.replay; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.Packet; +import net.minestom.web.Direction; +import net.minestom.web.internal.Uuids; +import net.minestom.web.internal.codec.PacketDecoder; +import net.minestom.web.internal.persist.HistoryFile; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.LockSupport; + +/// Drives a [SessionRegistry] from a SQLite export. The file's `format.protocol_version` must +/// match the running build exactly — frames are only decodable by their original codec. +public final class ReplaySource implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(ReplaySource.class); + private static final long MAX_SLEEP_SLICE_NS = 100_000_000L; + + private final Connection db; + private final SessionRegistry registry; + private final boolean respectTimestamps; + private final AtomicBoolean running = new AtomicBoolean(); + + public ReplaySource(Path path, SessionRegistry registry, boolean respectTimestamps) throws SQLException { + this.registry = registry; + this.respectTimestamps = respectTimestamps; + this.db = HistoryFile.openReadOnly(path); + } + + /// Replay every session in the file. Blocks until the last io_event is consumed. + public void runBlocking() throws SQLException, IOException { + if (!running.compareAndSet(false, true)) throw new IllegalStateException("replay already running"); + try { + final Map connections = loadConnections(); + final Map active = new HashMap<>(); + try (PreparedStatement ps = db.prepareStatement( + "SELECT connection_id, seq, ts_ms, direction, payload FROM io_events ORDER BY ts_ms ASC, seq ASC"); + ResultSet rs = ps.executeQuery()) { + long firstEventMs = Long.MIN_VALUE; + long replayStartedNs = 0L; + while (rs.next()) { + final UUID cid = Uuids.fromBytes(rs.getBytes(1)); + final long ioEventSeq = rs.getLong(2); + final long ts = rs.getLong(3); + final Direction dir = HistoryFile.directionFromId(rs.getInt(4)); + final byte[] payload = rs.getBytes(5); + if (respectTimestamps) { + if (firstEventMs == Long.MIN_VALUE) { + firstEventMs = ts; + replayStartedNs = System.nanoTime(); + } else { + paceReplay(firstEventMs, replayStartedNs, ts); + } + } + final ConnectionRow row = connections.get(cid); + final PerConnection pc = active.computeIfAbsent(cid, id -> { + final Session session = registry.openSession(cid, row == null ? null : row.address); + // No proxy worker in replay — spawn a default loop so the mailbox drains. + session.startDefaultLoop(); + return new PerConnection(session, row); + }); + pc.feed(dir, payload, ioEventSeq); + } + } + for (PerConnection pc : active.values()) pc.session.close(); + } finally { + running.set(false); + } + } + + /// Pace replay so an event recorded at `eventMs` (epoch ms) fires at + /// `firstEventMs + (eventMs - firstEventMs)` wall-clock time. A clock that jumped backward + /// during capture (rare; produces a row with `eventMs < firstEventMs`) fires immediately + /// rather than sleeping forever. + private static void paceReplay(long firstEventMs, long replayStartedNs, long eventMs) throws IOException { + final long targetElapsedNs = Math.max(0L, (eventMs - firstEventMs) * 1_000_000L); + while (true) { + final long remaining = targetElapsedNs - (System.nanoTime() - replayStartedNs); + if (remaining <= 0L) return; + LockSupport.parkNanos(Math.min(remaining, MAX_SLEEP_SLICE_NS)); + if (Thread.interrupted()) { + Thread.currentThread().interrupt(); + throw new IOException("replay interrupted"); + } + } + } + + private Map loadConnections() throws SQLException { + final Map out = new LinkedHashMap<>(); + try (PreparedStatement ps = db.prepareStatement( + "SELECT id, address, init_state_sb, init_state_cb, init_compression FROM connections ORDER BY connect_ms ASC"); + ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + final UUID id = Uuids.fromBytes(rs.getBytes(1)); + final int sbId = rs.getInt(3); + final boolean sbNull = rs.wasNull(); + final int cbId = rs.getInt(4); + final boolean cbNull = rs.wasNull(); + final int compression = rs.getInt(5); + final boolean compressionWasNull = rs.wasNull(); + out.put(id, new ConnectionRow(id, rs.getString(2), + sbNull ? null : HistoryFile.stateFromId(sbId), + cbNull ? null : HistoryFile.stateFromId(cbId), + compressionWasNull ? -1 : compression)); + } + } + return out; + } + + @Override + public void close() { + try { db.close(); } catch (SQLException _) {} + } + + /// Per-session decode pump. One [NetworkBuffer] per direction — interleaved SERVERBOUND / + /// CLIENTBOUND rows must not share a buffer or bytes bleed across parsers. + private final class PerConnection { + final Session session; + final NetworkBuffer serverbound; + final NetworkBuffer clientbound; + + PerConnection(Session session, @Nullable ConnectionRow row) { + this.session = session; + this.serverbound = NetworkBuffer.resizableBuffer(8 * 1024, session.registries); + this.clientbound = NetworkBuffer.resizableBuffer(8 * 1024, session.registries); + // Online-mode connections record their post-login state; offline-mode leaves the + // columns NULL and we start from HANDSHAKE so the recorded handshake transitions + // state naturally. + if (row != null) { + if (row.initStateSb != null) session.clientToServerState = row.initStateSb; + if (row.initStateCb != null) session.serverToClientState = row.initStateCb; + final boolean startsAtHandshake = row.initStateSb == ConnectionState.HANDSHAKE + || row.initStateCb == ConnectionState.HANDSHAKE; + if (!startsAtHandshake && row.initCompression > 0) { + session.clientCompressionThreshold = row.initCompression; + session.upstreamCompressionThreshold = row.initCompression; + } + } + } + + void feed(Direction direction, byte[] payload, long ioEventSeq) { + final NetworkBuffer buffer = direction == Direction.SERVERBOUND ? serverbound : clientbound; + buffer.write(NetworkBuffer.RAW_BYTES, payload); + while (true) { + switch (PacketDecoder.drain(session, direction, buffer)) { + case PacketDecoder.Result.Incomplete _ -> { return; } + case PacketDecoder.Result.Error _ -> { + LOGGER.warn("replay decode error on {} for session {}", direction, session.id); + return; + } + case PacketDecoder.Result.Frame frame -> { + final Packet packet = frame.packet(); + session.mutateState(_ -> + registry.applier().apply(session, direction, frame.beforeState(), + packet, frame.sizeBytes(), ioEventSeq)); + } + } + } + } + } + + private record ConnectionRow(UUID id, String address, + @Nullable ConnectionState initStateSb, + @Nullable ConnectionState initStateCb, + int initCompression) {} +} diff --git a/web/src/main/java/net/minestom/web/internal/scope/DashboardScope.java b/web/src/main/java/net/minestom/web/internal/scope/DashboardScope.java new file mode 100644 index 00000000000..23ae8fd4d55 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/scope/DashboardScope.java @@ -0,0 +1,361 @@ +package net.minestom.web.internal.scope; + +import com.google.gson.JsonObject; +import io.javalin.websocket.WsContext; +import net.minestom.web.ControlBridge; +import net.minestom.web.Direction; +import net.minestom.web.PacketEvent; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.expression.ExpressionEngine; +import net.minestom.web.internal.http.MetricsSampler; +import net.minestom.web.internal.http.Topics; +import net.minestom.web.internal.persist.PersistentHistory; +import net.minestom.web.internal.proxy.TcpAcceptor; +import net.minestom.web.internal.expression.QueryEngine; +import net.minestom.web.internal.replay.ReplaySource; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionRegistry; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.LongAdder; + +/// One isolated dashboard "world". Live mode runs one scope owning the TCP proxy and the +/// persistence writer; replay mode creates one scope per uploaded SQLite file so each browser +/// tab sees only its own data — independent registries, independent WS subscribers, independent +/// routines. +public final class DashboardScope implements AutoCloseable { + private static final Logger LOGGER = LoggerFactory.getLogger(DashboardScope.class); + + public final String id; + public final String label; + public final long createdAt; + + public final SessionRegistry registry; + public final ControlBridge control; + public final QueryEngine queries; + public final ExpressionEngine expressions; + public final MetricsSampler metrics; + + public final @Nullable PersistentHistory persistence; + public final @Nullable TcpAcceptor proxy; + + public volatile @Nullable ReplaySource replaySource; + public final @Nullable Path replaySourcePath; + public volatile @Nullable Thread replayThread; + public volatile ReplayStatus replayStatus = ReplayStatus.PENDING; + public volatile @Nullable String replayError; + /// Wall-clock ms at which the replay loop returned; 0 while still running. + public volatile long replayEndedAt; + + private final ConcurrentHashMap subscribers = new ConcurrentHashMap<>(); + private final ConcurrentHashMap subscriberCounts = new ConcurrentHashMap<>(); + private final AtomicLong lastActiveAt = new AtomicLong(System.currentTimeMillis()); + private final List pendingPacketAggregate = new ArrayList<>(); + /// Coalesced summary rows by player UUID — bridge writes per patch, scope ticker drains. + private final Map pendingSummary = new ConcurrentHashMap<>(); + /// Latest traffic snapshot per session id, folded into per-second rates by the metrics sampler. + private final Map sessionTraffic = new ConcurrentHashMap<>(); + + public enum ReplayStatus { PENDING, RUNNING, DONE, ERROR } + + public static DashboardScope live(String id, SessionRegistry registry, ControlBridge control, + QueryEngine queries, ExpressionEngine expressions, + MetricsSampler metrics, + @Nullable PersistentHistory persistence, TcpAcceptor proxy) { + return new DashboardScope(id, "live", registry, control, queries, expressions, + metrics, persistence, proxy, null); + } + + public static DashboardScope replay(String id, String label, SessionRegistry registry, + ControlBridge control, QueryEngine queries, + ExpressionEngine expressions, + MetricsSampler metrics, Path replaySourcePath) { + return new DashboardScope(id, label, registry, control, queries, expressions, + metrics, null, null, replaySourcePath); + } + + private DashboardScope(String id, String label, + SessionRegistry registry, ControlBridge control, + QueryEngine queries, ExpressionEngine expressions, + MetricsSampler metrics, + @Nullable PersistentHistory persistence, @Nullable TcpAcceptor proxy, + @Nullable Path replaySourcePath) { + this.id = id; + this.label = label; + this.createdAt = System.currentTimeMillis(); + this.registry = registry; + this.control = control; + this.queries = queries; + this.expressions = expressions; + this.metrics = metrics; + this.persistence = persistence; + this.proxy = proxy; + this.replaySourcePath = replaySourcePath; + } + + public boolean isReplay() { return replaySourcePath != null; } + + /// SQLite path packets can be resolved from — replay source if uploaded, else live persistence. + public @Nullable Path archivePath() { + if (replaySourcePath != null) return replaySourcePath; + return persistence == null ? null : persistence.path(); + } + public void touch() { lastActiveAt.set(System.currentTimeMillis()); } + public long lastActiveAt() { return lastActiveAt.get(); } + + // ---- Summary / publishing ----------------------------------------------------------- + + public WebPayloads.ScopeSummary summary() { + return new WebPayloads.ScopeSummary( + id, label, isReplay(), createdAt, registry.players().size(), + isReplay() ? replayStatus.name().toLowerCase() : null, + isReplay() ? replayError : null, + isReplay() && replayEndedAt != 0 ? replayEndedAt : null); + } + + public void publishStatus() { + publish(Topics.SCOPE, WebJson.encodeAsObject(WebCodecs.SCOPE_SUMMARY, summary())); + } + + /// Forward control-bridge events (console / metrics / global) onto WS topics. + public void wireControlSinks() { + control.setOnConsoleLine(line -> publish(Topics.CONSOLE, + WebJson.encodeAsObject(WebCodecs.CONSOLE_LINE, line))); + control.setOnMetrics(m -> publish(Topics.METRICS, + WebJson.encodeAsObject(WebCodecs.CONTROL_METRICS, m))); + control.setOnGlobalData(data -> publish(Topics.GLOBAL, + WebJson.encodeAsObject(WebCodecs.GLOBAL_DATA, new WebPayloads.GlobalData(data)))); + } + + /// Compute and broadcast a one-second metrics sample. Run on the scheduler. + public void sampleMetrics() { + try { + final TrafficTotals t = trafficTotals(); + MetricsSampler.Sample s = metrics.tick(System.currentTimeMillis(), + t.bytesIn(), t.bytesOut(), t.packetsIn(), t.packetsOut(), t.connections()); + if (s != null) publish(Topics.SERVER_METRICS, + WebJson.encodeAsObject(WebCodecs.METRICS_SAMPLE, s)); + } catch (Throwable e) { + LOGGER.warn("metrics sampler for {} failed", id, e); + } + } + + /// Read packet events for `session` from persistence / archive / in-memory ring buffer. + public List packetEvents(Session session, long sinceSeq, int limit, + @Nullable Direction dirFilter, + @Nullable String classFilter, + @Nullable String subjectFilter) { + if (limit <= 0) return List.of(); + try { + if (persistence != null) { + return persistence.packetEvents(session.id, sinceSeq, limit, dirFilter, classFilter, subjectFilter); + } + final Path archive = archivePath(); + if (archive != null) { + return PersistentHistory.readPacketEvents(archive, session.id, sinceSeq, limit, + dirFilter, classFilter, subjectFilter); + } + } catch (SQLException e) { + LOGGER.debug("packet event read failed for {}: {}", session.id, e.toString()); + } + return session.packets.events(sinceSeq, limit, dirFilter, classFilter, subjectFilter); + } + + // ---- WS plumbing ------------------------------------------------------------------- + + public Subscriber addSubscriber(WsContext ctx) { + final Subscriber sub = new Subscriber(ctx); + subscribers.put(ctx, sub); + return sub; + } + + public @Nullable Subscriber subscriber(WsContext ctx) { + return subscribers.get(ctx); + } + + public boolean hasSubscribers() { + return !subscribers.isEmpty(); + } + + public void removeSubscriber(WsContext ctx) { + final Subscriber sub = subscribers.remove(ctx); + if (sub == null) return; + sub.close(); + for (String topic : sub.topics) decrementCount(topic); + } + + public void subscribe(Subscriber sub, String topic) { + if (!sub.topics.add(topic)) return; + subscriberCounts.computeIfAbsent(topic, _ -> new LongAdder()).increment(); + } + + public void unsubscribe(Subscriber sub, String topic) { + if (!sub.topics.remove(topic)) return; + decrementCount(topic); + } + + public boolean hasSubscriber(String topic) { + final LongAdder count = subscriberCounts.get(topic); + return count != null && count.sum() > 0; + } + + /// Stamp `topic` onto the payload and fan it out to every subscriber of `topic`. No-op when + /// nobody is listening (callers also gate on [#hasSubscriber] to skip building the payload). + public void publish(String topic, JsonObject payload) { + if (!hasSubscriber(topic)) return; + payload.addProperty("topic", topic); + final String body = payload.toString(); + for (Subscriber sub : subscribers.values()) { + if (sub.topics.contains(topic)) sub.enqueue(body); + } + } + + public void notePacketAggregate(WebPayloads.PlayerPacketEvent event) { + if (event.uuid() == null) return; + synchronized (pendingPacketAggregate) { + pendingPacketAggregate.add(event); + } + } + + /// Flush buffered packet rows to aggregate subscribers. No-op when nobody is listening. + public void flushPacketAggregate() { + if (!hasSubscriber(Topics.PACKETS_AGGREGATE)) return; + final List rows; + synchronized (pendingPacketAggregate) { + if (pendingPacketAggregate.isEmpty()) return; + rows = List.copyOf(pendingPacketAggregate); + pendingPacketAggregate.clear(); + } + publish(Topics.PACKETS_AGGREGATE, + WebJson.encodeAsObject(WebCodecs.PACKETS_AGGREGATE, new WebPayloads.PacketsAggregate(rows))); + } + + public void notePlayerSummary(WebPayloads.PlayersSummaryRow row) { + pendingSummary.put(row.uuid(), row); + } + + public void publishPlayersSummary() { + if (!hasSubscriber(Topics.PLAYERS_SUMMARY) || pendingSummary.isEmpty()) return; + // Drain per-key with remove-if-same so a row written between read and clear isn't lost: + // a newer value for the same UUID fails the CAS, stays in the map, and ships next tick. + final List rows = new ArrayList<>(); + for (Map.Entry e : pendingSummary.entrySet()) { + final WebPayloads.PlayersSummaryRow row = e.getValue(); + if (pendingSummary.remove(e.getKey(), row)) rows.add(row); + } + if (rows.isEmpty()) return; + publish(Topics.PLAYERS_SUMMARY, + WebJson.encodeAsObject(WebCodecs.PLAYERS_SUMMARY, new WebPayloads.PlayersSummaryPayload(rows))); + } + + public void recordSessionTraffic(UUID sessionId, long bytesIn, long bytesOut, + long packetsIn, long packetsOut) { + sessionTraffic.put(sessionId, new long[] { bytesIn, bytesOut, packetsIn, packetsOut }); + } + + public void forgetSessionTraffic(UUID sessionId) { + sessionTraffic.remove(sessionId); + } + + public TrafficTotals trafficTotals() { + long bi = 0, bo = 0, pi = 0, po = 0; + for (long[] t : sessionTraffic.values()) { + bi += t[0]; bo += t[1]; pi += t[2]; po += t[3]; + } + return new TrafficTotals(bi, bo, pi, po, sessionTraffic.size()); + } + + public record TrafficTotals(long bytesIn, long bytesOut, long packetsIn, long packetsOut, + int connections) {} + + private void decrementCount(String topic) { + final LongAdder count = subscriberCounts.get(topic); + if (count == null) return; + count.decrement(); + if (count.sum() <= 0) subscriberCounts.remove(topic, count); + } + + @Override + public void close() { + // Stop the replay driver first so it doesn't try to write into a closing registry. + final Thread rt = replayThread; + if (rt != null) rt.interrupt(); + final ReplaySource rs = replaySource; + if (rs != null) try { rs.close(); } catch (Exception _) {} + try { registry.closeAll(); } catch (Exception _) {} + try { control.close(); } catch (Exception _) {} + for (Subscriber sub : subscribers.values()) sub.close(); + subscribers.clear(); + subscriberCounts.clear(); + if (persistence != null) try { persistence.close(); } catch (Exception _) {} + if (replaySourcePath != null) { + try { Files.deleteIfExists(replaySourcePath); } + catch (IOException e) { LOGGER.debug("failed to delete replay temp {}: {}", replaySourcePath, e.toString()); } + } + } + + /// Per-WS outbox carrier. Workers enqueue and return; a VT drains onto the wire so a slow + /// client never stalls the proxy. Overflow drops the new message. + public static final class Subscriber { + final Set topics = ConcurrentHashMap.newKeySet(); + private final WsContext ctx; + private final ArrayBlockingQueue outbox = new ArrayBlockingQueue<>(1024); + private final Thread drainer; + private volatile boolean alive = true; + + Subscriber(WsContext ctx) { + this.ctx = ctx; + this.drainer = Thread.ofVirtual().name("web-ws-out").start(this::drain); + } + + void enqueue(String body) { + if (alive) outbox.offer(body); + } + + private void drain() { + while (alive) { + final String first; + try { first = outbox.take(); } + catch (InterruptedException _) { return; } + try { + final java.util.ArrayList batch = new java.util.ArrayList<>(); + batch.add(first); + outbox.drainTo(batch, 63); + if (batch.size() == 1) { + ctx.send(batch.getFirst()); + } else { + // Each body is already a complete JSON object string — concatenate into the + // batch array directly instead of parsing + re-serializing every message. + final StringBuilder sb = new StringBuilder(batch.size() * 64).append("{\"batch\":["); + for (int i = 0; i < batch.size(); i++) { + if (i > 0) sb.append(','); + sb.append(batch.get(i)); + } + ctx.send(sb.append("]}").toString()); + } + } catch (Exception _) { alive = false; } + } + } + + void close() { + alive = false; + drainer.interrupt(); + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/scope/ScopeSessionBridge.java b/web/src/main/java/net/minestom/web/internal/scope/ScopeSessionBridge.java new file mode 100644 index 00000000000..53d5c216139 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/scope/ScopeSessionBridge.java @@ -0,0 +1,185 @@ +package net.minestom.web.internal.scope; + +import com.google.gson.JsonObject; +import net.minestom.web.PacketEvent; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebJsonBuilders; +import net.minestom.web.internal.codec.WebPayloads; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.http.Topics; +import net.minestom.web.internal.persist.HistoryFile; +import net.minestom.web.internal.session.PlayerView; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/// Subscribes to every session and translates events into dashboard topic publishes. Runs on the +/// session worker thread — handlers must be cheap. Feeds the scope's `players:summary` and global +/// metrics aggregates inline (no polling); wires [Session#setActivityProbes] so the session skips +/// the expensive per-player work when no subscriber wants it. +public final class ScopeSessionBridge { + private static final Logger LOGGER = LoggerFactory.getLogger(ScopeSessionBridge.class); + + private final DashboardScope scope; + private final Set joinedSessions = ConcurrentHashMap.newKeySet(); + + public ScopeSessionBridge(DashboardScope scope) { + this.scope = scope; + scope.registry.onSessionOpen(this::onSessionOpen); + scope.registry.onSessionClose(this::onSessionClose); + scope.registry.onSessionEvict(this::onSessionEvicted); + for (Session existing : scope.registry.sessions()) onSessionOpen(existing); + } + + private void onSessionOpen(Session session) { + if (scope.persistence != null) { + // TcpAcceptor defers notifyOpened until after setBackendAddress + setJourneyId, + // so by the time this listener fires the routing columns are populated. + if (session.journeyId() != null) { + scope.persistence.recordJourneyOpen(session.journeyId(), null, HistoryFile.nowMs()); + } + final java.net.InetSocketAddress backend = session.backendAddress(); + final String backendLabel = backend == null ? null : backend.getHostString() + ":" + backend.getPort(); + scope.persistence.recordConnect(session.id, session.journeyId(), + backendLabel, session.initialAddress(), HistoryFile.nowMs()); + } + session.setActivityProbes(() -> patchWanted(session), () -> minimapWanted(session)); + session.addListener(event -> dispatch(session, event)); + } + + private boolean patchWanted(Session session) { + // Summary subscribers consume the same field changes as a profile viewer. + if (scope.hasSubscriber(Topics.PLAYERS_SUMMARY)) return true; + final UUID uuid = session.playerUuid(); + return uuid != null && scope.hasSubscriber(Topics.playerState(uuid)); + } + + private boolean minimapWanted(Session session) { + final UUID uuid = session.playerUuid(); + return uuid != null && scope.hasSubscriber(Topics.playerMinimap(uuid)); + } + + private void onSessionClose(Session session) { + if (scope.persistence != null) { + scope.persistence.recordDisconnect(session.id, HistoryFile.nowMs()); + } + if (joinedSessions.contains(session.id)) { + publishPlayers("disconnect", session); + } + scope.forgetSessionTraffic(session.id); + } + + private void onSessionEvicted(PlayerView.Retained snapshot) { + if (joinedSessions.remove(snapshot.sessionId())) publishPlayerRemove(snapshot.uuid()); + scope.forgetSessionTraffic(snapshot.sessionId()); + } + + private void dispatch(Session session, SessionEvent event) { + try { + switch (event) { + case SessionEvent.Lifecycle(var ev) -> handleLifecycle(session, ev); + case SessionEvent.PacketSeen p -> handlePacket(session, p); + case SessionEvent.Patch p -> handlePatch(session, p); + case SessionEvent.MinimapFrame m -> handleMinimap(session, m); + case SessionEvent.TrafficSnapshot t -> scope.recordSessionTraffic(session.id, + t.bytesIn(), t.bytesOut(), t.packetsIn(), t.packetsOut()); + case SessionEvent.Closed ignored -> { } + } + } catch (Throwable t) { + LOGGER.debug("scope bridge dispatch failed: {}", t.toString()); + } + } + + private void handleLifecycle(Session session, net.minestom.web.LifecycleEvent ev) { + final UUID uuid = session.playerUuid(); + if (uuid == null) return; + final String topic = Topics.playerLifecycle(uuid); + if (!scope.hasSubscriber(topic)) return; + scope.publish(topic, WebJson.encodeAsObject(WebCodecs.LIFECYCLE_EVENT, ev)); + } + + private void handlePacket(Session session, SessionEvent.PacketSeen ev) { + final PacketEvent timelineEvent = ev.timelineEvent(); + if (scope.persistence != null && timelineEvent != null) { + scope.persistence.recordPacketEvent(session.id, timelineEvent); + } + if (ev.playerUuid() == null) return; + if (joinedSessions.add(session.id)) { + // First time we know who this connection belongs to — back-fill the journey row's + // player_uuid. The :web module never queries it; the column + idx_journey_player exist + // for external/archive consumers that want every connection on a player's journey. + if (scope.persistence != null && session.journeyId() != null) { + scope.persistence.recordJourneyPlayerUuid(session.journeyId(), ev.playerUuid()); + } + publishPlayers("add", session); + } + if (timelineEvent == null) return; + // Build the wire event at most once, even when both the aggregate and per-player topics + // are subscribed. + final String packetsTopic = Topics.playerPackets(ev.playerUuid()); + final boolean aggregateWanted = scope.hasSubscriber(Topics.PACKETS_AGGREGATE); + final boolean perPlayerWanted = scope.hasSubscriber(packetsTopic); + if (!aggregateWanted && !perPlayerWanted) return; + final WebPayloads.PlayerPacketEvent event = buildPlayerEvent(ev, timelineEvent); + if (aggregateWanted) scope.notePacketAggregate(event); + if (perPlayerWanted) scope.publish(packetsTopic, WebJson.encodeAsObject(WebCodecs.PLAYER_PACKET_EVENT, event)); + } + + private void handlePatch(Session session, SessionEvent.Patch ev) { + final UUID uuid = session.playerUuid(); + if (uuid == null) return; + // Listener fires synchronously on the owner thread — direct PlayerState read is safe. + if (scope.hasSubscriber(Topics.PLAYERS_SUMMARY)) { + scope.notePlayerSummary(WebPayloads.PlayersSummaryRow.from(session.playerForOwnerThread())); + } + final String topic = Topics.playerState(uuid); + if (!scope.hasSubscriber(topic)) return; + scope.publish(topic, WebJson.encodeAsObject(WebCodecs.STATE_PATCH, ev.patch(), session.jsonCoder)); + } + + private void handleMinimap(Session session, SessionEvent.MinimapFrame ev) { + final UUID uuid = session.playerUuid(); + if (uuid == null) return; + final String topic = Topics.playerMinimap(uuid); + if (!scope.hasSubscriber(topic)) return; + scope.publish(topic, ev.frame()); + } + + private WebPayloads.PlayerPacketEvent buildPlayerEvent(SessionEvent.PacketSeen ev, PacketEvent timelineEvent) { + return new WebPayloads.PlayerPacketEvent( + ev.playerUuid(), + ev.connectionId(), + ev.username(), + timelineEvent.seq(), + timelineEvent.ts(), + timelineEvent.direction(), + timelineEvent.state(), + timelineEvent.className(), + timelineEvent.sizeBytes(), + timelineEvent.subject(), + timelineEvent.subjectLabel(), + timelineEvent.subjectGroup(), + timelineEvent.ioEventSeq()); + } + + private void publishPlayers(String event, Session session) { + final UUID uuid = session.playerUuid(); + if (uuid == null) return; + final JsonObject player = "add".equals(event) || "disconnect".equals(event) + ? session.readState(p -> WebJsonBuilders.playerStateJson(p, session.jsonCoder)) : null; + publishRoster(new WebPayloads.PlayersRosterEvent(event, uuid, player)); + } + + private void publishPlayerRemove(UUID uuid) { + publishRoster(new WebPayloads.PlayersRosterEvent("remove", uuid, null)); + } + + private void publishRoster(WebPayloads.PlayersRosterEvent event) { + scope.publish(Topics.PLAYERS, WebJson.encodeAsObject(WebCodecs.PLAYERS_ROSTER_EVENT, event)); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/ActionRunner.java b/web/src/main/java/net/minestom/web/internal/session/ActionRunner.java new file mode 100644 index 00000000000..56ada4c5528 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/ActionRunner.java @@ -0,0 +1,146 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import net.kyori.adventure.text.Component; +import net.minestom.server.codec.Codec; +import net.minestom.web.Action; +import net.minestom.web.internal.AddressResolver; +import net.minestom.web.PlayerState; +import net.minestom.web.internal.codec.PatchValue; +import net.minestom.web.internal.codec.WebCodecs; +import net.minestom.web.internal.codec.WebJson; +import net.minestom.web.internal.expression.ExprValue; +import net.minestom.web.internal.expression.ExpressionEngine; +import net.minestom.web.internal.http.PacketCatalog; +import net.minestom.web.internal.http.PacketCodec; +import net.minestom.web.internal.http.PacketSchema.Kind; +import net.minestom.web.internal.proxy.TcpAcceptor; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.UUID; + +public final class ActionRunner { + private static final Logger LOGGER = LoggerFactory.getLogger(ActionRunner.class); + + private final @Nullable TcpAcceptor proxy; + private final ExpressionEngine expressions; + + public ActionRunner(@Nullable TcpAcceptor proxy, ExpressionEngine expressions) { + this.proxy = proxy; + this.expressions = expressions; + } + + public void execute(Action action, PlayerState p) throws Exception { + if (p.uuid == null) return; + switch (action) { + case Action.Inject inj -> inject(p, inj.className(), WebJson.encodeAsObject(PatchValue.STRING_MAP, inj.fields())); + case Action.Chat c -> { + Object raw = c.component(); + Component message = raw instanceof Component comp ? comp + : WebCodecs.componentFromEval(expressions.compile((String) raw).eval(p)); + JsonObject fields = new JsonObject(); + fields.add("message", WebJson.encode(Codec.COMPONENT, message)); + fields.addProperty("overlay", false); + inject(p, "SystemChatPacket", fields); + } + case Action.SetCustom sc -> p.custom.put(sc.key(), expressions.compile(sc.value()).eval(p).toObject()); + case Action.Move m -> { + if (proxy == null) return; + final String spec = expressions.compile(m.address()).eval(p).str(); + if (spec == null || spec.isBlank()) { + throw new IllegalArgumentException("move: address expression '" + m.address() + "' returned blank"); + } + // SRV resolution can block for seconds. Offload off the owner thread so the + // player's mailbox keeps draining (keep-alives, packet apply) while DNS works. + final UUID target = p.uuid; + final TcpAcceptor px = proxy; + Thread.ofVirtual().name("Minestom-Web-Move-" + target).start(() -> { + try { px.movePlayer(target, AddressResolver.parseMinecraft(spec)); } + catch (RuntimeException e) { + LOGGER.warn("move {} → {} failed: {}", target, spec, e.toString()); + } + }); + } + case Action.Sequence seq -> { for (var a : seq.actions()) execute(a, p); } + } + } + + private void inject(PlayerState p, String className, JsonObject fields) throws Exception { + if (proxy == null) return; + proxy.inject(p.uuid, PacketCatalog.directionFor(className), + PacketCodec.decode(className, fields, (src, kind) -> evaluate(src, kind, p))); + } + + /// Evaluator passed into [PacketCodec#decode]: compile + evaluate the expression, then + /// coerce to a JSON primitive that matches the field's kind. Failures bubble up with + /// the source so the user sees `compile error in 'health +': expected expression` + /// instead of an opaque `NumberFormatException` from Gson. + private JsonPrimitive evaluate(String src, Kind kind, PlayerState p) { + // Empty input means "use the field's default value" rather than "evaluate '' as an + // expression" — empty would fail compile and the user expects unfilled rows to send 0/null. + if (src.isEmpty()) return emptyDefault(kind); + ExprValue v; + try { + v = expressions.compile(src).eval(p); + } catch (RuntimeException e) { + throw new IllegalArgumentException("expression '" + src + "': " + e.getMessage(), e); + } + return switch (kind) { + case BYTE, SHORT, INT, LONG, FLOAT, DOUBLE -> numericPrimitive(v, kind, src); + case CHAR -> { + String s = v.str(); + yield new JsonPrimitive(s.isEmpty() ? "\0" : s.substring(0, 1)); + } + case STRING -> new JsonPrimitive(v.str()); + case UUID -> new JsonPrimitive(v instanceof ExprValue.Null ? NIL_UUID : v.str()); + default -> throw new IllegalStateException("evaluator called for non-expression kind: " + kind); + }; + } + + private static final String NIL_UUID = "00000000-0000-0000-0000-000000000000"; + + private static JsonPrimitive emptyDefault(Kind kind) { + return switch (kind) { + case STRING -> new JsonPrimitive(""); + case CHAR -> new JsonPrimitive("\0"); + case UUID -> new JsonPrimitive(NIL_UUID); + case FLOAT, DOUBLE -> new JsonPrimitive(0.0); + case BYTE, SHORT, INT, LONG -> new JsonPrimitive(0); + default -> throw new IllegalStateException("evaluator called for non-expression kind: " + kind); + }; + } + + private static JsonPrimitive numericPrimitive(ExprValue v, Kind kind, String src) { + double d = switch (v) { + case ExprValue.Num n -> n.value(); + case ExprValue.Bool b -> b.value() ? 1 : 0; + case ExprValue.Null _ -> throw new IllegalArgumentException( + "expression '" + src + "' returned null but field expects " + kind.name().toLowerCase()); + default -> throw new IllegalArgumentException( + "expression '" + src + "' returned " + v.getClass().getSimpleName() + + " but field expects " + kind.name().toLowerCase()); + }; + if (Double.isNaN(d) || Double.isInfinite(d)) + throw new IllegalArgumentException("expression '" + src + "' = " + d + " is not a finite number"); + // Range-check in double space — `(long) d` saturates at Long.MIN/MAX, so an int-space + // bounds check on the cast result would silently pass for huge doubles. Use double + // bounds compared against the double-precision representation of LONG min/max. + return switch (kind) { + case BYTE -> bounded(d, Byte.MIN_VALUE, Byte.MAX_VALUE, src, kind); + case SHORT -> bounded(d, Short.MIN_VALUE, Short.MAX_VALUE, src, kind); + case INT -> bounded(d, Integer.MIN_VALUE, Integer.MAX_VALUE, src, kind); + case LONG -> bounded(d, Long.MIN_VALUE, Long.MAX_VALUE, src, kind); + default -> new JsonPrimitive(d); + }; + } + + private static JsonPrimitive bounded(double d, double min, double max, String src, Kind kind) { + if (d < min || d > max) + throw new IllegalArgumentException("expression '" + src + "' = " + d + + " out of range for " + kind.name().toLowerCase()); + return new JsonPrimitive((long) d); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/LifecycleHistory.java b/web/src/main/java/net/minestom/web/internal/session/LifecycleHistory.java new file mode 100644 index 00000000000..f1670af538e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/LifecycleHistory.java @@ -0,0 +1,32 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonElement; +import net.minestom.web.LifecycleEvent; + +import java.util.ArrayList; +import java.util.List; + +/// Per-connection append-only log of [LifecycleEvent]s. Pure storage — emitters of events +/// (SessionRegistry for CONNECT/DISCONNECT, StateApplier for protocol-phase milestones) call +/// `record(...)` and then publish a [net.minestom.web.internal.session.SessionEvent.Lifecycle] +/// on the session stream. There is no listener registry here. +public final class LifecycleHistory { + /// Hard cap so a misbehaving session can't grow this without bound. Way above the realistic + /// upper end of ~20 events per connection. + private static final int CAPACITY = 256; + + private final List events = new ArrayList<>(); + private long nextSeq = 1; + + public synchronized LifecycleEvent record(LifecycleEvent.Kind kind, long packetSeq, JsonElement data) { + final LifecycleEvent e = new LifecycleEvent( + nextSeq++, System.currentTimeMillis(), packetSeq, kind, data); + if (events.size() >= CAPACITY) events.removeFirst(); + events.add(e); + return e; + } + + public synchronized List snapshot() { + return List.copyOf(events); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/MailboxException.java b/web/src/main/java/net/minestom/web/internal/session/MailboxException.java new file mode 100644 index 00000000000..8cd9fb05f55 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/MailboxException.java @@ -0,0 +1,31 @@ +package net.minestom.web.internal.session; + +/// Mailbox-related failure thrown by [Session]. The [Reason] maps to an HTTP status the +/// dashboard surfaces directly. +public final class MailboxException extends RuntimeException { + public enum Reason { + /// Inbox at capacity or worker stopped — HTTP 503. + BUSY(503, "session mailbox busy"), + /// Owner thread didn't finish within the caller's timeout — HTTP 504. + TIMEOUT(504, "session mailbox timeout"); + + final int status; + final String label; + + Reason(int status, String label) { + this.status = status; + this.label = label; + } + } + + private final Reason reason; + + public MailboxException(Reason reason, String message) { + super(message); + this.reason = reason; + } + + public int httpStatus() { return reason.status; } + + public String httpMessage() { return reason.label + ": " + getMessage(); } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/PacketTimeline.java b/web/src/main/java/net/minestom/web/internal/session/PacketTimeline.java new file mode 100644 index 00000000000..53191fea62e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/PacketTimeline.java @@ -0,0 +1,92 @@ +package net.minestom.web.internal.session; + +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.web.Direction; +import net.minestom.web.PacketEvent; +import net.minestom.web.PacketRecord; +import net.minestom.web.internal.http.PacketCatalog; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/// Per-session packet timeline plus a bounded decoded-packet cache. +/// +/// Timeline events are append-only and cover the whole connection, capped at [#MAX_EVENTS] so +/// long-lived sessions do not grow heap without bound. Full decoded packet records are +/// intentionally bounded because the inspector can recover old packets from persisted raw +/// bytes when persistence is enabled. +public final class PacketTimeline { + /// In-memory event cap per connection. Persistence / archive still hold full history. + public static final int MAX_EVENTS = 32_768; + + private final int decodedCacheSize; + private final List events = new ArrayList<>(); + private final LinkedHashMap decodedCache; + private long nextSeq = 1; + + public PacketTimeline(int decodedCacheSize) { + if (decodedCacheSize < 0) throw new IllegalArgumentException("decodedCacheSize < 0"); + this.decodedCacheSize = decodedCacheSize; + this.decodedCache = new LinkedHashMap<>(Math.max(16, decodedCacheSize), 0.75f, true) { + @Override protected boolean removeEldestEntry(Map.Entry eldest) { + return PacketTimeline.this.decodedCacheSize > 0 && size() > PacketTimeline.this.decodedCacheSize; + } + }; + } + + public synchronized PacketRecord recordDecoded(Direction direction, ConnectionState state, + Packet packet, int sizeBytes, long ioEventSeq) { + final long seq = nextSeq++; + final long ts = System.currentTimeMillis(); + final PacketCatalog.Subject subject = PacketCatalog.classify(packet); + events.add(new PacketEvent(seq, ts, direction, state, packet.getClass().getSimpleName(), sizeBytes, + subject.id(), subject.label(), subject.groupId(), ioEventSeq)); + if (events.size() > MAX_EVENTS) events.removeFirst(); + + final PacketRecord record = new PacketRecord(seq, ts, direction, state, + packet.getClass().getSimpleName(), sizeBytes, packet); + if (decodedCacheSize != 0) decodedCache.put(seq, record); + return record; + } + + public synchronized void bumpSeq() { + nextSeq++; + } + + public synchronized void seedAfter(long packetSeq) { + if (packetSeq >= 0) nextSeq = packetSeq + 1; + } + + public synchronized long latestSeq() { + return nextSeq - 1; + } + + public synchronized @Nullable PacketRecord decoded(long seq) { + return decodedCache.get(seq); + } + + public synchronized @Nullable PacketEvent latestEvent() { + return events.isEmpty() ? null : events.getLast(); + } + + public synchronized List events(long sinceSeq, int limit, + @Nullable Direction dirFilter, + @Nullable String classFilter, + @Nullable String subjectFilter) { + if (limit <= 0) return List.of(); + final List out = new ArrayList<>(Math.min(limit, events.size())); + for (PacketEvent event : events) { + if (event.seq() <= sinceSeq) continue; + if (dirFilter != null && event.direction() != dirFilter) continue; + if (classFilter != null && !event.className().equalsIgnoreCase(classFilter)) continue; + if (subjectFilter != null && !subjectFilter.isEmpty() && !event.subject().equals(subjectFilter)) continue; + out.add(event); + if (out.size() >= limit) break; + } + return out; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/PlayerView.java b/web/src/main/java/net/minestom/web/internal/session/PlayerView.java new file mode 100644 index 00000000000..ffdbedde967 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/PlayerView.java @@ -0,0 +1,83 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonObject; +import net.minestom.web.PlayerState; +import net.minestom.web.internal.codec.WebJsonBuilders; +import net.minestom.web.internal.codec.WebJson; + +import java.util.UUID; + +public sealed interface PlayerView permits PlayerView.Live, PlayerView.Retained { + UUID uuid(); + + UUID sessionId(); + + long connectedAt(); + + long disconnectedAt(); + + JsonObject playerJson(); + + /// View backed by a still-connected [Session]; reads go through the mailbox. + record Live(Session session) implements PlayerView { + @Override + public UUID uuid() { + return session.playerUuid(); + } + + @Override + public UUID sessionId() { + return session.id; + } + + @Override + public long connectedAt() { + return session.connectedAt; + } + + @Override + public long disconnectedAt() { + return 0L; + } + + @Override + public JsonObject playerJson() { + return session.tryReadState(p -> WebJsonBuilders.playerStateJson(p, session.jsonCoder), + Session.HTTP_READ_TIMEOUT_MS); + } + } + + /// Immutable snapshot retained after a player disconnects. + record Retained( + UUID uuid, + UUID sessionId, + long connectedAt, + long disconnectedAt, + JsonObject playerJson, + JsonObject provenanceHistoryJson + ) implements PlayerView { + static Retained from(Session session, PlayerState player) { + return new Retained( + player.uuid, + session.id, + player.connectedAt, + player.disconnectedAt, + WebJsonBuilders.playerStateJson(player, WebJson.CODER), + WebJsonBuilders.provenanceHistoryJson(player, null)); + } + + @Override + public JsonObject playerJson() { + return playerJson.deepCopy(); + } + + public JsonObject provenanceHistoryJson(String field) { + if (field == null) return provenanceHistoryJson.deepCopy(); + JsonObject out = new JsonObject(); + if (provenanceHistoryJson.has(field)) { + out.add(field, provenanceHistoryJson.get(field).deepCopy()); + } + return out; + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/Session.java b/web/src/main/java/net/minestom/web/internal/session/Session.java new file mode 100644 index 00000000000..bf7aa0ad6fd --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/Session.java @@ -0,0 +1,536 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minestom.server.codec.Transcoder; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.registry.Registries; +import net.minestom.web.Action; +import net.minestom.web.PlayerState; +import net.minestom.web.RegisteredRoutine; +import net.minestom.web.Routine; +import net.minestom.web.StatePatch; +import net.minestom.web.internal.codec.MinimapCodec; +import net.minestom.web.internal.codec.PatchValue; +import net.minestom.web.internal.codec.WebJson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BooleanSupplier; +import java.util.function.Consumer; +import java.util.function.Function; + +/// Transport-agnostic session actor. Exactly one owner thread mutates [PlayerState] and ticks +/// cadence; external threads enqueue work through the mailbox and the owner drains it. +public final class Session { + private static final Logger LOGGER = LoggerFactory.getLogger(Session.class); + private static final int STATE_QUEUE_CAPACITY = 4096; + private static final long POLL_TIMEOUT_MS = 50L; + /// Long enough to ride out worst-case worker stalls; short enough that a wedged owner + /// surfaces as a 504 rather than a hung browser tab. + public static final long HTTP_READ_TIMEOUT_MS = 5_000L; + + @FunctionalInterface + public interface ActionExecutor { + void execute(Action action, PlayerState player) throws Exception; + } + + private static final long PATCH_INTERVAL_MS = 100L; + private static final long MINIMAP_INTERVAL_MS = 100L; + private static final BooleanSupplier ALWAYS_ACTIVE = () -> true; + + /// Resolvers for [PlayerState#markDirty]ed paths — their serialized form is computed at + /// drain time rather than at edit time. + private static final Map> COMPUTERS = Map.of( + "visibleEntities", PatchValue::visibleEntities, + "openedWindow", p -> p.openedWindow, + "scoreboard", p -> p.scoreboard, + "clientConnectionState", p -> String.valueOf(p.clientConnectionState), + "serverConnectionState", p -> String.valueOf(p.serverConnectionState)); + + public final UUID id; + public final PacketTimeline packets; + public final LifecycleHistory lifecycle = new LifecycleHistory(); + public final Registries registries = Registries.vanilla(); + public final Transcoder jsonCoder = WebJson.coder(registries); + public final long connectedAt; + + /// Listeners fire on the session's owner thread, synchronously, in registration order. + /// Keep handlers cheap — forward to a queue, increment a counter, build a small JSON object. + private final java.util.List listeners = new java.util.concurrent.CopyOnWriteArrayList<>(); + + public volatile ConnectionState clientToServerState = ConnectionState.HANDSHAKE; + public volatile ConnectionState serverToClientState = ConnectionState.HANDSHAKE; + public volatile int clientCompressionThreshold = -1; + public volatile int upstreamCompressionThreshold = -1; + + private final AtomicBoolean closed = new AtomicBoolean(); + private final PlayerState player = new PlayerState(); + private final ArrayBlockingQueue> stateTasks = new ArrayBlockingQueue<>(STATE_QUEUE_CAPACITY); + private volatile Thread ownerThread; + private volatile Thread defaultLoopThread; + private volatile UUID playerUuid; + private volatile long disconnectedAt; + private volatile Runnable onClosed; + private final AtomicBoolean stopping = new AtomicBoolean(); + /// Read off-owner during `onSessionOpen` before any worker has bound — `player.address` + /// can't be reached from the queue at that point. + private volatile String initialAddress; + /// Backend assignment for this session. Set by the acceptor right after the router picks + /// a target; immutable for the connection's lifetime (a `SERVER_SWITCH` always means a + /// new `Session`, never a swap on this one). + private volatile java.net.InetSocketAddress backendAddress; + /// Journey id stitching this session to any previous sessions for the same player UUID. + private volatile UUID journeyId; + + /// State-thread-only cadence trackers (last-fired wall-clock ms). + private long lastPatchMs; + private long lastMinimapMs; + + /// Cadence gates set by the host. `false` skips drainPatch / minimap raster on the next tick; + /// `flushTrafficCounters` + [SessionEvent.TrafficSnapshot] keep firing either way. + private volatile BooleanSupplier patchActive = ALWAYS_ACTIVE; + private volatile BooleanSupplier minimapActive = ALWAYS_ACTIVE; + + /// State-thread-only routine evaluator state. Mutated only from the session worker. + private List routines = List.of(); + private final Map routineMatched = new HashMap<>(); + private final Map routineLastFired = new HashMap<>(); + private volatile ActionExecutor actionExecutor; + + public Session(int decodedPacketCacheSize) { + this(UUID.randomUUID(), decodedPacketCacheSize); + } + + public Session(UUID id, int decodedPacketCacheSize) { + this.id = id; + this.connectedAt = player.connectedAt; + this.player.connectionId = id; + this.packets = new PacketTimeline(decodedPacketCacheSize); + } + + public void addListener(SessionListener listener) { + listeners.add(listener); + } + + public int listenerCount() { + return listeners.size(); + } + + public void publish(SessionEvent event) { + for (SessionListener listener : listeners) { + try { listener.onEvent(event); } + catch (Throwable t) { LOGGER.debug("listener failed for {}: {}", id, t.toString()); } + } + } + + /// Direct write before any owner binds — used by the registry to seed `address` before the + /// session is exposed. + public void initAddress(String address) { + if (ownerThread != null) throw new IllegalStateException("owner already bound; cannot init"); + player.address = address; + this.initialAddress = address; + } + + public String initialAddress() { + return initialAddress; + } + + public void setBackendAddress(java.net.InetSocketAddress address) { + this.backendAddress = address; + final String label = address == null ? null : address.getHostString() + ":" + address.getPort(); + if (ownerThread == null) player.backendAddress = label; + else send(new SessionMessage.Mutate(p -> p.backendAddress = label)); + } + + public void setJourneyId(UUID id) { + this.journeyId = id; + if (ownerThread == null) player.journeyId = id; + else send(new SessionMessage.Mutate(p -> p.journeyId = id)); + } + + public java.net.InetSocketAddress backendAddress() { return backendAddress; } + public UUID journeyId() { return journeyId; } + + public void onClosed(Runnable callback) { + final Runnable prev = this.onClosed; + this.onClosed = prev == null ? callback : () -> { prev.run(); callback.run(); }; + } + + public boolean isOpen() { + return !closed.get(); + } + + public UUID playerUuid() { + return playerUuid; + } + + public long disconnectedAt() { + return disconnectedAt; + } + + public int stateQueueDepth() { + return stateTasks.size(); + } + + public void bindOwner() { + final Thread current = Thread.currentThread(); + if (ownerThread == current) return; + if (ownerThread != null) { + throw new IllegalStateException( + "session " + id + " already bound to " + ownerThread.getName()); + } + ownerThread = current; + } + + public boolean isOwnerThread() { + return Thread.currentThread() == ownerThread; + } + + /// For sessions without a proxy worker (replay, tests): spawn a VT that binds as owner and + /// just drains the mailbox + ticks cadence forever. + public synchronized void startDefaultLoop() { + if (ownerThread != null) return; + defaultLoopThread = Thread.ofVirtual().name("Minestom-Web-Session-" + id).start(() -> { + bindOwner(); + runDefaultLoop(); + }); + } + + private void runDefaultLoop() { + while (true) { + try { + final StateTask task = stateTasks.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS); + if (task != null) task.run(player); + } catch (InterruptedException _) { + Thread.currentThread().interrupt(); + return; + } + if (stopping.get()) return; + tickCadence(System.currentTimeMillis()); + } + } + + public int drainMailbox() { + assertOwnerThread(); + int n = 0; + for (StateTask task; (task = stateTasks.poll()) != null; ) { + task.run(player); + n++; + } + return n; + } + + public T readState(Function body) { + try { + return callState(body::apply); + } catch (RuntimeException | Error e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public T callState(StateCall body) throws Exception { + if (isOwnerThread()) return body.apply(player); + if (stopping.get()) { + throw new MailboxException(MailboxException.Reason.BUSY, + "session worker stopped for session " + id); + } + final var task = new StateTask<>(body); + enqueueStateTask(task); + return task.get(); + } + + /// Inbox-full → [MailboxException] with [MailboxException.Reason#BUSY] (HTTP 503); + /// worker didn't finish within `timeoutMs` → [MailboxException.Reason#TIMEOUT] (HTTP 504). + public T tryReadState(Function body, long timeoutMs) { + if (isOwnerThread()) return body.apply(player); + if (stopping.get()) { + throw new MailboxException(MailboxException.Reason.BUSY, + "session worker stopped for session " + id); + } + final var task = new StateTask(body::apply); + if (!stateTasks.offer(task)) { + throw new MailboxException(MailboxException.Reason.BUSY, + "state worker queue full for session " + id); + } + try { + return task.getWithin(timeoutMs); + } catch (MailboxException e) { + throw e; + } catch (RuntimeException | Error e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + public void tryMutateState(Consumer body, long timeoutMs) { + tryReadState(player -> { body.accept(player); return null; }, timeoutMs); + } + + public void mutateState(Consumer body) { + readState(player -> { + body.accept(player); + return null; + }); + } + + public boolean send(SessionMessage message) { + return enqueueMessage(message); + } + + public void setActionExecutor(ActionExecutor executor) { + this.actionExecutor = executor; + } + + public void setActivityProbes(BooleanSupplier patch, BooleanSupplier minimap) { + this.patchActive = patch == null ? ALWAYS_ACTIVE : patch; + this.minimapActive = minimap == null ? ALWAYS_ACTIVE : minimap; + } + + private boolean enqueueMessage(SessionMessage message) { + final boolean accepted = stateTasks.offer(adapt(message)); + if (!accepted) { + // A dropped Mutate/SetRoutines silently diverges session state from intent (a lost + // SERVER_SWITCH mutate fails a transfer reconnect; a lost SetRoutines freezes a stale + // routine set), so a full mailbox must never be silent. + LOGGER.warn("mailbox full for session {} — dropped {}", id, message.getClass().getSimpleName()); + } + return accepted; + } + + private StateTask adapt(SessionMessage message) { + return switch (message) { + case SessionMessage.Mutate m -> new StateTask<>(p -> { m.body().accept(p); return null; }); + case SessionMessage.SetRoutines set -> new StateTask<>(_ -> { + routines = List.copyOf(set.routines()); + routineMatched.keySet().retainAll(routineIds(routines)); + routineLastFired.keySet().retainAll(routineIds(routines)); + return null; + }); + }; + } + + private static java.util.Set routineIds(List routines) { + final java.util.Set ids = new java.util.HashSet<>(routines.size()); + for (RegisteredRoutine r : routines) ids.add(r.routine().id()); + return ids; + } + + public void evaluateRoutinesOnPacket(Packet packet) { + // Mirror the cadence/match paths: don't evaluate against a not-yet-identified player. + if (routines.isEmpty() || playerUuid == null) return; + final long now = System.currentTimeMillis(); + for (RegisteredRoutine reg : routines) { + if (!reg.enabled()) continue; + final Routine r = reg.routine(); + if (!(r.trigger() instanceof Routine.Trigger.OnPacket(Class cls))) continue; + if (!cls.isInstance(packet)) continue; + if (!r.ql().matches(player)) continue; + tryFire(reg, now); + } + } + + private void evaluateRoutinesCadence(long now) { + if (routines.isEmpty()) return; + for (RegisteredRoutine reg : routines) { + if (!reg.enabled()) continue; + final Routine r = reg.routine(); + switch (r.trigger()) { + case Routine.Trigger.OnMatch _ -> evaluateMatchEdge(reg, true, now); + case Routine.Trigger.OnUnmatch _ -> evaluateMatchEdge(reg, false, now); + case Routine.Trigger.Interval interval -> { + if (playerUuid == null || !r.ql().matches(player)) continue; + final Long last = routineLastFired.get(r.id()); + if (last == null || (now - last) >= interval.millis()) tryFire(reg, now); + } + case Routine.Trigger.OnPacket _ -> { /* handled by evaluateRoutinesOnPacket */ } + } + } + } + + private void evaluateMatchEdge(RegisteredRoutine reg, boolean fireOnMatch, long now) { + final UUID id = reg.routine().id(); + final boolean matches = playerUuid != null && reg.routine().ql().matches(player); + final boolean was = routineMatched.getOrDefault(id, false); + if (matches != was && matches == fireOnMatch) tryFire(reg, now); + routineMatched.put(id, matches); + } + + private void tryFire(RegisteredRoutine reg, long now) { + final Routine r = reg.routine(); + if (r.debounceMs() > 0) { + final Long last = routineLastFired.get(r.id()); + if (last != null && (now - last) < r.debounceMs()) return; + } + routineLastFired.put(r.id(), now); + final ActionExecutor exec = actionExecutor; + if (exec == null) return; + try { exec.execute(r.action(), player); } + catch (Throwable t) { LOGGER.warn("routine {} action failed: {}", r.id(), t.toString()); } + } + + public void assertOwnerThread() { + if (ownerThread == null) { + throw new IllegalStateException("session " + id + " has no owner thread bound"); + } + if (Thread.currentThread() != ownerThread) { + throw new IllegalStateException( + "PlayerState access must run on " + ownerThread.getName() + + " (was " + Thread.currentThread().getName() + ")"); + } + } + + public PlayerState playerForOwnerThread() { + assertOwnerThread(); + return player; + } + + public UUID refreshPlayerUuid() { + assertOwnerThread(); + playerUuid = player.uuid; + return playerUuid; + } + + public boolean close() { + if (!closed.compareAndSet(false, true)) return false; + if (isOwnerThread()) { + finishClose(); + } else if (ownerThread == null) { + // No worker ever bound (e.g. login failed before ConnectionWorker.run started): + // adopt the calling thread as owner so the teardown — including the retain() + // readState in the onClosed callback — runs inline instead of blocking forever on + // a mailbox nobody will ever drain. + bindOwner(); + finishClose(); + } else { + try { mutateState(_ -> finishClose()); } + catch (Throwable t) { LOGGER.debug("close ack failed for {}: {}", id, t.toString()); } + } + final Thread defaultLoop = defaultLoopThread; + if (defaultLoop != null) defaultLoop.interrupt(); + return true; + } + + private void finishClose() { + assertOwnerThread(); + disconnectedAt = System.currentTimeMillis(); + player.disconnectedAt = disconnectedAt; + final Runnable cb = onClosed; + if (cb != null) cb.run(); + publish(new SessionEvent.Closed(disconnectedAt)); + listeners.clear(); + stopping.set(true); + } + + private void enqueueStateTask(StateTask task) { + if (!stateTasks.offer(task)) { + throw new MailboxException(MailboxException.Reason.BUSY, + "state worker queue full for session " + id); + } + } + + public void tickCadence(long now) { + assertOwnerThread(); + if (listeners.isEmpty()) { + lastPatchMs = lastMinimapMs = now; + return; + } + if (now - lastPatchMs >= PATCH_INTERVAL_MS) { + lastPatchMs = now; + player.flushTrafficCounters(); + // Bridge sums these into the scope's global metrics — fires unconditionally so a + // late metrics subscriber sees fresh totals without a mailbox roundtrip. + publish(new SessionEvent.TrafficSnapshot( + now, + player.traffic.bytesIn, + player.traffic.bytesOut, + player.traffic.packetsIn, + player.traffic.packetsOut)); + if (patchActive.getAsBoolean() && player.hasPending()) { + StatePatch patch = player.drainPatch(path -> { + Function fn = COMPUTERS.get(path); + return fn == null ? null : fn.apply(player); + }); + if (patch != null && !patch.isEmpty()) { + publish(new SessionEvent.Patch(patch)); + } + } + } + if (now - lastMinimapMs >= MINIMAP_INTERVAL_MS) { + lastMinimapMs = now; + if (minimapActive.getAsBoolean()) { + JsonObject frame = MinimapCodec.frameJson(player); + if (frame != null) publish(new SessionEvent.MinimapFrame(frame)); + } + } + evaluateRoutinesCadence(now); + } + + private static final class StateTask { + private final StateCall body; + private final CompletableFuture result = new CompletableFuture<>(); + + StateTask(StateCall body) { + this.body = body; + } + + void run(PlayerState player) { + try { + result.complete(body.apply(player)); + } catch (Throwable t) { + result.completeExceptionally(t); + } + } + + T get() throws Exception { + try { + return result.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + if (cause instanceof Exception exception) throw exception; + throw new RuntimeException(cause); + } + } + + T getWithin(long timeoutMs) throws Exception { + try { + return result.get(timeoutMs, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw e; + } catch (java.util.concurrent.TimeoutException _) { + throw new MailboxException(MailboxException.Reason.TIMEOUT, + "session worker exceeded " + timeoutMs + "ms"); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + if (cause instanceof Exception exception) throw exception; + throw new RuntimeException(cause); + } + } + } + + @FunctionalInterface + public interface StateCall { + T apply(PlayerState player) throws Exception; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/session/SessionEvent.java b/web/src/main/java/net/minestom/web/internal/session/SessionEvent.java new file mode 100644 index 00000000000..6e91d5b10e3 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionEvent.java @@ -0,0 +1,40 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonObject; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.web.Direction; +import net.minestom.web.LifecycleEvent; +import net.minestom.web.PacketEvent; +import net.minestom.web.StatePatch; + +import java.util.UUID; + +public sealed interface SessionEvent { + + record Patch(StatePatch patch) implements SessionEvent {} + + record Lifecycle(LifecycleEvent event) implements SessionEvent {} + + record PacketSeen( + Direction direction, + ConnectionState state, + Packet packet, + PacketEvent timelineEvent, + UUID playerUuid, + UUID connectionId, + String username + ) implements SessionEvent {} + + record MinimapFrame(JsonObject frame) implements SessionEvent {} + + record TrafficSnapshot( + long ts, + long bytesIn, + long bytesOut, + long packetsIn, + long packetsOut + ) implements SessionEvent {} + + record Closed(long ts) implements SessionEvent {} +} diff --git a/web/src/main/java/net/minestom/web/internal/session/SessionListener.java b/web/src/main/java/net/minestom/web/internal/session/SessionListener.java new file mode 100644 index 00000000000..c36246eee65 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionListener.java @@ -0,0 +1,7 @@ +package net.minestom.web.internal.session; + +/// Receives [SessionEvent]s on the subscription's drainer thread (not the session worker). +@FunctionalInterface +public interface SessionListener { + void onEvent(SessionEvent event); +} diff --git a/web/src/main/java/net/minestom/web/internal/session/SessionMessage.java b/web/src/main/java/net/minestom/web/internal/session/SessionMessage.java new file mode 100644 index 00000000000..d2913c2e0df --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionMessage.java @@ -0,0 +1,16 @@ +package net.minestom.web.internal.session; + +import net.minestom.web.PlayerState; +import net.minestom.web.RegisteredRoutine; + +import java.util.List; +import java.util.function.Consumer; + +/// Typed messages a producer sends into a [Session] mailbox. They are fire-and-forget — the +/// body runs on the session worker thread when the mailbox is drained. +public sealed interface SessionMessage { + + record Mutate(Consumer body) implements SessionMessage {} + + record SetRoutines(List routines) implements SessionMessage {} +} diff --git a/web/src/main/java/net/minestom/web/internal/session/SessionRegistry.java b/web/src/main/java/net/minestom/web/internal/session/SessionRegistry.java new file mode 100644 index 00000000000..8c7640fc20d --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionRegistry.java @@ -0,0 +1,323 @@ +package net.minestom.web.internal.session; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import net.minestom.web.Action; +import net.minestom.web.LifecycleEvent; +import net.minestom.web.PlayerState; +import net.minestom.web.Query; +import net.minestom.web.RegisteredAction; +import net.minestom.web.RegisteredRoutine; +import net.minestom.web.Routine; +import net.minestom.web.internal.codec.RoutineCodecs; +import net.minestom.web.internal.proxy.JourneyTracker; +import net.minestom.web.internal.expression.QueryEngine; +import net.minestom.web.internal.state.StateApplier; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/// Live sessions + retained snapshots + the routine/action catalogue. Routine CRUD broadcasts +/// a [SessionMessage.SetRoutines] to every session so per-session evaluators stay in sync. +public final class SessionRegistry { + private static final Logger LOGGER = LoggerFactory.getLogger(SessionRegistry.class); + + private final int decodedPacketCacheSize; + private final @Nullable QueryEngine queries; + private volatile @Nullable JourneyTracker journeys; + private final StateApplier applier = new StateApplier(this); + private final Map sessions = new ConcurrentHashMap<>(); + private final Map liveByPlayerUuid = new ConcurrentHashMap<>(); + private final Map retainedByPlayerUuid = new ConcurrentHashMap<>(); + + private final Map routines = new ConcurrentHashMap<>(); + private final Map actions = new ConcurrentHashMap<>(); + private volatile List routineSnapshot = List.of(); + /// Late-bound to break the proxy↔registry construction cycle. + private volatile @Nullable ActionRunner actionRunner; + + private final List> openListeners = new CopyOnWriteArrayList<>(); + private final List> closeListeners = new CopyOnWriteArrayList<>(); + private final List> evictListeners = new CopyOnWriteArrayList<>(); + + /// Convenience for tests / replay paths that never compile routine queries. + public SessionRegistry(int decodedPacketCacheSize) { + this(decodedPacketCacheSize, null); + } + + public SessionRegistry(int decodedPacketCacheSize, @Nullable QueryEngine queries) { + this.decodedPacketCacheSize = decodedPacketCacheSize; + this.queries = queries; + } + + public StateApplier applier() { return applier; } + + public void attachJourneyTracker(@Nullable JourneyTracker tracker) { this.journeys = tracker; } + + public void attachActionRunner(ActionRunner runner) { this.actionRunner = runner; } + + public @Nullable ActionRunner actionRunner() { return actionRunner; } + + public void onSessionOpen(Consumer listener) { openListeners.add(listener); } + public void onSessionClose(Consumer listener) { closeListeners.add(listener); } + public void onSessionEvict(Consumer listener) { evictListeners.add(listener); } + + /// Index `session` by its player UUID so `/api/players/{uuid}` and `inject(uuid, …)` can + /// find it without scanning. Called by [StateApplier] once the UUID is revealed. + public void markLive(Session session) { + final UUID uuid = session.playerUuid(); + if (uuid == null) return; + liveByPlayerUuid.put(uuid, session); + final JourneyTracker tracker = journeys; + if (tracker != null && session.journeyId() != null && session.backendAddress() != null) { + tracker.recordAssignment(uuid, session.backendAddress()); + } + } + + public Collection players() { + final Map players = new LinkedHashMap<>(); + for (PlayerView.Retained snapshot : retainedByPlayerUuid.values()) { + players.put(snapshot.uuid(), snapshot); + } + for (Session session : livePlayerSessions()) { + UUID uuid = session.playerUuid(); + if (uuid != null) players.put(uuid, new PlayerView.Live(session)); + } + return new ArrayList<>(players.values()); + } + + public Collection livePlayerSessions() { + final Map live = new LinkedHashMap<>(); + for (Session session : sessions.values()) { + final UUID uuid = session.playerUuid(); + if (uuid == null || session.disconnectedAt() != 0) continue; + final Session existing = live.get(uuid); + if (existing == null || session.connectedAt >= existing.connectedAt) live.put(uuid, session); + } + return new ArrayList<>(live.values()); + } + + public Collection sessionsMatching(Query query) { + Objects.requireNonNull(query, "query"); + final ArrayList matches = new ArrayList<>(); + for (Session session : livePlayerSessions()) { + if (playerMatches(query, session)) matches.add(session); + } + return matches; + } + + public boolean playerMatches(Query query, Session session) { + Objects.requireNonNull(query, "query"); + Objects.requireNonNull(session, "session"); + // Bounded read: a single wedged owner thread must not pin the HTTP request thread while + // sessionsMatching scans every session. A busy/slow owner just counts as non-matching. + try { + return session.tryReadState(query::matches, Session.HTTP_READ_TIMEOUT_MS); + } catch (Exception e) { + return false; + } + } + + public Collection sessions() { + return sessions.values(); + } + + public Session sessionFor(UUID uuid) { + Session indexed = liveByPlayerUuid.get(uuid); + if (indexed != null) return indexed; + Session latest = null; + for (Session session : sessions.values()) { + if (!uuid.equals(session.playerUuid()) || session.disconnectedAt() != 0) continue; + if (latest == null || session.connectedAt >= latest.connectedAt) latest = session; + } + return latest; + } + + public PlayerView player(UUID uuid) { + final Session session = sessionFor(uuid); + if (session != null) return new PlayerView.Live(session); + return retainedByPlayerUuid.get(uuid); + } + + public Session sessionById(UUID sessionId) { + return sessions.get(sessionId); + } + + public Session openSession(String address) { + return openSession(UUID.randomUUID(), address); + } + + public Session openSession(UUID id, String address) { + final Session session = createSession(id, address); + notifyOpened(session); + return session; + } + + /// Create a session and register it, but defer firing open listeners until + /// [#notifyOpened] is called. The proxy uses this to stamp routing data (backend address, + /// journey id) on the session *before* listeners see it — otherwise persistence rows are + /// written with null routing columns. + public Session createSession(UUID id, String address) { + final Session session = new Session(id, decodedPacketCacheSize); + session.initAddress(address); + sessions.put(session.id, session); + closeOnSessionClose(session); + wireRoutines(session); + final JsonObject data = new JsonObject(); + data.addProperty("address", address == null ? "?" : address); + session.lifecycle.record(LifecycleEvent.Kind.CONNECT, -1, data); + return session; + } + + public void notifyOpened(Session session) { + fire(openListeners, session); + } + + private void closeOnSessionClose(Session session) { + session.onClosed(() -> { + final LifecycleEvent disconnect = session.lifecycle.record(LifecycleEvent.Kind.DISCONNECT, -1, new JsonObject()); + session.publish(new SessionEvent.Lifecycle(disconnect)); + fire(closeListeners, session); + retain(session); + }); + } + + private void retain(Session session) { + final PlayerView.Retained snapshot = session.readState(player -> { + if (player.uuid == null) return null; + return PlayerView.Retained.from(session, player); + }); + if (snapshot == null) { + // Never-identified session (e.g. failed/STATUS connection): nothing to retain, and + // nothing will ever evict() it, so drop it from the live map here. + sessions.remove(session.id); + return; + } + liveByPlayerUuid.remove(snapshot.uuid(), session); + retainedByPlayerUuid.merge(snapshot.uuid(), snapshot, (existing, candidate) -> + candidate.connectedAt() >= existing.connectedAt() ? candidate : existing); + } + + public void evict(PlayerView.Retained snapshot) { + retainedByPlayerUuid.remove(snapshot.uuid(), snapshot); + sessions.remove(snapshot.sessionId()); + fire(evictListeners, snapshot); + } + + public void closeAll() { + for (Session s : sessions.values()) s.close(); + sessions.clear(); + liveByPlayerUuid.clear(); + retainedByPlayerUuid.clear(); + } + + // ---- routines / actions ------------------------------------------------------------- + + public Collection listRoutines() { + return List.copyOf(routines.values()); + } + + public Collection routines() { + return routines.values().stream().map(RegisteredRoutine::routine).toList(); + } + + public Routine removeRoutine(UUID id) { + final RegisteredRoutine removed = routines.remove(id); + broadcastRoutines(); + return removed == null ? null : removed.routine(); + } + + public @Nullable RegisteredRoutine setRoutineEnabled(UUID id, boolean enabled) { + final RegisteredRoutine current = routines.get(id); + if (current == null) return null; + final RegisteredRoutine next = new RegisteredRoutine(current.routine(), enabled); + routines.put(id, next); + broadcastRoutines(); + return next; + } + + public RegisteredRoutine upsertRoutine(String json) { + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + UUID id = obj.has("id") ? UUID.fromString(obj.get("id").getAsString()) : UUID.randomUUID(); + String name = obj.has("name") ? obj.get("name").getAsString() : "routine-" + id; + String ql = obj.has("ql") && !obj.get("ql").isJsonNull() ? obj.get("ql").getAsString() : null; + Routine.Trigger trigger = RoutineCodecs.decodeTrigger(obj.getAsJsonObject("trigger")); + Action action = resolveAction(obj.getAsJsonObject("action")); + long debounceMs = obj.has("debounceMs") ? obj.get("debounceMs").getAsLong() : 0; + RegisteredRoutine previous = routines.get(id); + boolean enabled = previous == null || previous.enabled(); + Routine r = new Routine(id, name, compileQuery(ql), trigger, action, debounceMs); + RegisteredRoutine registered = new RegisteredRoutine(r, enabled); + routines.put(id, registered); + broadcastRoutines(); + return registered; + } + + public Collection listActions() { return actions.values(); } + + public RegisteredAction upsertAction(String json) { + JsonObject obj = JsonParser.parseString(json).getAsJsonObject(); + UUID id = obj.has("id") ? UUID.fromString(obj.get("id").getAsString()) : UUID.randomUUID(); + String name = obj.has("name") ? obj.get("name").getAsString() : "action-" + id; + RegisteredAction ra = new RegisteredAction(id, name, resolveAction(obj.getAsJsonObject("action"))); + actions.put(id, ra); + return ra; + } + + public RegisteredAction removeAction(UUID id) { return actions.remove(id); } + + /// Resolve inline action JSON or `{"type":"ref","id":""}`. + public Action resolveAction(JsonObject obj) { + return RoutineCodecs.decodeAction(obj, refId -> { + RegisteredAction ra = actions.get(refId); + if (ra == null) throw new IllegalArgumentException("unknown action ref: " + refId); + return ra.action(); + }); + } + + private void wireRoutines(Session session) { + session.setActionExecutor((action, player) -> { + final ActionRunner runner = actionRunner; + if (runner != null) runner.execute(action, player); + }); + session.send(new SessionMessage.SetRoutines(routineSnapshot)); + } + + private void broadcastRoutines() { + routineSnapshot = List.copyOf(routines.values()); + for (Session session : sessions.values()) { + session.send(new SessionMessage.SetRoutines(routineSnapshot)); + } + } + + private Query compileQuery(@Nullable String ql) { + if (queries == null) throw new IllegalStateException("registry has no QueryEngine; cannot compile routines"); + try { return queries.compile(ql); } + catch (Exception e) { + LOGGER.warn("query compile failed for `{}`: {}", ql, e.toString()); + final String source = ql == null ? "" : ql; + return new Query() { + @Override public String source() { return source; } + @Override public boolean matches(PlayerState state) { return false; } + }; + } + } + + private static void fire(List> listeners, T value) { + for (Consumer listener : listeners) { + try { listener.accept(value); } + catch (Throwable _) { /* subscribers defend themselves */ } + } + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/BlockColors.java b/web/src/main/java/net/minestom/web/internal/state/BlockColors.java new file mode 100644 index 00000000000..43ac1cb5d20 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/BlockColors.java @@ -0,0 +1,37 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.instance.block.Block; +import net.minestom.server.map.MapColors; + +/// Block → top-down minimap colour. Resolved from the block's registry `mapColorId`, which +/// indexes into [MapColors] — the same table vanilla maps use, so every block already carries +/// the right colour without per-block branching. +public final class BlockColors { + + public static final int UNKNOWN = rgb(120, 120, 120); + public static final int VOID = rgb(16, 20, 24); + + private static final int[] RGB_BY_ID; + + static { + final MapColors[] values = MapColors.values(); + RGB_BY_ID = new int[values.length]; + for (int i = 0; i < values.length; i++) { + final MapColors c = values[i]; + RGB_BY_ID[i] = rgb(c.red(), c.green(), c.blue()); + } + } + + private BlockColors() {} + + public static int colorOf(Block block) { + if (block == null) return VOID; + final int id = block.registry().mapColorId(); + if (id <= 0 || id >= RGB_BY_ID.length) return VOID; + return RGB_BY_ID[id]; + } + + private static int rgb(int r, int g, int b) { + return ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0xFF); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/ChatHudUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/ChatHudUpdaters.java new file mode 100644 index 00000000000..16bed12ab51 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/ChatHudUpdaters.java @@ -0,0 +1,192 @@ +package net.minestom.web.internal.state; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.play.ClientChatMessagePacket; +import net.minestom.server.network.packet.client.play.ClientCommandChatPacket; +import net.minestom.server.network.packet.client.play.ClientSignedCommandChatPacket; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.server.scoreboard.Sidebar; +import net.minestom.web.PlayerState; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +final class ChatHudUpdaters { + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(SystemChatPacket.class, (s, _, _, p) -> appendReceived(s, null, p.message(), + p.overlay() ? "actionbar" : "system")), + entry(PlayerChatMessagePacket.class, (s, _, _, p) -> appendReceived(s, p.sender().toString(), + p.unsignedContent() != null ? p.unsignedContent() : Component.text(p.messageBody().content()), + "player")), + entry(DisguisedChatPacket.class, (s, _, _, p) -> appendReceived(s, null, p.message(), "player")), + entry(ClientChatMessagePacket.class, (s, _, _, p) -> recordSent(s, "chat", p.message())), + entry(ClientCommandChatPacket.class, (s, _, _, p) -> recordSent(s, "command", p.message())), + entry(ClientSignedCommandChatPacket.class, (s, _, _, p) -> recordSent(s, "command", p.message())), + entry(ActionBarPacket.class, (s, _, _, p) -> + s.lastActionBar = s.set("lastActionBar", s.lastActionBar, p.text())), + entry(BossBarPacket.class, (s, _, _, p) -> { + UUID id = p.uuid(); + if (p.action() instanceof BossBarPacket.RemoveAction) { + s.set("bossBars." + id, s.bossBars.remove(id), null); + return; + } + var next = applyBossBar(p.action(), s.bossBars.get(id)); + if (next != null) s.bossBars.put(id, s.set("bossBars." + id, s.bossBars.get(id), next)); + }), + entry(DisplayScoreboardPacket.class, (s, _, _, p) -> { + if (s.scoreboard == null) { + s.scoreboard = s.set("scoreboard", null, new PlayerState.ScoreboardSnapshot( + p.scoreName(), null, String.valueOf(p.position()), new LinkedHashMap<>())); + } + }), + entry(ScoreboardObjectivePacket.class, (s, _, _, p) -> { + LinkedHashMap rows = s.scoreboard != null + ? new LinkedHashMap<>(s.scoreboard.rows()) : new LinkedHashMap<>(); + s.scoreboard = s.set("scoreboard", s.scoreboard, new PlayerState.ScoreboardSnapshot( + p.objectiveName(), p.objectiveValue(), + s.scoreboard != null ? s.scoreboard.slot() : null, rows)); + }), + entry(UpdateScorePacket.class, (s, _, _, p) -> { + if (s.scoreboard == null) return; + Component display = composeRowDisplay(p.entityName(), p.displayName(), + s.teams.get(s.teamByMember.get(p.entityName()))); + Sidebar.NumberFormat raw = p.numberFormat(); + PlayerState.NumberFormat fmt = raw == null ? null + : new PlayerState.NumberFormat(raw.formatType().name(), raw.content()); + s.scoreboard.rows().put(p.entityName(), + new PlayerState.ScoreboardRow(p.score(), display, fmt)); + s.markDirty("scoreboard"); + }), + entry(ResetScorePacket.class, (s, _, _, p) -> { + if (s.scoreboard == null) return; + if (p.objective() != null && !p.objective().equals(s.scoreboard.objectiveName())) return; + if (s.scoreboard.rows().remove(p.owner()) != null) s.markDirty("scoreboard"); + }), + entry(TeamsPacket.class, (s, _, _, p) -> applyTeam(s, p)), + entry(PlayerListHeaderAndFooterPacket.class, (s, _, _, p) -> + s.tabList = s.set("tabList", s.tabList, new PlayerState.TabListSnapshot(p.header(), p.footer())))); + + private ChatHudUpdaters() { + } + + private static void appendReceived(PlayerState s, String sender, Component content, String style) { + s.append("recentChat", s.chatReceived, + new PlayerState.ChatLine(System.currentTimeMillis(), sender, content, style), 200); + } + + private static void recordSent(PlayerState s, String kind, String text) { + if (text == null) return; + s.append("sentChat", s.chatSent, new PlayerState.SentChatLine(System.currentTimeMillis(), kind, text), 200); + } + + private static void applyTeam(PlayerState s, TeamsPacket packet) { + final String name = packet.teamName(); + switch (packet.action()) { + case TeamsPacket.CreateTeamAction create -> { + s.teams.put(name, new PlayerState.TeamSnapshot(create.teamPrefix(), create.teamSuffix(), + teamColorName(create.teamColor()))); + for (String entity : create.entities()) s.teamByMember.put(entity, name); + recomposeForTeam(s, name); + } + case TeamsPacket.UpdateTeamAction update -> { + s.teams.put(name, new PlayerState.TeamSnapshot(update.teamPrefix(), update.teamSuffix(), + teamColorName(update.teamColor()))); + recomposeForTeam(s, name); + } + case TeamsPacket.RemoveTeamAction _ -> { + if (s.teams.remove(name) == null) return; + List orphaned = new ArrayList<>(); + s.teamByMember.entrySet().removeIf(e -> { + if (!name.equals(e.getValue())) return false; + orphaned.add(e.getKey()); + return true; + }); + for (String entity : orphaned) recomposeForEntity(s, entity); + } + case TeamsPacket.AddEntitiesToTeamAction add -> { + for (String entity : add.entities()) { + s.teamByMember.put(entity, name); + recomposeForEntity(s, entity); + } + } + case TeamsPacket.RemoveEntitiesToTeamAction remove -> { + for (String entity : remove.entities()) { + if (name.equals(s.teamByMember.get(entity))) s.teamByMember.remove(entity); + recomposeForEntity(s, entity); + } + } + } + } + + private static void recomposeForTeam(PlayerState s, String teamName) { + if (s.scoreboard == null) return; + boolean changed = false; + for (Map.Entry entry : s.scoreboard.rows().entrySet()) { + if (!teamName.equals(s.teamByMember.get(entry.getKey()))) continue; + PlayerState.ScoreboardRow row = entry.getValue(); + Component display = composeRowDisplay(entry.getKey(), null, s.teams.get(teamName)); + entry.setValue(new PlayerState.ScoreboardRow(row.score(), display, row.numberFormat())); + changed = true; + } + if (changed) s.markDirty("scoreboard"); + } + + private static void recomposeForEntity(PlayerState s, String entityName) { + if (s.scoreboard == null) return; + PlayerState.ScoreboardRow row = s.scoreboard.rows().get(entityName); + if (row == null) return; + Component display = composeRowDisplay(entityName, null, s.teams.get(s.teamByMember.get(entityName))); + s.scoreboard.rows().put(entityName, + new PlayerState.ScoreboardRow(row.score(), display, row.numberFormat())); + s.markDirty("scoreboard"); + } + + /// Priority: `displayName` from `UpdateScorePacket`, then `team.prefix + colored(entityName) + /// + team.suffix`, then the entityName itself (with legacy `§` codes parsed). + private static Component composeRowDisplay(String entityName, @Nullable Component displayName, + @Nullable PlayerState.TeamSnapshot team) { + if (displayName != null) return displayName; + Component name = entityName.indexOf('§') >= 0 + ? LegacyComponentSerializer.legacySection().deserialize(entityName) + : Component.text(entityName); + if (team == null) return name; + if (team.teamColor() != null) { + NamedTextColor color = NamedTextColor.NAMES.value(team.teamColor()); + if (color != null) name = name.colorIfAbsent(color); + } + Component prefix = team.prefix() != null ? team.prefix() : Component.empty(); + Component suffix = team.suffix() != null ? team.suffix() : Component.empty(); + return Component.empty().append(prefix).append(name).append(suffix); + } + + private static @Nullable String teamColorName(@Nullable NamedTextColor color) { + return color == null ? null : NamedTextColor.NAMES.key(color); + } + + private static PlayerState.BossBarSnapshot applyBossBar(BossBarPacket.Action action, PlayerState.BossBarSnapshot p) { + return switch (action) { + case BossBarPacket.AddAction a -> new PlayerState.BossBarSnapshot( + a.title(), a.health(), a.color().name(), a.overlay().name(), a.flags() & 0xFF); + case BossBarPacket.UpdateHealthAction h -> p == null ? null : new PlayerState.BossBarSnapshot( + p.title(), h.health(), p.color(), p.division(), p.flags()); + case BossBarPacket.UpdateTitleAction t -> p == null ? null : new PlayerState.BossBarSnapshot( + t.title(), p.progress(), p.color(), p.division(), p.flags()); + case BossBarPacket.UpdateStyleAction st -> p == null ? null : new PlayerState.BossBarSnapshot( + p.title(), p.progress(), st.color().name(), st.overlay().name(), p.flags()); + case BossBarPacket.UpdateFlagsAction f -> p == null ? null : new PlayerState.BossBarSnapshot( + p.title(), p.progress(), p.color(), p.division(), f.flags() & 0xFF); + default -> null; + }; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/EntityGroups.java b/web/src/main/java/net/minestom/web/internal/state/EntityGroups.java new file mode 100644 index 00000000000..1497e0b21fc --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/EntityGroups.java @@ -0,0 +1,71 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.entity.EntityType; + +import java.util.Set; + +/// Coarse minimap classification for an entity type. Mirrors the filter-chip buckets in the +/// frontend. The unmatched fallthrough is `passive`, which keeps friendly mobs, NPCs, and new +/// vanilla entities readable on the dashboard until they're classified explicitly. +public final class EntityGroups { + + public static final String PLAYERS = "players"; + public static final String ITEMS = "items"; + public static final String PROJECTILES = "projectiles"; + public static final String VEHICLES = "vehicles"; + public static final String HOSTILE = "hostile"; + public static final String PASSIVE = "passive"; + public static final String OTHER = "other"; + + private static final Set ITEM_TYPES = Set.of( + EntityType.ITEM, EntityType.EXPERIENCE_ORB, + EntityType.ITEM_FRAME, EntityType.GLOW_ITEM_FRAME); + + private static final Set PROJECTILE_TYPES = Set.of( + EntityType.ARROW, EntityType.SPECTRAL_ARROW, EntityType.TRIDENT, + EntityType.FIREBALL, EntityType.SMALL_FIREBALL, EntityType.DRAGON_FIREBALL, + EntityType.SNOWBALL, EntityType.EGG, + EntityType.SPLASH_POTION, EntityType.LINGERING_POTION, + EntityType.SHULKER_BULLET, EntityType.LLAMA_SPIT, EntityType.WITHER_SKULL, + EntityType.FISHING_BOBBER, EntityType.EYE_OF_ENDER, EntityType.ENDER_PEARL, + EntityType.FIREWORK_ROCKET); + + private static final Set VEHICLE_TYPES = Set.of( + EntityType.OAK_BOAT, EntityType.SPRUCE_BOAT, EntityType.BIRCH_BOAT, + EntityType.JUNGLE_BOAT, EntityType.ACACIA_BOAT, EntityType.DARK_OAK_BOAT, + EntityType.MANGROVE_BOAT, EntityType.CHERRY_BOAT, EntityType.PALE_OAK_BOAT, + EntityType.OAK_CHEST_BOAT, EntityType.SPRUCE_CHEST_BOAT, EntityType.BIRCH_CHEST_BOAT, + EntityType.JUNGLE_CHEST_BOAT, EntityType.ACACIA_CHEST_BOAT, EntityType.DARK_OAK_CHEST_BOAT, + EntityType.MANGROVE_CHEST_BOAT, EntityType.CHERRY_CHEST_BOAT, EntityType.PALE_OAK_CHEST_BOAT, + EntityType.MINECART, EntityType.CHEST_MINECART, EntityType.FURNACE_MINECART, + EntityType.HOPPER_MINECART, EntityType.TNT_MINECART, EntityType.SPAWNER_MINECART, + EntityType.COMMAND_BLOCK_MINECART); + + /// Hostile mobs as of 1.21. Anything not matched by an earlier rule falls into `passive`. + private static final Set HOSTILE_TYPES = Set.of( + EntityType.ZOMBIE, EntityType.ZOMBIE_VILLAGER, EntityType.HUSK, EntityType.DROWNED, + EntityType.ZOMBIFIED_PIGLIN, EntityType.ZOGLIN, + EntityType.SKELETON, EntityType.STRAY, EntityType.WITHER_SKELETON, EntityType.BOGGED, + EntityType.SPIDER, EntityType.CAVE_SPIDER, + EntityType.CREEPER, EntityType.ENDERMAN, EntityType.ENDERMITE, EntityType.WITCH, + EntityType.BLAZE, EntityType.GHAST, EntityType.MAGMA_CUBE, EntityType.SLIME, + EntityType.PILLAGER, EntityType.VINDICATOR, EntityType.EVOKER, EntityType.VEX, + EntityType.RAVAGER, EntityType.ILLUSIONER, + EntityType.GUARDIAN, EntityType.ELDER_GUARDIAN, EntityType.PHANTOM, + EntityType.HOGLIN, EntityType.PIGLIN, EntityType.PIGLIN_BRUTE, + EntityType.SHULKER, EntityType.WARDEN, EntityType.WITHER, EntityType.ENDER_DRAGON, + EntityType.SILVERFISH, EntityType.BREEZE); + + private EntityGroups() { + } + + public static String classify(EntityType type) { + if (type == null) return OTHER; + if (type == EntityType.PLAYER) return PLAYERS; + if (ITEM_TYPES.contains(type)) return ITEMS; + if (PROJECTILE_TYPES.contains(type)) return PROJECTILES; + if (VEHICLE_TYPES.contains(type)) return VEHICLES; + if (HOSTILE_TYPES.contains(type)) return HOSTILE; + return PASSIVE; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/EntityUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/EntityUpdaters.java new file mode 100644 index 00000000000..a50c57ad408 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/EntityUpdaters.java @@ -0,0 +1,86 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.web.PlayerState; +import net.minestom.web.PlayerState.VisibleEntity; + +import java.util.Map; + +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +/// Visible entities (spawn / move / rotate / destroy). Each mutation flags `visibleEntities` +/// dirty so the next [PlayerState#drainPatch] reships the bucket — the entities collection +/// has no stable per-field path, so it's a "computed" patch field (resolved in +/// [net.minestom.web.internal.session.Session]'s cadence drain). The mark is cheap on the +/// repeat path: [PlayerState#markDirty] early-returns once the bucket is already pending. +/// +/// Per-entity provenance + change log are recorded inside [VisibleEntity#set] and surfaced +/// on demand by the entity drilldown REST endpoint — they don't ship in every patch. +final class EntityUpdaters { + + private static final String DIRTY = "visibleEntities"; + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(SpawnEntityPacket.class, (s, _, _, p) -> spawn(s, p)), + entry(EntityPositionPacket.class, (s, _, _, p) -> + moveDelta(s, p.entityId(), p.deltaX(), p.deltaY(), p.deltaZ(), Float.NaN)), + entry(EntityPositionAndRotationPacket.class, (s, _, _, p) -> + moveDelta(s, p.entityId(), p.deltaX(), p.deltaY(), p.deltaZ(), p.yaw())), + entry(EntityRotationPacket.class, (s, _, _, p) -> { + final VisibleEntity e = s.visibleEntities.get(p.entityId()); + if (e == null) return; + e.yaw = e.set(s.currentProvenance, "yaw", e.yaw, p.yaw()); + s.markDirty(DIRTY); + }), + entry(EntityPositionSyncPacket.class, (s, _, _, p) -> + moveAbs(s, p.entityId(), p.position(), p.yaw())), + entry(EntityTeleportPacket.class, (s, _, _, p) -> + moveAbs(s, p.entityId(), p.position(), p.position().yaw())), + entry(DestroyEntitiesPacket.class, (s, _, _, p) -> { + if (p.entityIds().isEmpty()) return; + for (Integer id : p.entityIds()) s.visibleEntities.remove(id); + s.markDirty(DIRTY); + })); + + private EntityUpdaters() { + } + + private static void spawn(PlayerState s, SpawnEntityPacket p) { + final VisibleEntity e = new VisibleEntity(); + e.id = p.entityId(); + e.uuid = p.uuid(); + e.type = e.set(s.currentProvenance, "type", null, p.type().key().asString()); + e.group = EntityGroups.classify(p.type()); + e.x = e.set(s.currentProvenance, "x", 0.0, p.position().x()); + e.y = e.set(s.currentProvenance, "y", 0.0, p.position().y()); + e.z = e.set(s.currentProvenance, "z", 0.0, p.position().z()); + e.yaw = e.set(s.currentProvenance, "yaw", 0f, p.position().yaw()); + e.spawnSeq = s.currentProvenance != null ? s.currentProvenance.seq() : 0; + s.visibleEntities.put(e.id, e); + s.markDirty(DIRTY); + } + + /// NaN yaw means "rotation unchanged" — the position-only variant. + private static void moveDelta(PlayerState s, int entityId, short dx, short dy, short dz, float yaw) { + final VisibleEntity e = s.visibleEntities.get(entityId); + if (e == null) return; + e.x = e.set(s.currentProvenance, "x", e.x, e.x + dx / 4096.0); + e.y = e.set(s.currentProvenance, "y", e.y, e.y + dy / 4096.0); + e.z = e.set(s.currentProvenance, "z", e.z, e.z + dz / 4096.0); + if (!Float.isNaN(yaw)) e.yaw = e.set(s.currentProvenance, "yaw", e.yaw, yaw); + s.markDirty(DIRTY); + } + + private static void moveAbs(PlayerState s, int entityId, Point pos, float yaw) { + final VisibleEntity e = s.visibleEntities.get(entityId); + if (e == null) return; + e.x = e.set(s.currentProvenance, "x", e.x, pos.x()); + e.y = e.set(s.currentProvenance, "y", e.y, pos.y()); + e.z = e.set(s.currentProvenance, "z", e.z, pos.z()); + e.yaw = e.set(s.currentProvenance, "yaw", e.yaw, yaw); + s.markDirty(DIRTY); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/InventoryUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/InventoryUpdaters.java new file mode 100644 index 00000000000..c8412e695da --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/InventoryUpdaters.java @@ -0,0 +1,185 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.item.ItemStack; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.play.ClientClickWindowPacket; +import net.minestom.server.network.packet.client.play.ClientCloseWindowPacket; +import net.minestom.server.network.packet.client.play.ClientHeldItemChangePacket; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.web.PlayerState; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static net.minestom.web.internal.codec.WebCodecs.nullIfAir; +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +/// Hotbar / main / armor slots, the cursor, the offhand, and any container the player has open. +/// Live-mirrors slot mutations so the dashboard's inventory tab reflects per-click changes +/// before vanilla's bulk `WindowItemsPacket` resync arrives. +final class InventoryUpdaters { + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(HeldItemChangePacket.class, (s, _, _, p) -> + s.selectedHotbar = s.set("selectedHotbar", s.selectedHotbar, p.slot())), + entry(ClientHeldItemChangePacket.class, (s, _, _, p) -> + s.selectedHotbar = s.set("selectedHotbar", s.selectedHotbar, p.slot())), + entry(SetSlotPacket.class, (s, _, _, p) -> { + if (p.windowId() == -1 && p.slot() == -1) { + s.cursor = s.set("cursor", s.cursor, nullIfAir(p.itemStack())); + return; + } + applyWindowSlot(s, p.windowId(), p.slot(), nullIfAir(p.itemStack())); + }), + entry(SetPlayerInventorySlotPacket.class, (s, _, _, p) -> applyPlayerInventorySlot(s, p.slot(), nullIfAir(p.itemStack()))), + entry(WindowItemsPacket.class, (s, _, _, p) -> { + if (p.windowId() == 0) { + var items = p.items(); + for (int i = 0; i < items.size(); i++) { + applyWindow0Slot(s, i, nullIfAir(items.get(i))); + } + } else { + // Snapshot for the currently opened container — the full slot vector arrives in + // one packet right after OpenWindow, and again as a resync after large mutations. + var win = s.openedWindow; + if (win != null && p.windowId() == win.id()) { + var items = p.items(); + ItemStack[] slots = new ItemStack[items.size()]; + for (int i = 0; i < items.size(); i++) slots[i] = nullIfAir(items.get(i)); + var fresh = new PlayerState.OpenedWindow(win.id(), win.type(), win.title(), slots, win.properties()); + s.openedWindow = s.set("openedWindow", win, fresh); + } + } + s.cursor = s.set("cursor", s.cursor, nullIfAir(p.carriedItem())); + }), + entry(SetCursorItemPacket.class, (s, _, _, p) -> + s.cursor = s.set("cursor", s.cursor, nullIfAir(p.itemStack()))), + entry(OpenWindowPacket.class, (s, _, _, p) -> { + var fresh = new PlayerState.OpenedWindow(p.windowId(), String.valueOf(p.windowType()), + p.title(), new ItemStack[0], new LinkedHashMap<>()); + s.openedWindow = s.set("openedWindow", s.openedWindow, fresh); + }), + entry(CloseWindowPacket.class, (s, _, _, p) -> + s.openedWindow = s.set("openedWindow", s.openedWindow, null)), + // Client-initiated close (player pressed Esc / closed inventory). The server doesn't + // echo CloseWindowPacket back, so without this the dashboard would keep the open-window + // widget around until the next OpenWindow / disconnect. + entry(ClientCloseWindowPacket.class, (s, _, _, p) -> + s.openedWindow = s.set("openedWindow", s.openedWindow, null)), + // Inbound slot intent. Keep the highlight event, then apply the client's changed-slots + // prediction so click-driven remove/set effects are visible until server packets reconcile. + entry(ClientClickWindowPacket.class, (s, _, _, p) -> { + int containerSize = s.openedWindow != null && p.windowId() == s.openedWindow.id() + ? s.openedWindow.slots().length : 0; + SlotRef ref = classifyClickSlot(p.windowId(), p.slot(), containerSize); + long seq = s.currentProvenance != null ? s.currentProvenance.seq() : 0L; + var ev = new PlayerState.ClickEvent( + seq, System.currentTimeMillis(), + p.windowId(), p.slot(), + ref.kind(), ref.localSlot(), + p.button() & 0xFF, + p.clickType().name()); + s.append("recentClicks", s.recentClicks, ev, 32); + + for (var changed : p.changedSlots().entrySet()) { + applyWindowSlot(s, p.windowId(), changed.getKey(), nullIfAir(changed.getValue().asItemStack())); + } + s.cursor = s.set("cursor", s.cursor, nullIfAir(p.clickedItem().asItemStack())); + })); + + private InventoryUpdaters() { + } + + private static void applyWindowSlot(PlayerState s, int windowId, int slot, ItemStack item) { + if (windowId == 0) { + applyWindow0Slot(s, slot, item); + return; + } + + // Live update for the open container: mutate the slot in-place so the dashboard sees + // per-click changes without waiting for a full WindowItemsPacket re-send. The patch + // ships the full `openedWindow` snapshot via `markDirty` because slot indices inside + // the container don't have stable per-field paths. + var win = s.openedWindow; + if (win == null || windowId != win.id() || slot < 0) return; + int containerSize = win.slots().length; + if (slot < containerSize) { + win.slots()[slot] = item; + s.markDirty("openedWindow"); + return; + } + applyContainerPlayerSlot(s, slot - containerSize, item); + } + + private static void applyWindow0Slot(PlayerState s, int slot, ItemStack item) { + applySlotRef(s, classifyClickSlot(0, slot, 0), item); + } + + private static void applyContainerPlayerSlot(PlayerState s, int slot, ItemStack item) { + if (slot >= 0 && slot < 27) applySlotRef(s, new SlotRef("main", slot), item); + else if (slot >= 27 && slot < 36) applySlotRef(s, new SlotRef("hotbar", slot - 27), item); + } + + private static void applyPlayerInventorySlot(PlayerState s, int slot, ItemStack item) { + if (slot >= 0 && slot <= 8) applySlotRef(s, new SlotRef("hotbar", slot), item); + else if (slot >= 9 && slot <= 35) applySlotRef(s, new SlotRef("main", slot - 9), item); + else if (slot >= 36 && slot <= 39) applySlotRef(s, new SlotRef("armor", 39 - slot), item); + else if (slot == 40) applySlotRef(s, new SlotRef("offhand", 0), item); + } + + private static void applySlotRef(PlayerState s, SlotRef ref, ItemStack item) { + switch (ref.kind()) { + case "hotbar" -> { + int slot = ref.localSlot(); + if (slot >= 0 && slot < s.hotbar.length) + s.hotbar[slot] = s.set("hotbar." + slot, s.hotbar[slot], item); + } + case "main" -> { + int slot = ref.localSlot(); + if (slot >= 0 && slot < s.mainInventory.length) + s.mainInventory[slot] = s.set("mainInventory." + slot, s.mainInventory[slot], item); + } + case "armor" -> { + int slot = ref.localSlot(); + if (slot >= 0 && slot < s.armor.length) + s.armor[slot] = s.set("armor." + slot, s.armor[slot], item); + } + case "offhand" -> s.offHand = s.set("offHand", s.offHand, item); + } + } + + /// One resolved click target — the wire `(windowId, slot)` pair translated into a logical + /// inventory section so the frontend's highlight animation can find the matching cell. + private record SlotRef(String kind, int localSlot) { + } + + /// Map a vanilla click `(windowId, slot)` to a `(kind, localSlot)` pair the inventory grid + /// can address. `slot == -999` (drop-outside) returns the `outside` sentinel. + /// + /// Player-inventory layout (windowId == 0): + /// `0` crafting result · `1..4` crafting grid · `5..8` armor · `9..35` main · + /// `36..44` hotbar · `45` offhand. + /// + /// Container layout (windowId != 0): first `containerSize` slots are the container, the + /// rest are the player's main+hotbar (27 + 9) in that order. + private static SlotRef classifyClickSlot(int windowId, int slot, int containerSize) { + if (slot < 0) return new SlotRef("outside", slot); + if (windowId == 0) { + if (slot == 0) return new SlotRef("crafting", 0); + if (slot < 5) return new SlotRef("craftingGrid", slot - 1); + if (slot < 9) return new SlotRef("armor", slot - 5); + if (slot < 36) return new SlotRef("main", slot - 9); + if (slot < 45) return new SlotRef("hotbar", slot - 36); + if (slot == 45) return new SlotRef("offhand", 0); + return new SlotRef("unknown", slot); + } + if (containerSize > 0 && slot < containerSize) return new SlotRef("container", slot); + if (containerSize > 0) { + int rel = slot - containerSize; + if (rel < 27) return new SlotRef("main", rel); + if (rel < 36) return new SlotRef("hotbar", rel - 27); + } + return new SlotRef("container", slot); + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/SessionWorldUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/SessionWorldUpdaters.java new file mode 100644 index 00000000000..464c9fb5b2a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/SessionWorldUpdaters.java @@ -0,0 +1,121 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.entity.GameMode; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.common.ClientPluginMessagePacket; +import net.minestom.server.network.packet.client.common.ClientSettingsPacket; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.client.login.ClientLoginStartPacket; +import net.minestom.server.network.packet.client.play.ClientPlayerPositionAndRotationPacket; +import net.minestom.server.network.packet.client.play.ClientPlayerPositionPacket; +import net.minestom.server.network.packet.client.play.ClientPlayerRotationPacket; +import net.minestom.server.network.packet.server.common.PluginMessagePacket; +import net.minestom.server.network.packet.server.login.LoginSuccessPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.server.network.packet.server.play.ChangeGameStatePacket; +import net.minestom.server.network.packet.server.play.JoinGamePacket; +import net.minestom.server.network.packet.server.play.RespawnPacket; +import net.minestom.server.world.DimensionType; +import net.minestom.web.PlayerState; + +import java.nio.charset.StandardCharsets; +import java.util.Map; + +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +/// Identity, world/dimension, gamemode, and player position/rotation. The "session shell" — +/// everything that frames a player's place in the world before vitals or inventory. +final class SessionWorldUpdaters { + + /// Plugin-message channel for the server/client brand exchange — same constant the vanilla + /// `PluginMessagePacket.brandPacket` factory writes. + private static final String BRAND_CHANNEL = "minecraft:brand"; + /// Dimension keys whose `min_y` is 0 and height is 256 (no overworld-style negative-Y). + private static final String NETHER = DimensionType.THE_NETHER.name(); + private static final String END = DimensionType.THE_END.name(); + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(ClientHandshakePacket.class, (s, _, _, p) -> + s.protocolVersion = s.set("protocolVersion", s.protocolVersion, p.protocolVersion())), + entry(ClientLoginStartPacket.class, (s, _, _, p) -> { + s.username = s.set("username", s.username, p.username()); + s.uuid = s.set("uuid", s.uuid, p.profileId()); + }), + entry(LoginSuccessPacket.class, (s, _, _, p) -> { + s.username = s.set("username", s.username, p.gameProfile().name()); + s.uuid = s.set("uuid", s.uuid, p.gameProfile().uuid()); + }), + entry(SetCompressionPacket.class, (s, _, _, p) -> + s.traffic.compressionThreshold = s.set("traffic.compressionThreshold", + s.traffic.compressionThreshold, p.threshold())), + entry(ClientSettingsPacket.class, (s, _, _, p) -> + s.locale = s.set("locale", s.locale, p.settings().locale().toLanguageTag())), + entry(PluginMessagePacket.class, (s, _, _, p) -> { + if (BRAND_CHANNEL.equals(p.channel())) + s.serverBrand = s.set("serverBrand", s.serverBrand, new String(p.data(), StandardCharsets.UTF_8)); + }), + entry(ClientPluginMessagePacket.class, (s, _, _, p) -> { + if (BRAND_CHANNEL.equals(p.channel())) + s.clientBrand = s.set("clientBrand", s.clientBrand, new String(p.data(), StandardCharsets.UTF_8)); + }), + entry(JoinGamePacket.class, (s, _, _, p) -> { + s.dimension = s.set("dimension", s.dimension, p.world()); + s.hardcore = s.set("hardcore", s.hardcore, p.isHardcore()); + s.gamemode = s.set("gamemode", s.gamemode, p.gameMode().name()); + resetForDimension(s, p.world()); + }), + entry(RespawnPacket.class, (s, _, _, p) -> { + s.dimension = s.set("dimension", s.dimension, p.worldName()); + s.gamemode = s.set("gamemode", s.gamemode, p.gameMode().name()); + resetForDimension(s, p.worldName()); + }), + entry(ChangeGameStatePacket.class, (s, _, _, p) -> { + if (p.reason() != ChangeGameStatePacket.Reason.CHANGE_GAMEMODE) return; + int ord = (int) p.value(); + GameMode[] modes = GameMode.values(); + if (ord >= 0 && ord < modes.length) + s.gamemode = s.set("gamemode", s.gamemode, modes[ord].name()); + }), + entry(ClientPlayerPositionPacket.class, (s, _, _, p) -> { + setPos(s, p.position()); + s.onGround = s.set("onGround", s.onGround, p.onGround()); + }), + entry(ClientPlayerPositionAndRotationPacket.class, (s, _, _, p) -> { + setPos(s, p.position()); + setRot(s, p.position().yaw(), p.position().pitch()); + s.onGround = s.set("onGround", s.onGround, p.onGround()); + }), + entry(ClientPlayerRotationPacket.class, (s, _, _, p) -> { + setRot(s, p.yaw(), p.pitch()); + s.onGround = s.set("onGround", s.onGround, p.onGround()); + })); + + private SessionWorldUpdaters() { + } + + private static void setPos(PlayerState s, net.minestom.server.coordinate.Point pos) { + s.posX = s.set("posX", s.posX, pos.x()); + s.posY = s.set("posY", s.posY, pos.y()); + s.posZ = s.set("posZ", s.posZ, pos.z()); + } + + private static void setRot(PlayerState s, float yaw, float pitch) { + s.yaw = s.set("yaw", s.yaw, yaw); + s.pitch = s.set("pitch", s.pitch, pitch); + } + + /// Wipe per-dimension state and re-seed minY/height so the next chunk-data decode picks the + /// right bits-per-entry. We can't read [net.minestom.server.world.DimensionType] from a + /// packet, so we follow vanilla defaults; custom dimensions decode into the right shape with + /// absolute Y slightly off until corrected by the next chunk. Visible entities are cleared + /// too — vanilla doesn't re-send `DestroyEntitiesPacket` across dimensions. + private static void resetForDimension(PlayerState s, String worldName) { + s.world.clearForDimensionChange(); + s.visibleEntities.clear(); + s.markDirty("visibleEntities"); + final boolean tall = worldName == null || !(NETHER.equals(worldName) || END.equals(worldName)); + s.world.dimensionMinY = tall ? -64 : 0; + s.world.dimensionHeight = tall ? 384 : 256; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/StateApplier.java b/web/src/main/java/net/minestom/web/internal/state/StateApplier.java new file mode 100644 index 00000000000..5c8e5ccebe1 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/StateApplier.java @@ -0,0 +1,175 @@ +package net.minestom.web.internal.state; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import net.minestom.server.network.ConnectionState; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.configuration.ClientFinishConfigurationPacket; +import net.minestom.server.network.packet.client.handshake.ClientHandshakePacket; +import net.minestom.server.network.packet.client.login.ClientLoginAcknowledgedPacket; +import net.minestom.server.network.packet.client.login.ClientLoginStartPacket; +import net.minestom.server.network.packet.server.configuration.FinishConfigurationPacket; +import net.minestom.server.network.packet.server.login.LoginSuccessPacket; +import net.minestom.server.network.packet.server.login.SetCompressionPacket; +import net.minestom.web.*; +import net.minestom.web.internal.http.JsonSerialization; +import net.minestom.web.internal.session.Session; +import net.minestom.web.internal.session.SessionEvent; +import net.minestom.web.internal.session.SessionRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/// Applies a decoded packet on the session's owner thread: records in the ring buffer, runs +/// the dispatched updater, mirrors connection state + traffic counters, emits lifecycle events +/// for protocol-phase milestones, and publishes a [SessionEvent.PacketSeen] to the session's +/// stream. +public final class StateApplier { + private static final Logger LOGGER = LoggerFactory.getLogger(StateApplier.class); + + @FunctionalInterface + interface Updater

    { + void apply(PlayerState state, Direction direction, ConnectionState connState, P packet); + } + + private static final Map, Updater> UPDATERS; + + static { + var map = new HashMap, Updater>(); + map.putAll(SessionWorldUpdaters.LISTENERS); + map.putAll(VitalsUpdaters.LISTENERS); + map.putAll(InventoryUpdaters.LISTENERS); + map.putAll(ChatHudUpdaters.LISTENERS); + map.putAll(EntityUpdaters.LISTENERS); + map.putAll(WorldUpdaters.LISTENERS); + UPDATERS = Map.copyOf(map); + } + + @SuppressWarnings("unchecked") + static

    Map.Entry, Updater> entry(Class

    cls, Updater

    updater) { + return Map.entry(cls, updater); + } + + @SafeVarargs + static Map, Updater> listeners(Map.Entry, Updater>... entries) { + return Map.ofEntries(entries); + } + + /// Apply a single packet's updaters — used by unit tests that drive [PlayerState] directly. + @SuppressWarnings("unchecked") + public static void applyPacket(PlayerState state, Direction direction, ConnectionState connState, Packet packet) { + var updater = UPDATERS.get(packet.getClass()); + if (updater != null) ((Updater) updater).apply(state, direction, connState, packet); + } + + private final SessionRegistry registry; + + public StateApplier(SessionRegistry registry) { + this.registry = registry; + } + + public void apply(Session session, Direction direction, ConnectionState state, + Packet packet, int sizeBytes, long ioEventSeq) { + final PlayerState player = session.playerForOwnerThread(); + final ConnectionState clientStateBefore = player.clientConnectionState; + final ConnectionState serverStateBefore = player.serverConnectionState; + + // Record first so the seq we hand to updaters matches the wire record. + final PacketRecord record = session.packets.recordDecoded(direction, state, packet, sizeBytes, ioEventSeq); + player.currentProvenance = new Provenance( + record.seq(), + System.currentTimeMillis(), + packet.getClass().getSimpleName(), + direction); + try { + applyPacket(player, direction, state, packet); + } catch (Throwable t) { + LOGGER.warn("state update failed for {}: {}", packet.getClass().getSimpleName(), t.toString()); + } finally { + player.currentProvenance = null; + } + // Player-POV counters: SERVERBOUND = bytes/packets FROM the player. + if (direction == Direction.SERVERBOUND) player.traffic.packetsIn++; + else player.traffic.packetsOut++; + // Mirror live session state for HTTP readers. The displayed threshold tracks the + // upstream leg — that's where compression was always set previously, and any + // independent client-leg value only differs during the brief online-mode auth. + player.clientConnectionState = session.clientToServerState; + player.serverConnectionState = session.serverToClientState; + if (clientStateBefore != player.clientConnectionState) { + player.markDirty("clientConnectionState"); + } + if (serverStateBefore != player.serverConnectionState) { + player.markDirty("serverConnectionState"); + } + player.traffic.compressionThreshold = session.upstreamCompressionThreshold; + + final UUID playerUuid = session.refreshPlayerUuid(); + if (playerUuid != null) registry.markLive(session); + recordLifecycle(session, packet, direction, record.seq(), clientStateBefore, serverStateBefore); + // Run on-packet routines before publishing — any SetCustom side-effect must be part of + // the same state revision the next patch will ship. + session.evaluateRoutinesOnPacket(packet); + session.publish(new SessionEvent.PacketSeen( + direction, state, packet, session.packets.latestEvent(), + player.uuid, player.connectionId, player.username)); + } + + private void recordLifecycle(Session session, Packet packet, Direction direction, long seq, + ConnectionState clientBefore, ConnectionState serverBefore) { + final LifecycleEvent.Kind kind = switch (packet) { + case ClientHandshakePacket _ -> LifecycleEvent.Kind.HANDSHAKE; + case ClientLoginStartPacket _ -> LifecycleEvent.Kind.LOGIN_START; + case SetCompressionPacket _ -> LifecycleEvent.Kind.COMPRESSION_SET; + case LoginSuccessPacket _ -> LifecycleEvent.Kind.LOGIN_SUCCESS; + case ClientLoginAcknowledgedPacket _ -> LifecycleEvent.Kind.CONFIGURATION_START; + case FinishConfigurationPacket _, + ClientFinishConfigurationPacket _ -> LifecycleEvent.Kind.CONFIGURATION_FINISH; + default -> null; + }; + if (kind != null) { + emit(session, session.lifecycle.record(kind, seq, serialisePacket(packet, direction))); + return; + } + // Direction-level transitions not covered by the packet matches above — the decoder has + // already advanced session.{client,server}ToClientState; surface a single PLAY_START + // per direction. + if (clientBefore != ConnectionState.PLAY && session.clientToServerState == ConnectionState.PLAY) { + emit(session, session.lifecycle.record(LifecycleEvent.Kind.PLAY_START, seq, directionJson("CLIENT_TO_SERVER"))); + } + if (serverBefore != ConnectionState.PLAY && session.serverToClientState == ConnectionState.PLAY) { + emit(session, session.lifecycle.record(LifecycleEvent.Kind.PLAY_START, seq, directionJson("SERVER_TO_CLIENT"))); + } + } + + private void emit(Session session, LifecycleEvent event) { + session.publish(new SessionEvent.Lifecycle(event)); + } + + /// JSON-ify the live packet using the same Gson adapter the per-packet REST endpoint uses; + /// fall back to a `{ error }` payload if serialisation throws so the lifecycle entry still + /// renders. + private static JsonElement serialisePacket(Packet packet, Direction direction) { + try { + JsonObject o = new JsonObject(); + o.addProperty("className", packet.getClass().getSimpleName()); + o.addProperty("direction", direction.name()); + o.add("record", JsonSerialization.GSON.toJsonTree(packet)); + return o; + } catch (Throwable t) { + JsonObject o = new JsonObject(); + o.addProperty("className", packet.getClass().getSimpleName()); + o.addProperty("error", t.toString()); + return o; + } + } + + private static JsonObject directionJson(String direction) { + JsonObject o = new JsonObject(); + o.addProperty("direction", direction); + return o; + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/VitalsUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/VitalsUpdaters.java new file mode 100644 index 00000000000..8cfc76c0da0 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/VitalsUpdaters.java @@ -0,0 +1,87 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.common.ClientKeepAlivePacket; +import net.minestom.server.network.packet.server.common.KeepAlivePacket; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.web.Direction; +import net.minestom.web.PlayerState; + +import java.util.Map; + +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +/// The player's own body: health, hunger, XP, abilities, status effects, attribute modifiers, +/// last combat damage, and proxied keep-alive round-trip time. +final class VitalsUpdaters { + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(UpdateHealthPacket.class, (s, _, _, p) -> { + s.health = s.set("health", s.health, p.health()); + s.food = s.set("food", s.food, p.food()); + s.saturation = s.set("saturation", s.saturation, p.foodSaturation()); + }), + entry(DamageEventPacket.class, (s, _, _, p) -> { + var fresh = new PlayerState.DamageEvent(System.currentTimeMillis(), 0, + String.valueOf(p.damageTypeId()), p.sourceEntityId()); + s.lastDamage = s.set("lastDamage", s.lastDamage, fresh); + }), + entry(SetExperiencePacket.class, (s, _, _, p) -> { + s.xpBar = s.set("xpBar", s.xpBar, p.percentage()); + s.xpLevel = s.set("xpLevel", s.xpLevel, p.level()); + }), + entry(PlayerAbilitiesPacket.class, (s, _, _, p) -> { + byte flags = p.flags(); + s.invulnerable = s.set("invulnerable", s.invulnerable, (flags & PlayerAbilitiesPacket.FLAG_INVULNERABLE) != 0); + s.flying = s.set("flying", s.flying, (flags & PlayerAbilitiesPacket.FLAG_FLYING) != 0); + s.allowFlying = s.set("allowFlying", s.allowFlying, (flags & PlayerAbilitiesPacket.FLAG_ALLOW_FLYING) != 0); + s.instantBreak = s.set("instantBreak", s.instantBreak, (flags & PlayerAbilitiesPacket.FLAG_INSTANT_BREAK) != 0); + s.flySpeed = s.set("flySpeed", s.flySpeed, p.flyingSpeed()); + s.walkSpeed = s.set("walkSpeed", s.walkSpeed, p.walkingSpeed()); + }), + entry(EntityEffectPacket.class, (s, _, _, p) -> { + var potion = p.potion(); + String id = potion.effect().key().asString(); + var fresh = new PlayerState.ActiveEffect(id, potion.amplifier(), potion.duration(), + (potion.flags() & 0x01) != 0, (potion.flags() & 0x02) != 0); + s.activeEffects.put(id, s.set("activeEffects." + id, s.activeEffects.get(id), fresh)); + }), + entry(RemoveEntityEffectPacket.class, (s, _, _, p) -> { + String id = p.potionEffect().key().asString(); + var prev = s.activeEffects.remove(id); + if (prev != null) s.set("activeEffects." + id, prev, null); + }), + entry(EntityAttributesPacket.class, (s, _, _, p) -> { + for (EntityAttributesPacket.Property prop : p.properties()) { + String name = prop.attribute().key().asString(); + // Box explicitly: attributes is Map; set's primitive double + // overload would auto-unbox and we'd lose the null check on first insert. + s.attributes.put(name, s.set("attributes." + name, s.attributes.get(name), Double.valueOf(prop.value()))); + // Surface max health as its own field so the dashboard's health gauge isn't + // pinned to 20 (matches both the `generic.max_health` and `max_health` keys). + if (name.endsWith("max_health")) { + s.maxHealth = s.set("maxHealth", s.maxHealth, (float) prop.value()); + } + } + }), + // Keep-alive RTT along the proxied path: stamp send time on the outbound (clientbound) + // keep-alive, measure on the matching client response decoded back from the player. + entry(KeepAlivePacket.class, (s, dir, _, p) -> { + if (dir != Direction.CLIENTBOUND) return; + s.traffic.lastKeepAliveOutId = p.id(); + s.traffic.lastKeepAliveOutAt = System.nanoTime(); + }), + entry(ClientKeepAlivePacket.class, (s, dir, _, p) -> { + if (dir != Direction.SERVERBOUND) return; + final PlayerState.Traffic t = s.traffic; + if (p.id() != t.lastKeepAliveOutId || t.lastKeepAliveOutAt <= 0) return; + final long ms = (System.nanoTime() - t.lastKeepAliveOutAt) / 1_000_000L; + if (ms == t.pingMs) return; + t.pingMs = s.set("traffic.pingMs", t.pingMs, ms); + s.append("traffic.pingHistory", t.pingHistory, ms, 200); + })); + + private VitalsUpdaters() { + } +} diff --git a/web/src/main/java/net/minestom/web/internal/state/WorldUpdaters.java b/web/src/main/java/net/minestom/web/internal/state/WorldUpdaters.java new file mode 100644 index 00000000000..4ef2f511d33 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/WorldUpdaters.java @@ -0,0 +1,279 @@ +package net.minestom.web.internal.state; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.instance.block.Block; +import net.minestom.server.instance.heightmap.Heightmap; +import net.minestom.server.instance.palette.Palette; +import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.network.packet.Packet; +import net.minestom.server.network.packet.client.play.ClientPlayerActionPacket; +import net.minestom.server.network.packet.client.play.ClientPlayerBlockPlacementPacket; +import net.minestom.server.network.packet.server.play.*; +import net.minestom.server.network.packet.server.play.data.ChunkData; +import net.minestom.web.PlayerState; +import net.minestom.web.PlayerWorld; + +import java.util.Arrays; +import java.util.Iterator; +import java.util.Map; + +import static net.minestom.server.coordinate.CoordConversion.*; +import static net.minestom.web.PlayerWorld.*; +import static net.minestom.web.internal.state.StateApplier.entry; +import static net.minestom.web.internal.state.StateApplier.listeners; + +/// World state mutators. Mirrors the chunk / block packet stream the vanilla client follows: +/// chunk data primes palettes and columns, block / section updates patch them, unload drops +/// them. Serverbound place / dig sequences land in [PlayerWorld#pendingChanges] until +/// [AcknowledgeBlockChangePacket] clears them. +final class WorldUpdaters { + + private static final NetworkBuffer.Type SECTION_SERIALIZER = ChunkData.Section.networkType(64); + + static final Map, StateApplier.Updater> LISTENERS = listeners( + entry(ChunkDataPacket.class, (s, _, _, p) -> applyChunkData(s, p)), + entry(UnloadChunkPacket.class, (s, _, _, p) -> applyUnload(s, p)), + entry(BlockChangePacket.class, (s, _, _, p) -> applyBlock(s, + p.blockPosition().blockX(), p.blockPosition().blockY(), p.blockPosition().blockZ(), + p.blockStateId())), + entry(MultiBlockChangePacket.class, (s, _, _, p) -> applyMultiBlockChange(s, p)), + entry(BlockEntityDataPacket.class, (s, _, _, p) -> applyBlockEntityData(s, p)), + entry(ClientPlayerBlockPlacementPacket.class, (s, _, _, p) -> + record(s, p.sequence(), p.blockPosition(), PredictedBlockChange.Kind.PLACE)), + entry(ClientPlayerActionPacket.class, (s, _, _, p) -> { + if (p.status() == ClientPlayerActionPacket.Status.FINISHED_DIGGING) { + record(s, p.sequence(), p.blockPosition(), PredictedBlockChange.Kind.BREAK); + } + }), + entry(AcknowledgeBlockChangePacket.class, (s, _, _, p) -> + s.world.pendingChanges.entrySet().removeIf(e -> e.getKey() <= p.sequence()))); + + private WorldUpdaters() { + } + + private static short[] decodeHeightmap(long[] data, int dimensionHeight, int dimensionMinY) { + if (data == null || data.length == 0) return null; + final int bitsPerEntry = 32 - Integer.numberOfLeadingZeros(Math.max(1, dimensionHeight)); + if (bitsPerEntry < 1 || bitsPerEntry > 31) return null; + final int entriesPerLong = 64 / bitsPerEntry; + final long mask = (1L << bitsPerEntry) - 1; + final int absOffset = dimensionMinY - 1; + final short[] out = new short[COLUMNS_PER_CHUNK]; + int containerIndex = 0; + for (int i = 0; i < COLUMNS_PER_CHUNK; i++) { + final int indexInContainer = i % entriesPerLong; + if (containerIndex >= data.length) { + out[i] = UNKNOWN; + continue; + } + final long entry = (data[containerIndex] >>> (indexInContainer * bitsPerEntry)) & mask; + out[i] = entry == 0 ? UNKNOWN : (short) (entry + absOffset); + if (indexInContainer == entriesPerLong - 1) containerIndex++; + } + return out; + } + + private static void applyChunkData(PlayerState s, ChunkDataPacket p) { + final Map map = p.chunkData().heightmaps(); + if (map.isEmpty()) return; + long[] longs = map.get(Heightmap.Type.WORLD_SURFACE); + if (longs == null) longs = map.get(Heightmap.Type.MOTION_BLOCKING); + if (longs == null) return; + final short[] heights = decodeHeightmap(longs, s.world.dimensionHeight, s.world.dimensionMinY); + if (heights == null) return; + + final int minSection = globalToChunk(s.world.dimensionMinY); + final int maxSection = minSection + (s.world.dimensionHeight / SECTION_SIZE); + final Palette[] palettes = parseSections(p.chunkData().data(), minSection, maxSection); + + int[] colors = null; + if (palettes != null) { + colors = readColumnColors(palettes, heights, minSection, maxSection); + } + if (colors == null) { + colors = new int[COLUMNS_PER_CHUNK]; + Arrays.fill(colors, UNKNOWN_COLOR); + } + + final long key = chunkIndex(p.chunkX(), p.chunkZ()); + final PlayerWorld.Chunk chunk = new PlayerWorld.Chunk( + p.chunkX(), p.chunkZ(), minSection, + palettes, + p.chunkData().blockEntities(), + heights, colors); + s.world.putChunk(chunk); + s.world.dirtyChunks.add(key); + s.world.unloadedChunks.remove(key); + } + + private static Palette[] parseSections(byte[] data, int minSection, int maxSection) { + if (data == null || data.length == 0) return null; + final int sectionCount = maxSection - minSection; + if (sectionCount <= 0) return null; + final NetworkBuffer buffer = NetworkBuffer.wrap(data, 0, data.length); + final Palette[] palettes = new Palette[sectionCount]; + try { + for (int s = 0; s < sectionCount; s++) { + ChunkData.Section section = SECTION_SERIALIZER.read(buffer); + palettes[s] = section.blockStates(); + } + } catch (Throwable t) { + return null; + } + return palettes; + } + + private static int[] readColumnColors(Palette[] palettes, short[] heights, int minSection, int maxSection) { + if (palettes == null || heights == null) return null; + final int sectionCount = maxSection - minSection; + if (sectionCount <= 0 || palettes.length != sectionCount) return null; + + final int[] out = new int[COLUMNS_PER_CHUNK]; + for (int z = 0; z < SECTION_SIZE; z++) { + for (int x = 0; x < SECTION_SIZE; x++) { + final int idx = (z << 4) | x; + final short worldY = heights[idx]; + if (worldY == UNKNOWN) { + out[idx] = BlockColors.VOID; + continue; + } + final int relIndex = Math.floorDiv(worldY, SECTION_SIZE) - minSection; + if (relIndex < 0 || relIndex >= palettes.length || palettes[relIndex] == null) { + out[idx] = BlockColors.UNKNOWN; + continue; + } + final Block block = Block.fromStateId(palettes[relIndex].get( + x, Math.floorMod(worldY, SECTION_SIZE), z)); + out[idx] = block == null ? BlockColors.UNKNOWN : BlockColors.colorOf(block); + } + } + return out; + } + + private static void applyUnload(PlayerState s, UnloadChunkPacket p) { + final long key = chunkIndex(p.chunkX(), p.chunkZ()); + if (s.world.chunks.remove(key) != null) { + s.world.dirtyChunks.remove(key); + s.world.unloadedChunks.add(key); + } + } + + private static void applyMultiBlockChange(PlayerState s, MultiBlockChangePacket p) { + final long pos = p.chunkSectionPosition(); + final int chunkX = (int) (pos >> 42); + final int chunkZ = (int) (pos << 22 >> 42); + final int sectionY = (int) (pos << 44 >> 44); + // Every block in the packet lives in this one chunk — resolve it once instead of per block. + final long key = chunkIndex(chunkX, chunkZ); + final PlayerWorld.Chunk chunk = s.world.chunks.get(key); + if (chunk == null) return; + for (long entry : p.blocks()) { + final int index = (int) (entry & 0xFFF); + final int stateId = (int) (entry >>> 12); + final int localX = sectionBlockIndexGetX(index); + final int localY = sectionBlockIndexGetY(index); + final int localZ = sectionBlockIndexGetZ(index); + final Point block = chunkBlockRelativeGetGlobal( + localX, sectionY * SECTION_SIZE + localY, localZ, chunkX, chunkZ); + applyBlock(s, chunk, key, block.blockX(), block.blockY(), block.blockZ(), stateId); + } + } + + private static void applyBlockEntityData(PlayerState s, BlockEntityDataPacket p) { + final int wx = p.blockPosition().blockX(); + final int wy = p.blockPosition().blockY(); + final int wz = p.blockPosition().blockZ(); + final PlayerWorld.Chunk chunk = s.world.getChunkAtBlock(wx, wz); + if (chunk == null) return; + final Block base = Block.fromKey(p.type().key()); + if (base == null) return; + final Block block = p.data() == null ? base : base.withNbt(p.data()); + if (!block.registry().isBlockEntity()) return; + // Block entities are keyed by chunk-local block index in ChunkData. + chunk.blockEntities.put(chunkBlockIndex(wx, wy, wz), block); + s.world.dirtyChunks.add(chunkIndex(chunk.chunkX, chunk.chunkZ)); + } + + /// Lazily allocate the per-column color cache, filled with [#UNKNOWN_COLOR]. + private static void ensureColumnColors(PlayerWorld.Chunk chunk) { + if (chunk.columnColors == null) { + chunk.columnColors = new int[COLUMNS_PER_CHUNK]; + Arrays.fill(chunk.columnColors, UNKNOWN_COLOR); + } + } + + private static void applyBlock(PlayerState s, int wx, int wy, int wz, int stateId) { + final long key = chunkIndex(globalToChunk(wx), globalToChunk(wz)); + final PlayerWorld.Chunk chunk = s.world.chunks.get(key); + if (chunk != null) applyBlock(s, chunk, key, wx, wy, wz, stateId); + } + + private static void applyBlock(PlayerState s, PlayerWorld.Chunk chunk, long key, + int wx, int wy, int wz, int stateId) { + chunk.setBlockState(wx, wy, wz, stateId); + + final int i = chunk.columnIndex(wx, wz); + final short current = chunk.heights[i]; + ensureColumnColors(chunk); + if (stateId != 0) { + if (current == UNKNOWN || wy >= current) { + chunk.heights[i] = (short) wy; + final Block block = Block.fromStateId(stateId); + chunk.columnColors[i] = block == null ? BlockColors.UNKNOWN : BlockColors.colorOf(block); + s.world.dirtyChunks.add(key); + } + } else if (current != UNKNOWN && wy == current) { + rescanColumn(s, chunk, wx, wy - 1, wz); + s.world.dirtyChunks.add(key); + } + } + + /// Walk palettes downward to find the highest non-air block in this column. + private static void rescanColumn(PlayerState s, PlayerWorld.Chunk chunk, int wx, int maxY, int wz) { + final int i = chunk.columnIndex(wx, wz); + ensureColumnColors(chunk); + if (maxY < s.world.dimensionMinY) { + chunk.heights[i] = UNKNOWN; + chunk.columnColors[i] = UNKNOWN_COLOR; + return; + } + if (chunk.sections == null) { + chunk.heights[i] = (short) maxY; + chunk.columnColors[i] = UNKNOWN_COLOR; + return; + } + final int lx = globalToSectionRelative(wx); + final int lz = globalToSectionRelative(wz); + final int startRel = Math.min(chunk.sections.length - 1, globalToChunk(maxY) - chunk.minSection); + for (int rel = startRel; rel >= 0; rel--) { + final var palette = chunk.sections[rel]; + if (palette == null) continue; + final int sectionY = chunk.minSection + rel; + final int startLocalY = rel == startRel ? globalToSectionRelative(maxY) : SECTION_SIZE - 1; + for (int localY = startLocalY; localY >= 0; localY--) { + final int stateId = palette.get(lx, localY, lz); + if (stateId == 0) continue; + final Block block = Block.fromStateId(stateId); + if (block == null || block.isAir()) continue; + final int worldY = sectionY * SECTION_SIZE + localY; + chunk.heights[i] = (short) worldY; + chunk.columnColors[i] = BlockColors.colorOf(block); + return; + } + } + chunk.heights[i] = UNKNOWN; + chunk.columnColors[i] = UNKNOWN_COLOR; + } + + private static void record(PlayerState s, int sequence, Point pos, PredictedBlockChange.Kind kind) { + final var pending = s.world.pendingChanges; + pending.put(sequence, new PredictedBlockChange( + pos.blockX(), pos.blockY(), pos.blockZ(), kind)); + while (pending.size() > MAX_PENDING) { + final Iterator> it = pending.entrySet().iterator(); + if (!it.hasNext()) break; + it.next(); + it.remove(); + } + } +} diff --git a/web/src/main/java/net/minestom/web/package-info.java b/web/src/main/java/net/minestom/web/package-info.java new file mode 100644 index 00000000000..49eb609a925 --- /dev/null +++ b/web/src/main/java/net/minestom/web/package-info.java @@ -0,0 +1,21 @@ +/// Public API for the Minestom Web Interface. +/// +/// A transparent Minecraft-protocol proxy with an in-memory state engine plus an +/// HTTP + WebSocket dashboard. Wire it in with: +/// +/// ```java +/// ProxyServer web = ProxyServer.builder() +/// .bindProxy(new InetSocketAddress("0.0.0.0", 25565)) +/// .defaultBackend(new InetSocketAddress("127.0.0.1", 25566)) +/// .bindDashboard(new InetSocketAddress("127.0.0.1", 8080)) +/// .token(System.getenv("WEB_TOKEN")) +/// .build(); +/// web.start(); +/// ``` +/// +/// Types in this package form the stable surface area; anything under +/// `net.minestom.web.internal.*` is implementation detail. +/// +/// **Doc style.** Source documentation uses JEP 467 markdown comments (`///`), never legacy +/// `/** … */` Javadoc. +package net.minestom.web; diff --git a/web/src/main/resources/logback.xml b/web/src/main/resources/logback.xml new file mode 100644 index 00000000000..4492439b285 --- /dev/null +++ b/web/src/main/resources/logback.xml @@ -0,0 +1,14 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} -- %msg%n + + + + + + + + + + diff --git a/web/src/main/resources/web/app.js b/web/src/main/resources/web/app.js new file mode 100644 index 00000000000..b8a7bb061d8 --- /dev/null +++ b/web/src/main/resources/web/app.js @@ -0,0 +1,402 @@ +var Mm=Object.defineProperty;var Bp=(t,e)=>{for(var n in e)Mm(t,n,{get:e[n],enumerable:!0})};var _e=!1;var na=Array.isArray,zp=Array.prototype.indexOf,zn=Array.prototype.includes,Ri=Array.from,Fl=Object.keys,Br=Object.defineProperty,dn=Object.getOwnPropertyDescriptor,co=Object.getOwnPropertyDescriptors,Bl=Object.prototype,qp=Array.prototype,ri=Object.getPrototypeOf,zl=Object.isExtensible;var At=()=>{};function Hp(t){return t()}function Ni(t){for(var e=0;e{t=a,e=i});return{promise:n,resolve:t,reject:e}}function ql(t,e,n=!1){return t===void 0?n?e():e:t}function fr(t,e){if(Array.isArray(t))return t;if(e===void 0||!(Symbol.iterator in t))return Array.from(t);let n=[];for(let a of t)if(n.push(a),n.length===e)break;return n}var yr=Symbol("$state"),us=Symbol("legacy props"),jp=Symbol(""),uo=Symbol("proxy path"),fo=Symbol("attributes"),fs=Symbol("class"),vs=Symbol("style"),ms=Symbol("text"),Ba=Symbol("form reset"),Hl=Symbol("hmr anchor"),za=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"},ni=!!globalThis.document?.contentType&&globalThis.document.contentType.includes("xml");var ai=3,hn=8;function Up(t){if(_e){let e=new Error(`invariant_violation +An invariant violation occurred, meaning Svelte's internal assumptions were flawed. This is a bug in Svelte, not your app \u2014 please open an issue at https://github.com/sveltejs/svelte, citing the following message: "${t}" +https://svelte.dev/e/invariant_violation`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/invariant_violation")}function $s(t){if(_e){let e=new Error(`lifecycle_outside_component +\`${t}(...)\` can only be used during component initialisation +https://svelte.dev/e/lifecycle_outside_component`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function Yp(){if(_e){let t=new Error("async_derived_orphan\nCannot create a `$derived(...)` with an `await` expression outside of an effect tree\nhttps://svelte.dev/e/async_derived_orphan");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/async_derived_orphan")}function jl(){if(_e){let t=new Error("bind_invalid_checkbox_value\nUsing `bind:value` together with a checkbox input is not allowed. Use `bind:checked` instead\nhttps://svelte.dev/e/bind_invalid_checkbox_value");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/bind_invalid_checkbox_value")}function Gp(){if(_e){let t=new Error(`derived_references_self +A derived value cannot reference itself recursively +https://svelte.dev/e/derived_references_self`);throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/derived_references_self")}function Ul(t,e,n){if(_e){let a=new Error(`each_key_duplicate +${n?`Keyed each block has duplicate key \`${n}\` at indexes ${t} and ${e}`:`Keyed each block has duplicate key at indexes ${t} and ${e}`} +https://svelte.dev/e/each_key_duplicate`);throw a.name="Svelte error",a}else throw new Error("https://svelte.dev/e/each_key_duplicate")}function Wp(t,e,n){if(_e){let a=new Error(`each_key_volatile +Keyed each block has key that is not idempotent \u2014 the key for item at index ${t} was \`${e}\` but is now \`${n}\`. Keys must be the same each time for a given item +https://svelte.dev/e/each_key_volatile`);throw a.name="Svelte error",a}else throw new Error("https://svelte.dev/e/each_key_volatile")}function Kp(t){if(_e){let e=new Error(`effect_in_teardown +\`${t}\` cannot be used inside an effect cleanup function +https://svelte.dev/e/effect_in_teardown`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/effect_in_teardown")}function Xp(){if(_e){let t=new Error("effect_in_unowned_derived\nEffect cannot be created inside a `$derived` value that was not itself created inside an effect\nhttps://svelte.dev/e/effect_in_unowned_derived");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function Zp(t){if(_e){let e=new Error(`effect_orphan +\`${t}\` can only be used inside an effect (e.g. during component initialisation) +https://svelte.dev/e/effect_orphan`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/effect_orphan")}function Jp(){if(_e){let t=new Error(`effect_update_depth_exceeded +Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state +https://svelte.dev/e/effect_update_depth_exceeded`);throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function Qp(){if(_e){let t=new Error(`hydration_failed +Failed to hydrate the application +https://svelte.dev/e/hydration_failed`);throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/hydration_failed")}function eu(){if(_e){let t=new Error("invalid_snippet\nCould not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`\nhttps://svelte.dev/e/invalid_snippet");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/invalid_snippet")}function tu(t){if(_e){let e=new Error(`props_invalid_value +Cannot do \`bind:${t}={undefined}\` when \`${t}\` has a fallback value +https://svelte.dev/e/props_invalid_value`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/props_invalid_value")}function ru(t){if(_e){let e=new Error(`rune_outside_svelte +The \`${t}\` rune is only available inside \`.svelte\` and \`.svelte.js/ts\` files +https://svelte.dev/e/rune_outside_svelte`);throw e.name="Svelte error",e}else throw new Error("https://svelte.dev/e/rune_outside_svelte")}function nu(){if(_e){let t=new Error("set_context_after_init\n`setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression\nhttps://svelte.dev/e/set_context_after_init");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/set_context_after_init")}function au(){if(_e){let t=new Error("state_descriptors_fixed\nProperty descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.\nhttps://svelte.dev/e/state_descriptors_fixed");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function iu(){if(_e){let t=new Error("state_prototype_fixed\nCannot set prototype of `$state` object\nhttps://svelte.dev/e/state_prototype_fixed");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/state_prototype_fixed")}function su(){if(_e){let t=new Error("state_unsafe_mutation\nUpdating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`\nhttps://svelte.dev/e/state_unsafe_mutation");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function ou(){if(_e){let t=new Error("svelte_boundary_reset_onerror\nA `` `reset` function cannot be called while an error is still being handled\nhttps://svelte.dev/e/svelte_boundary_reset_onerror");throw t.name="Svelte error",t}else throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}var aa={};var Zt=Symbol(),pn=Symbol("filename"),lu=Symbol("hmr"),vo="http://www.w3.org/1999/xhtml",_s="http://www.w3.org/2000/svg",Vl="http://www.w3.org/1998/Math/MathML";var Yl="@attach";var qn="font-weight: bold",Hn="font-weight: normal";function cu(t){_e?console.warn(`%c[svelte] await_reactivity_loss +%cDetected reactivity loss when reading \`${t}\`. This happens when state is read in an async function after an earlier \`await\` +https://svelte.dev/e/await_reactivity_loss`,qn,Hn):console.warn("https://svelte.dev/e/await_reactivity_loss")}function du(t,e){_e?console.warn(`%c[svelte] await_waterfall +%cAn async derived, \`${t}\` (${e}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app +https://svelte.dev/e/await_waterfall`,qn,Hn):console.warn("https://svelte.dev/e/await_waterfall")}function pu(){_e?console.warn(`%c[svelte] derived_inert +%cReading a derived belonging to a now-destroyed effect may result in stale values +https://svelte.dev/e/derived_inert`,qn,Hn):console.warn("https://svelte.dev/e/derived_inert")}function uu(t,e,n){_e?console.warn(`%c[svelte] hydration_attribute_changed +%cThe \`${t}\` attribute on \`${e}\` changed its value between server and client renders. The client value, \`${n}\`, will be ignored in favour of the server value +https://svelte.dev/e/hydration_attribute_changed`,qn,Hn):console.warn("https://svelte.dev/e/hydration_attribute_changed")}function fu(t){_e?console.warn(`%c[svelte] hydration_html_changed +%c${t?`The value of an \`{@html ...}\` block ${t} changed between server and client renders. The client value will be ignored in favour of the server value`:"The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value"} +https://svelte.dev/e/hydration_html_changed`,qn,Hn):console.warn("https://svelte.dev/e/hydration_html_changed")}function qa(t){_e?console.warn(`%c[svelte] hydration_mismatch +%c${t?`Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near ${t}`:"Hydration failed because the initial UI does not match what was rendered on the server"} +https://svelte.dev/e/hydration_mismatch`,qn,Hn):console.warn("https://svelte.dev/e/hydration_mismatch")}function vu(){_e?console.warn(`%c[svelte] lifecycle_double_unmount +%cTried to unmount a component that was not mounted +https://svelte.dev/e/lifecycle_double_unmount`,qn,Hn):console.warn("https://svelte.dev/e/lifecycle_double_unmount")}function mu(){_e?console.warn("%c[svelte] select_multiple_invalid_value\n%cThe `value` property of a ` `),q_={hash:"svelte-86eee5",code:` + @layer pages { + /* ---- Landing (replay upload) ----------------------------------- */.landing {max-width:720px;margin:var(--pad-7) auto;padding:0 var(--pad-5);}.landing__head h1 {margin:0 0 var(--pad-2);}.landing__head p {margin:0 0 var(--pad-5);line-height:1.25;}.landing__zone {border:2px dashed var(--line);padding:var(--pad-7) var(--pad-5);text-align:center;cursor:pointer;transition:border-color var(--motion), background var(--motion);background:var(--bg-1);box-shadow:var(--bevel);}.landing__zone:hover, + .landing__zone--over {border-color:var(--acc-line);background:var(--acc-soft);}.landing__zone--busy {cursor:progress;opacity:0.7;}.landing__icon {font-size:var(--t-2xl);line-height:1;margin-bottom:var(--pad-3);color:var(--acc);}.landing__title {font-size:var(--t-md);margin-bottom:6px;color:var(--ink);}.landing__option {display:inline-flex;align-items:center;gap:var(--pad-2);margin-top:var(--pad-3);font-size:var(--t-sm);}.landing__error {margin-top:var(--pad-5);padding:var(--pad-3) var(--pad-4);background:var(--danger-soft);color:var(--danger);font-size:var(--t-sm);}.landing__current {margin-top:var(--pad-5);display:flex;align-items:center;gap:var(--pad-3);font-size:var(--t-sm);flex-wrap:wrap;}.landing__current code {padding:2px var(--pad-2);background:var(--bg-2);border:1px solid var(--line);font-size:var(--t-xs);} + }`};function fd(t,e){le(e,!0),Ut(t,q_);let n=X(!1),a=X(!1),i=X(null),o=X(!0),c;async function p(k){if(!r(a)){E(a,!0),E(i,null);try{let M=await k.arrayBuffer();await ur.uploadReplay(M,k.name,r(o)),Ga("/")}catch(M){E(i,M?.message??String(M),!0)}finally{E(a,!1)}}}function u(k){k.preventDefault(),E(n,!1);let M=k.dataTransfer?.files?.[0];M&&p(M)}function $(k){k.preventDefault(),E(n,!0)}function g(k){k.currentTarget===k.target&&E(n,!1)}function v(){c.click()}function m(k){let M=k.target.files?.[0];M&&p(M)}var h=z_(),b=d(l(h),2),w=d(l(b),2),C=l(w,!0);s(w);var A=d(w,2),I=l(A);s(A);var P=d(A,2);Tt(P,k=>c=k,()=>c),s(b);var F=d(b,2),L=l(F);kt(L),ve(2),s(F);var B=d(F,2);{var O=k=>{var M=F_(),q=l(M,!0);s(M),T(()=>y(q,r(i))),f(k,M)};z(B,k=>{r(i)&&k(O)})}var S=d(B,2);{var R=k=>{var M=B_(),q=d(l(M),2),j=l(q,!0);s(q);var H=d(q,4);s(M),T(()=>y(j,ur.scope.label)),U("click",H,()=>ur.deleteCurrentScope()),f(k,M)};z(S,k=>{ur.scope&&k(R)})}s(h),T(()=>{pe(b,1,`landing__zone ${r(n)?"landing__zone--over":""} ${r(a)?"landing__zone--busy":""}`),y(C,r(a)?"Uploading & decoding\u2026":"Drop a .sqlite file, or click to pick"),y(I,`Protocol version must match this build (v${ur.protocolVersion??"?"??""}).`)}),Rt("dragover",b,$),Rt("dragleave",b,g),Rt("drop",b,u),U("click",b,v),U("keydown",b,k=>{(k.key==="Enter"||k.key===" ")&&v()}),U("change",P,m),Fs(L,()=>r(o),k=>E(o,k)),f(t,h),ce()}Pe(["click","keydown","change"]);function ll(t){return t.disconnectedAt?"ghost":t.serverConnectionState==="PLAY"?"on":t.serverConnectionState==="CONFIGURATION"?"warn":"ghost"}function cl(t,e){let n=t.disconnectedAt||e,a=t.connectedAt||e;return n-a}var H_=_('/'),j_=_(' '),U_=_('

    ');function ki(t,e){le(e,!0);let n=ae(e,"steps",19,()=>[]);var a=U_();de(a,21,n,lt,(i,o,c)=>{var p=j_(),u=l(p);tr(u,()=>r(o));var $=d(u,2);{var g=v=>{var m=H_();f(v,m)};z($,v=>{c"),Y_=_(''),G_=_('
    '),W_=_("
    ");function et(t,e){let n=ae(e,"title",3,void 0),a=ae(e,"meta",3,void 0),i=ae(e,"actions",3,void 0),o=ae(e,"flush",3,!1),c=ae(e,"headless",3,!1),p=ae(e,"className",3,""),u=ae(e,"children",3,void 0),$=x(()=>typeof n()=="function"),g=x(()=>typeof a()=="function");var v=W_(),m=l(v);{var h=A=>{var I=G_(),P=l(I);{var F=k=>{var M=V_(),q=l(M);{var j=D=>{var N=Me(),G=ie(N);tr(G,n),f(D,N)},H=D=>{var N=bt();T(()=>y(N,n())),f(D,N)};z(q,D=>{r($)?D(j):D(H,-1)})}s(M),f(k,M)};z(P,k=>{n()!=null&&k(F)})}var L=d(P,2),B=l(L);{var O=k=>{var M=Y_(),q=l(M);{var j=D=>{var N=Me(),G=ie(N);tr(G,a),f(D,N)},H=D=>{var N=bt();T(()=>y(N,a())),f(D,N)};z(q,D=>{r(g)?D(j):D(H,-1)})}s(M),f(k,M)};z(B,k=>{a()!=null&&k(O)})}var S=d(B,2);{var R=k=>{var M=Me(),q=ie(M);tr(q,i),f(k,M)};z(S,k=>{i()&&k(R)})}s(L),s(I),f(A,I)};z(m,A=>{c()||A(h)})}var b=d(m,2);let w;var C=l(b);tr(C,()=>u()??At),s(b),s(v),T(()=>{pe(v,1,`panel ${p()??""}`),w=pe(b,1,"panel-body",null,w,{flush:o()})}),f(t,v)}var K_=_(''),X_=_(" ");function pr(t,e){let n=ae(e,"kind",3,"ghost"),a=ae(e,"dot",3,!1);var i=X_(),o=l(i);{var c=u=>{var $=K_();f(u,$)};z(o,u=>{a()&&u(c)})}var p=d(o,2);tr(p,()=>e.children??At),s(i),T(()=>pe(i,1,`pill ${n()??""}`)),f(t,i)}var Z_=sn(''),J_=sn(''),Q_=_('\xB7 ',1),eg=_('
    Sessions live
    \xB7
    Throughput
    /s
    Packets
    /s
    \u25C4 \xB7 \u25BA
    Tick
    ms
    \xB7
    ');function vd(t,e){le(e,!0);let n=256,a=50,i=X(null),o=X(null),c=X(0);Nf(()=>{Ge("/metrics/latest").then(Se=>{Se&&(E(i,Se.mspt??null,!0),E(o,Se.tps??null,!0))}).catch(()=>{})}),Zr(gr.metrics,Se=>{typeof Se.mspt=="number"&&E(i,Se.mspt,!0),typeof Se.tps=="number"&&E(o,Se.tps,!0)});let p=x(()=>Ca.series),u=x(()=>r(p).connections);ge(()=>{for(let Se of r(u))Se>r(c)&&E(c,Se,!0)});let $=x(()=>r(u).at(-1)??0),g=x(()=>r(u).length>=6?r(u).at(-6)??0:r(u)[0]??0),v=x(()=>r($)-r(g)),m=x(()=>(r(p).bytesIn.at(-1)??0)+(r(p).bytesOut.at(-1)??0)),h=x(()=>{let Se=r(p).bytesIn,nt=r(p).bytesOut,ut=Math.min(Se.length,nt.length,60);if(ut===0)return[];let vt=new Array(ut);for(let Ct=0;Ct{if(!r(h).length)return 0;let Se=0;for(let nt of r(h))Se+=nt;return Se/r(h).length}),w=x(()=>r(b)<=0?0:Math.round((r(m)-r(b))/r(b)*100)),C=x(()=>Math.round(r(p).packetsIn.at(-1)??0)),A=x(()=>Math.round(r(p).packetsOut.at(-1)??0)),I=x(()=>r(C)+r(A)),P=x(()=>r(i)==null?"\u2014":r(i)<10?r(i).toFixed(1):Math.round(r(i)).toString()),F=x(()=>r(i)==null?0:Math.max(0,Math.min(100,r(i)/a*100))),L=x(()=>r(i)==null?{word:"\u2014",tone:"dim"}:r(i)<25?{word:"ample",tone:"ok"}:r(i)<40?{word:"cozy",tone:"ok"}:r(i)<50?{word:"tight",tone:"warn"}:{word:"over",tone:"danger"});function B(Se){if(!Se||Se.length<2)return null;let nt=1/0,ut=-1/0;for(let ct of Se)ctut&&(ut=ct);if(!Number.isFinite(nt)||!Number.isFinite(ut))return null;let vt=ut-nt||1,Ct=100/(Se.length-1),Dt="";for(let ct=0;ctB(r(u))),S=x(()=>B(r(h)));function R(Se){return Se>0?"\u25B2":Se<0?"\u25BC":"\xB7"}function k(Se){return Se>0?"up":Se<0?"down":""}var M=eg(),q=l(M),j=d(l(q),2),H=l(j),D=l(H,!0);s(H);var N=d(H,2);N.textContent="/ 256",s(j);var G=d(j,2),Y=l(G),K=l(Y),Z=l(K,!0);s(K);var Q=d(K);s(Y);var ee=d(Y,4),J=l(ee);s(ee),s(G);var te=d(G,2);{var V=Se=>{var nt=Z_(),ut=l(nt),vt=d(ut);s(nt),T(()=>{ne(ut,"d",r(O).area),ne(vt,"d",r(O).line)}),f(Se,nt)};z(te,Se=>{r(O)&&Se(V)})}s(q);var W=d(q,2),re=d(l(W),2),oe=l(re),ue=l(oe,!0);s(oe);var $e=d(oe,2),me=l($e,!0);s($e),ve(2),s(re);var he=d(re,2),be=l(he),ye=l(be),ze=l(ye,!0);s(ye);var Re=d(ye);s(be),s(he);var De=d(he,2);{var Be=Se=>{var nt=J_(),ut=l(nt),vt=d(ut);s(nt),T(()=>{ne(ut,"d",r(S).area),ne(vt,"d",r(S).line)}),f(Se,nt)};z(De,Se=>{r(S)&&Se(Be)})}s(W);var ot=d(W,2),Ke=d(l(ot),2),ke=l(Ke),Ze=l(ke,!0);s(ke),ve(2),s(Ke);var je=d(Ke,2),Le=l(je),qe=d(l(Le));s(Le);var Ae=d(Le,4),Ce=d(l(Ae));s(Ae),s(je);var Fe=d(je,2),Ne=l(Fe);let Ve;var mt=d(Ne,2);let He;s(Fe),s(ot);var Ie=d(ot,2),Je=d(l(Ie),2),$t=l(Je),Ee=l($t,!0);s($t),ve(2),s(Je);var Ye=d(Je,2),We=l(Ye);We.textContent="budget 50";var Oe=d(We,4),st=l(Oe,!0);s(Oe);var rt=d(Oe,2);{var ht=Se=>{var nt=Q_(),ut=d(ie(nt),2),vt=l(ut);s(ut),T(Ct=>y(vt,`${Ct??""} tps`),[()=>r(o).toFixed(0)]),f(Se,nt)};z(rt,Se=>{r(o)!=null&&Se(ht)})}s(Ye);var wt=d(Ye,2),dt=l(wt);let Ue;var Qe=d(dt,2);we(Qe,"",{},{left:"80%"}),s(wt),s(Ie),s(M),T((Se,nt,ut,vt,Ct,Dt,Vt,ct,Mt,It,qt)=>{y(D,r($)),pe(Y,1,Se),y(Z,nt),y(Q,` ${r(v)>0?"+":""}${r(v)??""} in last 5s`),y(J,`\u03B5 ${r(c)??""} ever`),y(ue,ut),y(me,vt),pe(be,1,Ct),y(ze,Dt),y(Re,` ${r(w)>0?"+":""}${r(w)??""}% vs 1m avg`),y(Ze,Vt),y(qe,` ${ct??""}`),y(Ce,` ${Mt??""}`),Ve=we(Ne,"",Ve,It),He=we(mt,"",He,qt),pe(Ie,1,"stat-card stat-card--tick tone-"+r(L).tone),y(Ee,r(P)),pe(Oe,1,"stat-card__mood "+r(L).tone),y(st,r(L).word),Ue=we(dt,"",Ue,{width:r(F)+"%"})},[()=>"stat-card__delta "+k(r(v)),()=>R(r(v)),()=>zt(r(m)).replace(/ \w+$/,""),()=>zt(r(m)).replace(/^[\d.]+ /,""),()=>"stat-card__delta "+k(r(w)),()=>R(r(w)),()=>Dr(r(I)),()=>Dr(r(C)),()=>Dr(r(A)),()=>({"--w":r(I)?Math.min(100,r(C)/r(I)*100)+"%":"0%"}),()=>({"--w":r(I)?Math.min(100,r(A)/r(I)*100)+"%":"0%"})]),f(t,M),ce()}var tg=_("
    ");function es(t,e){le(e,!0);let n=ae(e,"xValues",3,null),a=ae(e,"className",3,""),i=ae(e,"style",3,""),o,c=null;ge(()=>(c=new tl(o,{series:e.series,yLabel:e.yLabel,yFormat:e.yFormat,xFormat:e.xFormat,padding:e.padding,gridX:e.gridX,gridY:e.gridY,showLegend:e.showLegend,showAxes:e.showAxes}),()=>c?.destroy())),ge(()=>{c?.set(e.data,n())});var p=tg();Tt(p,u=>o=u,()=>o),T(()=>{pe(p,1,St(a())),we(p,i())}),f(t,p),ce()}var rg=t=>{ve();var e=bt("Overview");f(t,e)},ng=[{key:"in",label:"Ingress",color:"var(--ink-2)"},{key:"out",label:"Egress",color:"var(--acc)",area:!0}],ag=[{key:"in",label:"Inbound",color:"var(--ink-2)"},{key:"out",label:"Outbound",color:"var(--acc)",area:!0}],ig=_("
    ",1),sg=_('
    Live counters have stopped updating.
    '),og=_(' '),lg=_(' '),cg=_('
    '),dg=_("Active Sessions",1),pg=_('View all \u2192'),ug=_(' '),fg=_('
    No sessions. The proxy is listening; clients have yet to arrive.
    '),vg=_('
    PlayerStateDimensionModeHealthPingSession
    ',1),mg=_('

    Live Operations

    ',1),$g={hash:"svelte-g5zs70",code:` + @layer pages { + /* ---- Replay-ended banner --------------------------------------- */.replay-ended {display:flex;align-items:center;gap:var(--pad-3);padding:var(--pad-3) var(--pad-4);background:var(--bg-2);border:1px solid var(--line);border-left:3px solid var(--ink-3);font-size:var(--t-sm);max-width:480px;}.replay-ended__dot {width:10px;height:10px;border-radius:50%;background:var(--ink-3);flex:0 0 auto;}.replay-ended__dot--err {background:var(--danger);}.replay-ended__text {display:flex;flex-direction:column;gap:2px;}.replay-ended__text strong {color:var(--ink);font-weight:600;} + }`};function dl(t,e){le(e,!0),Ut(t,$g);let n=x(()=>Ca.series),a=x(()=>Tr.visible),i=x(()=>Xr.now),o=X(null),c=X(!1),p=X(null);ge(()=>{Ge("/persistence").then(j=>E(o,j,!0)).catch(()=>E(o,{enabled:!1},!0))});async function u(){if(!r(c)){E(c,!0),E(p,null);try{let j={},H=sessionStorage.getItem("mw-token");H&&(j["X-Auth-Token"]=H);let D=await fetch("/api/export.sqlite",{headers:j});if(!D.ok)throw new Error(`HTTP ${D.status}`);let N=await D.blob(),G=document.createElement("a");G.href=URL.createObjectURL(N),G.download=`sessions-${new Date().toISOString().replace(/[:.]/g,"-")}.sqlite`,document.body.appendChild(G),G.click(),G.remove(),URL.revokeObjectURL(G.href)}catch(j){E(p,String(j.message??j),!0)}finally{E(c,!1)}}}let $=x(()=>({in:r(n).bytesIn,out:r(n).bytesOut})),g=x(()=>({in:r(n).packetsIn,out:r(n).packetsOut})),v=x(()=>ur.scope?.status),m=x(()=>!!r(v)&&Ki.has(r(v))),h=x(()=>ur.scope?.endedAt),b=x(()=>ur.scope?.error);var w=mg(),C=ie(w),A=l(C),I=l(A);{let j=x(()=>[rg]);ki(I,{get steps(){return r(j)}})}ve(2),s(A);var P=d(A,2);{var F=j=>{var H=sg(),D=l(H);let N;var G=d(D,2),Y=l(G),K=l(Y,!0);s(Y);var Z=d(Y,2),Q=l(Z);{var ee=V=>{var W=bt();T(re=>y(W,`Frozen at ${re??""}.`),[()=>Nn(r(h))]),f(V,W)};z(Q,V=>{r(h)&&V(ee)})}var J=d(Q,2);{var te=V=>{var W=ig(),re=d(ie(W),1,!0);T(()=>y(re,r(b))),f(V,W)};z(J,V=>{r(v)==="error"&&r(b)&&V(te)})}s(Z),s(G),s(H),T(()=>{N=pe(D,1,"replay-ended__dot",null,N,{"replay-ended__dot--err":r(v)==="error"}),y(K,r(v)==="error"?"Replay failed.":"Replay finished.")}),f(j,H)};z(P,j=>{r(m)&&j(F)})}var L=d(P,2);{var B=j=>{var H=cg(),D=l(H),N=l(D,!0);s(D);var G=d(D,2);{var Y=Z=>{var Q=og(),ee=l(Q,!0);s(Q),T(()=>y(ee,r(p))),f(Z,Q)},K=Z=>{var Q=lg(),ee=l(Q);s(Q),T(()=>y(ee,`protocol v${r(o).protocolVersion??""}`)),f(Z,Q)};z(G,Z=>{r(p)?Z(Y):Z(K,-1)})}s(H),T(()=>{D.disabled=r(c),y(N,r(c)?"Exporting\u2026":"Export history \u21E3")}),U("click",D,u),f(j,H)};z(L,j=>{r(o)?.enabled&&j(B)})}s(C);var O=d(C,2);vd(O,{});var S=d(O,2),R=l(S);et(R,{title:"Network Throughput",meta:"bytes / second",children:(j,H)=>{es(j,{get series(){return ng},get data(){return r($)},get xValues(){return r(n).ts},yLabel:"B/s",get yFormat(){return zt},className:"chart-md"})},$$slots:{default:!0}});var k=d(R,2);et(k,{title:"Packet Rate",meta:"packets / second",children:(j,H)=>{es(j,{get series(){return ag},get data(){return r(g)},get xValues(){return r(n).ts},yLabel:"/s",yFormat:D=>Dr(Math.round(D)),className:"chart-md"})},$$slots:{default:!0}}),s(S);var M=d(S,2),q=l(M);{let j=N=>{ve();var G=dg();ve(),f(N,G)},H=N=>{var G=pg();f(N,G)},D=x(()=>`${r(a).length} online`);et(q,{get meta(){return r(D)},flush:!0,className:"table-scroll",title:j,actions:H,children:(N,G)=>{var Y=vg(),K=ie(Y),Z=d(l(K));de(Z,21,()=>r(a),J=>J.uuid,(J,te)=>{let V=x(()=>!!r(te).disconnectedAt),W=x(()=>r(V)?"OFFLINE":r(te).serverConnectionState||"\u2014");var re=ug(),oe=l(re),ue=l(oe,!0);s(oe);var $e=d(oe),me=l($e);{let Ve=He=>{ve();var Ie=bt();T(()=>y(Ie,r(W))),f(He,Ie)},mt=x(()=>ll(r(te)));pr(me,{get kind(){return r(mt)},dot:!0,children:Ve,$$slots:{default:!0}})}s($e);var he=d($e),be=l(he,!0);s(he);var ye=d(he),ze=l(ye);pr(ze,{children:mt=>{ve();var He=bt();T(()=>y(He,r(te).gamemode||"\u2014")),f(mt,He)},$$slots:{default:!0}}),s(ye);var Re=d(ye),De=l(Re),Be=d(De),ot=l(Be);s(Be),s(Re);var Ke=d(Re),ke=l(Ke,!0),Ze=d(ke),je=l(Ze,!0);s(Ze),s(Ke);var Le=d(Ke),qe=l(Le,!0);s(Le);var Ae=d(Le),Ce=l(Ae),Fe=l(Ce),Ne=d(Fe,2);s(Ce),s(Ae),s(re),T((Ve,mt,He,Ie)=>{pe(re,1,St(r(V)?"row-offline":"")),y(ue,r(te).username||"\u2014"),y(be,Ve),y(De,`${mt??""} `),y(ot,`/ ${He??""}`),y(ke,r(V)?"\u2014":r(te).traffic.pingMs),y(je,r(V)?"":"ms"),y(qe,Ie),ne(Fe,"href","/p/"+r(te).uuid),ne(Ne,"href","/p/"+r(te).uuid+"/packets")},[()=>(r(te).dimension||"\u2014").replace("minecraft:",""),()=>(r(te).health??0).toFixed(1),()=>(r(te).maxHealth??20).toFixed(0),()=>pa(cl(r(te),r(i)))]),f(J,re)}),s(Z),s(K);var Q=d(K,2);{var ee=J=>{var te=fg();f(J,te)};z(Q,J=>{r(a).length===0&&J(ee)})}f(N,Y)},$$slots:{title:!0,actions:!0,default:!0}})}s(M),f(t,w),ce()}Pe(["click"]);var Gs={};Bp(Gs,{complete:()=>Tg,errorPos:()=>$v,loadSchema:()=>Vs,mqlError:()=>Pa,renderTokens:()=>Ys,tokenize:()=>_v});var gl={};Bp(gl,{appendArithAndPipeOps:()=>fl,complete:()=>kg,contextAt:()=>_l,finalize:()=>$l,isInString:()=>ml,loadSchema:()=>$d,operatorFor:()=>rs,operatorNames:()=>ma,schemaOrDefault:()=>ts,tokenize:()=>vl});var _g={fields:[],functions:[],operators:[],literals:[]},ul=null,md=null,ts=t=>t??ul??_g,gg=(t,e)=>rs(t,e)?.detail??"",rs=(t,e)=>ts(e).operators.find(n=>n.name===t),ma=(t,...e)=>ts(t).operators.filter(n=>e.includes(n.kind??"")).map(n=>n.name);function fl(t,e){for(let a of ma(e,"arithmetic"))t.push({label:a,kind:"op",insert:" "+a+" ",detail:gg(a,e)});let n=rs("|",e);n&&t.push({label:"|",kind:"op",insert:" | ",detail:n.detail||""})}async function $d(){return ul||(md??=Ge("/mql/constants").then(t=>ul=hg(t)).catch(t=>{throw md=null,t}),md)}function hg(t){let e=pl(t?.functions).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),sig:n.sig,detail:n.detail,pipe:!!n.pipe});return{fields:pl(t?.fields).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),detail:n.detail}),functions:e,operators:pl(t?.operators).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),detail:n.detail,kind:n.kind}),literals:pl(t?.literals).map(String)}}var pl=t=>Array.isArray(t)?t:[],bg=[["ws",/^\s+/],["literal",/^(true|false)\b/],["string",/^"([^"\\]|\\.)*"?/],["number",/^\d+(\.\d+)?/],["pipe",/^\|/],["op",/^(!=|<=|>=|=|<|>|~|\+|-|\*|\/|%)/],["paren",/^[()]/],["comma",/^,/],["dot",/^\./],["ident",/^[A-Za-z_][A-Za-z_0-9]*/]];function vl(t){let e=[];e:for(let n=0;nc.pipe))i.push({label:o.name,kind:"transform",insert:o.name,detail:o.detail||"transform"});else a.wants==="op"&&fl(i,n);return $l(i,a)}function $l(t,e){let n=e.partial.toLowerCase();if(!n)return[];let a=t.map(o=>{let c=o.label.toLowerCase(),p=c.startsWith(n)?0:c.includes(n)?1:-1;return{...o,score:p,range:e.range}}).filter(o=>o.score>=0),i=a.filter(o=>o.score===0);return i.length===1&&i[0].label.toLowerCase()===n?[]:a.sort((o,c)=>o.score-c.score||o.label.localeCompare(c.label)).slice(0,12)}function _l(t,e,n={}){let{isKeyword:a=()=>!1,valueStartKw:i=mv,cmpBoundaryKw:o=mv}=n,c=null,p=-1;for(let w=0;w=e&&yg.has(c.kind)),$=u?c.text.slice(0,e-c.start):"",g=u?[c.start,c.end]:[e,e],v=-1,m=null,h=u?p:c?p+1:t.length;for(let w=h-1;w>=0;w--)if(t[w].kind!=="ws"){v=w,m=t[w];break}let b={partial:$,range:g,prev:m,prevIdx:v};return m?m.kind==="dot"?{...b,wants:"path"}:m.kind==="pipe"?{...b,wants:"transform"}:m.kind==="op"||m.kind==="comma"||m.kind==="paren"&&m.text==="("||a(m)&&(i.has(m.text)||o.has(m.text))?{...b,wants:"value"}:wg.has(m.kind)||m.kind==="paren"&&m.text===")"?{...b,wants:$?"value":"op"}:{...b,wants:"value"}:{...b,wants:"value"}}var Vs=$d,Us=(t,e)=>`${Cr(e)}`;function Ys(t,e,n,a=null){let i=Number.isInteger(n),o=t.tokenize(e,a),c="";for(let p of o){if(!i||n=p.end){c+=Us(p.kind,p.text);continue}let u=n-p.start;u>0&&(c+=Us(p.kind,p.text.slice(0,u))),c+=Us("error",p.text.slice(u,u+1)),u+1=e.length&&(c+=Us("error"," ")),c}function $v(t){let e=/ at (\d+)\b/.exec(t??"");return e?Number(e[1]):null}function Pa(t,e="invalid expression"){let n=t?.message||e;return{kind:"error",message:n,position:$v(n)}}function _v(t,e=null){let n=new Set(ma(e,"keyword","logical")),a=vl(t);for(let i of a)(i.kind==="root"||i.kind==="function")&&n.has(i.text)&&(i.kind="keyword");return a}var Eg=t=>e=>e.kind==="keyword"||e.kind==="root"&&t.has(e.text);function Sg(t,e,n,a,i){let o=new Set(ma(n,"arithmetic")),c=0;for(let p=e-1;p>=0;p--){let u=t[p];if(u.kind!=="ws"){if(u.kind==="paren"){if(u.text===")"){c++;continue}if(c===0)return!1;c--;continue}if(!(c>0)){if(u.kind==="comma"||u.kind==="keyword"&&i.has(u.text))return!1;if(u.kind==="op"&&!o.has(u.text)||u.kind==="keyword"&&a.has(u.text))return!0}}}return!1}function Tg(t,e,n){if(ml(t,e))return[];n=ts(n);let a=new Set(ma(n,"keyword")),i=new Set(ma(n,"logical")),o=new Set([...a,...i]),c=new Set([...a,...i]),p=_v(t,n),u=_l(p,e,{isKeyword:Eg(o),valueStartKw:c,cmpBoundaryKw:i}),$=[];if(u.wants==="value"){for(let v of n.fields)$.push({label:v.name,kind:"field",insert:v.name,detail:v.detail||""});for(let v of n.functions)$.push({label:v.name,kind:"function",insert:v.name+"(",detail:v.detail||"function"});let g=rs("not",n);g&&$.push({label:"not",kind:"keyword",insert:"not ",detail:g.detail||""});for(let v of n.literals)$.push({label:v,kind:"literal",insert:v})}else if(u.wants==="path")$.push({label:"(any nbt key)",kind:"hint",insert:"",detail:"NBT / server-data sub-key"});else if(u.wants==="transform")for(let g of n.functions.filter(v=>v.pipe))$.push({label:g.name,kind:"transform",insert:g.name,detail:g.detail||"transform"});else if(u.wants==="op"){let g=v=>rs(v,n)?.detail||"";if(!Sg(p,u.prevIdx,n,a,i)){for(let v of ma(n,"comparison"))$.push({label:v,kind:"op",insert:v+" ",detail:g(v)});for(let v of ma(n,"keyword"))$.push({label:v,kind:"keyword",insert:v+" ",detail:g(v)})}fl($,n);for(let v of ma(n,"logical").filter(m=>m!=="not"))$.push({label:v,kind:"keyword",insert:v+" ",detail:g(v)})}return $l($,u)}var Cg=_('
    '),Ag=_('

    ');function Hr(t,e){let n=ae(e,"crumbs",19,()=>[]);var a=Ag(),i=l(a),o=l(i);ki(o,{get steps(){return n()}});var c=d(o,2),p=l(c);tr(p,()=>e.title),s(c);var u=d(c,2);{var $=m=>{var h=Me(),b=ie(h);tr(b,()=>e.subtitle),f(m,h)};z(u,m=>{e.subtitle&&m($)})}s(i);var g=d(i,2);{var v=m=>{var h=Cg(),b=l(h);tr(b,()=>e.actions),s(h),f(m,h)};z(g,m=>{e.actions&&m(v)})}s(a),f(t,a)}var ns=class{constructor(e,n,a){this.renderItem=n;this.accept=a;this.el=document.createElement("ul"),this.el.className=`combobox-pop ${e}`,this.el.setAttribute("role","listbox"),this.el.setAttribute("popover","manual"),this.el.style.position="fixed",this.el.style.margin="0"}el;items=[];selected=0;open=!1;mount(e=document.body){e.appendChild(this.el)}destroy(){this.el.remove()}contains(e){return!!e&&this.el.contains(e)}setItems(e,n=0){this.items=e,this.selected=n,this.render()}show(){this.open=!0;try{this.el.showPopover()}catch{}}hide(){this.open=!1;try{this.el.hidePopover()}catch{}}setSelected(e){this.selected=Math.max(0,Math.min(this.items.length-1,e)),this.reflectSelection()}move(e){this.setSelected(this.selected+e),this.scrollSelectedIntoView()}ensureParent(e){this.el.parentNode!==e&&(this.hide(),e.appendChild(this.el))}position(e,n,a){this.el.style.left=`${e}px`,this.el.style.top=`${n}px`,a!=null&&(this.el.style.minWidth=`${a}px`)}handleKey(e){if(!this.open)return!1;if(e.key==="ArrowDown")this.move(1);else if(e.key==="ArrowUp")this.move(-1);else if(e.key==="Enter"||e.key==="Tab")this.accept(this.selected);else if(e.key==="Escape")this.hide();else return!1;return e.preventDefault(),!0}render(){this.el.innerHTML=this.items.map((e,n)=>this.renderItem(e,n,n===this.selected)).join(""),this.el.querySelectorAll("li").forEach(e=>{e.onmousedown=n=>{n.preventDefault(),this.accept(Number(e.dataset.i))},e.onmouseenter=()=>this.setSelected(Number(e.dataset.i))})}reflectSelection(){this.el.querySelectorAll("li").forEach((e,n)=>{e.setAttribute("aria-selected",String(n===this.selected))})}scrollSelectedIntoView(){this.el.querySelectorAll("li")[this.selected]?.scrollIntoView({block:"nearest"})}};var Mg={field:"\u25C6",function:"\u0192",keyword:"\xB7",op:"=",literal:"\u220E",hint:"\u2026"},Pg=["boxSizing","height","borderTopWidth","borderRightWidth","borderBottomWidth","borderLeftWidth","paddingTop","paddingRight","paddingBottom","paddingLeft","fontStyle","fontWeight","fontSize","lineHeight","fontFamily","letterSpacing","tabSize"],$a=null;function hv(t){$a||($a=document.createElement("div"),$a.className="mql-mirror",document.body.appendChild($a));let e=window.getComputedStyle(t);for(let n of Pg)$a.style[n]=e[n];return $a.style.width=`${t.clientWidth}px`,e}function Rg(t,e){return hv(t),$a.textContent=e.endsWith(` +`)?e+` + `:e,$a.scrollHeight}function Ng(t,e){let n=hv(t);$a.textContent=t.value.slice(0,e);let a=document.createElement("span");return a.textContent="\u200B",$a.appendChild(a),{left:a.offsetLeft-t.scrollLeft,top:a.offsetTop-t.scrollTop,lineH:parseFloat(n.lineHeight)||parseFloat(n.fontSize)*1.2}}var gv=new WeakMap;var Lg=_('?'),Ig=_('
    '),Og=_('
    ');function jr(t,e){le(e,!0);let n=ae(e,"value",3,""),a=ae(e,"language",3,"mql"),i=ae(e,"placeholder",3,'gamemode = "SURVIVAL" and ping < 100'),o=ae(e,"rows",3,3),c=ae(e,"status",3,null),p=ae(e,"className",3,""),u=ae(e,"big",3,!1),$=ae(e,"compact",3,!1),g=ae(e,"focus",15,null),v=x(()=>a()==="expression"?gl:Gs),m,h,b,w=X(null),C=X(null);ge(()=>{let N=gv.get(r(v));N||(N=r(v).loadSchema(),gv.set(r(v),N));let G=!0;return N.then(Y=>{G&&E(C,Y,!0)}),()=>{G=!1}}),ge(()=>{h&&(h.innerHTML=Ys(r(v),n(),r(w),r(C))+` +`)}),ge(()=>(b=new ns("mql-pop",(N,G,Y)=>` +
  • + ${Mg[N.kind]||"\xB7"} + ${Cr(N.label)} + ${Cr(N.kind)} + ${Cr(N.detail||"")} +
  • `,P),b.mount(),()=>b.destroy())),ge(()=>{c()?.kind==="error"&&Number.isInteger(c().position)?E(w,c().position,!0):E(w,null)}),ge(()=>{let N=G=>{b?.open&&(b?.contains(G.target)||m?.contains(G.target)||A())};return document.addEventListener("pointerdown",N,!0),()=>document.removeEventListener("pointerdown",N,!0)}),ge(()=>{g()&&typeof g()=="object"&&g(g().focus=()=>m?.focus(),!0)});function A(){b&&b.hide()}function I(){if(!m||!b)return;let N=r(v).complete(m.value,m.selectionStart,r(C));if(N.length===0){A();return}b.setItems(N);let G=m.closest("dialog")||document.body;b.ensureParent(G),b.show();let{left:Y,top:K,lineH:Z}=Ng(m,m.selectionStart),Q=m.getBoundingClientRect();b.position(Q.left+Y,Q.top+K+Z+2)}function P(N){let G=b?.items[N];if(!m||!G)return;let[Y,K]=G.range,Z=m.value.slice(0,Y)+G.insert+m.value.slice(K),Q=Y+G.insert.length;m.value=Z,m.setSelectionRange(Q,Q),A(),E(w,null),e.onChange?.(Z),queueMicrotask(I)}function F(N){if(!b?.handleKey(N)){if(N.key==="Enter"&&(N.metaKey||N.ctrlKey)){e.onSubmit?.(m.value),N.preventDefault();return}N.key===" "&&(N.ctrlKey||N.metaKey)&&(I(),N.preventDefault())}}function L(){!m||!$()||(m.style.height=`${Rg(m,m.value||m.placeholder||"\u200B")}px`)}function B(N){E(w,null),e.onChange?.(N.target.value),L(),I()}function O(){h&&m&&(h.scrollTop=m.scrollTop,h.scrollLeft=m.scrollLeft)}ge(()=>{if(n(),i(),$(),!m||!$())return;L();let N=new ResizeObserver(L);return N.observe(m),()=>N.disconnect()});let S=x(()=>["mql-editor",p(),u()&&"big",$()&&"compact"].filter(Boolean).join(" "));var R=Og(),k=l(R);Tt(k,N=>h=N,()=>h);var M=d(k,2);vc(M),Tt(M,N=>m=N,()=>m);var q=d(M,2);{var j=N=>{var G=Lg();f(N,G)};z(q,N=>{a()==="mql"&&N(j)})}var H=d(q,2);{var D=N=>{var G=Ig(),Y=l(G,!0);s(G),T(()=>{pe(G,1,"mql-status "+(c().kind||"")),y(Y,c().message)}),f(N,G)};z(H,N=>{c()&&N(D)})}s(R),T(()=>{pe(R,1,St(r(S))),ne(M,"rows",o()),ne(M,"placeholder",i()),Ot(M,n())}),U("input",M,B),U("keydown",M,F),Rt("scroll",M,O),U("click",M,I),f(t,R),ce()}Pe(["input","keydown","click"]);var Dg=t=>{ve();var e=bt("Players");f(t,e)},_d=t=>t.traffic.pingMs,bv={connectedAt:(t,e)=>(t.connectedAt??0)-(e.connectedAt??0),ping:(t,e)=>_d(t)-_d(e),name:(t,e)=>(t.username||"").localeCompare(e.username||""),health:(t,e)=>(t.health??0)-(e.health??0)},Fg=_(" connected",1),Bg=_(""),zg=_(' /20 ms '),qg=_('
    No connected players.
    '),Hg=_('
    PlayerUUIDBackendStateDimensionModePosHealthFoodXPPingLatency 60sIn \xB7 OutSession
    ',1),jg=_('
    ',1);function gd(t,e){le(e,!0);let n=I=>{var P=Fg(),F=ie(P),L=l(F,!0);s(F),ve(),T(()=>y(L,r(v).length)),f(I,P)},a=I=>{var P=Bg(),F=l(P);F.value=F.__value="connectedAt";var L=d(F);L.value=L.__value="ping";var B=d(L);B.value=B.__value="name";var O=d(B);O.value=O.__value="health",s(P);var S;Zn(P),T(()=>{S!==(S=r(c))&&(P.value=(P.__value=r(c))??"",Sn(P,r(c)))}),U("change",P,R=>E(c,R.target.value,!0)),f(I,P)},i=x(()=>Tr.visible),o=X(null),c=X("connectedAt"),p=X(""),u=X(null),$=x(()=>Xr.now),g=Wa(async I=>{if(!I.trim()){E(o,null),E(u,null);return}try{let P=await Ge("/query",{method:"POST",body:{ql:I}});E(o,P.matches||[],!0),E(u,{kind:P.matches?.length??0?"ok":"dim",message:`${P.matches?.length??0} matched \xB7 live`},!0)}catch(P){E(u,Pa(P,"invalid query"),!0)}},220);ge(()=>{r(p),r(i),g(r(p))});let v=x(()=>{let I=r(i);if(r(o)){let F=new Set(r(o));I=I.filter(L=>F.has(L.uuid))}let P=bv[r(c)]||bv.connectedAt;return[...I].sort(P)}),m=x(()=>r(u)??{kind:"dim",message:`${r(i).length} / ${r(i).length} matched`});var h=jg(),b=ie(h);{let I=x(()=>[Dg]);Hr(b,{get crumbs(){return r(I)},get title(){return n},get actions(){return a}})}var w=d(b,2),C=l(w);jr(C,{get value(){return r(p)},onChange:I=>E(p,I,!0),rows:1,compact:!0,placeholder:'filter \u2014 e.g. ping > 100 or gamemode = "SURVIVAL"',get status(){return r(m)}}),s(w);var A=d(w,2);et(A,{headless:!0,flush:!0,className:"table-scroll",children:(I,P)=>{var F=Hg(),L=ie(F),B=d(l(L));de(B,21,()=>r(v),R=>R.uuid,(R,k)=>{let M=x(()=>[r(k).posX??0,r(k).posY??0,r(k).posZ??0]),q=x(()=>!!r(k).disconnectedAt);var j=zg(),H=l(j),D=l(H,!0);s(H);var N=d(H),G=l(N,!0);s(N);var Y=d(N),K=l(Y,!0);s(Y);var Z=d(Y),Q=l(Z);{let Ne=x(()=>ll(r(k)));pr(Q,{get kind(){return r(Ne)},dot:!0,children:(Ve,mt)=>{ve();var He=bt();T(()=>y(He,r(q)?"OFFLINE":r(k).serverConnectionState||"\u2014")),f(Ve,He)},$$slots:{default:!0}})}s(Z);var ee=d(Z),J=l(ee,!0);s(ee);var te=d(ee),V=l(te);pr(V,{children:(Ne,Ve)=>{ve();var mt=bt();T(()=>y(mt,r(k).gamemode||"\u2014")),f(Ne,mt)},$$slots:{default:!0}}),s(te);var W=d(te),re=l(W,!0);s(W);var oe=d(W),ue=l(oe,!0),$e=d(ue),me=l($e);s($e),s(oe);var he=d(oe),be=l(he,!0);ve(),s(he);var ye=d(he),ze=l(ye,!0);s(ye);var Re=d(ye),De=l(Re,!0);ve(),s(Re);var Be=d(Re),ot=l(Be),Ke=l(ot);xi(Ke,{get data(){return r(k).traffic.pingHistory},color:"var(--acc)",fill:"transparent"}),s(ot),s(Be);var ke=d(Be),Ze=l(ke);s(ke);var je=d(ke),Le=l(je,!0);s(je);var qe=d(je),Ae=l(qe),Ce=l(Ae),Fe=d(Ce,2);s(Ae),s(qe),s(j),T((Ne,Ve,mt,He,Ie,Je,$t,Ee,Ye)=>{pe(j,1,St(r(q)?"row-offline":"")),y(D,r(k).username||"\u2014"),y(G,Ne),y(K,r(k).backendAddress||"\u2014"),y(J,Ve),y(re,mt),y(ue,He),y(me,`/${Ie??""}`),y(be,r(k).food??0),y(ze,r(k).xpLevel??0),y(De,Je),y(Ze,`${$t??""}\xB7${Ee??""}`),y(Le,Ye),ne(Ce,"href","/p/"+r(k).uuid),ne(Fe,"href","/p/"+r(k).uuid+"/packets")},[()=>on(r(k).uuid),()=>(r(k).dimension||"\u2014").replace("minecraft:",""),()=>r(M).map(Ne=>Number(Ne).toFixed(0)).join(", "),()=>(r(k).health??0).toFixed(1),()=>(r(k).maxHealth??20).toFixed(0),()=>_d(r(k)),()=>zt(r(k).traffic.bytesIn),()=>zt(r(k).traffic.bytesOut),()=>pa(cl(r(k),r($)))]),f(R,j)}),s(B),s(L);var O=d(L,2);{var S=R=>{var k=qg();f(R,k)};z(O,R=>{r(v).length===0&&R(S)})}f(I,F)},$$slots:{default:!0}}),f(t,h),ce()}Pe(["change"]);var bd=["self","ent","world","hud","win","net","chat"];var Jr=t=>String(t).toUpperCase().startsWith("CLIENT"),yv=t=>(t||"").replace(/^Clientbound|^Client/,"").replace(/Packet$/,""),xr=t=>t.replace(/Packet$/,"");function xd(){return{byClass:new Map,byHeatmap:new Map,total:{count:0,bytes:0},byPlayer:new Map,window:{bucketTs:yd(),buckets:hd(),byteBuckets:Gg()},anomaly:{prev:new Map,seen:new Set}}}function wv(){return{count:0,cbBytes:0,sbBytes:0,buckets:hd(),cb:hd(),bucketTs:yd()}}function as(t){return{seq:Number(t.seq)||0,ts:Yg(t.ts),direction:String(t.direction??""),state:String(t.state??""),className:String(t.className??""),sizeBytes:Number(t.sizeBytes)||0,subject:String(t.subject??""),subjectGroup:String(t.subjectGroup??"net"),subjectLabel:String(t.subjectLabel??t.subject??""),uuid:String(t.uuid??""),connectionId:String(t.connectionId??"")}}function kv(t,e,n="",a=!0){let i=e.className;if(!i)return;let o=t.byClass.get(i);o||(o={count:0,bytes:0,cb:0,sb:0},t.byClass.set(i,o)),o.count++,o.bytes+=e.sizeBytes;let c=Jr(e.direction);c?o.cb++:o.sb++;let p=(c?"cb":"sb")+"|"+e.subjectGroup,u=t.byHeatmap.get(p);u||(u={count:0,bytes:0},t.byHeatmap.set(p,u)),u.count++,u.bytes+=e.sizeBytes,t.total.count++,t.total.bytes+=e.sizeBytes;let $=Math.floor(e.ts/1e3),g=19;if(t.window.bucketTs=hl(t.window.bucketTs,$,t.window.buckets,t.window.byteBuckets),t.window.buckets[g]++,t.window.byteBuckets[g]+=e.sizeBytes,!a||!n)return;let v=t.byPlayer.get(n);v||(v=wv(),t.byPlayer.set(n,v)),v.count++,c?v.cbBytes+=e.sizeBytes:v.sbBytes+=e.sizeBytes,v.bucketTs=hl(v.bucketTs,$,v.buckets,v.cb),v.buckets[g]++,c&&v.cb[g]++}function Ug(t){let e=yd();t.window.bucketTs=hl(t.window.bucketTs,e,t.window.buckets,t.window.byteBuckets);for(let n of t.byPlayer.values())n.bucketTs=hl(n.bucketTs,e,n.buckets,n.cb)}function Ev(t,e,n){if(Ug(t),!n)return e;let a=Vg(t);return a.length?[...a,...e].slice(0,8):e}function Sv(t,e){let n=0;for(let[c,p]of t.byHeatmap)c.startsWith("cb|")&&(n+=p.bytes);let a=t.total.bytes,i=null;for(let[c,p]of t.byClass)(!i||p.count>i.count)&&(i={k:c,count:p.count});let o={totalCount:t.total.count,totalBytes:a,cbBytes:n,sbBytes:a-n,cbPct:a?n/a*100:50,pps:xv(t.window.buckets)/3,bps:xv(t.window.byteBuckets)/3,topClass:i,classCount:t.byClass.size,streamCount:t.byPlayer.size,lanes:[],gmax:1};if(!e?.length)return o;o.lanes=e.map(c=>({p:c,lane:t.byPlayer.get(c.uuid)??wv()})).sort((c,p)=>(p.lane.buckets[19]||0)-(c.lane.buckets[19]||0));for(let{lane:c}of o.lanes)for(let p of c.buckets)p>o.gmax&&(o.gmax=p);return o}function Vg(t,e=8){let{prev:n,seen:a}=t.anomaly,i=[],o=Date.now();for(let[c,p]of t.byClass){let u=n.get(c)??0,$=xr(c);!a.has(c)&&p.count>=5?(a.add(c),i.push({kind:"new",msg:`New class ${$} on the wire`,ts:o})):u>=25&&p.count<=Math.max(1,u*.15)?i.push({kind:"drop",msg:`${$} fell to near-zero vs prior sample`,ts:o}):p.count-u>=Math.max(12,u*.5)&&i.push({kind:"spike",msg:`${$} +${u?Math.round((p.count-u)/u*100):100}% since last second`,ts:o})}return t.anomaly.prev=new Map([...t.byClass].map(([c,p])=>[c,p.count])),i.slice(0,e)}var yd=()=>Math.floor(Date.now()/1e3),hd=()=>new Uint16Array(20);function Yg(t){let e=Number(t);return!Number.isFinite(e)||e<=0||e>1e14?Date.now():e<1e11?e*1e3:e}var Gg=()=>new Uint32Array(20);function hl(t,e,...n){let a=e-t;if(a<=0)return t;for(let i of n)if(a>=20)i.fill(0);else{i.copyWithin(0,a);for(let o=20-a;o<20;o++)i[o]=0}return e}function xv(t){let e=0;for(let n=Math.max(0,t.length-3);n{e||(e=!0,requestAnimationFrame(()=>{e=!1,t()}))}}function Tv(t){let e=xd(),n=X(0),a=X(tt(Date.now())),i=X(tt([])),o=2e4,c=new Set,p=t.lanes!==!1,u=Wg(()=>{ba(n)}),$=v=>{let m=as(v),h=`${m.connectionId||m.uuid}:${m.seq}`;return c.has(h)?{row:m,fresh:!1}:(c.add(h),c.size>o&&c.delete(c.values().next().value),kv(e,m,String(v.uuid??m.uuid??""),p),{row:m,fresh:!0})},g=()=>{Object.assign(e,xd()),c.clear(),E(i,[],!0),E(n,0)};return ge(()=>{let v=setInterval(()=>{E(a,Date.now(),!0),E(i,Ev(e,r(i),!!t.anomalies),!0),u()},1e3);return()=>clearInterval(v)}),{get agg(){return e},get version(){return r(n)},get now(){return r(a)},get anomalies(){return r(i)},bump:u,tryIngest:$,reset:g}}function Cv(t,e={}){let n=Tv({lanes:e.lanes,anomalies:e.anomalies}),a=new Set,i=(p,u)=>{if(u&&e.enabled&&!e.enabled())return;let{row:$,fresh:g}=n.tryIngest(p);return g&&u&&(e.onRow?.($),n.bump()),$},o=(p,u="")=>{let $=!1;for(let g of p){let{fresh:v}=n.tryIngest({...g,uuid:u||g.uuid});v&&($=!0)}$&&n.bump()},c=()=>{a.clear(),n.reset()};return ge(()=>{e.resetKey?.()!=null&&c()}),ge(()=>{let p=t().filter($=>$.uuid);if(!p.length)return;let u=p.map($=>or.subscribe(Xo($.uuid),g=>i(g,!0)));return()=>u.forEach($=>$())}),ge(()=>{let p=!0;if(e.history===!1)return()=>{p=!1};let u=t().filter(m=>m.uuid&&m.connectionId&&!a.has(m.connectionId));if(!u.length)return()=>{p=!1};let $=e.historyLimit??400,g=$<=0?5e3:$,v=async m=>{let h=[],b=0;for(;;){let w=await Ge(`/connections/${m.connectionId}/packets?since=${b}&limit=${g}`);if(!w.length||(h.push(...w),b=Number(w[w.length-1]?.seq)||b,$>0||w.lengthv(m).catch(()=>({source:m,recs:[]})))).then(m=>{if(p){for(let{source:h,recs:b}of m){a.add(h.connectionId);for(let w of b)i({...w,uuid:h.uuid,connectionId:h.connectionId},!1)}m.some(h=>h.recs.length)&&n.bump()}}),()=>{p=!1}}),{get agg(){return n.agg},get version(){return n.version},get now(){return n.now},get anomalies(){return n.anomalies},ingestRows:o,reset:c}}function Av(t={}){let e=Tv({anomalies:t.anomalies});return ge(()=>or.subscribe(gr.packetsAggregate,n=>{let a=n.rows;if(!a?.length)return;let i=!1;for(let o of a)t.enabled&&!t.enabled()||e.tryIngest(o).fresh&&(i=!0);i&&e.bump()})),{get agg(){return e.agg},get version(){return e.version},get now(){return e.now},get anomalies(){return e.anomalies}}}var Xg=sn(''),Zg=sn(''),Jg=sn(""),Qg=_('
    pkt
    \u2193 in
    \u2191 out
    '),eh={hash:"svelte-15yunp0",code:` + @layer pages {.swimlane {display:grid;grid-template-columns:22px minmax(80px, 1fr) minmax(140px, 2fr) auto;align-items:center;gap:var(--pad-3);.swimlane__name {display:grid;gap:2px;color:var(--ink);.dim {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}}.swimlane__track {display:block;height:28px;background:var(--sunk);box-shadow:var(--bevel-sunk);min-width:0;svg {width:100%;height:100%;display:block;}}.swimlane__metrics {display:grid;grid-auto-flow:column;gap:var(--pad-3);}.swimlane__metric {display:grid;grid-template-rows:auto auto;text-align:right;font-size:var(--t-xs);.lbl {color:var(--ink-4);text-transform:uppercase;}.val {color:var(--ink);font-variant-numeric:tabular-nums;}}} + }`};function wd(t,e){le(e,!0),Ut(t,eh);var n=Qg(),a=l(n),i=l(a,!0);s(a);var o=d(a,2),c=l(o),p=d(c),u=l(p);s(p),s(o);var $=d(o,2),g=l($);de(g,21,()=>Array.from(e.lane.buckets),lt,(L,B,O)=>{var S=Me(),R=ie(S);{var k=M=>{let q=x(()=>e.lane.cb[O]||0),j=x(()=>r(B)-r(q)),H=x(()=>Math.max(2,r(q)/e.gmax*92)),D=x(()=>Math.max(0,r(j)/e.gmax*92));var N=Jg(),G=l(N);{var Y=Q=>{var ee=Xg();ne(ee,"x",O+.05),T(()=>{ne(ee,"y",100-r(H)),ne(ee,"height",r(H))}),f(Q,ee)};z(G,Q=>{r(H)>0&&Q(Y)})}var K=d(G);{var Z=Q=>{var ee=Zg();ne(ee,"x",O+.05),T(()=>{ne(ee,"y",100-r(H)-r(D)),ne(ee,"height",r(D))}),f(Q,ee)};z(K,Q=>{r(D)>0&&Q(Z)})}s(N),f(M,N)};z(R,M=>{r(B)&&M(k)})}f(L,S)}),s(g),s($);var v=d($,2),m=l(v),h=d(l(m)),b=l(h,!0);s(h),s(m);var w=d(m,2),C=d(l(w)),A=l(C,!0);s(C),s(w);var I=d(w,2),P=d(l(I)),F=l(P,!0);s(P),s(I),s(v),s(n),T((L,B,O,S,R,k)=>{y(i,L),y(c,`${B??""} `),y(u,`${O??""} \xB7 ${(e.player.gamemode||"\u2014")??""}`),ne(g,"viewBox",`0 0 ${20} 100`),y(b,S),y(A,R),y(F,k)},[()=>(e.player.username||"?").slice(0,2).toUpperCase(),()=>e.player.username||e.player.uuid.slice(0,8),()=>(e.player.dimension||"").replace("minecraft:",""),()=>Dr(e.lane.count),()=>zt(e.lane.cbBytes),()=>zt(e.lane.sbBytes)]),U("click",n,function(...L){e.onclick?.apply(this,L)}),U("keydown",n,L=>{(L.key==="Enter"||L.key===" ")&&(L.preventDefault(),e.onclick())}),f(t,n),ce()}Pe(["click","keydown"]);var th=_('
    '),rh=_('
    ');function Ra(t,e){le(e,!0);let n={PINK:"oklch(70% 0.22 320)",BLUE:"oklch(62% 0.16 250)",RED:"oklch(58% 0.22 25)",GREEN:"oklch(68% 0.18 145)",YELLOW:"oklch(82% 0.16 95)",PURPLE:"oklch(58% 0.2 300)",WHITE:"oklch(92% 0.02 250)"},a=ae(e,"value",3,0),i=ae(e,"variant",3,"spectrum"),o=ae(e,"class",3,""),c=x(()=>Math.max(0,Math.min(1,a()??0))),p=x(()=>i()==="boss"&&e.color?n[e.color.toUpperCase()]??n.PINK:void 0);var u=rh();ne(u,"aria-valuemin",0),ne(u,"aria-valuemax",100);var $=l(u),g=l($);let v;s($);var m=d($,2);{var h=b=>{var w=th(),C=l(w);tr(C,()=>e.children),s(w),f(b,w)};z(m,b=>{e.children&&b(h)})}s(u),T(b=>{pe(u,1,`progress-bar progress-bar--${i()??""} ${o()??""}`),ne(u,"aria-valuenow",b),v=we(g,"",v,{width:r(c)*100+"%","--fill":r(p)})},[()=>Math.round(r(c)*100)]),f(t,u),ce()}var nh=_('
    No packets yet.
    '),ah=_('
    '),ih=_('
    '),sh={hash:"svelte-ooj9yx",code:` + @layer pages {.leaderboard {display:grid;gap:1px;background:var(--line);max-height:520px;overflow:auto; + + /* Fixed columns so the bar-wrap track starts at the same X across every row. */.leaderboard__row {display:grid;grid-template-columns:24px minmax(90px, 1fr) minmax(60px, 1.6fr) 60px 72px;align-items:center;gap:var(--pad-2);}.leaderboard__rank {color:var(--ink-4);font-variant-numeric:tabular-nums;font-size:var(--t-xs);text-align:right;}.leaderboard__cls {color:var(--ink);}.leaderboard__num {color:var(--ink);font-size:var(--t-xs);}.leaderboard__chip {display:inline-grid;grid-template-columns:10px 1fr;column-gap:4px;align-items:baseline;font-size:var(--t-xs);padding:1px 0;color:var(--ink-4);text-transform:uppercase;white-space:nowrap;&.cb {color:var(--dir-cb);}&.sb {color:var(--dir-sb);}}.leaderboard__chip-dir {text-align:center;}} + + /* Two-column "value \xB7 unit" cell \u2014 digits and units land in fixed sub-columns. */.num-unit {display:inline-grid;grid-template-columns:1fr 22px;column-gap:4px;align-items:baseline;text-align:right;.num-unit__n {text-align:right;font-variant-numeric:tabular-nums;color:inherit;}.num-unit__u {text-align:left;color:var(--ink-4);font-size:var(--t-xs);}} + }`};function kd(t,e){le(e,!0),Ut(t,sh);function n(g){let v=String(g??"").match(/^([\d.,]+)\s*(\S*)$/);return v?[v[1],v[2]]:[String(g??""),""]}let a=ae(e,"max",3,14),i=ae(e,"version",3,0),o=x(()=>{i(),e.sortBy,a();let g=[...e.agg.byClass.entries()];g.sort((h,b)=>e.sortBy==="bytes"?b[1].bytes-h[1].bytes:b[1].count-h[1].count);let v=g.slice(0,a()),m=e.sortBy==="bytes"?v[0]?.[1]?.bytes||1:v[0]?.[1]?.count||1;return v.map(([h,b])=>({cls:h,info:b,pct:(e.sortBy==="bytes"?b.bytes:b.count)/m*100}))});var c=Me(),p=ie(c);{var u=g=>{var v=nh();f(g,v)},$=g=>{var v=ih();de(v,23,()=>r(o),m=>m.cls,(m,h,b)=>{let w=x(()=>r(h).info.cb>r(h).info.sb?"cb":"sb"),C=x(()=>r(w)==="cb"?"\u2193":"\u2191"),A=x(()=>{let[ee,J]=n(e.sortBy==="bytes"?zt(r(h).info.bytes):Dr(r(h).info.count));return{pN:ee,pU:J}}),I=x(()=>{let[ee,J]=n(e.sortBy==="bytes"?Dr(r(h).info.count):zt(r(h).info.bytes));return{sN:ee,sU:J}});var P=ah(),F=l(P),L=l(F,!0);s(F);var B=d(F,2),O=l(B,!0);s(B);var S=d(B,2);{let ee=x(()=>r(h).pct/100);Ra(S,{get value(){return r(ee)}})}var R=d(S,2),k=l(R),M=l(k,!0);s(k);var q=d(k),j=l(q,!0);s(q),s(R);var H=d(R,2),D=l(H),N=l(D,!0);s(D);var G=d(D,2),Y=l(G),K=l(Y,!0);s(Y);var Z=d(Y),Q=l(Z,!0);s(Z),s(G),s(H),s(P),T(ee=>{y(L,r(b)+1),y(O,ee),y(M,r(A).pN),y(j,r(A).pU),pe(H,1,"leaderboard__chip "+r(w)),y(N,r(C)),y(K,r(I).sN),y(Q,r(I).sU)},[()=>xr(r(h).cls)]),f(m,P)}),s(v),f(g,v)};z(p,g=>{r(o).length===0?g(u):g($,-1)})}f(t,c),ce()}var oh=_(' '),lh=_(' '),ch=_(' '),dh=_('
    \u2193 Inbound \u2191 Outbound
    '),ph={hash:"svelte-fbvh76",code:` + @layer pages {.heatmap {display:grid;grid-template-columns:minmax(80px, auto) repeat(7, minmax(48px, 1fr));gap:1px;background:var(--line);padding:1px;.heatmap__hdr, .heatmap__row-hdr {padding:6px 8px;font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;text-align:center;background:var(--bg-1);}.heatmap__row-hdr {text-align:left;}.heatmap__cell {position:relative;padding:6px 8px;background:var(--bg-1);text-align:center;font-variant-numeric:tabular-nums;font-size:var(--t-xs);color:var(--ink-2);overflow:hidden;.fill {position:absolute;inset:0;background:var(--dir-cb);opacity:calc(0.1 + var(--heat, 0) * 0.65);z-index:0;}&.sb .fill {background:var(--dir-sb);}.v {position:relative;z-index:1;}}} + }`};function Ed(t,e){le(e,!0),Ut(t,ph);let n=ae(e,"version",3,0),a=x(()=>{n(),e.sortBy;let $=1;for(let v of e.agg.byHeatmap.values()){let m=e.sortBy==="bytes"?v.bytes:v.count;m>$&&($=m)}let g=v=>bd.map(m=>{let h=e.agg.byHeatmap.get(v+"|"+m)||{count:0,bytes:0},b=e.sortBy==="bytes"?h.bytes:h.count;return{s:m,val:b,pct:Math.min(1,b/$)}});return{cb:g("cb"),sb:g("sb")}}),i=$=>$?e.sortBy==="bytes"?zt($):Dr($):"\xB7";var o=dh(),c=d(l(o),2);de(c,16,()=>bd,$=>$,($,g)=>{var v=oh(),m=l(v,!0);s(v),T(()=>y(m,g)),f($,v)});var p=d(c,4);de(p,17,()=>r(a).cb,$=>$.s,($,g)=>{var v=lh(),m=l(v);let h;var b=d(m,2),w=l(b,!0);s(b),s(v),T(C=>{h=we(m,"",h,{"--heat":r(g).pct}),y(w,C)},[()=>i(r(g).val)]),f($,v)});var u=d(p,4);de(u,17,()=>r(a).sb,$=>$.s,($,g)=>{var v=ch(),m=l(v);let h;var b=d(m,2),w=l(b,!0);s(b),s(v),T(C=>{h=we(m,"",h,{"--heat":r(g).pct}),y(w,C)},[()=>i(r(g).val)]),f($,v)}),s(o),f(t,o),ce()}var uh=_('
    '),fh=_(" ",1);function Ws(t,e){le(e,!0);let n=g=>{var v=uh(),m=l(v);let h;var b=d(m,2);let w;s(v),T(()=>{h=pe(m,1,"",null,h,{"is-on":e.sortBy==="count"}),w=pe(b,1,"",null,w,{"is-on":e.sortBy==="bytes"})}),U("click",m,()=>e.onSortBy("count")),U("click",b,()=>e.onSortBy("bytes")),f(g,v)},a=ae(e,"version",3,0),i=ae(e,"topMeta",3,""),o=ae(e,"heatmapMeta",19,()=>e.sortBy),c=ae(e,"max",3,14);var p=fh(),u=ie(p);et(u,{title:"Top packet classes",get meta(){return i()},flush:!0,actions:v=>{n(v)},children:(v,m)=>{kd(v,{get agg(){return e.agg},get sortBy(){return e.sortBy},get max(){return c()},get version(){return a()}})},$$slots:{actions:!0,default:!0}});var $=d(u,2);et($,{title:"Bandwidth \xB7 direction \xD7 subject",get meta(){return o()},flush:!0,actions:v=>{n(v)},children:(v,m)=>{Ed(v,{get agg(){return e.agg},get sortBy(){return e.sortBy},get version(){return a()}})},$$slots:{actions:!0,default:!0}}),f(t,p),ce()}Pe(["click"]);var mh=t=>{ve();var e=bt("Packets");f(t,e)},$h=t=>{ve();var e=bt("Global");f(t,e)},_h=t=>{ve();var e=gh();ve(),f(t,e)},gh=_("Global packet analysis",1),hh=_('

    Aggregate across

    '),bh=_(''),xh=_('top \xB7 ',1),yh=_('
    No active sessions.
    '),wh=_('
    '),kh=_('
    No anomalies detected.
    '),Eh=_('
    '),Sh=_('
    '),Th=_('
    Throughput
    /s
    Sessions
    Classes seen
    Total packets
    ',1),Ch={hash:"svelte-ffq7e8",code:` + @layer pages { + /* ---- Global Packets page --------------------------------------- */.gp-hero {display:grid;grid-template-columns:2fr 1fr 1fr 1fr;gap:1px;background:var(--line);border:1px solid var(--line);margin-bottom:var(--pad-3);.gp-hero__cell {padding:var(--pad-3) var(--pad-4);background:var(--bg-1);display:grid;gap:4px;min-width:0;}.gp-hero__lbl {font-size:var(--t-xs);color:var(--ink-3);text-transform:uppercase;}.gp-hero__val {font-size:var(--t-2xl);color:var(--ink);line-height:1.05;.unit {font-size:var(--t-md);color:var(--ink-3);margin-left:4px;}}.gp-hero__sub {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;.acc {color:var(--acc);}}.gp-hero__bar {position:relative;height:8px;margin-top:6px;background:var(--sunk);box-shadow:var(--bevel-sunk);}.gp-hero__bar-fill {position:absolute;top:0;bottom:0;&.cb {background:var(--dir-cb);left:0;}&.sb {background:var(--dir-sb);}}} + @media (max-width: 1100px) {.gp-hero {grid-template-columns:1fr 1fr;} }.swimlanes {display:grid;gap:1px;background:var(--line);}.anomalies {display:grid;gap:1px;background:var(--line);max-height:320px;overflow:auto;}.anomaly {display:grid;grid-template-columns:4px 1fr auto;gap:var(--pad-2);align-items:center;.anomaly__indicator {height:100%;background:var(--ink-4);}&.spike .anomaly__indicator {background:var(--warn);}&.note .anomaly__indicator {background:var(--acc);}&.drop .anomaly__indicator {background:var(--danger);}.anomaly__msg {color:var(--ink-2);.acc {color:var(--acc);}}.anomaly__when {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;white-space:nowrap;}} + }`};function Sd(t,e){le(e,!0),Ut(t,Ch);let n=re=>{var oe=hh(),ue=d(l(oe)),$e=l(ue,!0);s(ue);var me=d(ue);s(oe),T(()=>{y($e,r(o).length),y(me,` sessions \xB7 ${3}s rate window \xB7 click a swimlane for detail`)}),f(re,oe)},a=re=>{var oe=bh();U("click",oe,()=>Ga(r(o)[0]?"/p/"+r(o)[0].uuid+"/packets":"/players")),f(re,oe)},i=X("count"),o=x(()=>Tr.list),c=Av({anomalies:!0}),p=x(()=>(c.version,Sv(c.agg,r(o))));var u=Th(),$=ie(u);{let re=x(()=>[mh,$h]);Hr($,{get crumbs(){return r(re)},get title(){return _h},get subtitle(){return n},get actions(){return a}})}var g=d($,2),v=l(g),m=d(l(v),2),h=l(m,!0);ve(),s(m);var b=d(m,2),w=l(b);s(b);var C=d(b,2),A=l(C);let I;var P=d(A,2);let F;s(C),s(v);var L=d(v,2),B=d(l(L),2),O=l(B,!0);s(B);var S=d(B,2),R=l(S);s(S),s(L);var k=d(L,2),M=d(l(k),2),q=l(M,!0);s(M);var j=d(M,2),H=l(j);{var D=re=>{var oe=xh(),ue=d(ie(oe)),$e=l(ue,!0);s(ue),T(me=>y($e,me),[()=>xr(r(p).topClass.k)]),f(re,oe)},N=re=>{var oe=bt("\u2014");f(re,oe)};z(H,re=>{r(p).topClass?re(D):re(N,-1)})}s(j),s(k);var G=d(k,2),Y=d(l(G),2),K=l(Y,!0);s(Y);var Z=d(Y,2),Q=l(Z);s(Z),s(G),s(g);var ee=d(g,2),J=l(ee);{let re=x(()=>`${r(o).length} active`);et(J,{title:"Per-player swimlanes",get meta(){return r(re)},flush:!0,children:(oe,ue)=>{var $e=Me(),me=ie($e);{var he=ye=>{var ze=yh();f(ye,ze)},be=ye=>{var ze=wh();de(ze,21,()=>r(p).lanes,({p:Re,lane:De})=>Re.uuid,(Re,De)=>{let Be=()=>r(De).p,ot=()=>r(De).lane;wd(Re,{get player(){return Be()},get lane(){return ot()},get gmax(){return r(p).gmax},onclick:()=>Ga("/p/"+Be().uuid+"/packets")})}),s(ze),f(ye,ze)};z(me,ye=>{r(p).lanes.length?ye(be,-1):ye(he)})}f(oe,$e)},$$slots:{default:!0}})}var te=d(J,2);et(te,{title:"Anomalies",meta:"1s sampling",flush:!0,children:(re,oe)=>{var ue=Me(),$e=ie(ue);{var me=be=>{var ye=kh();f(be,ye)},he=be=>{var ye=Sh();de(ye,23,()=>c.anomalies,(ze,Re)=>ze.ts+":"+Re,(ze,Re)=>{var De=Eh(),Be=d(l(De),2),ot=l(Be,!0);s(Be);var Ke=d(Be,2),ke=l(Ke);s(Ke),s(De),T(Ze=>{pe(De,1,"anomaly data-row data-row--panel data-row--interactive "+r(Re).kind),y(ot,r(Re).msg),y(ke,`${Ze??""} ago`)},[()=>vn(c.now-r(Re).ts)]),f(ze,De)}),s(ye),f(be,ye)};z($e,be=>{c.anomalies.length?be(he,-1):be(me)})}f(re,ue)},$$slots:{default:!0}}),s(ee);var V=d(ee,2),W=l(V);Ws(W,{get agg(){return c.agg},get sortBy(){return r(i)},get version(){return c.version},topMeta:"all players",get heatmapMeta(){return r(i)},onSortBy:re=>E(i,re,!0)}),s(V),T((re,oe,ue,$e,me,he,be)=>{y(h,re),y(w,`${oe??""} pkt/s \xB7 ${ue??""} in view`),ne(C,"title",$e),I=we(A,"",I,{width:r(p).cbPct+"%"}),F=we(P,"",F,{left:r(p).cbPct+"%",width:100-r(p).cbPct+"%"}),y(O,r(o).length),y(R,`tracking ${r(p).streamCount??""} streams`),y(q,r(p).classCount),y(K,me),y(Q,`${he??""} \u2B07 \xB7 ${be??""} \u2B06`)},[()=>zt(r(p).bps),()=>Dr(Math.round(r(p).pps)),()=>zt(r(p).totalBytes),()=>`${r(p).cbPct.toFixed(0)}% server\u2192client`,()=>Dr(r(p).totalCount),()=>zt(r(p).cbBytes),()=>zt(r(p).sbBytes)]),f(t,u),ce()}Pe(["click"]);var bl=Symbol("prov-open"),xl=Symbol("prov-open-field");var Ah=(t,e=At)=>{var n=Me(),a=ie(n);{var i=u=>{var $=Me(),g=ie($);{var v=h=>{var b=Ph();T(()=>ne(b,"title",e().title)),f(h,b)},m=h=>{var b=Rh(),w=l(b);ne(w,"draggable",!1),s(b),T(()=>{ne(b,"title",e().title),ne(w,"src",`/api/material-icon/${e().id}`)}),f(h,b)};z(g,h=>{e().head?h(v):h(m,-1)})}f(u,$)},o=u=>{var $=Nh(),g=l($,!0),v=d(g);{var m=h=>{Mh(h,()=>e().hover)};z(v,h=>{e().hover&&h(m)})}s($),T((h,b,w)=>{pe($,1,h),we($,b),ne($,"title",w),y(g,e().text)},[()=>St(pv(e().style,e().hover,e().click)),()=>uv(e().style),()=>e().click?fv(e().click):void 0]),f(u,$)},c=x(()=>dv(e())),p=u=>{var $=bt();T(()=>y($,e().text)),f(u,$)};z(a,u=>{e().kind==="icon"?u(i):r(c)?u(o,1):u(p,-1)})}f(t,n)},Mh=(t,e=At)=>{let n=x(()=>vv(e()));var a=Bh(),i=l(a);{var o=m=>{is(m,{get node(){return r(n)}})},c=x(()=>e().action==="show_text"||typeof r(n)=="string"||wi(r(n))?.text!=null||wi(r(n))?.translate!=null||Array.isArray(r(n))),p=m=>{let h=x(()=>wi(r(n)));var b=Ih(),w=ie(b),C=l(w,!0);s(w);var A=d(w,2);{var I=P=>{var F=Lh(),L=l(F);s(F),T(()=>y(L,`\xD7${r(h).count??""}`)),f(P,F)};z(A,P=>{(r(h).count??1)>1&&P(I)})}T(P=>y(C,P),[()=>String(r(h).id??"?")]),f(m,b)},u=x(()=>e().action==="show_item"||wi(r(n))?.id!=null),$=m=>{let h=x(()=>wi(r(n)));var b=Dh(),w=ie(b),C=l(w,!0);s(w);var A=d(w,2);{var I=P=>{var F=Oh(),L=l(F);is(L,{get node(){return r(h).name}}),s(F),f(P,F)};z(A,P=>{r(h).name&&P(I)})}T(P=>y(C,P),[()=>String(r(h).type??"entity")]),f(m,b)},g=x(()=>e().action==="show_entity"||wi(r(n))?.type!=null),v=m=>{var h=Fh(),b=l(h,!0);s(h),T(w=>y(b,w),[()=>JSON.stringify(r(n),null,2)]),f(m,h)};z(i,m=>{r(c)?m(o):r(u)?m(p,1):r(g)?m($,2):m(v,-1)})}s(a),f(t,a)},Ph=_(''),Rh=_(''),Nh=_(" "),Lh=_('
    '),Ih=_('
    ',1),Oh=_('
    '),Dh=_('
    ',1),Fh=_('
     
    '),Bh=_('');function is(t,e){le(e,!0);let n=x(()=>cv(e.node));var a=Me(),i=ie(a);de(i,17,()=>r(n),lt,(o,c)=>{Ah(o,()=>r(c))}),f(t,a),ce()}var zh=_('');function ln(t,e){le(e,!0);let n=ae(e,"className",3,""),a=x(()=>("mc-component "+n()).trim());var i=Me(),o=ie(i);{var c=p=>{var u=zh(),$=l(u);is($,{get node(){return e.value}}),s(u),T(()=>pe(u,1,St(r(a)))),Rt("pointerenter",u,g=>va.track(e.value,g)),U("pointermove",u,g=>va.track(e.value,g)),Rt("pointerleave",u,()=>va.track(null,null)),Rt("click",u,g=>ol(g,e.value,"Text JSON copied"),!0),f(p,u)};z(o,p=>{e.value!=null&&e.value!==""&&p(c)})}f(t,i),ce()}Pe(["pointermove"]);var qh=` +struct Camera { + center: vec2f, + viewport: vec2f, + zoom: f32, + cosRot: f32, + sinRot: f32, +}; + +struct VertexOut { + @builtin(position) position: vec4f, + @location(0) @interpolate(flat) chunk: vec2i, + @location(1) @interpolate(flat) layer: i32, + @location(2) world: vec2f, +}; + +@group(0) @binding(0) var camera: Camera; +@group(0) @binding(1) var tileTexture: texture_2d_array; + +@vertex +fn vs_main( + @location(0) corner: vec2f, + @location(1) instance: vec4f, +) -> VertexOut { + let world = vec2f(instance.x * 16.0 + corner.x * 16.0, instance.y * 16.0 + corner.y * 16.0); + let delta = world - camera.center; + let rotated = vec2f( + camera.cosRot * delta.x - camera.sinRot * delta.y, + camera.sinRot * delta.x + camera.cosRot * delta.y, + ); + let screen = camera.viewport * 0.5 + rotated / camera.zoom; + + var out: VertexOut; + out.position = vec4f(screen.x / camera.viewport.x * 2.0 - 1.0, + 1.0 - screen.y / camera.viewport.y * 2.0, + 0.0, + 1.0); + out.chunk = vec2i(i32(instance.x), i32(instance.y)); + out.layer = i32(instance.z); + out.world = world; + return out; +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4f { + let block = vec2i(floor(in.world)); + let local = block - in.chunk * 16; + if (any(local < vec2i(0, 0)) || any(local >= vec2i(16, 16))) { + discard; + } + return textureLoad(tileTexture, local, in.layer, 0); +} +`,yl=class{constructor(e,n,a,i,o,c){this.width=e;this.height=n;this.centerX=a;this.centerZ=i;this.zoom=o;this.rotation=c;this.cosRot=Math.cos(c),this.sinRot=Math.sin(c),this.invZoom=1/o}cosRot;sinRot;invZoom;_scratch=[0,0];projectOffset(e,n){let a=e-this.centerX,i=n-this.centerZ;return this._scratch[0]=(this.cosRot*a-this.sinRot*i)*this.invZoom,this._scratch[1]=(this.sinRot*a+this.cosRot*i)*this.invZoom,this._scratch}worldToOffset(e,n){let a=this.projectOffset(e,n);return[a[0],a[1]]}worldToScreen(e,n){let a=e-this.centerX,i=n-this.centerZ,o=1/this.zoom;return[this.width/2+(this.cosRot*a-this.sinRot*i)*o,this.height/2+(this.sinRot*a+this.cosRot*i)*o]}screenToWorld(e,n){let a=(e-this.width/2)*this.zoom,i=(n-this.height/2)*this.zoom;return[this.centerX+this.cosRot*a+this.sinRot*i,this.centerZ-this.sinRot*a+this.cosRot*i]}screenPanDelta(e,n){let a=this.cosRot,i=-this.sinRot,o=-e*this.zoom,c=-n*this.zoom;return[a*o-i*c,i*o+a*c]}},wl=class t{constructor(e,n,a){this.canvas=e;let i=e.getContext("webgpu");if(!i)throw new Error("WebGPU canvas context unavailable");this.device=n,this.context=i,this.format=navigator.gpu.getPreferredCanvasFormat(),this.layerCapacity=a,this.freeLayers=Array.from({length:this.layerCapacity},(c,p)=>this.layerCapacity-1-p);let o=n.createShaderModule({code:qh});this.pipeline=n.createRenderPipeline({layout:"auto",vertex:{module:o,entryPoint:"vs_main",buffers:[{arrayStride:8,stepMode:"vertex",attributes:[{shaderLocation:0,offset:0,format:"float32x2"}]},{arrayStride:16,stepMode:"instance",attributes:[{shaderLocation:1,offset:0,format:"float32x4"}]}]},fragment:{module:o,entryPoint:"fs_main",targets:[{format:this.format}]},primitive:{topology:"triangle-list"}}),this.tileTexture=n.createTexture({size:[16,16,this.layerCapacity],format:"rgba8unorm",usage:6}),this.cameraBuffer=n.createBuffer({size:32,usage:72}),this.vertexBuffer=n.createBuffer({size:48,usage:40}),n.queue.writeBuffer(this.vertexBuffer,0,new Float32Array([0,0,1,0,1,1,0,0,1,1,0,1])),this.instanceBuffer=n.createBuffer({size:16,usage:40}),this.bindGroup=n.createBindGroup({layout:this.pipeline.getBindGroupLayout(0),entries:[{binding:0,resource:{buffer:this.cameraBuffer}},{binding:1,resource:this.tileTexture.createView({dimension:"2d-array"})}]})}device;context;format;pipeline;bindGroup;tileTexture;vertexBuffer;cameraBuffer;instanceBuffer;instanceCapacity=0;layerCapacity;freeLayers=[];tiles=new Map;configuredWidth=0;configuredHeight=0;static async create(e){let n=navigator.gpu;if(!n)throw new Error("WebGPU unavailable");let a=await n.requestAdapter({powerPreference:"low-power"});if(!a)throw new Error("WebGPU adapter unavailable");let i=Math.min(4096,a.limits.maxTextureArrayLayers),o=await a.requestDevice({requiredLimits:{maxTextureArrayLayers:i}});return new t(e,o,i)}static tileKey(e,n){return e+","+n}setTile(e,n,a){let i=t.tileKey(e,n),o=this.tiles.get(i);o||(o={rgba:new Uint8Array(1024),layer:-1},this.tiles.set(i,o)),o.rgba.fill(0),o.rgba.set(a.length>=1024?a.subarray(0,1024):a),o.layer>=0&&this.uploadLayer(o.layer,o.rgba)}removeTile(e,n){let a=this.tiles.get(t.tileKey(e,n));a&&(this.releaseLayer(a),this.tiles.delete(t.tileKey(e,n)))}clear(){this.tiles.clear(),this.freeLayers=Array.from({length:this.layerCapacity},(e,n)=>this.layerCapacity-1-n)}render(e){let n=this.collectVisibleInstances(e);this.configureCanvas(e.width,e.height),this.writeCamera(e);let a=this.device.createCommandEncoder(),i=a.beginRenderPass({colorAttachments:[{view:this.context.getCurrentTexture().createView(),clearValue:{r:16/255,g:20/255,b:24/255,a:1},loadOp:"clear",storeOp:"store"}]});n.length>0&&(this.ensureInstanceCapacity(n.length/4),this.device.queue.writeBuffer(this.instanceBuffer,0,n),i.setPipeline(this.pipeline),i.setBindGroup(0,this.bindGroup),i.setVertexBuffer(0,this.vertexBuffer),i.setVertexBuffer(1,this.instanceBuffer),i.draw(6,n.length/4)),i.end(),this.device.queue.submit([a.finish()])}dispose(){this.tiles.clear(),this.tileTexture.destroy(),this.vertexBuffer.destroy(),this.cameraBuffer.destroy(),this.instanceBuffer.destroy(),this.device.destroy()}collectVisibleInstances(e){let a=Math.SQRT2*Math.max(e.width,e.height)*e.zoom/2+16,i=Math.floor((e.centerX-a)/16),o=Math.ceil((e.centerX+a)/16),c=Math.floor((e.centerZ-a)/16),p=Math.ceil((e.centerZ+a)/16),u=new Set,$=[];for(let g=c;g<=p&&$.length=0&&!u.has(g)&&this.releaseLayer(v);return new Float32Array($)}ensureLayer(e){if(e.layer>=0)return!0;let n=this.freeLayers.pop();return n===void 0?!1:(e.layer=n,this.uploadLayer(n,e.rgba),!0)}releaseLayer(e){e.layer<0||(this.freeLayers.push(e.layer),e.layer=-1)}uploadLayer(e,n){this.device.queue.writeTexture({texture:this.tileTexture,origin:[0,0,e]},n,{bytesPerRow:64,rowsPerImage:16},[16,16,1])}configureCanvas(e,n){let a=window.devicePixelRatio||1,i=Math.max(1,Math.floor(e*a)),o=Math.max(1,Math.floor(n*a));this.configuredWidth===i&&this.configuredHeight===o||(this.canvas.width=i,this.canvas.height=o,this.context.configure({device:this.device,format:this.format,alphaMode:"opaque"}),this.configuredWidth=i,this.configuredHeight=o)}writeCamera(e){let n=e.rotation;this.device.queue.writeBuffer(this.cameraBuffer,0,new Float32Array([e.centerX,e.centerZ,e.width,e.height,e.zoom,Math.cos(n),Math.sin(n)]))}ensureInstanceCapacity(e){e<=this.instanceCapacity||(this.instanceBuffer.destroy(),this.instanceCapacity=Math.max(64,1<` +
    +

    Minimap off

    + saves ~24 KB/s \xB7 ~3% CPU +
    +
    + +

    + Subscribes to pre-rasterized chunk tiles, player pose, and entity markers on one + WebSocket topic (10 Hz). Optional \u2014 disabled by default when nobody is watching. +

    +
      +
    • Bandwidth~4\u201312 KB/s
    • +
    • Cadence10 Hz unified
    • +
    • WS topicplayer:${String(t||"").slice(0,8)}:minimap
    • +
    + +
    `,Rv=()=>` +
    +

    Minimap

    + 0 0 0 +
    + + + + +
    +
    +
    +
    +
    + + +
    +
    +
    + +
    +
    0 64 0
    +
    +
    + 16m +
    +
    +
    +
    + +
    + + + + + +
    +
    +
    +
    +
    +

    Waypoints \xB70

    +
    +
    +
      +
      `,Nv=t=>`
      ${Math.round(t[0])} \xB7 ${Math.round(t[1])}
      + + `,Lv=t=>`
      +

      New waypoint

      +
      + +
      + + + +
      + + +
      +
      + + +
      +
      `;var Hh=340,jh=500,Uh=5;function Vh(t){return t?(t.startsWith("minecraft:")?t.slice(10):t).split("_").map(n=>n&&n[0].toUpperCase()+n.slice(1)).join(" "):"Entity"}function Iv(t){let e=t._els.viewport,n=new Map,a=0,i={t:0,x:0,y:0},o=!1,c=null,p=g=>{let v=e.getBoundingClientRect();return{x:g.clientX-v.left,y:g.clientY-v.top}},u=()=>{clearTimeout(a),a=0};e.addEventListener("pointerdown",g=>{g.preventDefault(),e.setPointerCapture(g.pointerId);let v=p(g);if(n.set(g.pointerId,{x:v.x,y:v.y,sx:v.x,sy:v.y}),o=!1,u(),n.size===1){let m=g.clientX,h=g.clientY;a=setTimeout(()=>{!o&&n.size===1&&t.openContextMenu(m,h,t.viewportToWorld(v.x,v.y))},jh)}else if(n.size===2){let[m,h]=[...n.values()];c={d:Math.hypot(m.x-h.x,m.y-h.y),zoom:t.zoom,angle:Math.atan2(h.y-m.y,h.x-m.x)}}}),e.addEventListener("pointermove",g=>{let v=n.get(g.pointerId);if(!v){g.pointerType!=="touch"&&t._handleHover(g);return}let m=p(g),h=m.x-v.x,b=m.y-v.y;if(v.x=m.x,v.y=m.y,n.size===1){if(Math.hypot(m.x-v.sx,m.y-v.sy)>Uh){o=!0,u(),t._clearHover(),t.startManualPan();let w=t.buildCamera(e.clientWidth,e.clientHeight),[C,A]=w.screenPanDelta(h,b);t.panX+=C,t.panZ+=A,t.requestRender()}}else if(n.size===2&&c){let[w,C]=[...n.values()],A=Math.hypot(w.x-C.x,w.y-C.y),I=Math.atan2(C.y-w.y,C.x-w.x);t.twist+=I-c.angle,c.angle=I,t.setZoom(c.zoom*(c.d/A)),o=!0}}),e.addEventListener("pointerleave",()=>t._clearHover());let $=g=>{u();let v=n.get(g.pointerId);n.delete(g.pointerId),n.size<2&&(c=null);try{e.releasePointerCapture(g.pointerId)}catch{}if(!v||o)return;let m=t._entityHitAt(v.x,v.y);if(m){ft(`${Vh(m.type)} \xB7 ${Math.round(m.x)} ${Math.round(m.y)} ${Math.round(m.z)}`),i={t:0,x:0,y:0};return}let h=performance.now();h-i.t{g.preventDefault(),t.setZoom(t.zoom*(g.deltaY>0?1.15:1/1.15))},{passive:!1}),e.addEventListener("contextmenu",g=>{g.preventDefault();let v=p(g);t.openContextMenu(g.clientX,g.clientY,t.viewportToWorld(v.x,v.y))})}var Ad=.15,Ov=8,Yh=10,Dv=.01,Gh=.5,Md={players:{label:"Players",glyph:"\u25C6",sprite:"diamond",color:"var(--em-player)"},hostile:{label:"Hostile",glyph:"\u25B2",sprite:"triangle",color:"var(--em-hostile)"},passive:{label:"Passive",glyph:"\u25A0",sprite:"square",color:"var(--em-passive)"},items:{label:"Items",glyph:"+",sprite:"plus",color:"var(--em-item)"},projectiles:{label:"Projectiles",glyph:"\xB7",sprite:"dot",color:"var(--em-proj)"},vehicles:{label:"Vehicles",glyph:"\u25C6",sprite:"diamond",color:"var(--em-vehicle)"}},Ei=Object.keys(Md),Wh=64,zv=3.5;function Kh(t,e,n){let a=zv;switch(e){case"diamond":for(let i=0;i[a,!0])),this.waypoints=Qh(n),this.unsubs=[],this._els=null,this._markerEls=new Map,this._raf=0,this._lastTick=0,this._terrainInit=0,this._lastCamera=null,this._hoverEntityId=null,this._colorCache=null,this._entityBuckets=null}boot(){this.host.classList.add("panel","mm-host","mm-root"),this.renderShell(),this.enabled&&this.subscribe()}subscribe(){this.unsubs.push(or.subscribe(If(this.uuid),e=>this.applyFrame(e)))}async fetchInitial(){try{this.applySnapshot(await Ge(`/players/${encodeURIComponent(this.uuid)}/minimap`))}catch{}}updatePlayer(e){!e||this.paused||this.enabled&&this.requestRender()}applySnapshot(e){!e||this.paused||(this._terrain?.clear(),this.applyFrame(e,!0))}applyFrame(e,n=!1){if(!e||this.paused)return;typeof e.posX=="number"&&(this.player.position[0]=e.posX),typeof e.posY=="number"&&(this.player.position[1]=e.posY),typeof e.posZ=="number"&&(this.player.position[2]=e.posZ),typeof e.yaw=="number"&&(this.player.rotation[0]=e.yaw),this.seedDisplayFromTarget(),Array.isArray(e.entities)&&(this.entities=e.entities,this.renderFilters());let a=n?e.chunks:e.loaded;if(Array.isArray(a))for(let i of a)this.ingestTile(i);if(Array.isArray(e.unloaded)&&this._terrain)for(let i of e.unloaded)this._terrain.removeTile(i.x,i.z);this.requestRender()}setPaused(e){this.paused=!!e}seedDisplayFromTarget(){this.displayX==null&&(this.displayX=this.player.position[0],this.displayZ=this.player.position[2],this.displayYaw=this.player.rotation[0]||0)}ingestTile(e){!e?.tile||!this._terrain||this._terrain.setTile(e.x|0,e.z|0,Mv(e.tile))}renderShell(){if(this.host.innerHTML=this.enabled?Rv():Pv(this.uuid),!this.enabled){this._els=null,this.host.querySelector('[data-act="enable"]').onclick=()=>this.toggleEnabled(!0);return}let e=n=>this.host.querySelector(n);this._els={viewport:e("[data-mm-viewport]"),canvas:e("[data-mm-canvas]"),overlay:e("[data-mm-overlay]"),markers:e("[data-mm-markers]"),cardinals:e("[data-mm-cardinals]"),player:e("[data-mm-player]"),coords:e("[data-mm-coords]"),inset:e("[data-mm-inset]"),zoombar:e("[data-mm-zoombar]"),scaleLbl:e("[data-mm-scale-l]"),scaleBar:e(".mm-scale-bar"),filters:e("[data-mm-filters]"),wpList:e("[data-mm-wp-list]"),wpCount:e("[data-mm-wp-count]")},this._markerEls.clear(),this._hoverEntityId=null,this._colorCache=null,this._filterChips=null,this._els.markers.onclick=n=>this.onMarkersClick(n),this.initTerrain(this._els.canvas),this.bindHeader(),this.bindToolbar(),this.bindFilters(),Iv(this),this.bindWaypoints(),this.renderFilters(),this.renderWaypointList(),this.wireResize(),this.requestRender()}async initTerrain(e){let n=++this._terrainInit;this._terrain?.dispose(),this._terrain=null;try{let a=await wl.create(e);if(n!==this._terrainInit||!this.enabled||this._els?.canvas!==e){a.dispose();return}this._terrain=a,this.fetchInitial(),this.requestRender()}catch{if(n!==this._terrainInit||!this.enabled)return;ft("WebGPU required for minimap"),this.toggleEnabled(!1)}}onMarkersClick(e){let n=Ks(e,".mm-wp");if(!n)return;let a=this.waypoints.find(i=>i.id===n.dataset.wp);a&&this.centerOnWaypoint(a)}wireResize(){this._ro&&(this._ro.disconnect(),this._ro=null),!(typeof ResizeObserver>"u")&&(this._ro=new ResizeObserver(()=>this.requestRender()),this._ro.observe(this._els.viewport))}bindHeader(){let e=(n,a)=>this.host.querySelector(`[data-act="${n}"]`).onclick=a;e("recenter",()=>{this.panX=0,this.panZ=0,this.follow=!0,this.twist=0,this.snapDisplay(),this.requestRender()}),e("north",()=>{this.northUp=!this.northUp;let n=this.host.querySelector('[data-act="north"]');n.textContent=this.northUp?"N":"\u21BB",n.classList.toggle("is-on",this.northUp),n.title=this.northUp?"North-up \xB7 click to follow":"Follow yaw \xB7 click to lock N",this.requestRender()}),e("full",()=>this.toggleFullscreen()),e("disable",()=>this.toggleEnabled(!1))}bindToolbar(){let e=(a,i)=>this.host.querySelector(`[data-act="${a}"]`).onclick=i;e("zin",()=>this.setZoom(this.zoom/1.25)),e("zout",()=>this.setZoom(this.zoom*1.25));let n=(a,i)=>{let o=this.host.querySelector(`[data-act="${i}"]`);o.onclick=()=>{this[a]=!this[a],o.classList.toggle("is-on",this[a]),this.requestRender()}};n("showGrid","grid"),n("showCardinals","card"),e("help",()=>ft("Drag: pan \xB7 scroll/pinch: zoom \xB7 2-finger twist: rotate \xB7 double-tap: waypoint \xB7 right-click: menu"))}bindFilters(){this._els.filters.onclick=e=>{let n=Ks(e,".mm-chip");if(!n)return;let a=n.dataset.group;this.filters[a]=!this.filters[a],n.classList.toggle("is-on",this.filters[a]),this.requestRender()}}bindWaypoints(){this.host.querySelector('[data-act="wp-here"]').onclick=()=>this.openWaypointDraft([this.player.position[0],this.player.position[2]]),this._els.wpList.onclick=e=>{let n=Ks(e,".mm-wp-item");if(!n)return;if(Ks(e,".mm-wp-x")){e.stopPropagation(),this.waypoints=this.waypoints.filter(i=>i.id!==n.dataset.id),Bv(this.uuid,this.waypoints),this.renderWaypointList(),this.requestRender();return}let a=this.waypoints.find(i=>i.id===n.dataset.id);a&&this.centerOnWaypoint(a)}}setZoom(e){this.zoom=eb(e,Ad,Ov),this.requestRender()}startManualPan(){this.follow&&this.snapDisplay(),this.follow=!1}centerOnWaypoint(e){this.startManualPan();let[n,a]=this.effectiveCenter(!1);this.panX=e.x-n,this.panZ=e.z-a,this.requestRender()}toggleEnabled(e){this.enabled=e,Jh(e),e||(this.unsubs.forEach(n=>n()),this.unsubs=[],this._terrainInit++,this._terrain?.dispose(),this._terrain=null),this.renderShell(),e&&this.subscribe()}toggleFullscreen(){this.fullscreen=!this.fullscreen,this.host.classList.toggle("is-fullscreen",this.fullscreen),this.fullscreen?(this._fsEsc=e=>{e.key==="Escape"&&this.toggleFullscreen()},document.addEventListener("keydown",this._fsEsc)):this._fsEsc&&(document.removeEventListener("keydown",this._fsEsc),this._fsEsc=null),this.requestRender()}requestRender(){this._raf||!this.enabled||(this._raf=requestAnimationFrame(e=>{this._raf=0;let n=this._lastTick?Math.min((e-this._lastTick)/1e3,.1):1/60;this._lastTick=e;let a=this.tickInterpolators(n);this.render(),a?this.requestRender():this._lastTick=0}))}snapDisplay(){this.displayX=this.player.position[0],this.displayZ=this.player.position[2],this.displayYaw=this.player.rotation[0]||0}buildCamera(e,n){let[a,i]=this.cameraCenter();return new yl(e,n,a,i,this.zoom,this.mapRotation())}tickInterpolators(e){let n=1-Math.exp(-Yh*e),a=!1;if(this.displayX!=null){let i=this.player.position[0],o=this.player.position[2];if(this.follow){let u=i-this.displayX,$=o-this.displayZ;Math.abs(u)>Dv||Math.abs($)>Dv?(this.displayX+=u*n,this.displayZ+=$*n,a=!0):(this.displayX=i,this.displayZ=o)}let c=this.player.rotation[0]||0,p=tb(this.displayYaw,c);Math.abs(p)>Gh?(this.displayYaw=(this.displayYaw+p*n+360)%360,a=!0):this.displayYaw=c}return a}effectiveCenter(e){return this.displayX!=null&&(!e||this.follow)?[this.displayX,this.displayZ]:[this.player.position[0],this.player.position[2]]}cameraCenter(){let[e,n]=this.effectiveCenter(!1);return[e+this.panX,n+this.panZ]}mapRotation(){return this.northUp?this.twist:Math.PI-this.displayYaw*Math.PI/180+this.twist}viewportToWorld(e,n){let a=this._els?.viewport;return a?this.buildCamera(a.clientWidth,a.clientHeight).screenToWorld(e,n):[0,0]}render(){if(!this.enabled||!this._els)return;let e=this._els.viewport,n=e.clientWidth,a=e.clientHeight;if(n<=0||a<=0)return;let i=this.buildCamera(n,a);this._lastCamera=i,this._terrain?.render(i),this.renderOverlay(i),this.renderMarkers(i),this.renderCardinals(n),this.renderHeader(),this.renderZoomBar(),this.renderPlayer(i),this.renderScale()}renderOverlay(e){let n=this._els.overlay,a=e.width,i=e.height,o=window.devicePixelRatio||1;(n.width!==a*o||n.height!==i*o)&&(n.width=a*o,n.height=i*o);let c=n.getContext("2d");c.setTransform(o,0,0,o,0,0),c.imageSmoothingEnabled=!1,c.clearRect(0,0,a,i),this.showGrid&&this.drawGrid(c,e,a,i),this.drawEntities(c,e,a,i)}drawGrid(e,n,a,i){let o=16;for(;o/n.zoom<8;)o*=2;let c=Math.SQRT2*Math.max(a,i)*n.zoom/2+o,p=Math.floor((n.centerX-c)/o)*o,u=Math.ceil((n.centerX+c)/o)*o,$=Math.floor((n.centerZ-c)/o)*o,g=Math.ceil((n.centerZ+c)/o)*o;e.lineWidth=1,e.strokeStyle="rgba(0,0,0,0.28)",e.beginPath();for(let v=p;v<=u;v+=o){let[m,h]=n.worldToScreen(v,$),[b,w]=n.worldToScreen(v,g);e.moveTo(m,h),e.lineTo(b,w)}for(let v=$;v<=g;v+=o){let[m,h]=n.worldToScreen(p,v),[b,w]=n.worldToScreen(u,v);e.moveTo(m,h),e.lineTo(b,w)}e.stroke()}drawEntities(e,n,a,i){let o=this.entities;if(o.length===0)return;let c=a/2,p=i/2,u=zv+1,$=this._selfUuidLower,g=this.filters,v=this._entityBuckets;if(!v){v=this._entityBuckets={};for(let m of Ei)v[m]=[]}for(let m of Ei)v[m].length=0;for(let m=0,h=o.length;mc+u||I<-p-u||I>p+u||w.push(c+A,p+I)}e.lineWidth=1,e.strokeStyle="rgba(0,0,0,0.6)";for(let m of Ei){let h=v[m];if(h.length===0)continue;let b=Md[m];e.fillStyle=this._resolveColor(b.color),e.beginPath(),Kh(e,b.sprite,h),e.fill(),e.stroke()}}_resolveColor(e){let n=this._colorCache;n||(n=this._colorCache=new Map);let a=n.get(e);if(a!==void 0)return a;let i=e;if(e.startsWith("var(")){let o=e.slice(4,-1).trim();i=getComputedStyle(this.host).getPropertyValue(o).trim()||"#fff"}return n.set(e,i),i}renderMarkers(e){let n=this._els.markers,a=e.width/2,i=e.height/2,o=10,[c,p]=this.effectiveCenter(!0),u=new Set;for(let $ of this.waypoints){let[g,v]=e.worldToOffset($.x,$.z);if(Math.abs(g)>a-o||Math.abs(v)>i-o)continue;let m="wp:"+$.id;u.add(m);let h=this._markerEls.get(m);h||(h=document.createElement("div"),h.className="mm-wp",h.dataset.wp=String($.id),h.style.setProperty("--wp-c",$.color),h.innerHTML=`${$.icon}${Cr($.name)}`,this._markerEls.set(m,h),n.appendChild(h)),h.style.transform=`translate(${g.toFixed(2)}px,${v.toFixed(2)}px)`;let b=Math.round(Math.hypot($.x-c,$.z-p))+"m",w=h.querySelector(".mm-wp-d");w.textContent!==b&&(w.textContent=b)}for(let[$,g]of this._markerEls)u.has($)||(g.remove(),this._markerEls.delete($))}_entityHitAt(e,n){let a=this._lastCamera,i=this.entities;if(!a||i.length===0)return null;let o=a.width/2,c=a.height/2,p=e-o,u=n-c,$=this._selfUuidLower,g=this.filters,v=null,m=Wh;for(let h=0,b=i.length;h${Fv[u]}`}n.innerHTML=p}renderHeader(){let e=this.player.position,n=`${Math.round(e[0])} ${Math.round(e[1])} ${Math.round(e[2])}`;this._els.coords.textContent!==n&&(this._els.coords.textContent=n),this._els.inset.innerHTML=`${Math.round(e[0])} ${Math.round(e[1])} ${Math.round(e[2])}`}renderZoomBar(){let e=(Math.log(this.zoom)-Math.log(Ad))/(Math.log(Ov)-Math.log(Ad));this._els.zoombar.style.height=`${(1-e)*100}%`}renderPlayer(e){let[n,a]=this.effectiveCenter(!0),[i,o]=e.worldToOffset(n,a),c=this.displayYaw*Math.PI/180,p=-Math.sin(c),u=Math.cos(c),$=e.rotation,g=Math.cos($)*p-Math.sin($)*u,v=Math.sin($)*p+Math.cos($)*u,m=Math.atan2(g,-v)*180/Math.PI;this._els.player.style.transform=`translate(${i.toFixed(2)}px,${o.toFixed(2)}px) translate(-50%,-50%) rotate(${m.toFixed(1)}deg)`}renderScale(){let e=1/this.zoom,n=4;for(let a of[4,8,16,32,64,128,256,512])if(a*e<60)n=a;else{n=a;break}this._els.scaleLbl.textContent=n+"m",this._els.scaleBar.style.width=n*e+"px"}renderFilters(){if(!this._els)return;let e=Object.fromEntries(Ei.map(n=>[n,0]));for(let n of this.entities)e[n.group]!==void 0&&e[n.group]++;if(!this._filterChips){let n=document.createDocumentFragment();this._filterChips={};for(let a of Ei){let i=Md[a],o=document.createElement("button");o.className="mm-chip"+(this.filters[a]?" is-on":""),o.dataset.group=a,o.style.setProperty("--chip-c",i.color),o.innerHTML=`${i.glyph}${i.label}${e[a]}`,n.appendChild(o),this._filterChips[a]=o}this._els.filters.replaceChildren(n);return}for(let n of Ei){let a=this._filterChips[n];if(!a)continue;a.classList.toggle("is-on",!!this.filters[n]);let i=a.querySelector(".mm-chip-count"),o=String(e[n]||0);i&&i.textContent!==o&&(i.textContent=o)}}renderWaypointList(){if(!this._els)return;this._els.wpCount.textContent="\xB7"+this.waypoints.length;let e=this.player.position[0],n=this.player.position[2];this._els.wpList.innerHTML=this.waypoints.map(a=>{let i=a.x-e,o=a.z-n,c=Math.hypot(i,o),p=(Math.atan2(i,-o)*180/Math.PI+360)%360,u=Xh[Math.floor((p+22.5)/45)%8];return`
    • + ${a.icon} + + ${Cr(a.name)} + ${a.x} ${a.y} ${a.z} + + ${u}${Math.round(c)}m + +
    • `}).join("")}openContextMenu(e,n,a){this.closeContextMenu();let i=document.createElement("div");i.className="mm-ctx",i.style.left=e+"px",i.style.top=n+"px",i.innerHTML=Nv(a),document.body.appendChild(i),requestAnimationFrame(()=>{let c=i.getBoundingClientRect(),p=8;i.style.left=Math.max(p,Math.min(innerWidth-c.width-p,e))+"px",i.style.top=Math.max(p,Math.min(innerHeight-c.height-p,n))+"px"}),i.onclick=c=>{let p=c.target.closest("button")?.getAttribute("data-a");p==="add"?this.openWaypointDraft(a):p==="copy"&&(navigator.clipboard?.writeText(`${Math.round(a[0])} 64 ${Math.round(a[1])}`),ft("Coordinates copied")),this.closeContextMenu()};let o=c=>{Ks(c,".mm-ctx")||(this.closeContextMenu(),document.removeEventListener("pointerdown",o,!0))};setTimeout(()=>document.addEventListener("pointerdown",o,!0),0),this.contextMenu=i}closeContextMenu(){this.contextMenu&&(this.contextMenu.remove(),this.contextMenu=null)}openWaypointDraft(e){let n={id:"wp-"+Date.now(),x:Math.round(e[0]),y:Math.round(this.player.position[1]),z:Math.round(e[1]),name:"Waypoint "+(this.waypoints.length+1),color:Td[0],icon:Cd[0]},a=document.createElement("div");a.className="overlay-scrim overlay-scrim--modal",a.innerHTML=Lv(n),document.body.appendChild(a),a.addEventListener("click",o=>{o.target===a&&a.remove()});let i=(o,c)=>a.querySelectorAll(`[${o}]`).forEach(p=>p.onclick=()=>{n[c]=p.getAttribute(o),a.querySelectorAll(`[${o}]`).forEach(u=>u.classList.remove("is-on")),p.classList.add("is-on")});i("data-color","color"),i("data-icon","icon"),a.querySelectorAll('[data-a="cancel"]').forEach(o=>o.onclick=()=>a.remove()),a.querySelector('[data-a="save"]').onclick=()=>{n.name=a.querySelector('[data-f="name"]').value||n.name,n.x=parseInt(a.querySelector('[data-f="x"]').value,10)||0,n.y=parseInt(a.querySelector('[data-f="y"]').value,10)||64,n.z=parseInt(a.querySelector('[data-f="z"]').value,10)||0,this.waypoints.push(n),Bv(this.uuid,this.waypoints),a.remove(),this.renderWaypointList(),this.requestRender()}}destroy(){this.unsubs.forEach(e=>e()),this.unsubs=[],this._terrainInit++,this._terrain?.dispose(),this._terrain=null,this._raf&&cancelAnimationFrame(this._raf),this._ro&&(this._ro.disconnect(),this._ro=null),this._fsEsc&&(document.removeEventListener("keydown",this._fsEsc),this._fsEsc=null),this.closeContextMenu(),this._markerEls.clear(),Ji(),this.host.classList.remove("mm-host","mm-root","is-fullscreen"),this.host.innerHTML=""}};var rb=_("
      "),nb={hash:"svelte-1fj6ewf",code:` + @layer components { + /* ---- Minimap ---- */ + /* === Host ============================================================================ */.mm-host {display:flex;flex-direction:column;overflow:hidden;position:relative;height:100%;min-height:320px;> header.mm-header {display:grid;grid-template-columns:auto 1fr auto;align-items:center;gap:var(--pad-3);padding:var(--pad-3) var(--pad-4);border-bottom:1px solid var(--line);h2 {font-size:var(--t-sm);text-transform:uppercase;}.meta {font-size:var(--t-xs);color:var(--ink-3);text-transform:uppercase;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}}}.mm-header-actions {display:inline-flex;gap:4px;.icon {padding:0;width:26px;min-width:26px;height:26px;min-height:26px;&.is-on {color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft);}}} + + /* === Disabled CTA ==================================================================== */.mm-disabled {display:grid;gap:var(--pad-4);padding:var(--pad-5) var(--pad-4) var(--pad-4);grid-template-columns:96px 1fr;align-items:center;> .primary {grid-column:1 / -1;}}.mm-disabled-art {width:96px;height:96px;background:var(--sunk);box-shadow:var(--bevel-sunk);padding:8px;}.mm-disabled-body {color:var(--ink-3);font-size:var(--t-sm);line-height:1.25;}.mm-disabled-stats {grid-column:1 / -1;margin:0;padding:0;list-style:none;display:grid;gap:4px;font-size:var(--t-xs);li {display:grid;grid-template-columns:1fr auto;padding:6px 0;border-top:1px solid var(--line);color:var(--ink-3);text-transform:uppercase;&:first-child {border-top:0;}}code {color:var(--ink);background:transparent;padding:0;font-size:var(--t-xs);text-transform:none;}} + + /* === Stage (map + toolbar) =========================================================== */.mm-body {flex:1 1 0;min-height:0;display:grid;grid-template-columns:1fr auto;gap:var(--pad-2);padding:var(--pad-3);background:var(--sunk);} + + /* Min-height keeps the canvas readable in a small overview column. */.mm-stage {position:relative;width:100%;height:100%;min-height:280px;}.mm-viewport {position:absolute;inset:0;overflow:hidden;background:var(--mm-deep);border:1px solid #111;box-shadow:inset 0 0 0 1px rgba(0, 0, 0, .6), inset 0 0 0 2px rgba(255, 255, 255, .06);touch-action:none;user-select:none;cursor:grab;image-rendering:pixelated;&:active {cursor:grabbing;}}.mm-canvas {position:absolute;inset:0;width:100%;height:100%;display:block;image-rendering:pixelated;}.mm-overlay {pointer-events:none;}.mm-markers {position:absolute;inset:0;pointer-events:none;.mm-wp {pointer-events:auto;}} + + /* === Player marker ==================================================================== */ + /* Entity markers are painted on .mm-overlay; see minimap.ts drawEntities(). */.mm-player {position:absolute;left:50%;top:50%;pointer-events:none;z-index:5;image-rendering:pixelated;} + + /* === Waypoint markers ================================================================ */.mm-wp {position:absolute;left:50%;top:50%;cursor:pointer;z-index:4;&:hover {z-index:7;}&:hover .mm-wp-label {opacity:1;transform:translateX(0);}}.mm-wp-dot {display:grid;place-items:center;width:14px;height:14px;color:#fff;background:var(--wp-c);font-size:var(--t-xs);transform:translate(-50%, -50%);box-shadow:0 0 0 1px #000, inset 0 0 0 1px rgba(255, 255, 255, .55); + animation: mmwp-pop 240ms ease-out;image-rendering:pixelated;} + @keyframes mmwp-pop { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } + }.mm-wp-label {position:absolute;left:12px;top:-5px;color:#fff;background:var(--mm-scrim);padding:1px 5px;font-size:var(--t-xs);text-shadow:1px 1px 0 rgba(0, 0, 0, .6);pointer-events:none;white-space:nowrap;opacity:0;transform:translateX(-4px);transition:opacity 80ms ease-out, transform 80ms ease-out;z-index:6;i {font-style:normal;margin-left:6px;color:color-mix(in oklab, var(--wp-c) 70%, #fff);}} + + /* === Cardinal letters + in-map readouts ============================================== */.mm-cardinals {position:absolute;inset:0;pointer-events:none;}.mm-cardinal {position:absolute;left:50%;top:50%;color:var(--ink-2);font-size:var(--t-xs);background:var(--mm-scrim);padding:1px 4px;&.is-n {color:var(--acc);background:var(--mm-scrim-2);}}.mm-coords-inset {position:absolute;left:6px;bottom:6px;background:var(--mm-scrim);padding:2px 6px;font-size:var(--t-xs);color:#fff;pointer-events:none;.acc {color:var(--acc);}}.mm-scale {position:absolute;right:6px;bottom:6px;display:inline-flex;align-items:center;gap:6px;padding:2px 5px;background:var(--mm-scrim);color:#fff;font-size:var(--t-xs);pointer-events:none;}.mm-scale-bar {display:inline-flex;height:6px;border-top:1px solid #fff;i {display:block;flex:1;height:100%;&:nth-child(odd) {background:var(--mm-scrim-2);}&:nth-child(even) {background:rgba(255, 255, 255, .85);}}} + + /* === Toolbar (right of the map) ====================================================== */.mm-toolbar {display:flex;flex-direction:column;gap:4px;padding:6px 4px;background:var(--bg-2);border:1px solid var(--line);box-shadow:var(--bevel-sunk);align-self:center;}.mm-tool {width:28px;height:28px;display:grid;place-items:center;padding:0;background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);color:var(--ink-2);font-size:var(--t-md);cursor:pointer;text-transform:none;&:hover {color:var(--ink);border-color:var(--acc-line);}&.is-on {color:var(--acc);border-color:var(--acc-line);background:var(--acc-soft);}}.mm-tool-sep {display:block;height:1px;background:var(--line);margin:4px 2px;}.mm-zoom-track {width:4px;height:60px;background:var(--sunk);box-shadow:var(--bevel-sunk);margin:0 auto;position:relative;}.mm-zoom-bar {position:absolute;left:0;right:0;top:0;background:var(--acc);transition:height 80ms;} + + /* === Filter chips ==================================================================== */.mm-filters {display:flex;flex-wrap:wrap;padding:var(--pad-2) var(--pad-3);background:var(--bg-1);border-top:1px solid var(--line);border-bottom:1px solid var(--line);}.mm-chip {display:inline-flex;align-items:center;gap:6px;padding:4px 10px;background:var(--bg-2);border:1px solid var(--line);color:var(--ink-3);font-size:var(--t-xs);text-transform:uppercase;margin-left:-1px;cursor:pointer;text-decoration:line-through;text-decoration-color:var(--ink-4);&:first-child {margin-left:0;}&.is-on {color:var(--ink);background:color-mix(in oklab, var(--chip-c) 12%, var(--bg-2));border-color:color-mix(in oklab, var(--chip-c) 45%, var(--line));text-decoration:none;}&:not(.is-on) .mm-chip-glyph, + &:not(.is-on) .mm-chip-count {color:var(--ink-4);}}.mm-chip-glyph {color:var(--chip-c);font-size:12px;}.mm-chip-count {font-variant-numeric:tabular-nums;color:var(--chip-c);} + + /* === Waypoints list ================================================================== */.mm-waypoints {padding:var(--pad-2) var(--pad-3);}.mm-wp-head {display:flex;align-items:center;justify-content:space-between;padding:4px 0;h3 {font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-2);}}.mm-wp-list {list-style:none;padding:0;margin:0;display:grid;gap:2px;max-height:220px;}.mm-wp-item {display:grid;grid-template-columns:22px 1fr auto 18px;align-items:center;gap:var(--pad-2);padding:4px 6px;background:var(--sunk);box-shadow:var(--bevel-sunk);cursor:pointer;font-size:var(--t-sm);transition:background var(--motion), outline var(--motion);&:hover {background:var(--bg-2);outline:1px solid var(--acc-line);.mm-wp-x {opacity:1;}}}.mm-wp-color {width:18px;height:18px;display:grid;place-items:center;color:#fff;text-shadow:1px 1px 0 rgba(0, 0, 0, .5);font-size:12px;}.mm-wp-text {display:grid;gap:1px;min-width:0;}.mm-wp-name {color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.mm-wp-meta {font-size:var(--t-xs);color:var(--ink-4);font-variant-numeric:tabular-nums;}.mm-wp-bearing {text-align:right;font-size:var(--t-xs);display:grid;gap:1px;min-width:38px;b {font-weight:400;color:var(--ink);}i {font-style:normal;color:var(--acc);font-variant-numeric:tabular-nums;}}.mm-wp-x {width:18px;height:18px;padding:0;background:transparent;border:1px solid transparent;color:var(--ink-4);font-size:var(--t-xs);cursor:pointer;line-height:1;opacity:0;transition:opacity var(--motion);&:hover {color:var(--danger);border-color:color-mix(in oklab, var(--danger) 40%, var(--line));}} + + /* === Context menu ==================================================================== */.mm-ctx {position:fixed;z-index:80;min-width:200px;background:var(--bg-1);border:1px solid var(--acc-line);box-shadow:var(--bevel), var(--float-2);padding:4px 0;transform:translate(0, 6px);button {width:100%;display:grid;grid-template-columns:20px 1fr;gap:6px;align-items:center;text-align:left;background:transparent;border:0;padding:6px 10px;box-shadow:none;color:var(--ink-2);font-size:var(--t-sm);text-transform:none;cursor:pointer;&:hover {background:var(--acc-soft);color:var(--ink);}> span:first-child {color:var(--acc);text-align:center;}&.danger {color:var(--danger);&:hover {background:color-mix(in oklab, var(--danger) 14%, transparent);color:var(--danger);}> span:first-child {color:var(--danger);}}}}.mm-ctx-head {padding:6px 10px;border-bottom:1px solid var(--line);font-size:var(--t-xs);color:var(--ink);font-variant-numeric:tabular-nums;} + + /* === Waypoint dialog ================================================================= */.mm-modal {width:min(440px, calc(100vw - 32px));background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel), 0 16px 40px rgba(0, 0, 0, .6);> header {display:flex;align-items:center;justify-content:space-between;padding:var(--pad-3) var(--pad-4);border-bottom:1px solid var(--line);h2 {font-size:var(--t-sm);text-transform:uppercase;color:var(--acc);}}> footer {display:flex;justify-content:flex-end;gap:var(--pad-2);padding:var(--pad-3) var(--pad-4);border-top:1px solid var(--line);}}.mm-modal-body {padding:var(--pad-4);display:grid;gap:var(--pad-3);.field {display:grid;gap:6px;> span {font-size:var(--t-xs);color:var(--ink-3);text-transform:uppercase;}}.field-row {display:grid;grid-template-columns:1fr 1fr 1fr;gap:var(--pad-3);}}.mm-color-swatches {display:flex;gap:6px;}.mm-sw {width:28px;height:28px;padding:0;box-shadow:var(--bevel);border:1px solid var(--line);cursor:pointer;&.is-on {outline:2px solid var(--ink);outline-offset:2px;}}.mm-icon-grid {display:grid;grid-template-columns:repeat(6, 1fr);gap:4px;}.mm-ig {aspect-ratio:1;padding:0;background:var(--bg-2);border:1px solid var(--line);box-shadow:var(--bevel);color:var(--ink-2);font-size:var(--t-md);text-transform:none;cursor:pointer;&.is-on {color:var(--bg-0);background:var(--acc);border-color:var(--acc-deep);}} + + /* === Fullscreen mode \u2014 exit via the header \u26F6 button or Escape. ====================== */.mm-root.is-fullscreen {position:fixed;z-index:90;inset:60px 24px 24px 24px;width:auto;min-width:0;background:var(--bg-1);box-shadow:var(--bevel), 0 24px 60px rgba(0, 0, 0, .7);overflow:auto;.mm-stage {width:100%;height:100%;}} + }`};function El(t,e){le(e,!0),Ut(t,nb);let n=ae(e,"paused",3,!1),a,i=null;ge(()=>(e.uuid,i=new kl(a,e.uuid),i.boot(),()=>i?.destroy())),ge(()=>{i?.setPaused(n())}),ge(()=>{i?.updatePlayer(e.player)});var o=rb();Tt(o,c=>a=c,()=>a),f(t,o),ce()}var ab=_('
      '),ib=_('
      Loading\u2026
      '),sb=_('
      No mutation history yet.
      '),ob=_(' '),lb=_('
    • '),cb=_('
        '),db=_('');function Pd(t,e){le(e,!0);let n=X(null),a=X(null),i=X(null),o=null;async function c(){try{let k=await Ge(`/players/${e.uuid}/provenance?field=${encodeURIComponent(e.field)}`);E(a,k[e.field]||[],!0),E(i,null)}catch(k){E(i,String(k.message||k),!0)}}ge(()=>{e.uuid,e.field,o=null,c()}),ge(()=>{let k=e.sourceSeq;k==null||k===o||(o=k,c())});let{pos:p}=Xi(()=>e.anchor,()=>r(n),(k,M)=>({left:Math.max(8,Math.min(window.innerWidth-M.offsetWidth-8,k.left+window.scrollX)),top:Math.max(8,k.top+window.scrollY-M.offsetHeight-6)}),()=>e.onClose(),{escape:!0,closeEvent:"pointerdown",deferOutsideClick:!0,repositionWhen:()=>r(a),closeOnScroll:!1}),u=x(()=>r(a)?r(a).slice().reverse():null),$=x(()=>Xr.now);var g=db();let v;var m=l(g),h=l(m),b=d(l(h),2),w=l(b,!0);s(b),s(h);var C=d(h,2);s(m);var A=d(m,2),I=d(l(A),2),P=l(I,!0);s(I),s(A);var F=d(A,2),L=l(F);{var B=k=>{var M=ab(),q=l(M,!0);s(M),T(()=>y(q,r(i))),f(k,M)},O=k=>{var M=ib();f(k,M)},S=k=>{var M=sb();f(k,M)},R=k=>{var M=cb();de(M,21,()=>r(u),lt,(q,j,H)=>{let D=x(()=>r(j).source||{}),N=x(()=>r(D).ts?r($)-r(D).ts:null),G=x(()=>r(D).seq!=null?`/p/${encodeURIComponent(e.uuid)}/packets?seq=${r(D).seq}`:`/p/${encodeURIComponent(e.uuid)}/packets`);var Y=lb();pe(Y,1,"prov-pop__step",null,{},{"prov-pop__step--latest":H===0});var K=l(Y),Z=d(l(K),2),Q=l(Z),ee=l(Q,!0);s(Q);var J=d(Q,2),te=l(J);s(J),s(Z);var V=d(Z,2),W=l(V),re=l(W,!0);s(W);var oe=d(W,2);{var ue=he=>{var be=ob(),ye=l(be);s(be),T(ze=>y(ye,`was ${ze??""}`),[()=>String(r(j).prev)]),f(he,be)};z(oe,he=>{r(j).prev!=null&&he(ue)})}s(V);var $e=d(V,2),me=l($e);s($e),s(K),s(Y),T((he,be,ye)=>{ne(K,"href",r(G)),y(ee,he),y(te,`${be??""} ago`),y(re,ye),y(me,`#${r(D).seq??"\u2014"??""}`)},[()=>ua(r(D).packetClass||"")||"unknown",()=>vn(r(N)),()=>String(r(j).value)]),U("click",K,()=>e.onClose?.()),f(q,Y)}),s(M),f(k,M)};z(L,k=>{r(i)?k(B):r(u)==null?k(O,1):r(u).length===0?k(S,2):k(R,-1)})}s(F),s(g),Tt(g,k=>E(n,k),()=>r(n)),T(k=>{ne(g,"aria-label",`Provenance for ${e.field??""}`),v=we(g,"",v,{left:`${p.left??""}px`,top:`${p.top??""}px`}),ne(b,"title",e.field),y(w,e.field),y(P,k)},[()=>String(e.valueOf?.(e.field)??"\u2014")]),U("click",C,function(...k){e.onClose?.apply(this,k)}),f(t,g),ce()}Pe(["click"]);var Rd=class{#e=X(null);get state(){return r(this.#e)}set state(e){E(this.#e,e,!0)}show(e,n){let a=e.getBoundingClientRect();this.state={...n,anchor:{top:a.top,bottom:a.bottom,cx:a.left+a.width/2}}}hide(){this.state=null}},Na=new Rd;var pb=_(' '),ub=_(' '),fb=_('
        ',1),vb=_('
        no source recorded
        ',1),mb=_('
        Click to pin history
        '),$b={hash:"svelte-1vmbsgn",code:` + @layer pages { + /* Floating provenance readout \u2014 anchored to a value badge, never steals the pointer so it + * can't flicker against the badge's own hover. */.prov-tip {position:fixed;z-index:210;pointer-events:none;visibility:hidden;min-width:132px;max-width:280px;padding:6px 9px 7px;background:var(--bg-1);border:1px solid var(--line-2);border-radius:4px;box-shadow:var(--bevel), var(--float-2);color:var(--ink-2);line-height:1;}.prov-tip--ready {visibility:visible;}.prov-tip__head {display:flex;align-items:baseline;gap:6px;font-size:var(--t-sm);}.prov-tip__dir {font-size:var(--t-xs);line-height:1;&[data-dir="cb"] {color:var(--dir-cb);}&[data-dir="sb"] {color:var(--dir-sb);}}.prov-tip__pkt {color:var(--acc);word-break:break-word;}.prov-tip__pkt--none {color:var(--ink-4);}.prov-tip__seq {margin-left:auto;padding-left:8px;color:var(--ink-3);font-size:var(--t-xs);font-variant-numeric:tabular-nums;}.prov-tip__sub {display:flex;align-items:baseline;flex-wrap:wrap;gap:2px 8px;margin-top:5px;font-size:var(--t-xs);color:var(--ink-4);}.prov-tip__age {color:var(--ink-3);font-variant-numeric:tabular-nums;}.prov-tip__field {margin-left:auto;word-break:break-all;}.prov-tip__hint {margin-top:6px;padding-top:5px;border-top:1px solid var(--line);font-size:var(--t-2xs);text-transform:uppercase;letter-spacing:.05em;color:var(--ink-4);} + + /* Caret \u2014 a rotated square tucked under the box, two borders matching the box edge so it + * reads as a continuous tip. Pinned at the anchor centre via inline \`left\`. */.prov-tip__caret {position:absolute;width:8px;height:8px;margin-left:-4px;background:var(--bg-1);transform:rotate(45deg);}.prov-tip:not(.prov-tip--below) .prov-tip__caret {bottom:-5px;border-right:1px solid var(--line-2);border-bottom:1px solid var(--line-2);}.prov-tip--below .prov-tip__caret {top:-5px;border-left:1px solid var(--line-2);border-top:1px solid var(--line-2);} + }`};function Nd(t,e){le(e,!0),Ut(t,$b);let n=X(null),a=X(tt({left:0,top:0,caret:16,below:!1,ready:!1})),i=x(()=>Na.state),o=x(()=>r(i)?.source??null),c=x(()=>r(o)?.ts?Xr.now-r(o).ts:null),p=x(()=>(r(o)?.direction||"").toUpperCase()),u=x(()=>Jr(r(p))?"\u25C0":r(p).startsWith("SERVER")?"\u25B6":""),$=x(()=>Jr(r(p))?"cb":r(p)?"sb":"");ge(()=>{let h=Na.state,b=r(n);if(!h||!b){r(a).ready=!1;return}let w=b.offsetWidth,C=b.offsetHeight,A=8,I=!1,P=h.anchor.top-A-C;P<8&&(I=!0,P=h.anchor.bottom+A);let F=Math.min(Math.max(8,h.anchor.cx-w/2),window.innerWidth-w-8),L=Math.min(Math.max(12,h.anchor.cx-F),w-12);E(a,{left:F,top:P,caret:L,below:I,ready:!0},!0)}),ge(()=>{if(!Na.state)return;let h=()=>Na.hide();return window.addEventListener("scroll",h,!0),window.addEventListener("resize",h),()=>{window.removeEventListener("scroll",h,!0),window.removeEventListener("resize",h)}});var g=Me(),v=ie(g);{var m=h=>{var b=mb();let w,C;var A=l(b);{var I=B=>{var O=fb(),S=ie(O),R=l(S);{var k=Z=>{var Q=pb(),ee=l(Q,!0);s(Q),T(()=>{ne(Q,"data-dir",r($)),y(ee,r(u))}),f(Z,Q)};z(R,Z=>{r(u)&&Z(k)})}var M=d(R,2),q=l(M,!0);s(M);var j=d(M,2);{var H=Z=>{var Q=ub(),ee=l(Q);s(Q),T(()=>y(ee,`#${r(o).seq??""}`)),f(Z,Q)};z(j,Z=>{r(o).seq!=null&&Z(H)})}s(S);var D=d(S,2),N=l(D),G=l(N);s(N);var Y=d(N,2),K=l(Y,!0);s(Y),s(D),T((Z,Q)=>{y(q,Z),y(G,`${Q??""} ago`),y(K,r(i).field)},[()=>ua(r(o).packetClass).replace(/Packet$/,"")||"unknown",()=>vn(r(c))]),f(B,O)},P=B=>{var O=vb(),S=d(ie(O),2),R=l(S),k=l(R,!0);s(R),s(S),T(()=>y(k,r(i).field)),f(B,O)};z(A,B=>{r(o)?B(I):B(P,-1)})}var F=d(A,4);let L;s(b),Tt(b,B=>E(n,B),()=>r(n)),T(()=>{w=pe(b,1,"prov-tip",null,w,{"prov-tip--below":r(a).below,"prov-tip--ready":r(a).ready}),C=we(b,"",C,{left:`${r(a).left??""}px`,top:`${r(a).top??""}px`}),L=we(F,"",L,{left:`${r(a).caret??""}px`})}),f(h,b)};z(v,h=>{r(i)&&h(m)})}f(t,g),ce()}var jv=/(!?)([a-zA-Z]+):((?:"[^"]*")|[^\s]+)|(!?)([^\s]+)/g;function Sl(t){let e=[],n;for(jv.lastIndex=0;n=jv.exec(t);)if(n[2]){let c=!!n[1],p=n[3],u="=";p.startsWith(">=")||p.startsWith("<=")?(u=p.slice(0,2),p=p.slice(2)):p.startsWith(">")||p.startsWith("<")?(u=p[0],p=p.slice(1)):p.startsWith('"')&&p.endsWith('"')&&(p=p.slice(1,-1)),e.push({kind:"kv",key:n[2].toLowerCase(),op:u,val:p.toLowerCase(),neg:c,raw:n[0]})}else n[5]&&e.push({kind:"text",val:n[5].toLowerCase(),neg:!!n[4],raw:n[0]});function a(c,p){let u=p.val,$;switch(p.key){case"class":case"c":return c.className.toLowerCase().includes(u);case"dir":return u==="cb"||u==="in"||u==="clientbound"?Jr(c.direction):u==="sb"||u==="out"||u==="serverbound"?c.direction==="SERVERBOUND":!1;case"state":case"s":return(c.state||"").toLowerCase().startsWith(u);case"subject":case"subj":return(c.subjectLabel||"").toLowerCase().includes(u)||String(c.subject).toLowerCase()===u;case"group":case"g":return(c.subjectGroup||"").toLowerCase()===u;case"size":return $=Number(u),Number.isNaN($)?!1:p.op===">"?c.sizeBytes>$:p.op==="<"?c.sizeBytes<$:p.op===">="?c.sizeBytes>=$:p.op==="<="?c.sizeBytes<=$:c.sizeBytes===$;case"seq":return $=Number(u),Number.isNaN($)?!1:p.op===">"?c.seq>$:p.op==="<"?c.seq<$:p.op===">="?c.seq>=$:p.op==="<="?c.seq<=$:c.seq===$;case"has":return u==="bookmark"?!!c._bookmarked:!1;default:return!1}}function i(c,p){let u=p.val;return c.className.toLowerCase().includes(u)||(c.subjectLabel||"").toLowerCase().includes(u)||(c.summary||"").toLowerCase().includes(u)}function o(c){for(let p of e){let u=p.kind==="kv"?a(c,p):i(c,p);if(p.neg?u:!u)return!1}return!0}return{tokens:e,match:o}}function Tl(t){let e=0;for(let a=0;a28?n.slice(0,26)+"\u2026":n}var gb=400,La=new Map;function Ld(t,e){return La.get(`${t}:${e}`)}function Vv(t,e,n){let a=`${t}:${e}`;if(La.size>=gb&&!La.has(a)){let i=La.keys().next().value;i&&La.delete(i)}La.set(a,n)}function Yv(t){if(!t){La.clear();return}let e=t+":";for(let n of[...La.keys()])n.startsWith(e)&&La.delete(n)}var Zs={phosphor:{acc:"oklch(78% 0.18 148)",deep:"oklch(54% 0.14 148)"},amber:{acc:"oklch(82% 0.16 75)",deep:"oklch(60% 0.14 75)"},cyan:{acc:"oklch(78% 0.13 200)",deep:"oklch(56% 0.12 200)"},magenta:{acc:"oklch(72% 0.20 320)",deep:"oklch(54% 0.16 320)"}};function Id(t,e){let n=t;return n.summary??=Xs(t),n._bookmarked=e.has(t.seq),n}function Gv(t){return Object.entries(t).map(([e,n])=>{let a=new Set,i=new Set;for(let[o,c]of Object.entries(n))c==="include"?a.add(o):c==="exclude"&&i.add(o);return{field:e,includes:a,excludes:i}})}function Wv(t){let e=new Set,n=new Set;for(let[a,i]of Object.entries(t))i==="include"?e.add(a):i==="exclude"&&n.add(a);return{includes:e,excludes:n}}function Kv(t,e,n){for(let{field:a,includes:i,excludes:o}of e){let c=t[a];if(o.has(c)||i.size&&!i.has(c))return!1}return!(n.excludes.has(t.className)||n.includes.size&&!n.includes.has(t.className))}function Xv(t,e,n,a){let i=new Map;for(let u of e)i.set(u.seq,u);let o=[],c=null,p=0;for(;p=4){o.push({kind:"group",first:u,last:t[g-1],count:v,seqStart:u.seq,seqEnd:t[g-1].seq}),c=t[g-1].ts,p=g;continue}}o.push({kind:"row",p:u,delta:c==null?null:u.ts-c,bookmark:a.get(u.seq)}),c=u.ts,p++}return o}function Zv(t,e,n){if(!t)return[];let a=n?.indexOfSeq(t.seq)??-1;if(a<0)return[];let i=[],o=50,c=Math.max(0,a-o),p=Math.min(e.length,a+o);for(let u=c;u({...e,matcher:e.enabled?Sl(e.match).match:null}))}function Qv(t,e,n){return t.map(a=>{let{matcher:i,...o}=a;if(!i)return{...o,matchedSeqs:[],hitCount:o.hitCount??0};let c=[],p=0;for(let u of e)i(Id(u,n))&&(p++,c.length<6&&c.push(u.seq));return{...o,matchedSeqs:c,hitCount:p}})}var Cl=class{rows=[];pending=[];flushScheduled=!1;onFlush;maxRows=5e4;classCounts=new Map;minSeq=0;maxSeq=0;constructor(e){this.onFlush=e}get length(){return this.rows.length}clear(){this.rows.length=0,this.pending=[],this.classCounts.clear(),this.minSeq=0,this.maxSeq=0}push(e){e.seq&&(this.pending.push(e),this.scheduleFlush())}loadHistory(e){if(!e.length)return;let n=0;for(let a of e)a.seq&&this.upsert(a)&&n++;n&&this.onFlush({added:n,minSeq:this.minSeq,maxSeq:this.maxSeq,length:this.rows.length})}scheduleFlush(){this.flushScheduled||(this.flushScheduled=!0,requestAnimationFrame(()=>this.flush()))}flush(){this.flushScheduled=!1;let e=this.pending;if(this.pending=[],!e.length)return;let n=0;for(let a of e)this.upsert(a)&&n++;n&&(this.trim(),this.onFlush({added:n,minSeq:this.minSeq,maxSeq:this.maxSeq,length:this.rows.length}))}trim(){let e=this.rows.length-this.maxRows;if(e<=0)return;let n=this.rows.splice(0,e);for(let a of n)this.dec(a.className);this.minSeq=this.rows.length?this.rows[0].seq:0}upsert(e){let n=this.lowerBound(e.seq);if(nthis.maxSeq&&(this.maxSeq=e.seq),!0}inc(e){this.classCounts.set(e,(this.classCounts.get(e)??0)+1)}dec(e){let n=(this.classCounts.get(e)??0)-1;n>0?this.classCounts.set(e,n):this.classCounts.delete(e)}rowAtSeq(e){let n=this.indexAt(e);return n<0?void 0:this.rows[n]}snapshot(){return this.rows.slice()}indexOfSeq(e){return this.indexAt(e)}forEachSampled(e,n){let a=this.rows.length;if(a===0||e<=0)return;if(a<=e){for(let c of this.rows)n(c);return}let i=a-1,o=Math.max(1,e-1);for(let c=0;cthis.rows[p],a=0,i=this.rows.length;for(;a>1;n(p).seq=this.rows.length)return n(this.rows.length-1).seq;let o=n(a).seq;if(a===0)return o;let c=n(a-1).seq;return Math.abs(e-c)<=Math.abs(e-o)?c:o}findPrevSameClass(e,n){let a=this.indexAt(e);if(a<=0)return null;if(!n)return this.rows[a-1];for(let i=a-1;i>=0;i--){let o=this.rows[i];if(o.className===n)return o}return this.rows[a-1]}indexAt(e){if(!this.rows.length||!e)return-1;let n=this.lowerBound(e);return n>1;this.rows[i].seq\u2715'),bb=_(" "),xb=_('
        '),yb=_('
        \u2315
        #seq
        p/s
        pkts
        ');function Od(t,e){le(e,!0);let n=ae(e,"query",3,""),a=ae(e,"parsed",3,null),i=ae(e,"live",3,!1),o=ae(e,"paused",3,!1),c=ae(e,"rate",3,0),p=ae(e,"totalPackets",3,0),u=ae(e,"jump",3,""),$=ae(e,"breakOn",3,!1),g=ae(e,"searchRef",15,null),v=ae(e,"onQuery",3,()=>{}),m=ae(e,"onPaused",3,()=>{}),h=ae(e,"onStep",3,()=>{}),b=ae(e,"onLive",3,()=>{}),w=ae(e,"onJump",3,()=>{}),C=ae(e,"onJumpChange",3,()=>{}),A=ae(e,"onHelp",3,()=>{}),I=ae(e,"onTweaks",3,()=>{});var P=yb(),F=l(P),L=d(l(F),2);kt(L),ne(L,"placeholder",'filter \u2014 try class:Position dir:sb size:>20 or just "chest"'),ne(L,"spellcheck",!1),Tt(L,ue=>g(ue),()=>g());var B=d(L,2);{var O=ue=>{var $e=hb();U("click",$e,()=>v()("")),f(ue,$e)};z(B,ue=>{n()&&ue(O)})}s(F);var S=d(F,2);{var R=ue=>{var $e=xb();de($e,21,()=>a().tokens,lt,(me,he)=>{var be=bb();let ye;var ze=l(be);s(be),T(()=>{ye=pe(be,1,"chip-filter chip-filter--sm",null,ye,{"is-exclude":r(he).neg,"is-include":!r(he).neg}),ne(be,"title",r(he).kind==="kv"?`${r(he).key} ${r(he).op} ${r(he).val}`:"text"),y(ze,`${r(he).neg?"\u2212":"+"} ${r(he).raw??""}`)}),f(me,be)}),s($e),f(ue,$e)};z(S,ue=>{a()&&a().tokens.length&&ue(R)})}var k=d(S,2),M=l(k);let q;var j=l(M,!0);s(M);var H=d(M,2),D=d(H,2),N=d(D,2);let G;var Y=d(N,4),K=d(l(Y),2);kt(K),s(Y);var Z=d(Y,4),Q=l(Z),ee=l(Q,!0);s(Q),ve(2),s(Z);var J=d(Z,2),te=l(J),V=l(te,!0);s(te),ve(2),s(J);var W=d(J,4);let re;var oe=d(W,2);s(k),s(P),T(ue=>{Ot(L,n()),q=pe(M,1,"btn sm icon",null,q,{"is-on":!o()}),ne(M,"title",o()?"Resume (space)":"Pause (space)"),y(j,o()?"\u25B6":"\u25AE\u25AE"),G=pe(N,1,"btn sm",null,G,{"is-on":i()}),Ot(K,u()),y(ee,c()),y(V,ue),re=pe(W,1,"btn sm icon",null,re,{danger:$(),"is-on":$()})},[()=>p().toLocaleString()]),U("input",L,ue=>v()(ue.currentTarget.value)),U("click",M,()=>m()(!o())),U("click",H,()=>h()(-1)),U("click",D,()=>h()(1)),U("click",N,function(...ue){b()?.apply(this,ue)}),U("input",K,ue=>C()(ue.currentTarget.value)),U("keydown",K,ue=>{ue.key==="Enter"&&w()()}),U("click",W,function(...ue){I()?.apply(this,ue)}),U("click",oe,function(...ue){A()?.apply(this,ue)}),f(t,P),ce()}Pe(["input","click","keydown"]);var wb=_('
        '),kb=_('
        '),Eb=_('
        \u25C6
        '),Sb=_('
        \u2605
        '),Tb=_('
        \u23FB
        '),Cb=_('
        '),Ab=_('
        '),Mb=_('
        ');function Dd(t,e){le(e,!0);let n=ae(e,"tape",3,null),a=ae(e,"tapeVersion",3,0),i=ae(e,"bookmarks",19,()=>[]),o=ae(e,"breakpoints",19,()=>[]),c=ae(e,"lifecycle",19,()=>[]),p=ae(e,"playhead",3,null),u=ae(e,"viewStart",3,null),$=ae(e,"viewEnd",3,null),g=ae(e,"related",19,()=>[]),v=ae(e,"onSeek",3,()=>{}),m=180,h=20,b=6e3,w=X(void 0),C=X(800);ge(()=>{if(!r(w))return;E(C,r(w).clientWidth||r(C),!0);let Y=new ResizeObserver(K=>{for(let Z of K)E(C,Z.contentRect.width,!0)});return Y.observe(r(w)),()=>Y.disconnect()});let A=x(()=>{a();let Y=Array.from({length:m},()=>({cb:0,sb:0}));if(!n()||n().length===0)return{a:Y,max:1,minSeq:1,maxSeq:1,span:1};let K=n().minSeq,Z=n().maxSeq,Q=Math.max(1,Z-K),ee=0;return n().forEachSampled(b,J=>{let te=Math.min(m-1,Math.floor((J.seq-K)/Q*m));Jr(J.direction)?Y[te].cb++:Y[te].sb++;let V=Y[te].cb+Y[te].sb;V>ee&&(ee=V)}),{a:Y,max:Math.max(1,ee),minSeq:K,maxSeq:Z,span:Q}}),I=Y=>(Y-r(A).minSeq)/r(A).span*r(C),P=Y=>Math.round(Y/Math.max(1,r(C))*r(A).span+r(A).minSeq);function F(Y){if(!r(w))return;let K=r(w).getBoundingClientRect(),Z=ee=>v()(P(ee.clientX-K.left));Z(Y);let Q=()=>{window.removeEventListener("mousemove",Z),window.removeEventListener("mouseup",Q)};window.addEventListener("mousemove",Z),window.addEventListener("mouseup",Q)}let L=x(()=>r(C)/m);var B=Mb(),O=d(l(B),4);de(O,17,()=>r(A).a,lt,(Y,K,Z)=>{let Q=x(()=>r(K).cb/r(A).max*h),ee=x(()=>r(K).sb/r(A).max*h);var J=wb();let te;var V=l(J);let W;var re=d(V,2);let oe;s(J),T(ue=>{te=we(J,"",te,ue),W=we(V,"",W,{bottom:"50%",height:`${r(Q)??""}px`}),oe=we(re,"",oe,{top:"50%",height:`${r(ee)??""}px`})},[()=>({left:`${Z*r(L)}px`,width:`${Math.max(1,r(L)-.5)}px`})]),f(Y,J)});var S=d(O,2);{var R=Y=>{var K=kb();let Z;T(Q=>Z=we(K,"",Z,Q),[()=>({left:`${I(u())??""}px`,width:`${Math.max(2,I($())-I(u()))}px`})]),f(Y,K)};z(S,Y=>{u()!=null&&$()!=null&&Y(R)})}var k=d(S,2);de(k,17,c,lt,(Y,K)=>{var Z=Eb();let Q;var ee=l(Z);s(Z),T(J=>{ne(Z,"title",r(K).label),Q=we(Z,"",Q,J)},[()=>({left:`${I(r(K).seq)??""}px`})]),U("click",ee,J=>{J.stopPropagation(),v()(r(K).seq)}),U("keydown",ee,J=>{J.key==="Enter"&&(J.stopPropagation(),v()(r(K).seq))}),f(Y,Z)});var M=d(k,2);de(M,17,i,lt,(Y,K)=>{var Z=Sb();let Q;var ee=l(Z);s(Z),T(J=>{ne(Z,"title",r(K).label),Q=we(Z,"",Q,J)},[()=>({left:`${I(r(K).seq)??""}px`})]),U("click",ee,J=>{J.stopPropagation(),v()(r(K).seq)}),U("keydown",ee,J=>{J.key==="Enter"&&(J.stopPropagation(),v()(r(K).seq))}),f(Y,Z)});var q=d(M,2);de(q,17,o,Y=>Y.id,(Y,K)=>{var Z=Me(),Q=ie(Z);de(Q,17,()=>r(K).matchedSeqs??[],lt,(ee,J)=>{var te=Tb();let V;T(W=>{ne(te,"title",r(K).label),V=we(te,"",V,W)},[()=>({left:`${I(r(J))??""}px`})]),f(ee,te)}),f(Y,Z)});var j=d(q,2);de(j,17,g,lt,(Y,K)=>{var Z=Cb();let Q;T(ee=>Q=we(Z,"",Q,ee),[()=>({left:`${I(r(K))??""}px`,background:"var(--ink-3)"})]),f(Y,Z)});var H=d(j,2);{var D=Y=>{var K=Ab();let Z;T(Q=>Z=we(K,"",Z,Q),[()=>({left:`${I(p())??""}px`})]),f(Y,K)};z(H,Y=>{p()!=null&&Y(D)})}var N=d(H,2),G=l(N);s(N),s(B),Tt(B,Y=>E(w,Y),()=>r(w)),T((Y,K)=>{ne(B,"aria-valuemin",r(A).minSeq),ne(B,"aria-valuemax",r(A).maxSeq),ne(B,"aria-valuenow",p()??r(A).maxSeq),y(G,`#${Y??""} \u2014 #${K??""}`)},[()=>r(A).minSeq.toLocaleString(),()=>r(A).maxSeq.toLocaleString()]),U("mousedown",B,F),f(t,B),ce()}Pe(["mousedown","click","keydown"]);var Pb=_(''),Rb=_(' '),Nb=_(''),Lb=_('
        '),Ib=_(''),Ob=_(' '),Db=_(''),Fb=_('
        Class
        '),Bb=_('
        No bookmarks yet.
        Press B on any packet to add.
        '),zb=_(''),qb=_('
        '),Hb=_('
        Pause when a packet matches a DSL filter.

        e.g. class:Disconnect
        '),jb=_('
        \u2715
        '),Ub=_('
        '),Vb=_('
        Save common queries here.
        They appear as one-click filters.
        '),Yb=_(''),Gb=_('
        '),Wb=_('');function Fd(t,e){le(e,!0);let n=ae(e,"tab",3,"filters"),a=ae(e,"rows",19,()=>[]),i=ae(e,"filters",19,()=>({})),o=ae(e,"classCounts",19,()=>new Map),c=ae(e,"classFilter",19,()=>({})),p=ae(e,"classQuery",3,""),u=ae(e,"bookmarks",19,()=>[]),$=ae(e,"breakpoints",19,()=>[]),g=ae(e,"saved",19,()=>[]),v=ae(e,"currentSeq",3,null),m=ae(e,"currentQuery",3,""),h=ae(e,"onSetTab",3,()=>{}),b=ae(e,"onSetFilter",3,()=>{}),w=ae(e,"onSetClassFilter",3,()=>{}),C=ae(e,"onSetClassQuery",3,()=>{}),A=ae(e,"onJumpBookmark",3,()=>{}),I=ae(e,"onAddBookmark",3,()=>{}),P=ae(e,"onRemoveBookmark",3,()=>{}),F=ae(e,"onToggleBreakpoint",3,()=>{}),L=ae(e,"onAddBreakpoint",3,()=>{}),B=ae(e,"onRemoveBreakpoint",3,()=>{}),O=ae(e,"onLoadSaved",3,()=>{}),S=ae(e,"onAddSaved",3,()=>{}),R=ae(e,"onRemoveSaved",3,()=>{}),k=[{id:"direction",title:"Direction",tones:{CLIENTBOUND:"var(--dir-cb)",SERVERBOUND:"var(--dir-sb)"},fmt:ke=>ke==="CLIENTBOUND"?"\u2193 CB":"\u2191 SB"},{id:"state",title:"Phase",fmt:ke=>ke},{id:"subjectGroup",title:"Subject group",tones:{self:"var(--sub-self)",ent:"var(--sub-ent)",world:"var(--sub-world)",hud:"var(--sub-hud)",win:"var(--sub-win)",net:"var(--sub-net)",chat:"var(--sub-chat)"},fmt:ke=>ke}],M=x(()=>{let ke={direction:new Map,state:new Map,subjectGroup:new Map};for(let Ze of a())for(let je of k){let Le=Ze[je.id]||"";Le&&ke[je.id].set(Le,(ke[je.id].get(Le)||0)+1)}return ke}),q=x(()=>{let ke=p().toLowerCase();return[...o().entries()].filter(([Ze])=>!ke||Ze.toLowerCase().includes(ke)).sort((Ze,je)=>je[1]-Ze[1])});function j(ke,Ze){let je=i()[ke]?.[Ze];b()(ke,Ze,D(je))}function H(ke){let Ze=c()[ke],je=D(Ze),Le={...c()};je==null?delete Le[ke]:Le[ke]=je,w()(Le)}function D(ke){return ke==="include"?"exclude":ke==="exclude"?null:"include"}function N(ke){return ke==="include"?"+":ke==="exclude"?"\u2212":""}function G(){!v()||!r(Q)||(I()({seq:v(),label:r(Q)}),E(Q,""))}function Y(){r(ee)&&(L()({match:r(ee),label:r(ee),enabled:!0}),E(ee,""))}function K(){!r(J)||!m()||(S()({name:r(J),q:m()}),E(J,""))}function Z(ke,Ze){ke.key==="Enter"&&Ze()}let Q=X(""),ee=X(""),J=X("");var te=Wb(),V=l(te),W=l(V);let re;var oe=d(W,2);let ue;var $e=l(oe);s(oe);var me=d(oe,2);let he;var be=l(me);s(me);var ye=d(me,2);let ze;s(V);var Re=d(V,2);{var De=ke=>{var Ze=Fb(),je=l(Ze);de(je,17,()=>k,Ie=>Ie.id,(Ie,Je)=>{let $t=x(()=>[...r(M)[r(Je).id].entries()].sort((Ue,Qe)=>Qe[1]-Ue[1])),Ee=x(()=>!!i()[r(Je).id]&&Object.keys(i()[r(Je).id]).length>0);var Ye=Lb(),We=l(Ye),Oe=l(We),st=l(Oe,!0);s(Oe);var rt=d(Oe,2);{var ht=Ue=>{var Qe=Pb();U("click",Qe,()=>b()(r(Je).id,null,null)),f(Ue,Qe)},wt=Ue=>{var Qe=Rb(),Se=l(Qe,!0);s(Qe),T(()=>y(Se,r($t).length)),f(Ue,Qe)};z(rt,Ue=>{r(Ee)?Ue(ht):Ue(wt,-1)})}s(We);var dt=d(We,2);de(dt,17,()=>r($t),([Ue,Qe])=>Ue,(Ue,Qe)=>{var Se=x(()=>fr(r(Qe),2));let nt=()=>r(Se)[0],ut=()=>r(Se)[1],vt=x(()=>i()[r(Je).id]?.[nt()]);var Ct=Nb();let Dt;var Vt=l(Ct);let ct;var Mt=d(Vt,2),It=l(Mt,!0);s(Mt);var qt=d(Mt,2),nr=l(qt,!0);s(qt);var Wt=d(qt,2),Ft=l(Wt,!0);s(Wt),s(Ct),T((Pt,Kt,Ht)=>{Dt=pe(Ct,1,"pt-facet-row",null,Dt,{"is-include":r(vt)==="include","is-exclude":r(vt)==="exclude"}),ne(Ct,"title",r(vt)?`${r(vt)} \xB7 click to cycle`:"click to include, again to exclude"),ct=we(Vt,"",ct,{background:r(Je).tones?.[nt()]||"transparent"}),y(It,Pt),y(nr,Kt),y(Ft,Ht)},[()=>N(r(vt)),()=>r(Je).fmt(nt()),()=>ut().toLocaleString()]),U("click",Ct,()=>j(r(Je).id,nt())),f(Ue,Ct)}),s(Ye),T(()=>y(st,r(Je).title)),f(Ie,Ye)});var Le=d(je,2),qe=l(Le),Ae=d(l(qe),2);{var Ce=Ie=>{var Je=Ib();U("click",Je,()=>w()({})),f(Ie,Je)},Fe=x(()=>Object.keys(c()).length>0),Ne=Ie=>{var Je=Ob(),$t=l(Je,!0);s(Je),T(()=>y($t,o().size)),f(Ie,Je)};z(Ae,Ie=>{r(Fe)?Ie(Ce):Ie(Ne,-1)})}s(qe);var Ve=d(qe,2),mt=l(Ve);kt(mt),s(Ve);var He=d(Ve,2);de(He,17,()=>r(q),([Ie,Je])=>Ie,(Ie,Je)=>{var $t=x(()=>fr(r(Je),2));let Ee=()=>r($t)[0],Ye=()=>r($t)[1],We=x(()=>c()[Ee()]);var Oe=Db();let st;var rt=l(Oe);let ht;var wt=d(rt,2),dt=l(wt,!0);s(wt);var Ue=d(wt,2),Qe=l(Ue,!0);s(Ue);var Se=d(Ue,2),nt=l(Se,!0);s(Se),s(Oe),T((ut,vt,Ct,Dt)=>{st=pe(Oe,1,"pt-facet-row",null,st,{"is-include":r(We)==="include","is-exclude":r(We)==="exclude"}),ht=we(rt,"",ht,ut),y(dt,vt),y(Qe,Ct),y(nt,Dt)},[()=>({background:Tl(Ee())}),()=>N(r(We)),()=>xr(Ee()),()=>Ye().toLocaleString()]),U("click",Oe,()=>H(Ee())),f(Ie,Oe)}),s(Le),s(Ze),T(()=>Ot(mt,p())),U("input",mt,Ie=>C()(Ie.currentTarget.value)),f(ke,Ze)},Be=ke=>{var Ze=qb(),je=l(Ze),Le=l(je);kt(Le);var qe=d(Le,2);s(je);var Ae=d(je,2);{var Ce=Ne=>{var Ve=Bb();f(Ne,Ve)};z(Ae,Ne=>{u().length===0&&Ne(Ce)})}var Fe=d(Ae,2);de(Fe,23,u,(Ne,Ve)=>`${Ne.seq}-${Ve}`,(Ne,Ve,mt)=>{var He=zb(),Ie=d(l(He),2),Je=l(Ie),$t=l(Je);s(Je);var Ee=d(Je,2),Ye=l(Ee,!0);s(Ee),s(Ie);var We=d(Ie,2);s(He),T(Oe=>{y($t,`#${Oe??""}`),y(Ye,r(Ve).label)},[()=>r(Ve).seq.toLocaleString()]),U("click",He,()=>A()(r(Ve).seq)),U("click",We,Oe=>{Oe.stopPropagation(),P()(r(mt))}),U("keydown",We,Oe=>{Oe.key==="Enter"&&(Oe.stopPropagation(),P()(r(mt)))}),f(Ne,He)}),s(Fe),s(Ze),T(()=>{Ot(Le,r(Q)),ne(Le,"placeholder",`bookmark #${v()??"\u2014"}`)}),U("input",Le,Ne=>{E(Q,Ne.currentTarget.value,!0)}),U("keydown",Le,Ne=>Z(Ne,G)),U("click",qe,G),f(ke,Ze)},ot=ke=>{var Ze=Ub(),je=l(Ze),Le=l(je);kt(Le);var qe=d(Le,2);s(je);var Ae=d(je,2);{var Ce=Ne=>{var Ve=Hb();f(Ne,Ve)};z(Ae,Ne=>{$().length===0&&Ne(Ce)})}var Fe=d(Ae,2);de(Fe,23,$,Ne=>Ne.id,(Ne,Ve,mt)=>{var He=jb();let Ie,Je;var $t=l(He);we($t,"",{},{cursor:"pointer"});var Ee=l($t,!0);s($t);var Ye=d($t,2),We=l(Ye);we(We,"",{},{color:"var(--ink)"});var Oe=l(We,!0);s(We);var st=d(We,2),rt=l(st);s(st),s(Ye);var ht=d(Ye,2);s(He),T(()=>{Ie=pe(He,1,"pt-list__item brk",null,Ie,{disabled:!r(Ve).enabled}),Je=we(He,"",Je,{opacity:r(Ve).enabled?1:.4}),y(Ee,r(Ve).enabled?"\u23FB":"\u25CC"),y(Oe,r(Ve).label),y(rt,`matched ${r(Ve).hitCount??0??""} \xD7`)}),U("click",$t,()=>F()(r(mt))),U("keydown",$t,wt=>{wt.key==="Enter"&&F()(r(mt))}),U("click",ht,()=>B()(r(mt))),U("keydown",ht,wt=>{wt.key==="Enter"&&B()(r(mt))}),f(Ne,He)}),s(Fe),s(Ze),T(()=>Ot(Le,r(ee))),U("input",Le,Ne=>{E(ee,Ne.currentTarget.value,!0)}),U("keydown",Le,Ne=>Z(Ne,Y)),U("click",qe,Y),f(ke,Ze)},Ke=ke=>{var Ze=Gb(),je=l(Ze),Le=l(je);kt(Le);var qe=d(Le,2);s(je);var Ae=d(je,2);{var Ce=Ne=>{var Ve=Vb();f(Ne,Ve)};z(Ae,Ne=>{g().length===0&&Ne(Ce)})}var Fe=d(Ae,2);de(Fe,23,g,(Ne,Ve)=>`${Ne.name}-${Ve}`,(Ne,Ve,mt)=>{var He=Yb(),Ie=l(He);we(Ie,"",{},{color:"var(--acc)"});var Je=d(Ie,2),$t=l(Je),Ee=l($t,!0);s($t);var Ye=d($t,2),We=l(Ye,!0);s(Ye),s(Je);var Oe=d(Je,2);s(He),T(()=>{y(Ee,r(Ve).name),y(We,r(Ve).q)}),U("click",He,()=>O()(r(Ve).q)),U("click",Oe,st=>{st.stopPropagation(),R()(r(mt))}),U("keydown",Oe,st=>{st.key==="Enter"&&(st.stopPropagation(),R()(r(mt)))}),f(Ne,He)}),s(Fe),s(Ze),T(()=>Ot(Le,r(J))),U("input",Le,Ne=>{E(J,Ne.currentTarget.value,!0)}),U("keydown",Le,Ne=>Z(Ne,K)),U("click",qe,K),f(ke,Ze)};z(Re,ke=>{n()==="filters"?ke(De):n()==="bookmarks"?ke(Be,1):n()==="breaks"?ke(ot,2):ke(Ke,-1)})}s(te),T(()=>{re=pe(W,1,"",null,re,{"is-on":n()==="filters"}),ue=pe(oe,1,"",null,ue,{"is-on":n()==="bookmarks"}),y($e,`Marks (${u().length??""})`),he=pe(me,1,"",null,he,{"is-on":n()==="breaks"}),y(be,`Breaks (${$().length??""})`),ze=pe(ye,1,"",null,ze,{"is-on":n()==="saved"})}),U("click",W,()=>h()("filters")),U("click",oe,()=>h()("bookmarks")),U("click",me,()=>h()("breaks")),U("click",ye,()=>h()("saved")),f(t,te),ce()}Pe(["click","input","keydown"]);var Kb=_('
        \u25C6
        '),Xb=_(''),Zb=_('\u2605'),Jb=_('
        '),Qb=_('
        No packets match the current filters.
        '),ex=_('
        #seq \u0394t dir class \xB7 summary subject size
        ',1);function Bd(t,e){le(e,!0);function n(J){return J==null?"\u2014":J<1?"<1ms":J<1e3?"+"+Math.round(J)+"ms":"+"+(J/1e3).toFixed(2)+"s"}let a=ae(e,"entries",19,()=>[]),i=ae(e,"playhead",3,null),o=ae(e,"multi",19,()=>new Set),c=ae(e,"related",19,()=>new Set),p=ae(e,"classColors",19,()=>new Map),u=ae(e,"scrollToken",3,0),$=ae(e,"rowHeight",3,26),g=ae(e,"onSelect",3,()=>{}),v=ae(e,"onShiftSelect",3,()=>{}),m=ae(e,"onContext",3,()=>{}),h=ae(e,"onExpandGroup",3,()=>{}),b=X(void 0),w=X(600),C=X(0),A=!1,I={token:-1,idx:-1};ge(()=>{if(!r(b))return;let J=new ResizeObserver(te=>{for(let V of te)E(w,V.contentRect.height,!0)});return J.observe(r(b)),()=>J.disconnect()});let P=x(()=>a().length),F=8,L=x(()=>Math.max(0,Math.floor(r(C)/$())-F)),B=x(()=>Math.min(r(P),Math.ceil((r(C)+r(w))/$())+F)),O=x(()=>a().slice(r(L),r(B))),S=x(()=>r(L)*$()),R=x(()=>(r(P)-r(B))*$()),k=x(()=>{let J=new Map;for(let te=0;ter(k).get(J)??-1,q=J=>J.summary??Xs(J);ge(()=>{let J=u(),te=i();if(a().length,$(),r(w),te==null||!r(b))return;let V=M(te);if(V<0||J===I.token&&V===I.idx)return;I={token:J,idx:V};let W=Math.max(0,V*$()-r(w)/2+$()/2),re=!0;return A=!0,E(C,W,!0),r(b)&&(r(b).scrollTop=W),Vi().then(()=>{!re||!r(b)||(r(b).scrollTop=W,A=!1)}),()=>{re=!1,A=!1}});function j(J,te){J.shiftKey?v()(te.seq):g()(te.seq)}var H=ex(),D=d(ie(H),2),N=l(D);let G;var Y=d(N,2);de(Y,19,()=>r(O),(J,te)=>J.kind==="row"?`r-${J.p.seq}`:J.kind==="group"?`g-${J.seqStart}`:`l-${J.seq}-${te}`,(J,te)=>{var V=Me(),W=ie(V);{var re=$e=>{var me=Kb(),he=d(l(me),2),be=l(he,!0);s(he);var ye=d(he,2),ze=l(ye);s(ye),s(me),T(Re=>{y(be,r(te).label),y(ze,`#${Re??""}`)},[()=>r(te).seq.toLocaleString()]),f($e,me)},oe=$e=>{var me=Xb(),he=d(l(me),2),be=l(he);s(he);var ye=d(he,6);we(ye,"",{},{color:"var(--ink-3)"});var ze=l(ye,!0);s(ye);var Re=d(ye,2),De=l(Re);s(Re),s(me),T((Be,ot)=>{y(be,`#${Be??""}`),y(ze,ot),y(De,`\xD7${r(te).count??""}`)},[()=>r(te).seqStart.toLocaleString(),()=>xr(r(te).first.className)]),U("click",me,()=>h()(r(te).seqStart,r(te).seqEnd)),f($e,me)},ue=$e=>{let me=x(()=>r(te).p),he=x(()=>Jr(r(me).direction)),be=x(()=>i()===r(me).seq),ye=x(()=>o().has(r(me).seq)),ze=x(()=>c().has(r(me).seq));var Re=Jb();let De;var Be=l(Re),ot=l(Be);{var Ke=rt=>{var ht=Zb();T(()=>ne(ht,"title",r(te).bookmark.label)),f(rt,ht)};z(ot,rt=>{r(te).bookmark&&rt(Ke)})}s(Be);var ke=d(Be,2),Ze=l(ke);s(ke);var je=d(ke,2),Le=l(je,!0);s(je);var qe=d(je,2),Ae=l(qe,!0);s(qe);var Ce=d(qe,2),Fe=l(Ce);let Ne;var Ve=d(Fe,2),mt=l(Ve,!0);s(Ve);var He=d(Ve,2),Ie=l(He,!0);s(He),s(Ce);var Je=d(Ce,2),$t=l(Je);let Ee;var Ye=d($t,2),We=l(Ye,!0);s(Ye),s(Je);var Oe=d(Je,2),st=l(Oe,!0);s(Oe),s(Re),T((rt,ht,wt,dt,Ue)=>{De=pe(Re,1,"pt-row data-row data-row--interactive",null,De,{"is-cb":r(he),"is-sb":!r(he),"data-row--selected":r(be),"is-selected":r(be),"is-multi":r(ye)&&!r(be),"is-related":r(ze)&&!r(be)}),y(Ze,`#${r(me).seq??""}`),y(Le,rt),y(Ae,r(he)?"\u2193":"\u2191"),Ne=we(Fe,"",Ne,ht),y(mt,wt),y(Ie,dt),Ee=we($t,"",Ee,{background:`var(--sub-${r(me).subjectGroup})`}),y(We,r(me).subjectLabel||r(me).subjectGroup),y(st,Ue)},[()=>n(r(te).delta),()=>({"--class-c":p().get(r(me).className)??"var(--ink-3)"}),()=>xr(r(me).className),()=>q(r(me)),()=>Jo(r(me).sizeBytes)]),U("click",Re,rt=>j(rt,r(me))),U("contextmenu",Re,rt=>{rt.preventDefault(),m()(rt,r(me))}),U("keydown",Re,rt=>{(rt.key==="Enter"||rt.key===" ")&&(rt.preventDefault(),g()(r(me).seq))}),f($e,Re)};z(W,$e=>{r(te).kind==="lifecycle"?$e(re):r(te).kind==="group"?$e(oe,1):$e(ue,-1)})}f(J,V)});var K=d(Y,2);let Z;var Q=d(K,2);{var ee=J=>{var te=Qb();f(J,te)};z(Q,J=>{a().length===0&&J(ee)})}s(D),Tt(D,J=>E(b,J),()=>r(b)),T(()=>{G=we(N,"",G,{height:`${r(S)??""}px`}),Z=we(K,"",Z,{height:`${r(R)??""}px`})}),Rt("scroll",D,J=>{A||E(C,J.currentTarget.scrollTop,!0)}),f(t,H),ce()}Pe(["click","contextmenu","keydown"]);var tx=_('
        '),rx=_('
        '),nx=_('
        Select a packet to inspect.

        \xB7 click any row to open
        \xB7 shift-click to multi-select
        \xB7 right-click for context actions
        \xB7 press B to bookmark playhead
        '),ax=_('
        Inspector
        ',1),ix=_(' '),sx=_(''),ox=_('
        Loading\u2026
        '),lx=_('
        '),cx=_('
        No decoded record in buffer.
        '),dx=_("
        "),px=_('
        '),ux=_('
        This class does not mutate player state directly.
        '),fx=_(' \u2192',1),vx=_('
        '),mx=_('
        '),$x=_('
        No related packets in the current view.
        '),_x=_(''),gx=_(''),hx=_(''),bx=_('
        '),xx=_('
        \u2192
        '),yx=_('
        \u2192
        '),wx=_('
        subj size ts
        ',1),kx=_('');function zd(t,e){le(e,!0);let n=ae(e,"row",3,null),a=ae(e,"seq",3,0),i=ae(e,"record",3,null),o=ae(e,"prevSameClass",3,null),c=ae(e,"prevRecord",3,null),p=ae(e,"related",19,()=>[]),u=ae(e,"isBookmarked",3,!1),$=ae(e,"onClose",3,()=>{}),g=ae(e,"onJumpSeq",3,()=>{}),v=ae(e,"onStep",3,()=>{}),m=ae(e,"onToggleBookmark",3,()=>{}),h=ae(e,"onCopyClass",3,()=>{}),b=ae(e,"onBreakOnClass",3,()=>{}),w=X("decoded"),C=x(()=>i()?.full),A=x(()=>r(C)?.record??null),I=x(()=>n()?o():null);function P(D,N){let G=D||{},Y=N||{},K=new Set([...Object.keys(G),...Object.keys(Y)]),Z=[];for(let Q of K){let ee=JSON.stringify(G[Q]),J=JSON.stringify(Y[Q]);Z.push({k:Q,a:G[Q],b:Y[Q],changed:ee!==J})}return Z}let F=x(()=>!r(I)||!r(A)||!c()?null:new Set(P(c(),r(A)).filter(D=>D.changed).map(D=>D.k))),L=x(()=>[{id:"decoded",label:"Decoded",badge:void 0},{id:"mutates",label:"Mutates",badge:n()&&ss(n().className).length||void 0},{id:"related",label:"Related",badge:p().length},{id:"diff",label:"Diff",badge:void 0}]),B=x(()=>{if(!r(A))return[];let D=[];return S(r(A),0,D,r(F)),D});function O(D){return D===null?'null':typeof D=="boolean"?`${D}`:typeof D=="number"?`${Number.isInteger(D)?D:D.toFixed(3)}`:typeof D=="string"?`"${Cr(D)}"`:`${Cr(String(D))}`}function S(D,N,G,Y,K,Z=!1){let Q=K==null?"":`${Cr(K)}: `,ee=Z?',':"",J=K!=null&&Y?.has(K);if(Array.isArray(D)){if(D.length===0){G.push({depth:N,html:`${Q}[]${ee}`,changed:J});return}G.push({depth:N,html:`${Q}[`,changed:J}),D.forEach((W,re)=>S(W,N+1,G,Y,void 0,re]${ee}`});return}if(D===null||typeof D!="object"){G.push({depth:N,html:`${Q}${O(D)}${ee}`,changed:J});return}let te=D,V=Object.keys(te);if(V.length===0){G.push({depth:N,html:`${Q}{}${ee}`,changed:J});return}G.push({depth:N,html:`${Q}{`,changed:J}),V.forEach((W,re)=>S(te[W],N+1,G,Y,W,re}${ee}`})}function R(D){let N={"Same subject":[],"Same class":[]};for(let G of D)N[G.reason].push(G);return N}function k(D){return D==null?"\u2014":typeof D=="number"?Number.isInteger(D)?String(D):D.toFixed(2):String(D)}var M=kx(),q=l(M);{var j=D=>{var N=ax(),G=d(ie(N),2);{var Y=Q=>{var ee=tx(),J=l(ee);s(ee),T(()=>y(J,`Loading seq #${a()??""}\u2026`)),f(Q,ee)},K=Q=>{var ee=rx(),J=l(ee);s(ee),T(()=>y(J,`Error \xB7 ${i().error??""}`)),f(Q,ee)},Z=Q=>{var ee=nx();f(Q,ee)};z(G,Q=>{i()?.loading?Q(Y):i()?.error?Q(K,1):Q(Z,-1)})}f(D,N)},H=D=>{let N=x(()=>Jr(n().direction));var G=wx(),Y=ie(G),K=l(Y),Z=l(K);let Q;var ee=l(Z,!0);s(Z);var J=d(Z,2),te=l(J,!0);s(J);var V=d(J,2),W=l(V);let re;var oe=d(W);s(V);var ue=d(V,2),$e=l(ue);s(ue);var me=d(ue,2);s(K);var he=d(K,2);we(he,"",{},{"margin-top":"8px"});var be=l(he),ye=l(be,!0);s(be),s(he);var ze=d(he,2),Re=l(ze),De=d(l(Re)),Be=l(De,!0);s(De),s(Re);var ot=d(Re,2),Ke=d(l(ot)),ke=l(Ke,!0);s(Ke),s(ot);var Ze=d(ot,2),je=d(l(Ze)),Le=l(je,!0);s(je),s(Ze),s(ze),s(Y);var qe=d(Y,2),Ae=l(qe),Ce=d(Ae,2),Fe=d(Ce,4);let Ne;var Ve=d(Fe,2),mt=d(Ve,2);s(qe);var He=d(qe,2);de(He,21,()=>r(L),Oe=>Oe.id,(Oe,st)=>{var rt=sx();let ht;var wt=l(rt,!0),dt=d(wt);{var Ue=Qe=>{var Se=ix(),nt=l(Se,!0);s(Se),T(()=>y(nt,r(st).badge)),f(Qe,Se)};z(dt,Qe=>{r(st).badge!=null&&Qe(Ue)})}s(rt),T(()=>{ht=pe(rt,1,"",null,ht,{"is-on":r(w)===r(st).id}),y(wt,r(st).label)}),U("click",rt,()=>{E(w,r(st).id,!0)}),f(Oe,rt)}),s(He);var Ie=d(He,2),Je=l(Ie);{var $t=Oe=>{var st=Me(),rt=ie(st);{var ht=Qe=>{var Se=ox();f(Qe,Se)},wt=Qe=>{var Se=lx(),nt=l(Se);s(Se),T(()=>y(nt,`Error \xB7 ${i().error??""}`)),f(Qe,Se)},dt=Qe=>{var Se=cx();f(Qe,Se)},Ue=Qe=>{var Se=px();de(Se,21,()=>r(B),lt,(nt,ut)=>{var vt=dx();let Ct,Dt;Ds(vt,()=>r(ut).html,!0),s(vt),T(()=>{Ct=pe(vt,1,"row",null,Ct,{changed:r(ut).changed}),Dt=we(vt,"",Dt,{"--depth":r(ut).depth})}),f(nt,vt)}),s(Se),f(Qe,Se)};z(rt,Qe=>{i()?.loading?Qe(ht):i()?.error?Qe(wt,1):r(A)==null?Qe(dt,2):Qe(Ue,-1)})}f(Oe,st)},Ee=Oe=>{let st=x(()=>ss(n().className));var rt=Me(),ht=ie(rt);{var wt=Ue=>{var Qe=ux();f(Ue,Qe)},dt=Ue=>{var Qe=mx();de(Qe,20,()=>r(st),Se=>Se,(Se,nt)=>{let ut=x(()=>r(A)??{}),vt=x(()=>c()??{}),Ct=x(()=>r(ut)[nt.split(".").pop()??nt]??r(ut)[nt]),Dt=x(()=>r(vt)[nt.split(".").pop()??nt]??r(vt)[nt]);var Vt=vx(),ct=l(Vt),Mt=l(ct,!0);s(ct);var It=d(ct,2),qt=l(It);{var nr=Pt=>{var Kt=fx(),Ht=ie(Kt),cn=l(Ht,!0);s(Ht),ve(2),T(Qr=>y(cn,Qr),[()=>k(r(Dt))]),f(Pt,Kt)};z(qt,Pt=>{r(Dt)!=null&&Pt(nr)})}var Wt=d(qt,2),Ft=l(Wt,!0);s(Wt),s(It),s(Vt),T(Pt=>{y(Mt,nt),y(Ft,Pt)},[()=>k(r(Ct))]),f(Se,Vt)}),s(Qe),f(Ue,Qe)};z(ht,Ue=>{r(st).length===0?Ue(wt):Ue(dt,-1)})}f(Oe,rt)},Ye=Oe=>{var st=Me(),rt=ie(st);{var ht=dt=>{var Ue=$x();f(dt,Ue)},wt=dt=>{let Ue=x(()=>R(p()));var Qe=hx();de(Qe,21,()=>Object.entries(r(Ue)),([Se,nt])=>Se,(Se,nt)=>{var ut=x(()=>fr(r(nt),2));let vt=()=>r(ut)[0],Ct=()=>r(ut)[1];var Dt=Me(),Vt=ie(Dt);{var ct=Mt=>{var It=gx(),qt=l(It),nr=l(qt);s(qt);var Wt=d(qt,2);de(Wt,17,Ct,Ft=>Ft.row.seq,(Ft,Pt)=>{let Kt=x(()=>Jr(r(Pt).row.direction));var Ht=_x();let cn;var Qr=l(Ht),$n=l(Qr);s(Qr);var en=d(Qr,2),Ur=l(en,!0);s(en);var rr=d(en,2);we(rr,"",{},{"white-space":"nowrap",overflow:"hidden","text-overflow":"ellipsis"});var _n=l(rr);we(_n,"",{},{color:"var(--ink)"});var Ja=l(_n,!0);s(_n);var In=d(_n,2);we(In,"",{},{color:"var(--ink-3)","margin-left":"6px","font-size":"var(--t-xs)"});var Mi=l(In,!0);s(In),s(rr);var Qa=d(rr,2),Da=l(Qa);s(Qa),s(Ht),T((On,Pi)=>{cn=pe(Ht,1,"pt-related__row",null,cn,{"is-cb":r(Kt),"is-sb":!r(Kt)}),y($n,`#${r(Pt).row.seq??""}`),y(Ur,r(Kt)?"\u2193":"\u2191"),y(Ja,On),y(Mi,r(Pt).row.subjectLabel||""),y(Da,`${r(Pt).dt<0?"":"+"}${Pi??""}ms`)},[()=>xr(r(Pt).row.className),()=>Math.round(r(Pt).dt)]),U("click",Ht,()=>g()(r(Pt).row.seq)),f(Ft,Ht)}),s(It),T(()=>y(nr,`${vt()??""} \xB7 ${Ct().length??""}`)),f(Mt,It)};z(Vt,Mt=>{Ct().length&&Mt(ct)})}f(Se,Dt)}),s(Qe),f(dt,Qe)};z(rt,dt=>{p().length===0?dt(ht):dt(wt,-1)})}f(Oe,st)},We=Oe=>{var st=Me(),rt=ie(st);{var ht=dt=>{var Ue=bx(),Qe=l(Ue);s(Ue),T(Se=>y(Qe,`No prior ${Se??""} packet in the buffer.`),[()=>xr(n().className)]),f(dt,Ue)},wt=dt=>{let Ue=x(()=>P(c(),r(A))),Qe=x(()=>r(Ue).filter(Pt=>Pt.changed));var Se=yx(),nt=l(Se),ut=l(nt),vt=l(ut),Ct=l(vt);s(vt);var Dt=d(vt);s(ut);var Vt=d(ut,2);we(Vt,"",{},{color:"var(--ink-4)"});var ct=d(Vt,2),Mt=l(ct),It=l(Mt);s(Mt);var qt=d(Mt),nr=d(qt);we(nr,"",{},{color:"var(--acc)"});var Wt=l(nr);s(nr),s(ct),s(nt);var Ft=d(nt,2);de(Ft,17,()=>r(Ue),Pt=>Pt.k,(Pt,Kt)=>{var Ht=xx();let cn;var Qr=l(Ht),$n=l(Qr,!0);s(Qr);var en=d(Qr,2),Ur=l(en,!0);s(en);var rr=d(en,4),_n=l(rr,!0);s(rr),s(Ht),T((Ja,In)=>{cn=pe(Ht,1,"pt-diff__row",null,cn,{changed:r(Kt).changed}),y($n,r(Kt).k),y(Ur,Ja),y(_n,In)},[()=>JSON.stringify(r(Kt).a),()=>JSON.stringify(r(Kt).b)]),f(Pt,Ht)}),s(Se),T((Pt,Kt)=>{y(Ct,`#${r(I).seq??""}`),y(Dt,` ${Pt??""}`),y(It,`#${n().seq??""}`),y(qt,` ${Kt??""} \xB7 `),y(Wt,`${r(Qe).length??""} of ${r(Ue).length??""} changed`)},[()=>Nn(r(I).ts).slice(0,12),()=>Nn(n().ts).slice(0,12)]),f(dt,Se)};z(rt,dt=>{r(I)?dt(wt,-1):dt(ht)})}f(Oe,st)};z(Je,Oe=>{r(w)==="decoded"?Oe($t):r(w)==="mutates"?Oe(Ee,1):r(w)==="related"?Oe(Ye,2):r(w)==="diff"&&Oe(We,3)})}s(Ie),T((Oe,st,rt,ht)=>{Q=pe(Z,1,"pt-tag",null,Q,{cb:r(N),sb:!r(N)}),y(ee,r(N)?"\u2193 CB":"\u2191 SB"),y(te,n().state),re=we(W,"",re,{width:"6px",height:"6px",background:`var(--sub-${n().subjectGroup})`}),y(oe,` ${n().subjectGroup??""}`),y($e,`#${Oe??""}`),y(ye,st),y(Be,n().subjectLabel||"\u2014"),y(ke,rt),y(Le,ht),Ne=pe(Fe,1,"btn sm",null,Ne,{"is-on":u()})},[()=>n().seq.toLocaleString(),()=>xr(n().className),()=>zt(n().sizeBytes),()=>Nn(n().ts)]),U("click",me,function(...Oe){$()?.apply(this,Oe)}),U("click",Ae,()=>v()(-1)),U("click",Ce,()=>v()(1)),U("click",Fe,function(...Oe){m()?.apply(this,Oe)}),U("click",Ve,function(...Oe){h()?.apply(this,Oe)}),U("click",mt,function(...Oe){b()?.apply(this,Oe)}),f(D,G)};z(q,D=>{n()?D(H,-1):D(j)})}s(M),f(t,M),ce()}Pe(["click"]);var Ex=_('
        ');function qd(t,e){le(e,!0);var n=Ex(),a=l(n),i=l(a),o=d(i,10);we(o,"",{},{color:"var(--ink-3)",margin:"0 0 10px"});var c=d(o,6);we(c,"",{},{color:"var(--ink-3)",margin:"0"}),s(a),s(n),U("click",n,function(...p){e.onClose?.apply(this,p)}),U("keydown",n,p=>{(p.key==="Escape"||p.key==="Enter")&&e.onClose()}),U("click",a,p=>p.stopPropagation()),U("keydown",a,p=>p.stopPropagation()),U("click",i,function(...p){e.onClose?.apply(this,p)}),f(t,n),ce()}Pe(["click","keydown"]);var Sx=_(''),Tx=_(''),Cx=_('');function Hd(t,e){le(e,!0);var n=Cx(),a=l(n),i=d(l(a),2);s(a);var o=d(a,2),c=d(l(o),2);de(c,20,()=>Object.keys(Zs),b=>b,(b,w)=>{var C=Sx();let A;T(()=>{pe(C,1,St(e.accent===w?"is-on":"")),ne(C,"title",w),ne(C,"aria-label",w),A=we(C,"",A,{background:Zs[w].acc})}),U("click",C,()=>e.onAccent(w)),f(b,C)}),s(c),s(o);var p=d(o,2),u=d(l(p),2);de(u,20,()=>["compact","normal","roomy"],b=>b,(b,w)=>{var C=Tx(),A=l(C,!0);s(C),T(()=>{pe(C,1,St(e.density===w?"is-on":"")),y(A,w)}),U("click",C,()=>e.onDensity(w)),f(b,C)}),s(u),s(p);var $=d(p,2),g=d(l($),2),v=l(g);s(g),s($);var m=d($,2),h=l(m);s(m),s(n),T(()=>pe(v,1,St(e.collapse?"is-on":""))),U("click",i,function(...b){e.onClose?.apply(this,b)}),U("click",v,function(...b){e.onToggleCollapse?.apply(this,b)}),U("click",h,()=>{e.onReset(),e.onClose()}),f(t,n),ce()}Pe(["click"]);var Ax=_(' '),Mx=_('
        shown \xB7 CB SB \xB7 bw \xB7 sel \xB7 marks breaks ? help / search space \u2190\u2192 step B bookmark
        '),Px={hash:"svelte-nazlxm",code:` + @layer components { + /* ---- Packet trace ---- */.pt {--row-h: 26px;--pt-control-h: 30px;--trace-panel: color-mix(in oklab, var(--bg-1) 88%, black);--trace-panel-2: color-mix(in oklab, var(--bg-2) 72%, black);--trace-line: color-mix(in oklab, var(--line) 72%, transparent);--trace-hover: color-mix(in oklab, var(--ink) 4%, transparent);display:grid;grid-template-rows:auto auto 1fr auto;min-height:720px;height:80vh;background:var(--bg-0);color:var(--ink-2);font-size:var(--t-md);line-height:1;border:1px solid var(--trace-line);overflow:hidden;position:relative;container:packet-trace / inline-size;box-shadow:inset 0 1px 0 color-mix(in oklab, white 4%, transparent);}.pt input {font:inherit;color:inherit;}.pt input:focus, .pt button:focus {outline:1px solid var(--acc);outline-offset:1px;} + + /* \u2500\u2500\u2500\u2500\u2500 top bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-top {display:flex;align-items:center;gap:8px;padding:10px 12px;min-height:52px;background:var(--trace-panel);border-bottom:1px solid var(--trace-line);flex-wrap:wrap;}.pt-query-tokens {align-items:center;gap:4px;}.pt-controls {display:flex;align-items:center;gap:4px;margin-left:auto;}.pt-top .btn, + .pt-top .pt-jump, + .pt-top .gauge-inline, + .pt-top .chip-filter--sm, + .pt-top .search-inline--bar {height:var(--pt-control-h);min-height:var(--pt-control-h);box-sizing:border-box;}.pt-top .btn {display:inline-flex;align-items:center;justify-content:center;padding:0 9px;}.pt-top .btn.icon {width:var(--pt-control-h);min-width:var(--pt-control-h);padding:0;}.pt-top .search-inline--bar {flex:1 1 340px;background:color-mix(in oklab, var(--sunk) 78%, black);border-color:var(--trace-line);box-shadow:inset 0 0 0 1px color-mix(in oklab, black 16%, transparent);}.pt-top .search-inline--bar input {font-size:var(--t-sm);}.pt-top .search-inline--bar input::placeholder {color:color-mix(in oklab, var(--ink-4) 78%, transparent);font-size:var(--t-xs);}.pt-top .gauge-inline {align-items:center;line-height:1;background:color-mix(in oklab, var(--bg-0) 64%, transparent);border:1px solid var(--trace-line);padding:0 8px;}.pt-top .gauge-inline .num, + .pt-top .gauge-inline .lbl {line-height:1;}.pt-top .divider-v {height:var(--pt-control-h);background:var(--trace-line);margin:0 3px;}.pt-jump {display:inline-flex;align-items:center;background:color-mix(in oklab, var(--sunk) 78%, black);border:1px solid var(--trace-line);}.pt-jump:focus-within {border-color:var(--acc-line);}.pt-jump .label {padding:0 8px;color:var(--ink-4);font-size:var(--t-xs);text-transform:uppercase;letter-spacing:0.1em;}.pt-jump input {width:64px;height:100%;background:transparent;border:0;color:var(--ink);font-size:var(--t-sm);padding:0 6px;} + + /* \u2500\u2500\u2500\u2500\u2500 minimap strip \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-strip {position:relative;height:46px;overflow:hidden;background:color-mix(in oklab, var(--bg-0) 82%, black);border-bottom:1px solid var(--trace-line);cursor:crosshair;user-select:none;}.pt-strip__axis {position:absolute;top:50%;left:0;right:0;height:1px;background:var(--trace-line);}.pt-strip__col {position:absolute;top:0;bottom:0;width:2px;pointer-events:none;}.pt-strip__col i {position:absolute;left:0;right:0;background:var(--dir-cb);opacity:0.5;}.pt-strip__col i.sb {background:var(--dir-sb);opacity:0.45;}.pt-strip__marker {position:absolute;width:2px;top:2px;bottom:2px;pointer-events:none;z-index:2;}.pt-strip__marker.bm {background:var(--warn);}.pt-strip__marker.brk {background:var(--danger);}.pt-strip__marker.life {background:var(--acc);}.pt-strip__marker .glyph {position:absolute;top:-1px;left:50%;transform:translateX(-50%);width:12px;height:12px;line-height:10px;background:var(--bg-0);border:1px solid currentColor;color:inherit;font-size:9px;text-align:center;pointer-events:auto;cursor:pointer;}.pt-strip__marker.bm .glyph {color:var(--warn);}.pt-strip__marker.brk .glyph {color:var(--danger);}.pt-strip__marker.life .glyph {color:var(--acc);}.pt-strip__playhead {position:absolute;top:0;bottom:0;width:1px;background:var(--ink);box-shadow:0 0 0 1px color-mix(in oklab, var(--acc) 30%, transparent);pointer-events:none;z-index:3;}.pt-strip__playhead::before, .pt-strip__playhead::after {content:'';position:absolute;left:-3px;width:7px;height:7px;background:var(--ink);}.pt-strip__playhead::before {top:0;clip-path:polygon(0 0, 100% 0, 50% 100%);}.pt-strip__playhead::after {bottom:0;clip-path:polygon(50% 0, 0 100%, 100% 100%);}.pt-strip__window {position:absolute;top:0;bottom:0;background:color-mix(in oklab, var(--ink) 5%, transparent);border-left:1px solid color-mix(in oklab, var(--ink-4) 70%, transparent);border-right:1px solid color-mix(in oklab, var(--ink-4) 70%, transparent);pointer-events:none;}.pt-strip__legend {position:absolute;left:8px;top:50%;display:grid;gap:2px;transform:translateY(-50%);pointer-events:none;z-index:4;}.pt-strip__legend span {display:inline-grid;grid-template-columns:3px 1fr;align-items:center;gap:4px;width:30px;padding:1px 3px;background:color-mix(in oklab, var(--bg-0) 70%, transparent);color:var(--ink-4);font-size:8px;letter-spacing:0.08em;text-transform:uppercase;border-left:1px solid color-mix(in oklab, currentColor 45%, transparent);}.pt-strip__legend i {width:3px;height:10px;background:currentColor;}.pt-strip__legend b {font-weight:700;}.pt-strip__legend .cb {color:var(--dir-cb);}.pt-strip__legend .sb {color:var(--dir-sb);}.pt-strip__seq {position:absolute;right:8px;bottom:4px;color:var(--ink-4);font-size:var(--t-xs);letter-spacing:0.05em;pointer-events:none;z-index:4;} + + /* \u2500\u2500\u2500\u2500\u2500 main 3-column \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-main {display:grid;grid-template-columns:minmax(160px, 190px) minmax(0, 1fr) 8px minmax(240px, var(--pt-inspector-w, 300px));min-height:0;overflow:hidden;}.pt-resize {background:var(--trace-panel);position:relative;touch-action:none;}.pt-resize::before {content:'';position:absolute;background:color-mix(in oklab, var(--ink-4) 35%, transparent);}.pt-resize:hover::before, + .pt-resize:active::before {background:var(--acc);}.pt-resize--v {width:8px;min-width:8px;cursor:col-resize;border-left:1px solid var(--trace-line);border-right:1px solid var(--trace-line);}.pt-resize--v::before {inset:0 3px;}.pt-resize--h {height:8px;min-height:8px;cursor:row-resize;border-top:1px solid var(--trace-line);border-bottom:1px solid var(--trace-line);grid-column:1 / -1;display:none;}.pt-resize--h::before {inset:3px 0;} + + /* \u2500\u2500 facets / left rail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-facets {display:flex;flex-direction:column;min-height:0;overflow:hidden;background:var(--trace-panel);border-right:1px solid var(--trace-line);}.pt-facets__body {flex:1;padding-bottom:16px;}.pt-facets__tabs {padding:8px;background:color-mix(in oklab, var(--bg-0) 36%, transparent);border-bottom:1px solid var(--trace-line);}.pt-facet-group {padding:9px 0;border-bottom:1px solid var(--trace-line);}.pt-facet-group__head {display:flex;align-items:center;justify-content:space-between;padding:4px 12px;color:var(--ink-3);font-size:var(--t-xs);letter-spacing:0.1em;text-transform:uppercase;}.pt-facet-group__head .count {color:var(--ink-4);}.pt-facet-group__head .reset {color:var(--ink-4);font-size:var(--t-xs);text-decoration:underline;text-underline-offset:2px;}.pt-facet-group__head .reset:hover {color:var(--ink);}.pt-facet-row {display:grid;grid-template-columns:14px 14px 1fr auto;align-items:center;gap:6px;padding:3px 12px;height:22px;width:100%;text-align:left;color:var(--ink-2);font-size:var(--t-sm);}.pt-facet-row:hover {background:var(--trace-hover);}.pt-facet-row.is-include {color:var(--acc);}.pt-facet-row.is-exclude {color:var(--danger);text-decoration:line-through;}.pt-facet-row .dot {width:8px;height:8px;}.pt-facet-row .sym {color:var(--ink-4);font-size:var(--t-xs);text-align:center;}.pt-facet-row.is-include .sym {color:var(--acc);}.pt-facet-row.is-exclude .sym {color:var(--danger);}.pt-facet-row .lbl {white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.pt-facet-row .num {color:var(--ink-4);font-size:var(--t-xs);font-variant-numeric:tabular-nums;}.pt-list {padding:6px 0;}.pt-list__item {display:grid;grid-template-columns:14px 1fr auto;align-items:center;gap:8px;padding:6px 12px;width:100%;text-align:left;color:var(--ink-2);font-size:var(--t-sm);border-bottom:1px solid var(--trace-line);}.pt-list__item:hover {background:var(--trace-hover);color:var(--ink);}.pt-list__item .glyph {color:var(--warn);font-size:var(--t-sm);}.pt-list__item.brk .glyph {color:var(--danger);}.pt-list__item .note {color:var(--ink-3);font-size:var(--t-xs);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.pt-list__item .del {color:var(--ink-4);font-size:var(--t-xs);padding:0 4px;}.pt-list__item .del:hover {color:var(--danger);}.pt-add-form {display:flex;gap:4px;margin:6px 8px;}.pt-add-form input {flex:1;min-width:0;height:22px;padding:0 6px;background:color-mix(in oklab, var(--sunk) 76%, black);border:1px solid var(--trace-line);color:var(--ink);font-size:var(--t-xs);}.pt-add-form button {padding:0 8px;height:22px;background:var(--trace-panel-2);border:1px solid var(--trace-line);color:var(--acc);font-size:var(--t-xs);} + + /* \u2500\u2500 stream / center \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-stream {display:flex;flex-direction:column;min-width:0;min-height:0;background:var(--bg-0);}.pt-stream__head, .pt-row, .pt-group {display:grid;grid-template-columns:16px 50px 46px 16px minmax(180px, 1fr) minmax(56px, 72px) 44px;align-items:center;gap:8px;padding:0 12px;}.pt-stream__head {height:26px;flex-shrink:0;background:var(--trace-panel);border-bottom:1px solid var(--trace-line);color:var(--ink-4);font-size:var(--t-xs);letter-spacing:0.08em;text-transform:uppercase;}.pt-stream__body {flex:1;position:relative;overflow-x:hidden;}.pt-row {height:var(--row-h);position:relative;cursor:pointer;color:var(--ink-2);font-size:var(--t-sm);border-bottom:1px solid color-mix(in oklab, var(--line) 50%, transparent);transition:background-color 120ms ease, color 120ms ease;}.pt-row::before {content:'';position:absolute;inset:0 auto 0 0;width:2px;background:var(--dir-cb);opacity:0.45;}.pt-row.is-sb::before {background:var(--dir-sb);}.pt-row:hover {background:var(--trace-hover);color:var(--ink);}.pt-row:focus, .pt-row:focus-visible {outline:none;}.pt-row.is-selected, + .pt-row.data-row--selected {background:color-mix(in oklab, var(--acc) 10%, transparent);box-shadow:inset 3px 0 0 var(--acc), inset 0 0 0 1px var(--acc-line);color:var(--ink);}.pt-row.is-selected::before, + .pt-row.data-row--selected::before {opacity:1;}.pt-row.is-multi {background:color-mix(in oklab, var(--acc) 6%, transparent);box-shadow:inset 3px 0 0 var(--acc-deep);}.pt-row.is-related {box-shadow:inset 3px 0 0 var(--warn);}.pt-row.is-cb .dir {color:var(--dir-cb);}.pt-row.is-sb .dir {color:var(--dir-sb);}.pt-row .bm {display:flex;align-items:center;justify-content:center;}.pt-row .bm-glyph {color:var(--warn);font-size:var(--t-sm);}.pt-row .seq, .pt-row .delta, .pt-row .size {font-variant-numeric:tabular-nums;font-size:var(--t-xs);}.pt-row .seq {color:var(--ink-3);}.pt-row .delta {color:var(--ink-4);}.pt-row .size {color:var(--ink-4);text-align:right;}.pt-row .dir {font-size:var(--t-md);font-weight:700;text-align:center;}.pt-row .class {display:flex;align-items:center;gap:6px;min-width:0;overflow:hidden;color:var(--ink);}.pt-row .class .swatch {width:3px;height:14px;flex-shrink:0;background:var(--class-c, var(--ink-3));opacity:0.75;}.pt-row .class .name {flex:0 0 auto;max-width:min(100%, 260px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.pt-row .class .summary {flex:1 1 auto;min-width:0;}.pt-row .summary {color:var(--ink-3);font-size:var(--t-xs);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.pt-row.is-selected .summary {color:var(--ink-2);}.pt-row .subj {display:inline-flex;align-items:center;gap:4px;overflow:hidden;color:var(--ink-3);font-size:var(--t-xs);}.pt-row .subj .pip {width:6px;height:6px;flex-shrink:0;}.pt-row .subj .lbl {white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.pt-group {height:var(--row-h);cursor:pointer;font-style:italic;color:var(--ink-3);font-size:var(--t-sm);background:var(--bg-1);border:0;border-bottom:1px solid color-mix(in oklab, var(--line) 50%, transparent);box-shadow:none;text-transform:none;text-align:left;width:100%;box-sizing:border-box;}.pt-group:hover {background:var(--trace-hover);color:var(--ink-2);}.pt-group .span {color:var(--ink-4);font-size:var(--t-xs);}.pt-group .count {grid-column:7;color:var(--acc);font-size:var(--t-xs);text-align:right;}.pt-lifecycle {display:flex;align-items:center;gap:12px;padding:4px 12px;height:24px;background:var(--acc-soft);border-top:1px dashed var(--acc-line);border-bottom:1px dashed var(--acc-line);color:var(--acc);font-size:var(--t-xs);letter-spacing:0.08em;text-transform:uppercase;}.pt-lifecycle .glyph {font-size:var(--t-md);}.pt-lifecycle .seq {color:var(--ink-4);margin-left:auto;} + + /* \u2500\u2500 inspector / right rail \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-insp {display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:var(--trace-panel);}.pt-insp__head {padding:12px 14px;border-bottom:1px solid var(--trace-line);background:var(--trace-panel);}.pt-insp__head-row {display:flex;align-items:center;gap:8px;flex-wrap:wrap;}.pt-insp__head .class-name {color:var(--ink);font-size:var(--t-lg);letter-spacing:0.01em;}.pt-insp__head .meta {display:flex;gap:12px;margin-top:6px;color:var(--ink-3);font-size:var(--t-xs);letter-spacing:0.05em;}.pt-insp__head .meta .k {color:var(--ink-4);text-transform:uppercase;}.pt-insp__head .meta .v {color:var(--ink-2);margin-left:4px;font-variant-numeric:tabular-nums;}.pt-insp__eyebrow {color:var(--ink-3);letter-spacing:0.12em;text-transform:uppercase;font-size:var(--t-xs);}.pt-insp__seq {margin-left:auto;color:var(--ink-4);font-size:var(--t-xs);font-variant-numeric:tabular-nums;}.pt-insp__empty-steps {text-align:left;max-width:280px;margin:0 auto;color:var(--ink-3);}.pt-tag {display:inline-flex;align-items:center;gap:4px;height:18px;padding:0 6px;background:var(--trace-panel-2);border:1px solid var(--trace-line);color:var(--ink-2);font-size:var(--t-xs);letter-spacing:0.05em;}.pt-tag.cb {color:var(--dir-cb);border-color:color-mix(in oklab, var(--dir-cb) 35%, transparent);background:var(--dir-cb-soft);}.pt-tag.sb {color:var(--dir-sb);border-color:color-mix(in oklab, var(--dir-sb) 35%, transparent);background:var(--dir-sb-soft);}.pt-insp__tabs button .badge {display:inline-flex;align-items:center;justify-content:center;min-width:14px;height:14px;padding:0 4px;margin-left:4px;background:var(--bg-2);color:var(--ink-3);font-size:9px;}.pt-insp__tabs button.is-on .badge {background:var(--acc-soft);color:var(--acc);}.pt-insp__body {flex:1;padding:12px 14px;min-height:0;background:color-mix(in oklab, var(--bg-0) 42%, transparent);}.pt-insp__actions {display:flex;gap:4px;flex-wrap:wrap;padding:6px 8px;background:color-mix(in oklab, var(--bg-0) 44%, transparent);border-bottom:1px solid var(--trace-line);}.pt-json {margin:0;font-size:var(--t-sm);line-height:1.55;white-space:normal;overflow-wrap:anywhere;}.pt-json .row {display:block;padding-left:calc(var(--depth, 0) * 1.4em);text-indent:0;min-height:1.55em;}.pt-json .k {color:var(--ink);}.pt-json .s {color:var(--sub-self);}.pt-json .n {color:var(--sub-win);}.pt-json .b {color:var(--sub-hud);}.pt-json .nul {color:var(--ink-4);font-style:italic;}.pt-json .brace, .pt-json .bracket, .pt-json .comma, .pt-json .colon {color:var(--ink-3);}.pt-json .row.changed {background:color-mix(in oklab, var(--warn) 14%, transparent);box-shadow:inset 2px 0 0 var(--warn);}.pt-muts {display:flex;flex-direction:column;gap:4px;}.pt-mut {display:grid;grid-template-columns:1fr auto;gap:8px;padding:6px 8px;font-size:var(--t-sm);background:var(--trace-panel-2);border:1px solid var(--trace-line);}.pt-mut .field {color:var(--ink);}.pt-mut .vals {display:flex;gap:6px;color:var(--ink-3);font-size:var(--t-xs);font-variant-numeric:tabular-nums;}.pt-mut .vals .from {color:var(--ink-4);text-decoration:line-through;}.pt-mut .vals .arrow {color:var(--ink-4);}.pt-mut .vals .to {color:var(--acc);}.pt-related {display:flex;flex-direction:column;gap:2px;}.pt-related__group {margin-top:8px;}.pt-related__group h4 {margin:0 0 4px;padding:0;color:var(--ink-3);font-size:var(--t-xs);letter-spacing:0.08em;text-transform:uppercase;}.pt-related__row {display:grid;grid-template-columns:50px 22px 1fr 50px;align-items:center;gap:6px;padding:3px 6px;width:100%;text-align:left;color:var(--ink-2);font-size:var(--t-sm);border-left:2px solid transparent;}.pt-related__row:hover {background:var(--trace-hover);border-left-color:var(--acc);}.pt-related__row .seq {color:var(--ink-3);font-size:var(--t-xs);font-variant-numeric:tabular-nums;}.pt-related__row .dir {font-weight:700;}.pt-related__row.is-cb .dir {color:var(--dir-cb);}.pt-related__row.is-sb .dir {color:var(--dir-sb);}.pt-related__row .delta {color:var(--ink-4);font-size:var(--t-xs);text-align:right;font-variant-numeric:tabular-nums;}.pt-diff__head {display:flex;gap:12px;align-items:baseline;margin-bottom:8px;padding-bottom:8px;color:var(--ink-3);font-size:var(--t-xs);letter-spacing:0.05em;text-transform:uppercase;border-bottom:1px dashed var(--line);}.pt-diff__head .v {color:var(--ink-2);}.pt-diff__row {display:grid;grid-template-columns:1fr 12px 1fr;align-items:center;gap:8px;padding:4px 6px;font-size:var(--t-sm);border-bottom:1px solid color-mix(in oklab, var(--line) 50%, transparent);}.pt-diff__row.changed {background:color-mix(in oklab, var(--warn) 7%, transparent);}.pt-diff__row .field {grid-column:1 / -1;padding-top:2px;color:var(--ink-4);font-size:var(--t-xs);letter-spacing:0.08em;text-transform:uppercase;}.pt-diff__row .from {color:var(--ink-3);}.pt-diff__row .to {color:var(--acc);}.pt-diff__row .arr {color:var(--ink-4);text-align:center;} + + /* \u2500\u2500\u2500\u2500\u2500 status bar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-status {display:flex;align-items:center;gap:12px;padding:0 14px;height:28px;flex-shrink:0;white-space:nowrap;overflow:hidden;background:var(--trace-panel);border-top:1px solid var(--trace-line);color:var(--ink-3);font-size:var(--t-xs);letter-spacing:0.04em;}.pt-status .k {color:var(--ink-4);text-transform:uppercase;letter-spacing:0.1em;}.pt-status .v {color:var(--ink-2);margin-left:4px;}.pt-status .v.acc {color:var(--acc);}.pt-status .v.warn {color:var(--warn);}.pt-status .v.danger {color:var(--danger);}.pt-status .sep {color:var(--ink-4);}.pt-status .right {margin-left:auto;display:flex;gap:12px;} + + @container packet-trace (max-width: 1180px) {.pt-main {grid-template-columns:minmax(144px, 172px) minmax(0, 1fr) 8px minmax(240px, var(--pt-inspector-w, 280px));}.pt-stream__head, + .pt-row, + .pt-group {grid-template-columns:14px 48px 42px 16px minmax(200px, 1fr) 44px;gap:6px;padding-inline:8px;}.pt-stream__head > :nth-child(6), + .pt-row .subj {display:none;}.pt-row .summary {display:none;}.pt-row .class .name {max-width:none;} + } + + @container packet-trace (max-width: 960px) {.pt-main {grid-template-columns:minmax(164px, 26%) minmax(0, 1fr);grid-template-rows:minmax(240px, 1fr) auto var(--pt-inspector-h, 38vh);}.pt-resize--v {display:none;}.pt-resize--h {display:block;}.pt-stream {border-right:0;}.pt-insp {grid-column:1 / -1;min-height:0;} + } + + @container packet-trace (max-width: 720px) {.pt-top {align-items:stretch;}.pt-controls {width:100%;margin-left:0;flex-wrap:wrap;}.pt-top .search-inline--bar {flex-basis:100%;}.pt-main {grid-template-columns:1fr;grid-template-rows:auto minmax(240px, 1fr) auto var(--pt-inspector-h, 40vh);overflow:auto;}.pt-facets {max-height:220px;border-right:0;border-bottom:1px solid var(--trace-line);}.pt-stream {min-height:240px;border-right:0;}.pt-insp {grid-column:auto;min-height:0;}.pt-status .right {display:none;} + }.pt-help h2 {margin:0 0 16px;color:var(--acc);font-size:var(--t-lg);letter-spacing:0.04em;}.pt-help h3 {margin:16px 0 8px;color:var(--ink);font-size:var(--t-sm);letter-spacing:0.1em;text-transform:uppercase;}.pt-help table {width:100%;border-collapse:collapse;font-size:var(--t-sm);}.pt-help td {padding:4px 8px;vertical-align:top;}.pt-help td.k {color:var(--ink-3);width:38%;}.pt-help td.v {color:var(--ink-2);}.pt-help kbd {display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:20px;padding:0 6px;background:var(--bg-2);border:1px solid var(--line);border-bottom-width:2px;color:var(--ink);font-size:var(--t-xs);}.pt-help kbd + kbd {margin-left:2px;}.pt-help code {padding:1px 6px;background:var(--bg-2);border:1px solid var(--line);color:var(--acc);font-size:var(--t-xs);}.pt-help .close {position:absolute;top:14px;right:18px;color:var(--ink-3);font-size:16px;} + + /* \u2500\u2500\u2500\u2500\u2500 tweaks panel \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 */.pt-tweaks {position:absolute;right:16px;bottom:50px;z-index:40;width:240px;font-size:var(--t-sm);}.pt-tweaks header {display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--sunk);border-bottom:1px solid var(--line);color:var(--acc);font-size:var(--t-xs);letter-spacing:0.1em;text-transform:uppercase;}.pt-tweaks header button {color:var(--ink-3);}.pt-tweaks header button:hover {color:var(--ink);}.pt-tweaks .group {padding:8px 12px;border-bottom:1px dashed var(--line);}.pt-tweaks .group h4 {margin:0 0 6px;color:var(--ink-4);font-size:var(--t-xs);letter-spacing:0.1em;text-transform:uppercase;}.pt-tweaks .swatches {display:flex;gap:6px;}.pt-tweaks .swatches button {width:22px;height:22px;border:1px solid var(--line);}.pt-tweaks .swatches button.is-on {border-color:var(--ink);box-shadow:0 0 0 1px var(--acc);}.pt-tweaks .seg-control {width:100%;}.pt-tweaks .seg-control > button {flex:1;} + + /* density variants */.pt[data-density="compact"] {--row-h: 22px;} + }`};function jd(t,e){le(e,!0),Ut(t,Px);let n=ae(e,"player",3,null),a=ae(e,"paused",3,!1),i=ae(e,"streamLive",3,!0),o=ae(e,"onHistory",3,()=>{}),c=ae(e,"onPlayheadChange",3,()=>{}),p=ae(e,"onResetFeed",3,()=>{}),u=x(()=>n()?.uuid??null),$=x(()=>n()?.connectionId??null),g=X(null),v=X(0),m=X(0),h=X(""),b=X(tt({})),w=X(tt({})),C=X(""),A=X(null),I=X(tt(new Set)),P=X(!1),F=X(!0),L=X(!1),B=X("filters"),O=X(tt([])),S=X(tt([])),R=X(tt([{name:"Movement noise",q:"class:Position"},{name:"Inventory ops",q:"group:win"},{name:"Keepalive pairs",q:"class:KeepAlive"}])),k=X(""),M=X(!1),q=X(!1),j=X("phosphor"),H=X("normal"),D=X(0),N=X(300),G=X(360),Y=X(null),K=X(null),Z=X(tt([])),Q=X(0),ee=0,J=Date.now(),te=X(null),V=X(void 0);function W(){return new Cl(se=>{E(m,se.maxSeq,!0),ee+=se.added;let fe=Date.now();fe-J>=1e3&&(E(Q,ee,!0),ee=0,J=fe),ba(v)})}function re(){r(g)?.clear(),E(A,null),E(m,0),E(O,[],!0),E(L,!1),E(Q,0),ee=0,J=Date.now(),E(Y,null),E(K,null),E(Z,[],!0),ba(v),p()()}let oe=x(()=>Sl(r(h))),ue=x(()=>{let se=new Map;for(let fe of r(O))se.set(fe.seq,fe);return se}),$e=x(()=>(r(v),r(g)?.snapshot()??[])),me=x(()=>se=>Id(se,r(ue))),he=x(()=>(r(v),r(g)?new Map(r(g).classCounts):new Map)),be=x(()=>{let se=new Map;for(let fe of r(he).keys())se.set(fe,Tl(fe));return se}),ye=x(()=>Gv(r(b))),ze=x(()=>Wv(r(w))),Re=x(()=>{let se=[],fe=0,it=0;for(let pt of r($e)){let Lt=r(me)(pt);r(oe).match(Lt)&&Kv(pt,r(ye),r(ze))&&(se.push(pt),fe+=pt.sizeBytes,Jr(pt.direction)&&it++)}return{rows:se,totalBytes:fe,cbCount:it,range:se.length?[se[0].seq,se[se.length-1].seq]:[null,null]}}),De=x(()=>r(Re).rows),Be=x(()=>r(Re).totalBytes),ot=x(()=>r(Re).cbCount),Ke=x(()=>r(Re).range),ke=x(()=>Jv(r(S))),Ze=x(()=>Qv(r(ke),r($e),r(ue))),je=x(()=>r(Z).filter(se=>se.packetSeq>0).map(se=>({seq:se.packetSeq,label:(Uv[se.kind]??"\xB7")+" "+se.kind}))),Le=x(()=>Xv(r(De),r(je),r(F)&&!r(L),r(ue)));function qe(se){return r(v),r(g)?.rowAtSeq(se)??null}let Ae=x(()=>r(A)!=null?qe(r(A)):null),Ce=x(()=>r(Ae)&&r(g)?r(g).findPrevSameClass(r(Ae).seq,r(Ae).className):null),Fe=x(()=>Zv(r(Ae),r($e),r(g))),Ne=x(()=>new Set(r(Fe).map(se=>se.row.seq))),Ve=x(()=>r(Ae)?r(ue).has(r(Ae).seq):!1),mt=x(()=>r(A)==null&&!r(P));function He(se){if(!r(u))return;let{segs:fe}=hi.current;if(fe[0]!=="p"||fe[1]!==r(u)||fe[2]!=="packets")return;let it=`/p/${r(u)}/packets`;jf(se!=null?`${it}?seq=${se}`:it)}function Ie(se,fe={}){let{keepMulti:it=!1,expand:pt=!1,syncUrl:Lt=!0}=fe;pt&&E(L,!0),E(A,se,!0),it||E(I,new Set,!0),ba(D),Lt&&He(se),c()(qe(se))}function Je(se){let fe=new Set(r(I));fe.has(se)?fe.delete(se):fe.add(se),fe.size>2?E(I,new Set([...fe].slice(-2)),!0):E(I,fe,!0),r(A)==null&&E(A,se,!0)}function $t(se,fe,it){if(se==null){E(b,{},!0);return}if(fe==null){let Lt={...r(b)};delete Lt[se],E(b,Lt,!0);return}let pt={...r(b)[se]||{}};it==null?delete pt[fe]:pt[fe]=it,E(b,{...r(b),[se]:pt},!0)}function Ee(se){let fe=[];for(let Lt of r(Le))Lt.kind==="row"&&fe.push(Lt.p);if(fe.length===0)return;let it=fe.findIndex(Lt=>Lt.seq===r(A)),pt=fe[Math.max(0,Math.min(fe.length-1,(it<0?0:it)+se))];pt&&Ie(pt.seq)}function Ye(){let se=Number(r(k));!Number.isFinite(se)||se<=0||Ie(se)}function We(){E(P,!1),E(A,null),E(Y,null),E(I,new Set,!0),He(null),c()(null)}function Oe(){if(!r(Ae))return;let se=r(O).findIndex(fe=>fe.seq===r(Ae).seq);se>=0?E(O,r(O).filter((fe,it)=>it!==se),!0):E(O,[...r(O),{seq:r(Ae).seq,label:xr(r(Ae).className)+" \xB7 "+Xs(r(Ae)).slice(0,30)}],!0)}function st(){E(A,null),E(Y,null),E(K,null),E(I,new Set,!0),He(null),c()(null)}function rt(se,fe){let it=r(w)[fe.className],pt=it==="include"?"exclude":it==="exclude"?null:"include",Lt={...r(w)};pt==null?delete Lt[fe.className]:Lt[fe.className]=pt,E(w,Lt,!0)}function ht(){if(!r(Ae))return;let se=r(Ae).className;navigator.clipboard?.writeText(se).then(()=>ft(se+" copied","ok")).catch(()=>ft("Copy failed","error"))}function wt(){if(!r(Ae))return;let se="pause on "+xr(r(Ae).className);E(S,[...r(S),{id:"b"+Date.now(),match:"class:"+xr(r(Ae).className),label:se,enabled:!0}],!0),E(B,"breaks")}function dt(se){let fe=r(V)?.clientWidth??1100,it=Math.max(260,Math.min(560,fe-520));return Math.max(240,Math.min(it,Math.round(se)))}function Ue(se){let fe=r(V)?.clientHeight??800,it=Math.max(240,fe-320);return Math.max(180,Math.min(it,Math.round(se)))}function Qe(se,fe){if(!r(V))return;fe.preventDefault();let it=Lt=>{let ar=r(V).getBoundingClientRect();se==="x"?E(N,dt(ar.right-Lt.clientX),!0):E(G,Ue(ar.bottom-Lt.clientY),!0)},pt=()=>{window.removeEventListener("pointermove",it),window.removeEventListener("pointerup",pt),window.removeEventListener("pointercancel",pt)};it(fe),window.addEventListener("pointermove",it),window.addEventListener("pointerup",pt,{once:!0}),window.addEventListener("pointercancel",pt,{once:!0})}ge(()=>{r(u),E(g,W(),!0),E(A,null),E(m,0),E(Z,[],!0),E(Y,null),E(K,null),E(v,0),r(u)&&Yv(r(u))}),ge(()=>{let se=r(u),fe=r($),it=r(g);if(!se||!fe||!it)return;let pt=!0;return(async()=>{let Lt=[],ar=0;for(;pt;){let Fn=await Ge(`/connections/${fe}/packets?since=${ar}&limit=5000`).catch(()=>[]);if(!pt||!Fn.length)break;for(let Bn of Fn)Lt.push(as({...Bn,uuid:se,connectionId:fe}));if(ar=Number(Fn[Fn.length-1]?.seq)||ar,Fn.length<5e3)break}pt&&Lt.length&&(it.loadHistory(Lt),o()(Lt))})(),()=>{pt=!1}}),ge(()=>{let se=r(u),fe=r(g);if(!(!se||!fe))return or.subscribe(Xo(se),it=>{!i()||a()||r(P)||fe.push(as(it))})}),ge(()=>{let se=r(u);if(!se)return;let fe=!0;return Ge(`/players/${se}/lifecycle`).then(it=>{fe&&E(Z,it||[],!0)}).catch(()=>{fe&&E(Z,[],!0)}),()=>{fe=!1}}),ge(()=>{let se=r(u);if(se)return Zr(()=>Ko(se),fe=>{fe?.seq&&E(Z,r(Z).some(it=>it.seq===fe.seq)?r(Z):[...r(Z),fe],!0)})});function Se(se){E(Y,{full:se},!0);let fe=as(se);fe.seq===r(A)&&c()(fe)}async function nt(se,fe,it,pt){let Lt=Ld(se,it);if(Lt)return Lt;let ar=await Ge(`/connections/${fe}/packets/${it}`,{signal:pt});return Vv(se,it,ar),ar}ge(()=>{let se=r(A),fe=r(u),it=r($);if(se==null||!fe||!it)return;let pt=Ld(fe,se);if(pt){Se(pt);return}let Lt=!0;E(Y,{loading:!0},!0);let ar=new AbortController,Fn=setTimeout(()=>{nt(fe,it,se,ar.signal).then(Bn=>{Lt&&Se(Bn)}).catch(Bn=>{if(!Lt||ar.signal.aborted)return;let ei=Bn;E(Y,{error:ei.status===404?`Packet #${se} not in memory or archive`:ei.message||String(Bn)},!0)})},80);return()=>{Lt=!1,ar.abort(),clearTimeout(Fn)}}),ge(()=>{E(K,null);let se=r(Ce),fe=r(u),it=r($);if(!se||!fe||!it)return;let pt=!0,Lt=new AbortController;return nt(fe,it,se.seq,Lt.signal).then(ar=>{pt&&E(K,ar.record??null,!0)}).catch(()=>{}),()=>{pt=!1,Lt.abort()}}),ge(()=>{let se=Number(hi.current.query?.seq);!Number.isFinite(se)||se<=0||se===r(A)||Ie(se,{expand:!0,syncUrl:!1})}),ge(()=>{if(r(P)||!r(m))return;let se=qe(r(m));if(!se)return;let fe=r(me)(se);for(let it of r(ke))if(it.matcher?.(fe)){E(P,!0),ft("Breakpoint hit at #"+r(m),"warn");break}});function ut(se){let fe=se.target;if(!(fe instanceof HTMLInputElement||fe instanceof HTMLTextAreaElement))switch(se.key){case" ":se.preventDefault(),E(P,!r(P));break;case"ArrowDown":case"j":se.preventDefault(),Ee(1);break;case"ArrowUp":case"k":se.preventDefault(),Ee(-1);break;case"ArrowRight":se.preventDefault(),Ee(se.shiftKey?10:1);break;case"ArrowLeft":se.preventDefault(),Ee(se.shiftKey?-10:-1);break;case"f":case"F":We();break;case"b":case"B":Oe();break;case"c":case"C":E(L,!r(L));break;case"Escape":st(),E(M,!1);break;case"?":E(M,!r(M));break;case"/":se.preventDefault(),r(te)?.focus();break}}ge(()=>{let se=Zs[r(j)];if(!r(V))return;let fe=r(V).style;fe.setProperty("--acc",se.acc),fe.setProperty("--acc-deep",se.deep),fe.setProperty("--acc-soft",`color-mix(in oklab, ${se.acc} 14%, transparent)`),fe.setProperty("--acc-line",`color-mix(in oklab, ${se.acc} 35%, transparent)`),fe.setProperty("--acc-glow",`color-mix(in oklab, ${se.acc} 55%, transparent)`)});var vt=Mx();Rt("keydown",Ms,ut);var Ct=l(vt);{let se=x(()=>r(S).some(fe=>fe.enabled));Od(Ct,{get query(){return r(h)},get parsed(){return r(oe)},get live(){return r(mt)},get paused(){return r(P)},get rate(){return r(Q)},get totalPackets(){return r($e).length},get jump(){return r(k)},get breakOn(){return r(se)},onQuery:fe=>{E(h,fe,!0)},onPaused:fe=>{E(P,fe,!0)},onStep:Ee,onLive:We,onJump:Ye,onJumpChange:fe=>{E(k,fe,!0)},onHelp:()=>{E(M,!0)},onTweaks:()=>{E(q,!r(q))},get searchRef(){return r(te)},set searchRef(fe){E(te,fe,!0)}})}var Dt=d(Ct,2);{let se=x(()=>[...r(Ne)]);Dd(Dt,{get tape(){return r(g)},get tapeVersion(){return r(v)},get bookmarks(){return r(O)},get breakpoints(){return r(Ze)},get lifecycle(){return r(je)},get playhead(){return r(A)},get viewStart(){return r(Ke)[0]},get viewEnd(){return r(Ke)[1]},get related(){return r(se)},onSeek:fe=>{Ie(r(g)?.nearestSeq(fe)??fe),E(P,!0)}})}var Vt=d(Dt,2);let ct;var Mt=l(Vt);{let se=x(()=>r(Ae)?.seq??null);Fd(Mt,{get tab(){return r(B)},get rows(){return r($e)},get filters(){return r(b)},get classCounts(){return r(he)},get classFilter(){return r(w)},get classQuery(){return r(C)},get bookmarks(){return r(O)},get breakpoints(){return r(Ze)},get saved(){return r(R)},get currentSeq(){return r(se)},get currentQuery(){return r(h)},onSetTab:fe=>{E(B,fe,!0)},onSetFilter:$t,onSetClassFilter:fe=>{E(w,fe,!0)},onSetClassQuery:fe=>{E(C,fe,!0)},onJumpBookmark:Ie,onAddBookmark:fe=>{E(O,[...r(O),fe],!0)},onRemoveBookmark:fe=>{E(O,r(O).filter((it,pt)=>pt!==fe),!0)},onToggleBreakpoint:fe=>{E(S,r(S).map((it,pt)=>pt===fe?{...it,enabled:!it.enabled}:it),!0)},onAddBreakpoint:fe=>{E(S,[...r(S),{...fe,id:"b"+Date.now()}],!0)},onRemoveBreakpoint:fe=>{E(S,r(S).filter((it,pt)=>pt!==fe),!0)},onLoadSaved:fe=>{E(h,fe,!0)},onAddSaved:fe=>{E(R,[...r(R),fe],!0)},onRemoveSaved:fe=>{E(R,r(R).filter((it,pt)=>pt!==fe),!0)}})}var It=d(Mt,2),qt=l(It);{let se=x(()=>r(H)==="compact"?22:r(H)==="roomy"?32:26);Bd(qt,{get entries(){return r(Le)},get playhead(){return r(A)},get multi(){return r(I)},get related(){return r(Ne)},get classColors(){return r(be)},get scrollToken(){return r(D)},get rowHeight(){return r(se)},onSelect:Ie,onShiftSelect:Je,onContext:rt,onExpandGroup:fe=>{E(L,!0),Ie(fe)}})}s(It);var nr=d(It,2),Wt=d(nr,2),Ft=d(Wt,2);{let se=x(()=>r(A)??0);zd(Ft,{get row(){return r(Ae)},get seq(){return r(se)},get record(){return r(Y)},get prevSameClass(){return r(Ce)},get prevRecord(){return r(K)},get related(){return r(Fe)},get isBookmarked(){return r(Ve)},onClose:st,onJumpSeq:Ie,onStep:Ee,onToggleBookmark:Oe,onCopyClass:ht,onBreakOnClass:wt})}s(Vt);var Pt=d(Vt,2),Kt=l(Pt),Ht=d(l(Kt)),cn=l(Ht,!0);s(Ht);var Qr=d(Ht);s(Kt);var $n=d(Kt,4),en=d(l($n)),Ur=l(en,!0);s(en),s($n);var rr=d($n,2),_n=d(l(rr)),Ja=l(_n,!0);s(_n),s(rr);var In=d(rr,4),Mi=d(l(In)),Qa=l(Mi,!0);s(Mi),s(In);var Da=d(In,4),On=d(l(Da),2),Pi=l(On,!0);s(On);var Ll=d(On,2);{var Il=se=>{var fe=Ax(),it=l(fe);s(fe),T(()=>y(it,`(+${r(I).size-1} multi)`)),f(se,fe)};z(Ll,se=>{r(I).size>1&&se(Il)})}s(Da);var ps=d(Da,4),oo=d(l(ps)),xe=l(oo,!0);s(oo),s(ps);var _t=d(ps,2),Xt=d(l(_t)),cr=l(Xt,!0);s(Xt),s(_t);var dr=d(_t,2),Dn=d(l(dr),4),ea=d(l(Dn));s(Dn),ve(4),s(dr),s(Pt);var ta=d(Pt,2);{var Fa=se=>{qd(se,{onClose:()=>{E(M,!1)}})};z(ta,se=>{r(M)&&se(Fa)})}var gn=d(ta,2);{var Fr=se=>{Hd(se,{get accent(){return r(j)},get density(){return r(H)},get collapse(){return r(F)},onAccent:fe=>{E(j,fe,!0)},onDensity:fe=>{E(H,fe,!0)},onToggleCollapse:()=>{E(F,!r(F)),E(L,!1)},onReset:re,onClose:()=>{E(q,!1)}})};z(gn,se=>{r(q)&&se(Fr)})}s(vt),Tt(vt,se=>E(V,se),()=>r(V)),T((se,fe,it,pt,Lt,ar)=>{ne(vt,"data-density",r(H)),ct=we(Vt,"",ct,{"--pt-inspector-w":r(N)+"px","--pt-inspector-h":r(G)+"px"}),y(cn,se),y(Qr,` / ${fe??""}`),y(Ur,it),y(Ja,pt),y(Qa,Lt),y(Pi,r(Ae)?"#"+r(Ae).seq:"\u2014"),y(xe,r(O).length),y(cr,ar),y(ea,` ${r(P)?"resume":"pause"}`)},[()=>r(De).length.toLocaleString(),()=>r($e).length.toLocaleString(),()=>r(ot).toLocaleString(),()=>(r(De).length-r(ot)).toLocaleString(),()=>Jo(r(Be)),()=>r(S).filter(se=>se.enabled).length]),U("pointerdown",nr,se=>Qe("x",se)),U("pointerdown",Wt,se=>Qe("y",se)),f(t,vt),ce()}Pe(["pointerdown"]);var Rx=[{group:"Identity",items:[{key:"username",label:"Name",get:t=>t.username},{key:"protocolVersion",label:"Protocol",get:t=>t.protocolVersion},{key:"clientBrand",label:"Brand",get:t=>t.clientBrand},{key:"locale",label:"Locale",get:t=>t.locale}]},{group:"World",items:[{key:"dimension",label:"Dimension",get:t=>(t.dimension||"").replace("minecraft:","")},{key:"gamemode",label:"Gamemode",get:t=>t.gamemode}]},{group:"Vitals",items:[{key:"health",label:"HP",get:t=>(t.health??0).toFixed(1)+" / "+(t.maxHealth??20)},{key:"food",label:"Food",get:t=>(t.food??0)+" / 20"},{key:"xpLevel",label:"XP Lvl",get:t=>t.xpLevel??0},{key:"xpBar",label:"XP Bar",get:t=>Math.round((t.xpBar??0)*100)+"%"}]},{group:"Position",items:[{key:"posX",label:"X",get:t=>(t.posX??0).toFixed(2)},{key:"posY",label:"Y",get:t=>(t.posY??0).toFixed(2)},{key:"posZ",label:"Z",get:t=>(t.posZ??0).toFixed(2)},{key:"yaw",label:"Yaw",get:t=>(t.yaw??0).toFixed(1)+"\xB0"},{key:"pitch",label:"Pitch",get:t=>(t.pitch??0).toFixed(1)+"\xB0"}]},{group:"Network",items:[{key:"traffic.pingMs",label:"Ping",get:t=>t.traffic.pingMs+" ms"}]}],em={health:t=>t.health,food:t=>t.food,xpLevel:t=>t.xpLevel,xpBar:t=>t.xpBar,"traffic.pingMs":t=>t.traffic.pingMs,posX:t=>t.posX,posY:t=>t.posY,posZ:t=>t.posZ,yaw:t=>t.yaw,pitch:t=>t.pitch},Nx=_('
        '),Lx=_('
        '),Ix=_('
        '),Ox=_('
        '),Dx={hash:"svelte-9fwvhw",code:` + @layer pages { + /* ---- Per-player Packets tab (state mirror) ---------------------- */.ppk-shell {display:grid;grid-template-columns:minmax(0, 1fr) minmax(280px, 360px);gap:var(--pad-3);align-items:start;}.ppk-aggs {display:grid;grid-template-columns:1fr 1fr;gap:var(--pad-3);min-width:0;} + + @media (max-width: 1100px) {.ppk-shell {grid-template-columns:1fr;}.ppk-aggs {grid-template-columns:1fr;} + }.ppk-state-mirror {min-width:0;position:sticky;top:var(--pad-3);.ppk-state-mirror__group {padding:6px var(--pad-3);font-size:var(--t-xs);color:var(--ink-3);text-transform:uppercase;background:var(--bg-2);border-bottom:1px solid var(--line);border-top:1px solid var(--line);&:first-child {border-top:none;}}.ppk-state-mirror__field {display:grid;grid-template-columns:90px minmax(0, 1fr) auto;align-items:center;column-gap:var(--pad-2);padding:6px var(--pad-3);border-bottom:1px solid var(--line);transition:background var(--motion);min-height:26px;&.flashed { + animation: ppk-flash 700ms ease-out;.ppk-state-mirror__val {color:var(--acc);}}&.linked {background:color-mix(in oklab, var(--acc) 14%, transparent);}}.ppk-state-mirror__lbl {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.ppk-state-mirror__val {text-align:right;color:var(--ink);font-variant-numeric:tabular-nums;font-size:var(--t-sm);white-space:nowrap;transition:color var(--motion);}.ppk-state-mirror__trail {height:14px;min-width:0;align-self:center;canvas {width:100%;height:100%;display:block;}}} + + @keyframes ppk-flash { + 0% { background: color-mix(in oklab, var(--acc) 36%, transparent); } + 50% { background: color-mix(in oklab, var(--acc) 18%, transparent); } + 100% { background: transparent; } + } + }`};function Ud(t,e){le(e,!0),Ut(t,Dx);let n=ae(e,"paused",3,!1),a=X({}),i=X({}),o=X(tt(Date.now())),c=X(""),p=X("count"),u=X(!0),$=X(!0);ge(()=>{let F=e.player?.uuid;if(E($,!e.player?.disconnectedAt),!!F)return or.subscribe(gr.players,L=>{L.uuid===F&&L.event==="disconnect"&&E($,!1)})});let g=Cv(()=>e.player?.uuid&&e.player?.connectionId?[{uuid:e.player.uuid,connectionId:e.player.connectionId}]:[],{lanes:!1,resetKey:()=>e.player?.uuid??null,enabled:()=>!n()&&r($),history:!1,onRow:F=>v(F)});function v(F){for(let L of ss(F.className))E(a,{...r(a),[L]:Date.now()});E(c,F.className,!0)}let m=x(()=>new Set(ss(r(c))));ge(()=>{let F=setInterval(()=>{if(n())return;let L=e.player;if(!L)return;let B={...r(i)};for(let O in em){let S=em[O](L);if(typeof S!="number")continue;let R=(B[O]||[]).slice();R.push(S),R.length>60&&R.shift(),B[O]=R}E(i,B)},500);return()=>clearInterval(F)}),ge(()=>{let F=setInterval(()=>{n()||E(o,Date.now(),!0)},300);return()=>clearInterval(F)});var h=Ox(),b=l(h),w=l(b),C=l(w);Ws(C,{get agg(){return g.agg},get sortBy(){return r(p)},get version(){return g.version},topMeta:"this player",get heatmapMeta(){return r(p)},max:10,onSortBy:F=>E(p,F,!0)}),s(w);var A=d(w,2);et(A,{title:"Packet trace",meta:"seq tape \xB7 Space pause \xB7 \u2190\u2192 step",flush:!0,headless:!0,children:(F,L)=>{jd(F,{get player(){return e.player},get paused(){return n()},get streamLive(){return r($)},onHistory:B=>g.ingestRows(B,e.player?.uuid??""),onPlayheadChange:B=>{B&&E(c,B.className,!0)},onResetFeed:()=>g.reset()})},$$slots:{default:!0}}),s(b);var I=d(b,2),P=l(I);{let F=B=>{var O=Nx(),S=l(O),R=d(S,2);s(O),T(()=>{pe(S,1,St(r(u)?"ghost sm":"primary sm")),pe(R,1,St(r(u)?"primary sm":"ghost sm"))}),U("click",S,()=>E(u,!1)),U("click",R,()=>E(u,!0)),f(B,O)},L=x(()=>r(u)?"trails on":"flashes only");et(P,{title:"State at playhead",get meta(){return r(L)},flush:!0,actions:F,children:(B,O)=>{var S=Me(),R=ie(S);de(R,17,()=>Rx,k=>k.group,(k,M)=>{var q=Ix(),j=l(q),H=l(j,!0);s(j);var D=d(j,2);de(D,17,()=>r(M).items,N=>N.key,(N,G)=>{let Y=x(()=>r(a)[r(G).key]),K=x(()=>r(Y)&&r(o)-r(Y)<700),Z=x(()=>r(i)[r(G).key]),Q=x(()=>r(u)&&r(Z)&&r(Z).length>4),ee=x(()=>r(m).has(r(G).key)),J=x(()=>e.player?.provenance?.[r(G).key]),te=x(()=>r(G).get(e.player));var V=Lx(),W=l(V),re=l(W,!0);s(W);var oe=d(W,2),ue=l(oe);{var $e=be=>{{let ye=x(()=>r(K)?"var(--acc)":"var(--ink-3)");xi(be,{get data(){return r(Z)},get color(){return r(ye)}})}};z(ue,be=>{r(Q)&&be($e)})}s(oe);var me=d(oe,2),he=l(me,!0);s(me),s(V),T(be=>{pe(V,1,"ppk-state-mirror__field"+(r(K)?" flashed":"")+(r(ee)?" linked":"")),ne(V,"title",be),y(re,r(G).label),y(he,r(te)==null||r(te)===""?"\xB7":r(te))},[()=>r(J)?`${ua(r(J).packetClass||"").replace(/Packet$/,"")} #${r(J).seq} \xB7 ${vn(r(o)-r(J).ts)} ago`:"no source"]),f(N,V)}),s(q),T(()=>y(H,r(M).group)),f(k,q)}),f(B,S)},$$slots:{actions:!0,default:!0}})}s(I),s(h),f(t,h),ce()}Pe(["click"]);var Js=[{id:"players",label:"Players",glyph:"\u25C6",color:"oklch(78% 0.18 148)"},{id:"hostile",label:"Hostile",glyph:"\u25B2",color:"oklch(70% 0.20 25)"},{id:"passive",label:"Passive",glyph:"\u25A0",color:"oklch(78% 0.16 70)"},{id:"items",label:"Items",glyph:"+",color:"oklch(74% 0.16 200)"},{id:"projectiles",label:"Projectiles",glyph:"\xB7",color:"oklch(80% 0.18 80)"},{id:"vehicles",label:"Vehicles",glyph:"\u25C7",color:"oklch(72% 0.17 270)"}],tm=Object.fromEntries(Js.map(t=>[t.id,t.color])),Fx=Object.fromEntries(Js.map(t=>[t.id,t.glyph])),Bx=[32,64,128,256];function Vd(t,e,n){let a=t.x-e,i=t.z-n;return Math.hypot(a,i)}function zx(t,e){let n=Number(t),a=Number(e);if(!Number.isFinite(n)||!Number.isFinite(a))return null;let i=a-n;if(i===0)return{text:"\xB7",sign:"zero"};let o=i>0?"pos":"neg",c=Math.abs(i),p=Number.isInteger(i)?c.toString():c.toFixed(2).replace(/\.?0+$/,"");return{text:(i>0?"+":"\u2212")+p,sign:o}}var qx=_('
        '),Hx=_(''),jx=_('
        '),Ux=_(""),Vx=_('
        '),Yx=sn(''),Gx=sn(' '),Wx=sn('',1),Kx=sn(""),Xx=sn(''),Zx=sn('',1),Jx=_(' '),Qx=_('
        N
        '),ey=_('
        '),ty=_('
        No entities match.
        '),ry=_('
        '),ny=_(''),ay=_('
        Loading detail\u2026
        '),iy=_('
        Loading detail\u2026
        '),sy=_('
        '),oy=_('
        Entity is no longer in view.
        '),ly=_(' '),cy=_('
        '),dy=_('
        No fields tracked yet.
        '),py=_('
        \u2192
        '),uy=_('
        No mutations yet.
        '),fy=_('
        Field state
        '),vy=_('
        '),my={hash:"svelte-18rq15w",code:` + @layer pages { + /* ---- Profile \xB7 Entities tab ------------------------------------ */.ent-shell {display:grid;gap:var(--pad-3);}.ent-body {display:grid;grid-template-columns:minmax(280px, 420px) minmax(0, 1fr);gap:var(--pad-3);align-items:start;} + @media (max-width: 1100px) {.ent-body {grid-template-columns:1fr;} }.ent-filters {display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:var(--pad-3);border-bottom:1px solid var(--line);}.ent-filter {display:inline-flex;align-items:center;gap:6px;padding:4px 8px;font-size:var(--t-xs);line-height:1;color:var(--ink-3);background:var(--bg-2);border:1px solid var(--line);text-transform:uppercase;cursor:pointer;user-select:none;transition:background var(--motion), color var(--motion), border-color var(--motion);&:hover {color:var(--ink);border-color:var(--line-2);}&.on {color:var(--gc);border-color:var(--gc);background:color-mix(in oklab, var(--gc) 14%, var(--bg-1));.ent-filter__count {background:var(--gc);color:var(--bg-0);}}.ent-filter__glyph {color:var(--gc);font-size:var(--t-sm);}.ent-filter__count {color:var(--ink-2);font-variant-numeric:tabular-nums;background:var(--bg-3);padding:0 6px;}}.ent-search {margin-left:auto;min-width:180px;padding:4px 8px;font-size:var(--t-xs);}.ent-radar {padding:var(--pad-3);display:grid;grid-template-rows:1fr auto;gap:var(--pad-2);svg {width:100%;aspect-ratio:1;display:block;background:var(--sunk);box-shadow:var(--bevel-sunk);}.ent-radar__legend {display:flex;flex-wrap:wrap;gap:6px var(--pad-3);font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.ent-radar__lg {display:inline-flex;align-items:center;gap:4px;}.ent-radar__lg-sw {width:8px;height:8px;display:inline-block;}}.ent-list {display:grid;grid-template-columns:repeat(auto-fill, minmax(220px, 1fr));gap:1px;padding:1px;background:var(--line);max-height:640px;overflow:auto;}.ent-card {display:grid;grid-template-columns:26px minmax(0, 1fr);gap:var(--pad-2);padding:6px var(--pad-2);background:var(--bg-1);border-left:2px solid transparent;cursor:pointer;transition:background var(--motion), border-color var(--motion);min-width:0;&:hover {background:var(--bg-2);}&.on {background:color-mix(in oklab, var(--gc) 12%, var(--bg-1));border-left-color:var(--gc);opacity:1 !important;}.ent-card__glyph {display:grid;place-items:center;width:26px;height:26px;background:color-mix(in oklab, var(--gc) 18%, var(--bg-2));color:var(--gc);font-size:var(--t-md);line-height:1;align-self:center;}.ent-card__body {display:grid;gap:1px;min-width:0;}.ent-card__row1, .ent-card__row2 {display:flex;align-items:baseline;justify-content:space-between;gap:4px;min-width:0;}.ent-card__row2 {font-size:var(--t-xs);color:var(--ink-4);font-variant-numeric:tabular-nums;}.ent-card__type {color:var(--ink);font-size:var(--t-sm);line-height:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.ent-card__id {color:var(--ink-4);font-size:var(--t-xs);font-variant-numeric:tabular-nums;flex-shrink:0;}.ent-card__pos {color:var(--ink-3);}.ent-card__dist {color:var(--gc);font-variant-numeric:tabular-nums;}}.ent-detail {display:grid;gap:var(--pad-3);.ent-detail__meta {display:flex;flex-wrap:wrap;align-items:baseline;gap:var(--pad-3);padding:var(--pad-3) var(--pad-3) 0;font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.ent-detail__type {color:var(--ink);font-size:var(--t-md);line-height:1;text-transform:none;}.ent-detail__id {color:var(--acc);}.ent-detail__uuid, .ent-detail__pos {color:var(--ink-3);font-variant-numeric:tabular-nums;text-transform:none;}.ent-detail__cols {display:grid;grid-template-columns:minmax(0, 1fr) minmax(0, 1.4fr);gap:var(--pad-3);padding:0 var(--pad-3) var(--pad-3);}.ent-detail__h {font-size:var(--t-xs);color:var(--ink-3);text-transform:uppercase;margin-bottom:6px;}.ent-detail__list {display:grid;gap:1px;background:var(--line);max-height:320px;overflow:auto;}.ent-detail__row, .ent-detail__chg {display:grid;align-items:baseline;gap:var(--pad-2);padding:6px var(--pad-3);background:var(--bg-1);font-size:var(--t-xs);line-height:1;}.ent-detail__row {grid-template-columns:80px minmax(0, 1fr);.lbl {color:var(--ink-4);text-transform:uppercase;}.src {color:var(--ink-2);.acc {color:var(--acc);}}}.ent-detail__chg {grid-template-columns:60px 90px minmax(0, 1fr) 56px;.t {color:var(--ink-4);font-variant-numeric:tabular-nums;}.field {color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.diff {display:inline-flex;align-items:baseline;gap:4px;min-width:0;overflow:hidden;}.from {color:var(--ink-4);text-decoration:line-through;text-decoration-color:color-mix(in oklab, var(--danger) 50%, transparent);}.arrow {color:var(--ink-4);flex-shrink:0;}.to {color:var(--acc);font-variant-numeric:tabular-nums;}.delta {text-align:right;font-variant-numeric:tabular-nums;color:var(--ink-4);font-size:var(--t-xs);&.pos {color:var(--acc);}&.neg {color:var(--danger);}&.zero {color:var(--ink-4);}}}} + @media (max-width: 1100px) {.ent-detail__cols {grid-template-columns:1fr;} + } + }`};function Yd(t,e){le(e,!0),Ut(t,my);let n=ae(e,"paused",3,!1),a=x(()=>e.player?.uuid),i=x(()=>{let k=String(r(a)||"").toLowerCase();return(e.player?.visibleEntities||[]).filter(M=>!M.uuid||String(M.uuid).toLowerCase()!==k)}),o=x(()=>e.player?.posX??0),c=x(()=>e.player?.posZ??0),p=X(null),u=X(""),$=X("distance"),g=X(null),v=X(128),m=X(null),h=x(()=>{let k=Object.fromEntries(Js.map(M=>[M.id,0]));for(let M of r(i))k[M.group]!==void 0&&k[M.group]++;return k}),b=x(()=>{let k=r(u).toLowerCase(),M=r(i).filter(q=>!(r(p)&&q.group!==r(p)||k&&!(q.type||"").toLowerCase().includes(k)&&!String(q.id).includes(k)));return r($)==="distance"?M.sort((q,j)=>Vd(q,r(o),r(c))-Vd(j,r(o),r(c))):r($)==="type"?M.sort((q,j)=>(q.type||"").localeCompare(j.type||"")):r($)==="id"&&M.sort((q,j)=>q.id-j.id),M});ge(()=>{if(r(g)==null||!r(a)){E(m,null);return}let k=!0,M=!0,q=async()=>{try{let H=await Ge(`/players/${r(a)}/entities/${r(g)}`);if(!k)return;E(m,{data:H},!0)}catch(H){k&&M&&E(m,{error:String(H.message||H)},!0)}finally{M=!1}};E(m,{loading:!0},!0),q();let j=n()?null:setInterval(q,1e3);return()=>{k=!1,j&&clearInterval(j)}});function w(k){let M=k.x-r(o),q=k.z-r(c),j=Math.hypot(M,q);return j>r(v)?null:{x:M/r(v)*95,y:q/r(v)*95,d:j}}let C=x(()=>e.player?.yaw||0),A=x(()=>e.player?.posX!=null),I=x(()=>Xr.now);var P=vy(),F=l(P);{let k=q=>{var j=qx(),H=l(j),D=d(H,2),N=d(D,2);s(j),T(()=>{pe(H,1,St(r($)==="distance"?"primary sm":"ghost sm")),pe(D,1,St(r($)==="type"?"primary sm":"ghost sm")),pe(N,1,St(r($)==="id"?"primary sm":"ghost sm"))}),U("click",H,()=>E($,"distance")),U("click",D,()=>E($,"type")),U("click",N,()=>E($,"id")),f(q,j)},M=x(()=>`${r(i).length} tracked`);et(F,{title:"Entities in view",get meta(){return r(M)},flush:!0,actions:k,children:(q,j)=>{var H=jx(),D=l(H);de(D,17,()=>Js,G=>G.id,(G,Y)=>{var K=Hx();let Z;var Q=l(K),ee=l(Q,!0);s(Q);var J=d(Q,2),te=l(J,!0);s(J);var V=d(J,2),W=l(V,!0);s(V),s(K),T(()=>{pe(K,1,"ent-filter"+(r(p)===r(Y).id?" on":"")),Z=we(K,"",Z,{"--gc":r(Y).color}),y(ee,r(Y).glyph),y(te,r(Y).label),y(W,r(h)[r(Y).id]||0)}),U("click",K,()=>E(p,r(p)===r(Y).id?null:r(Y).id,!0)),f(G,K)});var N=d(D,2);s(H),U("input",N,G=>E(u,G.target.value,!0)),f(q,H)},$$slots:{actions:!0,default:!0}})}var L=d(F,2),B=l(L);{let k=q=>{var j=Vx();de(j,20,()=>Bx,H=>H,(H,D)=>{var N=Ux(),G=l(N,!0);s(N),T(()=>{pe(N,1,St(r(v)===D?"primary sm":"ghost sm")),y(G,D)}),U("click",N,()=>E(v,D,!0)),f(H,N)}),s(j),f(q,j)},M=x(()=>`${r(v)} blocks`);et(B,{title:"Radar",get meta(){return r(M)},flush:!0,actions:k,children:(q,j)=>{var H=Qx(),D=l(H),N=l(D);de(N,16,()=>[25,50,75,100],Q=>Q,(Q,ee)=>{var J=Yx();T(()=>ne(J,"r",ee)),f(Q,J)});var G=d(N,3);de(G,16,()=>[25,50,75,100],Q=>Q,(Q,ee)=>{var J=Gx(),te=l(J,!0);s(J),T(V=>{ne(J,"y",-ee-1.5),y(te,V)},[()=>Math.round(ee/100*r(v))]),f(Q,J)});var Y=d(G);{var K=Q=>{var ee=Zx(),J=ie(ee);de(J,17,()=>r(b),V=>V.id,(V,W)=>{let re=x(()=>w(r(W)));var oe=Me(),ue=ie(oe);{var $e=me=>{let he=x(()=>tm[r(W).group]||"var(--ink-3)"),be=x(()=>r(g)===r(W).id);var ye=Xx(),ze=l(ye);{var Re=Be=>{var ot=Wx(),Ke=ie(ot),ke=d(Ke);ne(ke,"r",3),T(()=>{ne(Ke,"cx",r(re).x),ne(Ke,"cy",r(re).y),ne(Ke,"stroke",r(he)),ne(ke,"cx",r(re).x),ne(ke,"cy",r(re).y),ne(ke,"fill",r(he))}),f(Be,ot)},De=Be=>{var ot=Kx();ne(ot,"r",1.8),ne(ot,"opacity",.85),T(()=>{ne(ot,"cx",r(re).x),ne(ot,"cy",r(re).y),ne(ot,"fill",r(he))}),f(Be,ot)};z(ze,Be=>{r(be)?Be(Re):Be(De,-1)})}s(ye),T(()=>ne(ye,"aria-label","Entity "+(r(W).type||"unknown")+(r(be)?" (selected)":""))),U("click",ye,()=>E(g,r(W).id,!0)),U("keydown",ye,Be=>{(Be.key==="Enter"||Be.key===" ")&&(Be.preventDefault(),E(g,r(W).id,!0))}),Rt("pointerenter",ye,Be=>nl({...r(W),distance:r(re).d},Be)),U("pointermove",ye,function(...Be){al?.apply(this,Be)}),Rt("pointerleave",ye,function(...Be){Ji?.apply(this,Be)}),f(me,ye)};z(ue,me=>{r(re)&&me($e)})}f(V,oe)});var te=d(J);T(()=>ne(te,"transform",`rotate(${r(C)+180})`)),f(Q,ee)};z(Y,Q=>{r(A)&&Q(K)})}ve(),s(D);var Z=d(D,2);de(Z,21,()=>Js,Q=>Q.id,(Q,ee)=>{var J=Jx(),te=l(J);let V;var W=d(te);s(J),T(()=>{V=we(te,"",V,{background:r(ee).color}),y(W,` ${r(ee).label??""}`)}),f(Q,J)}),s(Z),s(H),f(q,H)},$$slots:{actions:!0,default:!0}})}var O=d(B,2);{let k=x(()=>`${r(b).length} ${r(p)?"\xB7 "+r(p):""}`);et(O,{title:"Constellation",get meta(){return r(k)},flush:!0,children:(M,q)=>{var j=ry(),H=l(j);de(H,17,()=>r(b),G=>G.id,(G,Y)=>{let K=x(()=>Vd(r(Y),r(o),r(c))),Z=x(()=>Math.max(.4,1-r(K)/256)),Q=x(()=>tm[r(Y).group]||"var(--ink-3)");var ee=ey();let J;var te=l(ee),V=l(te,!0);s(te);var W=d(te,2),re=l(W),oe=l(re),ue=l(oe,!0);s(oe);var $e=d(oe,2),me=l($e);s($e),s(re);var he=d(re,2),be=l(he),ye=l(be);s(be);var ze=d(be,2),Re=l(ze,!0);s(ze),s(he),s(W),s(ee),T((De,Be,ot,Ke,ke)=>{pe(ee,1,"ent-card"+(r(g)===r(Y).id?" on":"")),J=we(ee,"",J,{"--gc":r(Q),opacity:r(Z)}),y(V,Fx[r(Y).group]||"\xB7"),y(ue,De),y(me,`#${r(Y).id??""}`),y(ye,`${Be??""} ${ot??""} ${Ke??""}`),y(Re,ke)},[()=>js(r(Y).type),()=>r(Y).x.toFixed(0),()=>r(Y).y.toFixed(0),()=>r(Y).z.toFixed(0),()=>r(K)<1?"\xB7":Math.round(r(K))+"m"]),U("click",ee,()=>E(g,r(g)===r(Y).id?null:r(Y).id,!0)),U("keydown",ee,De=>{(De.key==="Enter"||De.key===" ")&&(De.preventDefault(),E(g,r(g)===r(Y).id?null:r(Y).id,!0))}),f(G,ee)});var D=d(H,2);{var N=G=>{var Y=ty();f(G,Y)};z(D,G=>{r(b).length===0&&G(N)})}s(j),f(M,j)},$$slots:{default:!0}})}s(L);var S=d(L,2);{var R=k=>{et(k,{title:"Detail",meta:"live",flush:!0,actions:q=>{var j=ny();U("click",j,()=>E(g,null)),f(q,j)},children:(q,j)=>{var H=Me(),D=ie(H);{var N=Q=>{var ee=ay();f(Q,ee)},G=Q=>{var ee=iy();f(Q,ee)},Y=Q=>{var ee=sy(),J=l(ee,!0);s(ee),T(()=>y(J,r(m).error)),f(Q,ee)},K=Q=>{var ee=oy();f(Q,ee)},Z=Q=>{let ee=x(()=>r(m).data),J=x(()=>Object.entries(r(ee).provenance||{})),te=x(()=>(r(ee).changeLog||[]).slice().reverse().slice(0,20));var V=fy(),W=l(V),re=l(W),oe=l(re,!0);s(re);var ue=d(re,2),$e=l(ue);s(ue);var me=d(ue,2);{var he=He=>{var Ie=ly(),Je=l(Ie,!0);s(Ie),T($t=>y(Je,$t),[()=>String(r(ee).uuid).slice(0,8)]),f(He,Ie)};z(me,He=>{r(ee).uuid&&He(he)})}var be=d(me,2),ye=l(be);s(be);var ze=d(be,2),Re=l(ze);s(ze);var De=d(ze,2),Be=l(De);s(De),s(W);var ot=d(W,2),Ke=l(ot),ke=d(l(Ke),2),Ze=l(ke);de(Ze,17,()=>r(J),([He,Ie])=>He,(He,Ie)=>{var Je=x(()=>fr(r(Ie),2));let $t=()=>r(Je)[0],Ee=()=>r(Je)[1];var Ye=cy(),We=l(Ye),Oe=l(We,!0);s(We);var st=d(We,2),rt=l(st),ht=l(rt,!0);s(rt);var wt=d(rt);s(st),s(Ye),T((dt,Ue)=>{y(Oe,$t()),y(ht,dt),y(wt,` #${Ee().seq??""} \xB7 ${Ue??""} ago`)},[()=>ua(Ee().packetClass||"").replace(/Packet$/,""),()=>vn(r(I)-Ee().ts)]),f(He,Ye)});var je=d(Ze,2);{var Le=He=>{var Ie=dy();f(He,Ie)};z(je,He=>{r(J).length===0&&He(Le)})}s(ke),s(Ke);var qe=d(Ke,2),Ae=l(qe),Ce=l(Ae);s(Ae);var Fe=d(Ae,2),Ne=l(Fe);de(Ne,17,()=>r(te),lt,(He,Ie)=>{let Je=x(()=>zx(r(Ie).prev,r(Ie).value));var $t=py(),Ee=l($t),Ye=l(Ee);s(Ee);var We=d(Ee,2),Oe=l(We,!0);s(We);var st=d(We,2),rt=l(st),ht=l(rt,!0);s(rt);var wt=d(rt,4),dt=l(wt,!0);s(wt),s(st);var Ue=d(st,2),Qe=l(Ue,!0);s(Ue),s($t),T((Se,nt,ut)=>{y(Ye,`${Se??""} ago`),y(Oe,r(Ie).field),y(ht,nt),y(dt,ut),pe(Ue,1,"delta"+(r(Je)?.sign?" "+r(Je).sign:"")),y(Qe,r(Je)?.text??"")},[()=>vn(r(I)-(r(Ie).source?.ts||0)),()=>String(r(Ie).prev??"\u2014"),()=>String(r(Ie).value??"\u2014")]),f(He,$t)});var Ve=d(Ne,2);{var mt=He=>{var Ie=uy();f(He,Ie)};z(Ve,He=>{r(te).length===0&&He(mt)})}s(Fe),s(qe),s(ot),s(V),T((He,Ie,Je,$t)=>{y(oe,He),y($e,`#${r(ee).id??""}`),y(ye,`${Ie??""} \xB7 ${Je??""} \xB7 ${$t??""}`),y(Re,`spawn #${r(ee).spawnSeq??""}`),y(Be,`${r(ee).packetCount??""} packets`),y(Ce,`Recent changes \xB7 ${r(te).length??""}`)},[()=>js(r(ee).type),()=>r(ee).x?.toFixed(1),()=>r(ee).y?.toFixed(1),()=>r(ee).z?.toFixed(1)]),f(Q,V)};z(D,Q=>{r(m)?r(m).loading?Q(G,1):r(m).error?Q(Y,2):r(m).data?Q(Z,-1):Q(K,3):Q(N)})}f(q,H)},$$slots:{actions:!0,default:!0}})};z(S,k=>{r(g)!=null&&k(R)})}s(P),f(t,P),ce()}Pe(["click","input","keydown","pointermove"]);var $y=(t,e=At)=>{let n=x(()=>e()*100);var a=gy(),i=l(a);let o;s(a),T(c=>{ne(a,"title",c),o=we(i,"",o,{"--pct":r(n)+"%"})},[()=>`${r(n).toFixed(1)}% custom`]),f(t,a)},_y=(t,e=At,n=At)=>{let a=x(()=>os(e().id)||"minecraft"),i=x(()=>Gd(e().id));var o=yy();let c;var p=d(l(o),2),u=l(p,!0);s(p);var $=d(p,2),g=l($),v=l(g,!0);s(g);var m=d(g,2),h=l(m,!0);s(m),s($);var b=d($,2),w=l(b,!0);s(b),s(o),T(C=>{c=pe(o,1,"reg-entry",null,c,{"reg-entry--custom":!e().vanilla,"reg-entry--vanilla":e().vanilla}),y(u,C),pe(g,1,St(e().vanilla?"reg-ns reg-ns--vanilla":"reg-ns reg-ns--custom")),y(v,r(a)),y(h,r(i)),y(w,e().vanilla?"vanilla":"custom")},[()=>String(n()+1).padStart(3,"0")]),f(t,o)};function os(t){let e=t.indexOf(":");return e<0?"":t.slice(0,e)}function Gd(t){let e=t.indexOf(":");return e<0?t:t.slice(e+1)}var gy=_(''),hy=_(' :',1),by=_(' /',1),xy=_(''),yy=_('
      1. :
      2. '),wy=_('
         
        '),ky=_('
        Reading per-connection registry tables\u2026
        '),Ey=_(' ',1),Sy=_(`
        This connection runs only stock Mojang registries \u2014 every entry below is + baseline content. Custom registrations made via Minestom will surface here.
        `),Ty=_('
        '),Cy=_('
        '),Ay=_('
        Ambient to inspect them.
        '),My=_('
        Select a registry on the left.
        '),Py=_(' :',1),Ry=_(' :',1),Ny=_('No custom additions in this registry. Every entry here is baseline Mojang content \u2014 switch to All to inspect vanilla entries.',1),Ly=_(' Clear the filter or switch to All to widen the search.',1),Iy=_("No entries."),Oy=_('
        '),Dy=_(''),Fy=_(''),By=_('
        '),zy=_('
          ',1),qy=_('
          registry / custom of total
          ',1),Hy=_('
          Registries
          Entries
          Custom
          Vanilla
          ',1),jy=_('
          '),Uy={hash:"svelte-19hbmpk",code:` + @layer pages { + /* ---- Registries tab ("Registry Telescope") --------------------- * + * Vanilla entries are ambient noise; custom entries are signal. The single rule that + * does the heavy lifting is namespace coloring (.reg-ns--custom vs .reg-ns--vanilla): + * minecraft:* renders dim, anything else renders accent everywhere on the page. */.reg-shell {display:grid;gap:var(--pad-3);}.reg-horizon {display:grid;grid-template-columns:auto auto auto auto 1fr;gap:var(--pad-5);align-items:stretch;padding:var(--pad-3) var(--pad-4);background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);.reg-horizon__cell {display:grid;gap:4px;align-content:center;padding-right:var(--pad-5);border-right:1px solid var(--line);position:relative;&:nth-last-of-type(2) {border-right:0;padding-right:0;}.reg-horizon__cell--big .reg-horizon__num {font-size:var(--t-3xl);color:var(--ink);}.reg-horizon__cell--accent .reg-horizon__num {color:var(--acc);}}.reg-horizon__num {font-size:var(--t-2xl);line-height:1;color:var(--ink);font-variant-numeric:tabular-nums;display:inline-flex;align-items:baseline;gap:8px;.reg-horizon__num--dim {color:var(--ink-3);}}.reg-horizon__dot {width:8px;height:8px;background:var(--acc);box-shadow:0 0 12px color-mix(in oklab, var(--acc) 60%, transparent);align-self:center; + animation: reg-pulse 2.4s infinite ease-in-out;}.reg-horizon__lbl {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.reg-horizon__spectrum {align-content:center;min-width:0;}.reg-horizon__spectrum-num {color:var(--acc);font-size:var(--t-md);line-height:1;font-variant-numeric:tabular-nums;text-transform:none;}} + + @keyframes reg-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } + }.reg-body {display:grid;grid-template-columns:minmax(280px, 340px) 1fr;gap:var(--pad-3);align-items:start;min-height:480px;} + @media (max-width: 1100px) {.reg-body {grid-template-columns:1fr;} }.reg-rail {background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);display:flex;flex-direction:column;min-width:0;.reg-rail__hd {display:flex;align-items:center;gap:8px;padding:var(--pad-2) var(--pad-3);border-bottom:1px solid var(--line);background:var(--bg-2);font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-3);.reg-rail__hd--ambient {margin-top:var(--pad-2);background:transparent;border-top:1px dashed var(--line);border-bottom:0;}}.reg-rail__hd-toggle {all:unset;cursor:pointer;display:flex;align-items:center;gap:8px;width:100%;padding:var(--pad-2) var(--pad-3);margin:calc(-1 * var(--pad-2)) calc(-1 * var(--pad-3));&:hover {color:var(--ink);}}.reg-rail__hd-mark {color:var(--acc);font-size:var(--t-xs);line-height:1;.reg-rail__hd-mark--dim {color:var(--ink-4);}}.reg-rail__hd-lbl {flex:0 0 auto;}.reg-rail__hd-count {margin-left:auto;color:var(--ink-4);font-variant-numeric:tabular-nums;}.reg-rail__hd-chev {color:var(--ink-4);font-size:var(--t-xs);}.reg-rail__list {display:flex;flex-direction:column;padding:4px 0;overflow:auto;max-height:360px;.reg-rail__list--ambient {max-height:280px;}}.reg-rail__hint {padding:var(--pad-3) var(--pad-3) var(--pad-4);color:var(--ink-4);font-size:var(--t-xs);line-height:1;em {color:var(--ink-2);font-style:normal;}.reg-rail__hint--ambient {padding:var(--pad-2) var(--pad-3) var(--pad-3);font-style:italic;}}.reg-rail__row {all:unset;cursor:pointer;display:grid;grid-template-columns:14px 1fr auto auto;gap:8px;align-items:center;padding:6px 10px 6px 8px;font-size:var(--t-sm);color:var(--ink-2);position:relative;transition:background var(--motion), color var(--motion);&:hover {background:var(--bg-2);color:var(--ink);}&:focus-visible {outline:1px solid var(--acc);outline-offset:-1px;}&.on {background:var(--acc-soft);color:var(--ink);&::before {content:"";position:absolute;left:0;top:0;bottom:0;width:2px;background:var(--acc);}}&.dim {color:var(--ink-3);.reg-rail__mark {color:var(--ink-4);}&:hover {color:var(--ink-2);}}}.reg-rail__mark {color:var(--acc);font-size:var(--t-xs);line-height:1;text-align:center;}.reg-rail__name {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.reg-rail__colon {color:var(--ink-4);margin:0 1px;}.reg-rail__path {color:inherit;}.reg-rail__counts {display:inline-flex;align-items:baseline;font-variant-numeric:tabular-nums;font-size:var(--t-xs);color:var(--ink-4);}.reg-rail__custom {color:var(--acc);}.reg-rail__slash {padding:0 2px;color:var(--ink-4);}.reg-rail__total {color:var(--ink-3);}} + + /* Micro density meter \u2014 narrow bar showing custom proportion. */.reg-meter {display:inline-block;width:28px;height:4px;background:var(--bg-3);border:1px solid var(--line);position:relative;align-self:center;.reg-meter__fill {position:absolute;inset:0 auto 0 0;width:var(--pct, 0%);background:var(--acc);transition:width 180ms ease-out;}}.reg-scope {background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);display:flex;flex-direction:column;min-width:0;.reg-scope__hd {display:grid;grid-template-columns:1fr auto;gap:var(--pad-3);padding:var(--pad-3) var(--pad-4);border-bottom:1px solid var(--line);background:var(--bg-1);}.reg-scope__title {min-width:0;}.reg-scope__id {font-size:var(--t-xl);line-height:1;color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.reg-scope__colon {color:var(--ink-4);margin:0 2px;}.reg-scope__path {color:var(--ink);}.reg-scope__sub {display:flex;align-items:center;gap:var(--pad-3);margin-top:6px;font-size:var(--t-xs);color:var(--ink-4);flex-wrap:wrap;}.reg-scope__chip {padding:1px 8px;background:var(--bg-2);border:1px solid var(--line);color:var(--ink-2);text-transform:uppercase;font-size:var(--t-xs);}.reg-scope__rule {flex:0 0 auto;width:1px;height:12px;background:var(--line);}.reg-scope__count {display:inline-flex;align-items:baseline;gap:4px;text-transform:uppercase;color:var(--ink-4);.num {color:var(--ink-2);font-size:var(--t-md);}}.reg-scope__count-custom {color:var(--acc);font-size:var(--t-md);}.reg-scope__count-slash {color:var(--ink-4);}.reg-scope__count-lbl {margin-left:6px;}.reg-scope__actions {display:flex;gap:var(--pad-2);align-items:center;}.reg-scope__search {width:220px;background:var(--bg-2);border:1px solid var(--line);color:var(--ink);font-size:var(--t-sm);line-height:1;padding:6px 8px;&:focus-visible {outline:1px solid var(--acc);outline-offset:-1px;}} + + /* Inline density indicator \u2014 full-width bar between header and list. */.reg-scope__density {height:2px;background:var(--bg-2);position:relative;overflow:hidden;}.reg-scope__density-fill {position:absolute;inset:0 auto 0 0;width:var(--pct, 0%);background:linear-gradient(90deg, + color-mix(in oklab, var(--acc) 30%, transparent), + var(--acc));transition:width 320ms cubic-bezier(.2, .6, .2, 1);}.reg-scope__empty {padding:var(--pad-6) var(--pad-4);display:grid;gap:8px;text-align:center;color:var(--ink-3);font-size:var(--t-sm);strong {color:var(--ink);font-weight:400;}em {color:var(--acc);font-style:normal;}}.reg-scope__foot {display:flex;align-items:center;gap:var(--pad-3);padding:var(--pad-2) var(--pad-4);border-top:1px dashed var(--line);font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.reg-scope__foot-num {color:var(--ink-2);font-size:var(--t-md);line-height:1;font-variant-numeric:tabular-nums;}.reg-scope__foot-lbl {flex:1;}}.reg-entries {list-style:none;margin:0;padding:0;counter-reset:regrow;max-height:620px;overflow:auto;}.reg-entry {display:grid;grid-template-columns:3px 42px 1fr auto;gap:var(--pad-3);align-items:center;padding:8px var(--pad-4) 8px 0;border-bottom:1px solid color-mix(in oklab, var(--line) 50%, transparent);font-size:var(--t-sm);color:var(--ink-2);position:relative;.reg-entry__rail {grid-column:1;align-self:stretch;background:transparent;}.reg-entry__idx {font-size:var(--t-xs);color:var(--ink-4);font-variant-numeric:tabular-nums;text-align:right;}.reg-entry__id {min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.reg-entry__colon {color:var(--ink-4);margin:0 2px;}.reg-entry__path {color:var(--ink);}.reg-entry__badge {font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-4);min-width:56px;text-align:right;}.reg-entry--custom {background:color-mix(in oklab, var(--acc) 5%, transparent);.reg-entry__rail {background:var(--acc);}.reg-entry__idx {color:var(--acc);}.reg-entry__path {color:var(--ink);}.reg-entry__badge {color:var(--acc);border:1px solid var(--acc-line);padding:1px 6px;background:var(--acc-soft);min-width:56px;}}.reg-entry--vanilla {color:var(--ink-3);.reg-entry__path {color:var(--ink-2);}.reg-entry__badge {font-style:italic;}}} + + /* Namespace coloring \u2014 vanilla dim, custom glows. The signature rule of the page. */.reg-ns {font-size:inherit;}.reg-ns--vanilla {color:var(--ink-4);}.reg-ns--custom {color:var(--acc);text-shadow:0 0 8px color-mix(in oklab, var(--acc) 35%, transparent);}.reg-error {padding:var(--pad-3);display:grid;gap:var(--pad-2);code {background:var(--bg-2);padding:1px 6px;color:var(--ink);}pre.code {background:var(--bg-2);padding:var(--pad-2);border:1px solid var(--line);color:var(--danger);font-size:var(--t-sm);overflow:auto;}} + + @media (prefers-reduced-motion: reduce) {.reg-horizon__dot { animation: none;} + } + }`};function Wd(t,e){le(e,!0),Ut(t,Uy);let n=(O,S=At)=>{let R=x(()=>S().id===r(c)),k=x(()=>Gd(S().id)),M=x(()=>os(S().id));var q=xy();let j;var H=l(q),D=l(H);{var N=ue=>{var $e=bt("\u25A0");f(ue,$e)},G=ue=>{var $e=bt("\u25A2");f(ue,$e)};z(D,ue=>{S().customCount>0?ue(N):ue(G,-1)})}s(H);var Y=d(H,2),K=l(Y);{var Z=ue=>{var $e=hy(),me=ie($e),he=l(me,!0);s(me),ve(),T(()=>y(he,r(M))),f(ue,$e)};z(K,ue=>{r(M)&&r(M)!=="minecraft"&&ue(Z)})}var Q=d(K,2),ee=l(Q,!0);s(Q),s(Y);var J=d(Y,2),te=l(J);{var V=ue=>{var $e=by(),me=ie($e),he=l(me,!0);s(me),ve(2),T(()=>y(he,S().customCount)),f(ue,$e)};z(te,ue=>{S().customCount>0&&ue(V)})}var W=d(te,2),re=l(W,!0);s(W),s(J);var oe=d(J,2);$y(oe,()=>S().customRatio),s(q),T(()=>{j=pe(q,1,"reg-rail__row",null,j,{on:r(R),dim:S().customCount===0}),ne(q,"aria-current",r(R)?"true":void 0),y(ee,r(k)),y(re,S().total)}),U("click",q,()=>{E(c,S().id,!0)}),f(O,q)},a=x(()=>e.player?.uuid),i=X(null),o=X(null),c=X(null),p=X(!0),u=X(!0),$=X("");ge(()=>{if(r(a),!r(a))return;let O=!0;return E(i,null),E(o,null),(async()=>{try{let S=await Ge("/players/"+r(a)+"/registries");if(!O)return;E(i,S.registries,!0)}catch(S){if(!O)return;E(o,S.message||"failed to load registries",!0)}})(),()=>{O=!1}});let g=x(()=>r(i)?r(i).map(O=>{let S=0;for(let k of O.entries)k.vanilla||S++;let R=O.entries.length;return{...O,total:R,customCount:S,vanillaCount:R-S,customRatio:R===0?0:S/R}}):[]),v=x(()=>r(g).filter(O=>O.customCount>0).sort((O,S)=>S.customCount-O.customCount||O.id.localeCompare(S.id))),m=x(()=>r(g).filter(O=>O.customCount===0).sort((O,S)=>S.total-O.total||O.id.localeCompare(S.id))),h=x(()=>{let O=r(g).length,S=0,R=0,k=0,M=0;for(let j of r(g))S+=j.total,R+=j.customCount,k+=j.vanillaCount,j.customCount>0&&M++;let q=S===0?0:R/S;return{regs:O,entries:S,custom:R,vanilla:k,customRegs:M,ratio:q}});ge(()=>{r(c)===null&&(r(v).length>0?E(c,r(v)[0].id,!0):r(g).length>0&&E(c,r(g)[0].id,!0))});let b=x(()=>r(g).find(O=>O.id===r(c))||null),w=x(()=>{if(!r(b))return[];let O=r($).trim().toLowerCase(),S=r(b).entries.slice();return r(p)&&(S=S.filter(R=>!R.vanilla)),O&&(S=S.filter(R=>R.id.toLowerCase().includes(O))),S.sort((R,k)=>R.vanilla!==k.vanilla?R.vanilla?1:-1:R.id.localeCompare(k.id)),S}),C=x(()=>r(w).length),A=x(()=>r(b)?r(b).total-r(C):0);var I=jy(),P=l(I);{var F=O=>{et(O,{title:"Registries",meta:"error",children:(S,R)=>{var k=wy(),M=l(k),q=l(M,!0);s(M),s(k),T(()=>y(q,r(o))),f(S,k)},$$slots:{default:!0}})},L=O=>{et(O,{title:"Registries",meta:"loading",children:(S,R)=>{var k=ky();f(S,k)},$$slots:{default:!0}})},B=O=>{var S=Hy(),R=ie(S),k=l(R),M=l(k),q=l(M,!0);s(M),ve(2),s(k);var j=d(k,2),H=l(j),D=l(H,!0);s(H),ve(2),s(j);var N=d(j,2),G=l(N),Y=d(l(G),1,!0);s(G),ve(2),s(N);var K=d(N,2),Z=l(K),Q=l(Z,!0);s(Z),ve(2),s(K);var ee=d(K,2);Ra(ee,{get value(){return r(h).ratio},class:"reg-horizon__spectrum",children:(Le,qe)=>{var Ae=Ey(),Ce=ie(Ae),Fe=l(Ce);s(Ce);var Ne=d(Ce,2),Ve=l(Ne);s(Ne),T(mt=>{y(Fe,`${mt??""}%`),y(Ve,`custom density \xB7 ${r(h).customRegs??""} of ${r(h).regs??""} registries diverge from vanilla`)},[()=>(r(h).ratio*100).toFixed(2)]),f(Le,Ae)},$$slots:{default:!0}}),s(R);var J=d(R,2),te=l(J),V=l(te),W=d(l(V),4),re=l(W,!0);s(W),s(V);var oe=d(V,2);{var ue=Le=>{var qe=Sy();f(Le,qe)},$e=Le=>{var qe=Ty();de(qe,21,()=>r(v),Ae=>Ae.id,(Ae,Ce)=>{n(Ae,()=>r(Ce))}),s(qe),f(Le,qe)};z(oe,Le=>{r(v).length===0?Le(ue):Le($e,-1)})}var me=d(oe,2),he=l(me),be=d(l(he),4),ye=l(be,!0);s(be);var ze=d(be,2),Re=l(ze,!0);s(ze),s(he),s(me);var De=d(me,2);{var Be=Le=>{var qe=Cy();de(qe,21,()=>r(m),Ae=>Ae.id,(Ae,Ce)=>{n(Ae,()=>r(Ce))}),s(qe),f(Le,qe)},ot=Le=>{var qe=Ay(),Ae=l(qe);ve(2),s(qe),T(()=>y(Ae,`${r(m).length??""} vanilla-only registries hidden \u2014 click `)),f(Le,qe)};z(De,Le=>{r(u)?Le(ot,-1):Le(Be)})}s(te);var Ke=d(te,2),ke=l(Ke);{var Ze=Le=>{var qe=My();f(Le,qe)},je=Le=>{let qe=x(()=>r(b));var Ae=qy(),Ce=ie(Ae),Fe=l(Ce),Ne=l(Fe),Ve=l(Ne);{var mt=ct=>{var Mt=Py(),It=ie(Mt),qt=l(It,!0);s(It),ve(),T(nr=>y(qt,nr),[()=>os(r(qe).id)]),f(ct,Mt)},He=x(()=>os(r(qe).id)&&os(r(qe).id)!=="minecraft"),Ie=ct=>{var Mt=Ry(),It=ie(Mt),qt=l(It,!0);s(It),ve(),T(nr=>y(qt,nr),[()=>os(r(qe).id)||"minecraft"]),f(ct,Mt)};z(Ve,ct=>{r(He)?ct(mt):ct(Ie,-1)})}var Je=d(Ve,2),$t=l(Je,!0);s(Je),s(Ne);var Ee=d(Ne,2),Ye=d(l(Ee),2);pr(Ye,{kind:"on",dot:!0,children:(ct,Mt)=>{ve();var It=bt("client registry");f(ct,It)},$$slots:{default:!0}});var We=d(Ye,4),Oe=l(We),st=l(Oe,!0);s(Oe);var rt=d(Oe,4),ht=l(rt,!0);s(rt),ve(2),s(We),s(Ee),s(Fe);var wt=d(Fe,2),dt=l(wt),Ue=l(dt),Qe=d(Ue,2);s(dt);var Se=d(dt,2);kt(Se),s(wt),s(Ce);var nt=d(Ce,2),ut=l(nt);let vt;s(nt);var Ct=d(nt,2);{var Dt=ct=>{var Mt=Oy(),It=l(Mt);{var qt=Ft=>{var Pt=Ny();ve(2),f(Ft,Pt)},nr=Ft=>{var Pt=Ly(),Kt=ie(Pt),Ht=l(Kt);s(Kt),ve(2),T(()=>y(Ht,`No entries match \u201C${r($)??""}\u201D.`)),f(Ft,Pt)},Wt=Ft=>{var Pt=Iy();f(Ft,Pt)};z(It,Ft=>{r(p)&&r(qe).customCount===0?Ft(qt):r($)?Ft(nr,1):Ft(Wt,-1)})}s(Mt),f(ct,Mt)},Vt=ct=>{var Mt=zy(),It=ie(Mt);de(It,23,()=>r(w),Wt=>Wt.id,(Wt,Ft,Pt)=>{_y(Wt,()=>r(Ft),()=>r(Pt))}),s(It);var qt=d(It,2);{var nr=Wt=>{var Ft=By(),Pt=l(Ft),Kt=l(Pt,!0);s(Pt);var Ht=d(Pt,2),cn=l(Ht);s(Ht);var Qr=d(Ht,2);{var $n=Ur=>{var rr=Dy();U("click",rr,()=>E(p,!1)),f(Ur,rr)},en=Ur=>{var rr=Fy();U("click",rr,()=>E($,"")),f(Ur,rr)};z(Qr,Ur=>{r(p)?Ur($n):r($)&&Ur(en,1)})}s(Ft),T(()=>{y(Kt,r(A)),y(cn,`${r(p)?"vanilla":"filtered"} ${r(A)===1?"entry":"entries"} hidden`)}),f(Wt,Ft)};z(qt,Wt=>{r(A)>0&&Wt(nr)})}f(ct,Mt)};z(Ct,ct=>{r(C)===0?ct(Dt):ct(Vt,-1)})}T((ct,Mt)=>{y($t,ct),y(st,r(qe).customCount),y(ht,r(qe).total),pe(Ue,1,St(r(p)?"primary sm":"ghost sm")),pe(Qe,1,St(r(p)?"ghost sm":"primary sm")),Ot(Se,r($)),vt=we(ut,"",vt,Mt)},[()=>Gd(r(qe).id),()=>({"--pct":(r(qe).customRatio*100).toFixed(3)+"%"})]),U("click",Ue,()=>E(p,!0)),U("click",Qe,()=>E(p,!1)),U("input",Se,ct=>E($,ct.target.value,!0)),f(Le,Ae)};z(ke,Le=>{r(b)?Le(je,-1):Le(Ze)})}s(Ke),s(J),T((Le,qe,Ae)=>{y(q,r(h).regs),y(D,Le),y(Y,qe),y(Q,Ae),y(re,r(v).length),ne(he,"aria-expanded",!r(u)),y(ye,r(m).length),y(Re,r(u)?"\u25B8":"\u25BE")},[()=>r(h).entries.toLocaleString(),()=>r(h).custom.toLocaleString(),()=>r(h).vanilla.toLocaleString()]),U("click",he,()=>E(u,!r(u))),f(O,S)};z(P,O=>{r(o)?O(F):r(i)===null?O(L,1):O(B,-1)})}s(I),f(t,I),ce()}Pe(["click","input"]);var rm=(t,e=At,n=At,a=At)=>{var i=Ky(),o=ie(i),c=l(o,!0);s(o);var p=d(o,2),u=l(p),$=l(u),g=l($,!0);s($);var v=d($,2);{var m=I=>{var P=Yy(),F=l(P);s(P),T(L=>y(F,`+${L??""}`),[()=>vn(a())]),f(I,P)};z(v,I=>{a()!=null&&I(m)})}var h=d(v,2);{var b=I=>{var P=Gy(),F=l(P);s(P),T(()=>y(F,`#${e().packetSeq??""}`)),f(I,P)};z(h,I=>{e().packetSeq>0&&I(b)})}s(u);var w=d(u,2);de(w,17,()=>nm(e().data),([I,P])=>I,(I,P)=>{var F=x(()=>fr(r(P),2));let L=()=>r(F)[0],B=()=>r(F)[1];var O=Wy(),S=l(O),R=l(S,!0);s(S);var k=d(S,2),M=l(k,!0);s(k),s(O),T(()=>{y(R,L()),y(M,B())}),f(I,O)}),s(p);var C=d(p,2),A=l(C,!0);s(C),T((I,P)=>{y(c,n().glyph),y(g,n().label),ne(C,"title",I),y(A,P)},[()=>new Date(e().ts).toISOString(),()=>new Date(e().ts).toLocaleTimeString("en-GB",{hour12:!1})]),f(t,i)},Vy={CONNECT:{label:"Connect",glyph:"\u25C9",accent:"var(--ink-3)"},HANDSHAKE:{label:"Handshake",glyph:"\u21AA",accent:"oklch(75% 0.13 230)"},LOGIN_START:{label:"Login start",glyph:"\u2317",accent:"oklch(78% 0.18 80)"},COMPRESSION_SET:{label:"Compression set",glyph:"\u224B",accent:"oklch(72% 0.18 310)"},LOGIN_SUCCESS:{label:"Login success",glyph:"\u2713",accent:"var(--acc)"},CONFIGURATION_START:{label:"Configuration start",glyph:"\u2699",accent:"oklch(78% 0.13 148)"},CONFIGURATION_FINISH:{label:"Configuration finish",glyph:"\u2699",accent:"oklch(78% 0.13 148)"},PLAY_START:{label:"Play start",glyph:"\u25B6",accent:"var(--acc)"},DISCONNECT:{label:"Disconnect",glyph:"\u2715",accent:"var(--danger)"}};function nm(t,e="",n=0){if(n>4||t==null)return[];if(Array.isArray(t))return t.length===0?[[e||"\xB7","[]"]]:t.every(i=>i==null||typeof i!="object")?[[e||"\xB7","["+t.map(String).join(", ")+"]"]]:[[e||"\xB7",`[${t.length} items]`]];if(typeof t=="object"){let a=[];for(let[i,o]of Object.entries(t)){let c=e?`${e}.${i}`:i;a.push(...nm(o,c,n+1))}return a}return[[e||"\xB7",String(t)]]}var Yy=_(' '),Gy=_(' '),Wy=_('
          '),Ky=_('
          ',1),Xy=_('
          '),Zy=_('
          No lifecycle events captured yet.
          '),Jy=_(''),Qy=_('
          '),ew=_('
        1. '),tw=_('
            ');function Kd(t,e){le(e,!0);let n=x(()=>e.player?.uuid),a=X(tt([])),i=X(null);ge(()=>{if(!r(n))return;let p=!0;return Ge(`/players/${r(n)}/lifecycle`).then(u=>{p&&(E(a,u||[],!0),E(i,null))}).catch(u=>{p&&E(i,String(u.message||u),!0)}),()=>{p=!1}}),Zr(()=>r(n)?Ko(r(n)):null,p=>{!p||p.seq==null||r(a).some(u=>u.seq===p.seq)||E(a,[...r(a),p],!0)});let o=x(()=>r(a)[0]?.ts??null),c=x(()=>r(a)[r(a).length-1]?.ts??null);{let p=x(()=>r(a).length===0?"\u2014":`${r(a).length} events${r(o)&&r(c)?` \xB7 ${vn(r(c)-r(o))} span`:""}`);et(t,{title:"Connection lifecycle",get meta(){return r(p)},flush:!0,children:(u,$)=>{var g=Me(),v=ie(g);{var m=w=>{var C=Xy(),A=l(C);s(C),T(()=>y(A,`Error \xB7 ${r(i)??""}`)),f(w,C)},h=w=>{var C=Zy();f(w,C)},b=w=>{var C=tw();de(C,23,()=>r(a),A=>A.seq,(A,I,P)=>{let F=x(()=>Vy[r(I).kind]||{label:r(I).kind,glyph:"\xB7",accent:"var(--ink-3)"}),L=x(()=>r(P)===0?null:r(I).ts-r(a)[r(P)-1].ts);var B=ew(),O=l(B);{var S=k=>{var M=Jy();let q;var j=l(M);rm(j,()=>r(I),()=>r(F),()=>r(L)),s(M),T(()=>q=we(M,"",q,{"--phase":r(F).accent})),U("click",M,()=>Ga(`/p/${r(n)}/packets?seq=${r(I).packetSeq}`)),f(k,M)},R=k=>{var M=Qy();let q;var j=l(M);rm(j,()=>r(I),()=>r(F),()=>r(L)),s(M),T(()=>q=we(M,"",q,{"--phase":r(F).accent})),f(k,M)};z(O,k=>{r(I).packetSeq>0?k(S):k(R,-1)})}s(B),f(A,B)}),s(C),f(w,C)};z(v,w=>{r(i)?w(m):r(a).length===0?w(h,1):w(b,-1)})}f(u,g)},$$slots:{default:!0}})}ce()}Pe(["click"]);function am(t){let e=tt({data:null});return t&&Ge(t).then(n=>{e.data=n}).catch(()=>{}),e}var lr={onMatch:"onMatch",onUnmatch:"onUnmatch",onPacket:"onPacket",interval:"interval"},Et={inject:"inject",chat:"chat",setCustom:"setCustom",move:"move",sequence:"sequence",ref:"ref"};function Jn(t){return t?.type===Et.ref}function ls(t){return t?.id??""}var rw={[lr.onMatch]:"\u2295",[lr.onUnmatch]:"\u2296",[lr.onPacket]:"\u25C7",[lr.interval]:"\u25F4"};function im(t){return rw[t?.type??""]??"\xB7"}function Xd(t){return t?.type?t.type===lr.interval?`every ${pa(t.millis??0)}`:t.type===lr.onPacket?`on ${ua(t.packet)||"(unset)"}`:t.type:lr.onMatch}var nw={[Et.inject]:"\u25C7",[Et.chat]:"#",[Et.setCustom]:"\u2699",[Et.move]:"\u2192",[Et.sequence]:"\u21B3"};function sm(t){return nw[t?.type??""]??"\xB7"}var aw={client:"\u25C0 client",server:"\u25B6 server"},om={PLAY:0,CONFIGURATION:1,LOGIN:2,STATUS:3,HANDSHAKE:4},Zd={full:null,analyzable:null};function Jd(t=!1){let e=t?"analyzable":"full";if(!Zd[e]){let n=t?"/packets/known?analyzable=true":"/packets/known";Zd[e]=Ge(n).catch(()=>[])}return Zd[e]}var iw=_('
            ');function Qs(t,e){le(e,!0);let n=ae(e,"value",3,""),a=ae(e,"placeholder",3,"ClientChatMessagePacket"),i=ae(e,"analyzable",3,!1),o,c,p=[];ge(()=>(c=new ns("ps-pop",(h,b,w)=>` +
          1. + ${Cr(h.simple)} + + ${Cr(aw[h.side]||h.side)} + ${Cr(h.state.toLowerCase())} + +
          2. `,g),c.mount(),()=>c.destroy())),ge(()=>{Jd(i()).then(h=>{p=h})});function u(){if(!o)return;let h=o.value.trim().toLowerCase(),b=p.map(w=>{let C=w.simple.toLowerCase(),A=h?C.startsWith(h)?0:C.includes(h)?1:-1:0;return{p:w,score:A}}).filter(w=>w.score>=0).sort((w,C)=>w.score-C.score||(om[w.p.state]??9)-(om[C.p.state]??9)||w.p.simple.localeCompare(C.p.simple)).map(w=>w.p);if(b.length===0){c?.hide();return}c.setItems(b),$(),c.show()}function $(){if(!c||!o)return;let h=o.closest("dialog")||document.body;c.ensureParent(h);let b=o.getBoundingClientRect();c.position(b.left,b.bottom+2,b.width)}function g(h){let b=c?.items[h];b&&(e.onChange?.(b.simple),c.hide())}var v=iw(),m=l(v);kt(m),Tt(m,h=>o=h,()=>o),s(v),T(()=>{Ot(m,n()),ne(m,"placeholder",a())}),U("input",m,h=>{e.onChange?.(h.target.value),u()}),Rt("focus",m,u),U("click",m,u),Rt("blur",m,()=>setTimeout(()=>c?.hide(),100)),U("keydown",m,h=>c?.handleKey(h)),f(t,v),ce()}Pe(["input","click","keydown"]);var sw=new Set(["byte","short","int","long","float","double","char","string","uuid"]),ow=new Set(["record","list","map","item","component"]);function Qd(t){return sw.has(t)}function lm(t){return ow.has(t)}function cm(t){return t.kind==="list"&&t.element?`list<${t.element.kind}>`:t.kind==="map"&&t.key&&t.value?`map<${t.key.kind}, ${t.value.kind}>`:t.kind}function Ia(t){if(Qd(t.kind))return"";if(t.kind==="boolean")return!1;if(t.kind==="enum")return t.values?.[0]??"";if(t.kind==="record"){let e={};for(let n of t.components??[])e[n.name]=Ia(n);return e}return t.kind==="list"?[]:t.kind==="map"?{}:t.kind==="item"?{id:"minecraft:stone",count:1}:t.kind==="component"?{text:""}:""}var Al=new Map;function dm(t){return Al.has(t)||Al.set(t,Ge("/packet/describe/"+encodeURIComponent(t)).catch(e=>{throw Al.delete(t),e})),Al.get(t)}var lw=_(''),cw=_('
            MATERIAL
            ');function ep(t,e){le(e,!0);let n=X(tt([])),a=X(""),i=X(null);ge(()=>{pm().then(A=>E(n,A,!0)).catch(()=>{})});let{pos:o}=Xi(()=>e.anchor,()=>r(i),A=>({left:A.left,top:A.bottom+4}),()=>e.onClose()),c=x(()=>r(a).toLowerCase().trim()),p=x(()=>r(c)?r(n).filter(A=>A.toLowerCase().includes(r(c))):r(n)),u=x(()=>zs(e.value));var $=cw();let g;var v=l($),m=d(l(v),2);kt(m),s(v);var h=d(v,2);de(h,20,()=>r(p),A=>A,(A,I)=>{let P=x(()=>zs(I));var F=lw(),L=l(F);ne(L,"draggable",!1),s(F),T(()=>{pe(F,1,`mat-grid__cell ${r(P)===r(u)?"is-on":""}`),ne(F,"title",r(P)),ne(L,"src",`/api/material-icon/${r(P)}`)}),U("click",F,()=>{e.onPick(I),e.onClose()}),f(A,F)}),s(h);var b=d(h,2),w=l(b);s(b);var C=d(b,2);kt(C),s($),Tt($,A=>E(i,A),()=>r(i)),T(()=>{g=we($,"",g,{left:`${o.left??""}px`,top:`${o.top??""}px`}),Ot(m,r(a)),y(w,`${r(p).length??""} of ${r(n).length??""}`),Ot(C,e.value)}),U("input",m,A=>E(a,A.currentTarget.value,!0)),U("input",C,A=>e.onPick(A.currentTarget.value)),f(t,$),ce()}Pe(["input","click"]);var um="mn.packet.library.v1",dw={items:[],components:[],records:[]};function pw(){try{let t=localStorage.getItem(um);if(t){let e=JSON.parse(t);return{items:e.items??[],components:e.components??[],records:e.records??[]}}}catch{}return structuredClone(dw)}var tp=class{#e=X(tt(pw()));get state(){return r(this.#e)}set state(e){E(this.#e,e,!0)}save(e,n){this.state[e].unshift(n),this.persist()}remove(e,n){this.state[e].splice(n,1),this.persist()}list(e){return this.state[e]}persist(){try{localStorage.setItem(um,JSON.stringify(this.state))}catch(e){ft("Library save failed: "+(e.message||"storage error"),"error",4e3)}}},eo=new tp;function Ml(t){return t==="item"?"items":t==="component"?"components":t==="record"?"records":null}var uw="application/x-mn-lib-",fm=t=>uw+t;function rp(t,e){return!!t&&t.types.includes(fm(e))}function vm(t,e){if(!rp(t,e))return null;try{let n=t.getData(fm(e));return n?JSON.parse(n):null}catch{return null}}function Xa(t,e){let n=tt({over:!1}),a=0,i=()=>typeof t=="function"?t():t;function o(c){let p=i();return!p||!rp(c.dataTransfer,p)?!1:(c.preventDefault(),c.dataTransfer&&(c.dataTransfer.dropEffect="copy"),!0)}return{get over(){return n.over},handlers:{ondragenter:c=>{o(c)&&(a+=1,n.over=!0)},ondragover:c=>{o(c)},ondragleave:()=>{a=Math.max(0,a-1),a===0&&(n.over=!1)},ondrop:c=>{let p=i();if(!p)return;let u=vm(c.dataTransfer,p);u!=null&&(c.preventDefault(),c.stopPropagation(),a=0,n.over=!1,e(u))}}}}var fw=["black","dark_blue","dark_green","dark_aqua","dark_red","dark_purple","gold","gray","dark_gray","blue","green","aqua","red","light_purple","yellow","white"],mm={black:"#000000",dark_blue:"#0000aa",dark_green:"#00aa00",dark_aqua:"#00aaaa",dark_red:"#aa0000",dark_purple:"#aa00aa",gold:"#ffaa00",gray:"#aaaaaa",dark_gray:"#555555",blue:"#5555ff",green:"#55ff55",aqua:"#55ffff",red:"#ff5555",light_purple:"#ff55ff",yellow:"#ffff55",white:"#ffffff"},vw=["bold","italic","underlined","strikethrough","obfuscated"],mw=_(' '),$w=_(''),_w=_(''),gw=_('
            '),hw=_('
            text component
            text color
            style
            extra
            ');function Si(t,e){le(e,!0);let n=ae(e,"embedded",3,!1),a=x(()=>e.value&&typeof e.value=="object"?e.value:{}),i=x(()=>typeof r(a).text=="string"?r(a).text:""),o=x(()=>typeof r(a).color=="string"?r(a).color:""),c=x(()=>Array.isArray(r(a).extra)?r(a).extra:[]);function p(M,q){let j={...r(a)};q==null||q===""||q===!1?delete j[M]:j[M]=q,e.onChange(j)}function u(M,q){let j=[...r(c)];j[M]=q,p("extra",j)}function $(M){let q=r(c).filter((j,H)=>H!==M);p("extra",q.length?q:null)}function g(){p("extra",[...r(c),{text:""}])}let v=Xa("components",M=>e.onChange(M));var m=hw();Ea(m,()=>({class:`cb-builder builder ${v.over?"drop-over":""}`,role:"region",...v.handlers}));var h=l(m),b=d(l(h),2);{var w=M=>{var q=mw(),j=l(q);s(q),T(()=>y(j,`+${r(c).length??""} extra`)),f(M,q)};z(b,M=>{r(c).length>0&&M(w)})}s(h);var C=d(h,2),A=l(C),I=d(l(A),2);jr(I,{language:"expression",get value(){return r(i)},onChange:M=>p("text",M),rows:1,placeholder:'hello, or player.name, or "score: " + player.health'});var P=d(I,4),F=l(P),L=d(F,2);de(L,16,()=>fw,M=>M,(M,q)=>{var j=$w();let H;T(()=>{pe(j,1,`cb-swatch ${r(o)===q?"is-on":""}`),ne(j,"title",q),ne(j,"aria-label",q),H=we(j,"",H,{background:mm[q]})}),U("click",j,()=>p("color",q)),f(M,j)});var B=d(L,2);kt(B),s(P);var O=d(P,4);de(O,20,()=>vw,M=>M,(M,q)=>{var j=_w(),H=l(j),D=l(H,!0);s(H);var N=d(H,2),G=l(N,!0);s(N),s(j),T(Y=>{pe(j,1,`cb-deco__chip ${r(a)[q]===!0?"is-on":""}`),pe(H,1,`cb-deco__chip-glyph cb-deco__chip-glyph--${q??""}`),y(D,Y),y(G,q)},[()=>q[0].toUpperCase()]),U("click",j,()=>p(q,r(a)[q]!==!0)),f(M,j)}),s(O);var S=d(O,4),R=l(S);de(R,17,()=>r(c),lt,(M,q,j)=>{var H=gw(),D=l(H);D.textContent=j;var N=d(D,2),G=l(N);Si(G,{get value(){return r(q)},onChange:K=>u(j,K),embedded:!0}),s(N);var Y=d(N,2);s(H),U("click",Y,()=>$(j)),f(M,H)});var k=d(R,2);s(S),s(A),s(C),s(m),T(()=>{pe(F,1,`cb-swatch cb-swatch--none ${r(o)?"":"is-on"}`),Ot(B,r(o)&&!mm[r(o)]?r(o):"")}),U("click",F,()=>p("color",null)),U("input",B,M=>p("color",M.currentTarget.value||null)),U("click",k,g),f(t,m),ce()}Pe(["click","input"]);var bw=_('
            '),xw=_(" "),yw=_('
            '),ww=_('
            Empty list. Add an entry below.
            '),kw=_('
            '),Ew=_('
            '),Sw=_('
            '),Tw=_('
            ',1),Cw=_('
            ');function to(t,e){le(e,!0);let n=x(()=>Array.isArray(e.value)?e.value:[]),a=X(!0),i=X(null),o=x(()=>r(i)??e.element.kind==="record"),c=Xa(()=>Ml(e.element.kind),D=>e.onChange([...r(n),D]));function p(){e.onChange([...r(n),Ia(e.element)])}function u(){e.onChange([])}function $(){r(n).length!==0&&e.onChange([...r(n),structuredClone(r(n)[r(n).length-1])])}function g(D,N){e.onChange(r(n).map((G,Y)=>Y===D?N:G))}function v(D){e.onChange(r(n).filter((N,G)=>G!==D))}let m=x(()=>e.element.kind==="record"&&e.element.components?e.element.components:[]);var h=Cw();Ea(h,()=>({class:`coll ${r(o)&&e.element.kind==="record"?"coll--table":""} ${c.over?"drop-over":""}`,role:"group",...c.handlers}));var b=l(h),w=l(b),C=l(w),A=l(C),I=l(A,!0);s(A);var P=d(A,2),F=d(l(P)),L=l(F,!0);s(F),ve(),s(P);var B=d(P,2),O=l(B,!0);s(B),s(C),s(w);var S=d(w,2),R=l(S);{var k=D=>{var N=bw(),G=l(N),Y=d(G,2);s(N),T(()=>{pe(G,1,St(r(o)?"":"is-on")),pe(Y,1,St(r(o)?"is-on":""))}),U("click",G,()=>E(i,!1)),U("click",Y,()=>E(i,!0)),f(D,N)};z(R,D=>{e.element.kind==="record"&&D(k)})}var M=d(R,2),q=d(M,2);s(S),s(b);var j=d(b,2);{var H=D=>{var N=Tw(),G=ie(N);{var Y=V=>{var W=yw();let re;var oe=l(W);de(oe,21,()=>r(m),ue=>ue.name,(ue,$e)=>{var me=xw(),he=l(me,!0);s(me),T(()=>y(he,r($e).name)),f(ue,me)}),s(oe),ve(2),s(W),T(()=>re=we(W,"",re,{"--cols":r(m).length})),f(V,W)};z(G,V=>{r(o)&&e.element.kind==="record"&&V(Y)})}var K=d(G,2),Z=l(K);{var Q=V=>{var W=ww();f(V,W)};z(Z,V=>{r(n).length===0&&V(Q)})}var ee=d(Z,2);de(ee,17,()=>r(n),lt,(V,W,re)=>{var oe=Sw(),ue=l(oe);{var $e=be=>{var ye=kw();let ze;de(ye,21,()=>r(m),Re=>Re.name,(Re,De)=>{{let Be=x(()=>r(W)?.[r(De).name]);Oa(Re,{get element(){return r(De)},get value(){return r(Be)},onChange:ot=>g(re,{...r(W)??{},[r(De).name]:ot})})}}),s(ye),T(()=>ze=we(ye,"",ze,{"--cols":r(m).length})),f(be,ye)},me=be=>{var ye=Ew(),ze=l(ye);Oa(ze,{get element(){return e.element},get value(){return r(W)},onChange:Re=>g(re,Re)}),s(ye),f(be,ye)};z(ue,be=>{r(o)&&e.element.kind==="record"?be($e):be(me,-1)})}var he=d(ue,2);s(oe),U("click",he,()=>v(re)),f(V,oe)});var J=d(ee,2),te=l(J);s(J),s(K),T(()=>y(te,`+ add ${e.element.kind??""}`)),U("click",J,p),f(D,N)};z(j,D=>{r(a)&&D(H)})}s(h),T(()=>{ne(w,"aria-expanded",r(a)),y(I,r(a)?"\u25BE":"\u25B8"),y(L,e.element.kind),y(O,r(n).length)}),U("click",w,()=>E(a,!r(a))),U("click",M,$),U("click",q,u),f(t,h),ce()}Pe(["click"]);var Pl=null;function pm(){return Pl||(Pl=Ge("/materials").then(t=>t.map(e=>"minecraft:"+e).sort()).catch(t=>{throw Pl=null,t})),Pl}var $m=[{key:"custom_name",kind:"component",label:"custom_name"},{key:"item_name",kind:"component",label:"item_name"},{key:"lore",kind:"list-component",label:"lore"},{key:"rarity",kind:"enum",label:"rarity",values:["COMMON","UNCOMMON","RARE","EPIC"]}];function Aw(t){switch(t.kind){case"enum":return t.values?.[0]??"";case"component":return{text:""};case"list-component":return[]}}var Mw=_(' '),Pw=_(''),Rw=_('?'),Nw=_(""),Lw=_(""),Iw=_(' '),Ow=_('
            '),Dw=_('
            '),Fw=_(''),Bw=_(''),zw=_('
            item stack
            ',1);function np(t,e){le(e,!0);let n=x(()=>e.value&&typeof e.value=="object"?e.value:{}),a=x(()=>typeof r(n).id=="string"?r(n).id:""),i=x(()=>zs(r(a))),o=x(()=>Number(r(n).count??1)),c=x(()=>r(n).components??{}),p=x(()=>Object.keys(r(c))),u=x(()=>r(i)?`/api/material-icon/${r(i)}`:""),$=X(null),g=X(!1),v=X(!1);function m(V){let W={...r(n),...V};e.onChange(W)}function h(V){return $m.find(W=>W.key===V)??null}function b(V,W){let re={...r(c)};W==null?delete re[V]:re[V]=W;let oe={...r(n)};Object.keys(re).length===0?delete oe.components:oe.components=re,e.onChange(oe)}function w(V){m({count:Math.max(1,Math.min(99,r(o)+V))})}let C=Xa("items",V=>e.onChange(V));var A=zw(),I=ie(A);Ea(I,()=>({class:`ib-builder builder ${C.over?"drop-over":""}`,role:"region",...C.handlers}));var P=l(I),F=d(l(P),2);{var L=V=>{var W=Mw(),re=l(W);s(W),T(()=>y(re,`+${r(p).length??""} component${r(p).length===1?"":"s"}`)),f(V,W)};z(F,V=>{r(p).length>0&&V(L)})}s(P);var B=d(P,2),O=l(B),S=l(O),R=l(S);{var k=V=>{var W=Pw();ne(W,"draggable",!1),T(()=>ne(W,"src",r(u))),f(V,W)},M=V=>{var W=Rw();f(V,W)};z(R,V=>{r(u)?V(k):V(M,-1)})}s(S),Tt(S,V=>E($,V),()=>r($));var q=d(S,2);kt(q);var j=d(q,2),H=l(j),D=d(H,2);kt(D);var N=d(D,2);s(j),s(O);var G=d(O,2);{var Y=V=>{var W=Dw();de(W,20,()=>r(p),re=>re,(re,oe)=>{let ue=x(()=>h(oe));var $e=Ow(),me=l($e),he=l(me,!0);s(me);var be=d(me,2),ye=l(be);{var ze=Ke=>{var ke=Lw();de(ke,20,()=>r(ue).values,je=>je,(je,Le)=>{var qe=Nw(),Ae=l(qe,!0);s(qe);var Ce={};T(()=>{y(Ae,Le),Ce!==(Ce=Le)&&(qe.value=(qe.__value=Le)??"")}),f(je,qe)}),s(ke);var Ze;Zn(ke),T(je=>{Ze!==(Ze=je)&&(ke.value=(ke.__value=je)??"",Sn(ke,je))},[()=>String(r(c)[oe]??r(ue).values[0])]),U("change",ke,je=>b(oe,je.currentTarget.value)),f(Ke,ke)},Re=Ke=>{{let ke=x(()=>r(c)[oe]??null);Si(Ke,{get value(){return r(ke)},onChange:Ze=>b(oe,Ze)})}},De=Ke=>{{let ke=x(()=>r(c)[oe]??[]);to(Ke,{get value(){return r(ke)},onChange:Ze=>b(oe,Ze),element:{name:"line",kind:"component"}})}},Be=Ke=>{var ke=Iw(),Ze=l(ke,!0);s(ke),T(je=>y(Ze,je),[()=>JSON.stringify(r(c)[oe])]),f(Ke,ke)};z(ye,Ke=>{r(ue)?.kind==="enum"&&r(ue).values?Ke(ze):r(ue)?.kind==="component"?Ke(Re,1):r(ue)?.kind==="list-component"?Ke(De,2):Ke(Be,-1)})}s(be);var ot=d(be,2);s($e),T(()=>y(he,r(ue)?.label??oe)),U("click",ot,()=>b(oe,null)),f(re,$e)}),s(W),f(V,W)};z(G,V=>{r(p).length>0&&V(Y)})}var K=d(G,2),Z=l(K),Q=d(Z,2);{var ee=V=>{var W=Bw();de(W,21,()=>$m,re=>re.key,(re,oe)=>{let ue=x(()=>r(p).includes(r(oe).key));var $e=Fw(),me=l($e),he=l(me,!0);s(me);var be=d(me,2),ye=l(be,!0);s(be),s($e),T(()=>{$e.disabled=r(ue),y(he,r(oe).label),y(ye,r(oe).kind)}),U("click",$e,()=>{b(r(oe).key,Aw(r(oe))),E(v,!1)}),f(re,$e)}),s(W),f(V,W)};z(Q,V=>{r(v)&&V(ee)})}s(K),s(B),s(I);var J=d(I,2);{var te=V=>{ep(V,{get value(){return r(a)},get anchor(){return r($)},onPick:W=>m({id:W}),onClose:()=>E(g,!1)})};z(J,V=>{r(g)&&r($)&&V(te)})}T(()=>{Ot(q,r(a)),Ot(D,r(o))}),U("click",S,()=>E(g,!r(g))),U("input",q,V=>m({id:V.currentTarget.value})),U("click",H,()=>w(-1)),U("input",D,V=>m({count:Math.max(1,Math.min(99,Number(V.currentTarget.value)||1))})),U("click",N,()=>w(1)),U("click",Z,()=>E(v,!r(v))),f(t,A),ce()}Pe(["click","input","change"]);var qw=_('
            Empty map. Add an entry below.
            '),Hw=_('
            \u2192
            '),jw=_('
            '),Uw=_('
            ');function ap(t,e){le(e,!0);let n=x(()=>Object.entries(e.value??{})),a=X(!0);function i(M,q){if(r(n).some(([H],D)=>D!==M&&H===q))return;let j={};r(n).forEach(([H,D],N)=>{j[N===M?q:H]=D}),e.onChange(j)}function o(M,q){let j={};r(n).forEach(([H,D],N)=>{j[H]=N===M?q:D}),e.onChange(j)}function c(M){let q={};r(n).forEach(([j,H],D)=>{D!==M&&(q[j]=H)}),e.onChange(q)}function p(){let M=e.keyField.kind;if(M==="uuid")return crypto.randomUUID();if(M==="int"||M==="long"||M==="byte"||M==="short"){let H=r(n).length;for(;String(H)in(e.value??{});)H+=1;return String(H)}let q=r(n).length,j=`key${q}`;for(;j in(e.value??{});)q+=1,j=`key${q}`;return j}function u(){e.onChange({...e.value??{},[p()]:Ia(e.valueField)})}function $(){e.onChange({})}var g=Uw(),v=l(g),m=l(v),h=l(m),b=l(h),w=l(b,!0);s(b);var C=d(b,2),A=d(l(C)),I=l(A,!0);s(A);var P=d(A,2),F=l(P,!0);s(P),ve(),s(C);var L=d(C,2),B=l(L,!0);s(L),s(h),s(m);var O=d(m,2),S=l(O);s(O),s(v);var R=d(v,2);{var k=M=>{var q=jw(),j=l(q);{var H=G=>{var Y=qw();f(G,Y)};z(j,G=>{r(n).length===0&&G(H)})}var D=d(j,2);de(D,17,()=>r(n),lt,(G,Y,K)=>{var Z=x(()=>fr(r(Y),2));let Q=()=>r(Z)[0],ee=()=>r(Z)[1];var J=Hw(),te=l(J),V=l(te);Oa(V,{get element(){return e.keyField},get value(){return Q()},onChange:ue=>i(K,String(ue))}),s(te);var W=d(te,4),re=l(W);Oa(re,{get element(){return e.valueField},get value(){return ee()},onChange:ue=>o(K,ue)}),s(W);var oe=d(W,2);s(J),U("click",oe,()=>c(K)),f(G,J)});var N=d(D,2);s(q),U("click",N,u),f(M,q)};z(R,M=>{r(a)&&M(k)})}s(g),T(()=>{ne(m,"aria-expanded",r(a)),y(w,r(a)?"\u25BE":"\u25B8"),y(I,e.keyField.kind),y(F,e.valueField.kind),y(B,r(n).length)}),U("click",m,()=>E(a,!r(a))),U("click",S,$),f(t,g),ce()}Pe(["click"]);var Vw=_("
            ");function ip(t,e){le(e,!0);let n=Xa("records",i=>e.onChange(i));var a=Vw();Ea(a,()=>({class:`record-block ${n.over?"drop-over":""}`,role:"group",...n.handlers})),de(a,21,()=>e.components,i=>i.name,(i,o)=>{{let c=x(()=>e.value?.[r(o).name]);ro(i,{get field(){return r(o)},get value(){return r(c)},onChange:p=>e.onChange({...e.value??{},[r(o).name]:p})})}}),s(a),f(t,a),ce()}var Yw=_(''),Gw=_(""),Ww=_("");function Oa(t,e){le(e,!0);function n(){switch(e.element.kind){case"string":return'"hello"';case"uuid":return"player.uuid";case"char":return'"x"';case"float":case"double":return"0.0";default:return"0"}}var a=Me(),i=ie(a);{var o=b=>{var w=Yw(),C=l(w);kt(C),s(w),T(()=>da(C,!!e.value)),U("change",C,A=>e.onChange(A.currentTarget.checked)),f(b,w)},c=b=>{var w=Ww();de(w,20,()=>e.element.values??[],A=>A,(A,I)=>{var P=Gw(),F=l(P,!0);s(P);var L={};T(()=>{y(F,I),L!==(L=I)&&(P.value=(P.__value=I)??"")}),f(A,P)}),s(w);var C;Zn(w),T(A=>{C!==(C=A)&&(w.value=(w.__value=A)??"",Sn(w,A))},[()=>String(e.value??e.element.values?.[0]??"")]),U("change",w,A=>e.onChange(A.currentTarget.value)),f(b,w)},p=b=>{{let w=x(()=>String(e.value??"")),C=x(n);jr(b,{language:"expression",get value(){return r(w)},get onChange(){return e.onChange},rows:1,get placeholder(){return r(C)}})}},u=x(()=>Qd(e.element.kind)),$=b=>{{let w=x(()=>e.value??null);np(b,{get value(){return r(w)},get onChange(){return e.onChange}})}},g=b=>{{let w=x(()=>e.value??null);Si(b,{get value(){return r(w)},get onChange(){return e.onChange}})}},v=b=>{{let w=x(()=>e.value??{});ip(b,{get components(){return e.element.components},get value(){return r(w)},get onChange(){return e.onChange}})}},m=b=>{{let w=x(()=>e.value??[]);to(b,{get value(){return r(w)},get onChange(){return e.onChange},get element(){return e.element.element}})}},h=b=>{{let w=x(()=>e.value??{});ap(b,{get value(){return r(w)},get onChange(){return e.onChange},get keyField(){return e.element.key},get valueField(){return e.element.value}})}};z(i,b=>{e.element.kind==="boolean"?b(o):e.element.kind==="enum"?b(c,1):r(u)?b(p,2):e.element.kind==="item"?b($,3):e.element.kind==="component"?b(g,4):e.element.kind==="record"&&e.element.components?b(v,5):e.element.kind==="list"&&e.element.element?b(m,6):e.element.kind==="map"&&e.element.key&&e.element.value&&b(h,7)})}f(t,a),ce()}Pe(["change"]);var Kw=_('
            '),Xw=_('
            '),Zw=_('');function sp(t,e){le(e,!0);let n=x(()=>eo.list(e.bucket)),a=X(null),{pos:i}=Xi(()=>e.anchor,()=>r(a),h=>({left:Math.max(8,h.right-260),top:h.bottom+4}),()=>e.onClose());function o(h){e.onPick(structuredClone(h)),e.onClose()}var c=Zw();let p;var u=l(c),$=l(u);s(u);var g=d(u,2);{var v=h=>{var b=Kw(),w=l(b);s(b),T(()=>y(w,`No saved ${e.bucket??""} yet. Hit \u2606 on a row to save one.`)),f(h,b)},m=h=>{var b=Me(),w=ie(b);de(w,17,()=>r(n),lt,(C,A,I)=>{var P=Xw(),F=l(P),L=l(F,!0);s(F);var B=d(F,2);s(P),T(()=>y(L,r(A).name)),U("click",F,()=>o(r(A).value)),U("click",B,()=>eo.remove(e.bucket,I)),f(C,P)}),f(h,b)};z(g,h=>{r(n).length===0?h(v):h(m,-1)})}s(c),Tt(c,h=>E(a,h),()=>r(a)),T(h=>{p=we(c,"",p,{left:`${i.left??""}px`,top:`${i.top??""}px`}),y($,`SAVED ${h??""}`)},[()=>e.bucket.toUpperCase()]),f(t,c),ce()}Pe(["click"]);var Jw=_(' ',1),Qw=_('
            ',1);function ro(t,e){le(e,!0);let n=x(()=>e.field.name),a=x(()=>cm(e.field)),i=x(()=>lm(e.field.kind)),o=x(()=>Ml(e.field.kind)??void 0),c=X(null),p=X(!1);function u(){if(!r(o))return;if(e.value==null||typeof e.value=="object"&&Object.keys(e.value).length===0){ft("Nothing to save \u2014 fill in the field first.","warn");return}let S=prompt(`Save ${r(o).replace(/s$/,"")} as\u2026`,r(n));S&&(eo.save(r(o),{name:S,value:structuredClone(e.value)}),ft(`Saved to ${r(o)} library`,"ok"))}var $=Qw(),g=ie($),v=l(g),m=l(v),h=l(m,!0);s(m);var b=d(m,2),w=l(b,!0);s(b),s(v);var C=d(v,2),A=l(C);Oa(A,{get element(){return e.field},get value(){return e.value},get onChange(){return e.onChange}}),s(C);var I=d(C,2),P=l(I);{var F=S=>{var R=Jw(),k=ie(R),M=d(k,2);Tt(M,q=>E(c,q),()=>r(c)),T(()=>{ne(k,"title",`Save to ${r(o)??""} library`),pe(M,1,`pkt-field__tool ${r(p)?"is-on":""}`),ne(M,"title",`Recall ${r(o)??""} from library`)}),U("click",k,u),U("click",M,()=>E(p,!r(p))),f(S,R)};z(P,S=>{r(o)&&S(F)})}var L=d(P,2);s(I),s(g);var B=d(g,2);{var O=S=>{sp(S,{get bucket(){return r(o)},get anchor(){return r(c)},onPick:R=>e.onChange(R),onClose:()=>E(p,!1)})};z(B,S=>{r(p)&&r(o)&&r(c)&&S(O)})}T(()=>{pe(g,1,`pkt-field ${r(i)?"pkt-field--col":""}`),y(h,r(n)),y(w,r(a))}),U("click",L,()=>e.onChange(Ia(e.field))),f(t,$),ce()}Pe(["click"]);var ek=_('
            Select a packet to edit its fields.
            '),tk=_('
            Loading packet schema\u2026
            '),rk=_('
            Failed to describe
            '),nk=_('
            is not in the analyzable packet catalog. Pick a different packet, or fix the name.
            '),ak=_(`
            is not analyzable. It contains components this editor can't break down.
            `),ik=_('
            No fields \u2014 this packet has no components.
            '),sk=_('
            ');function op(t,e){le(e,!0);let n=ae(e,"fields",19,()=>({})),a=X(null),i=X(!1),o=X(null),c="",p=0,u=X(null);ge(()=>{Jd(!0).then(O=>E(u,O,!0)).catch(()=>E(u,[],!0))});let $=x(()=>r(u)?new Set(r(u).map(O=>O.simple)):null);ge(()=>{if(e.components!==void 0){E(a,null);return}let O=(e.packet||"").trim();if(!O){E(a,null),E(o,null),c="";return}if(!r($))return;if(!r($).has(O)){E(a,null),E(o,null),c="";return}if(O===c)return;c=O;let S=++p;E(i,!0),E(o,null),dm(O).then(R=>{S===p&&(E(a,R,!0),E(i,!1),g(R))}).catch(R=>{S===p&&(E(a,null),E(o,R.message,!0),E(i,!1),c="")})});function g(O){if(!O?.analyzable||!O.components)return;let S={},R=Object.keys(n()).length!==O.components.length;for(let k of O.components)k.name in n()?S[k.name]=n()[k.name]:(S[k.name]=Ia(k),R=!0);R&&e.onChange(S)}let v=x(()=>e.components??r(a)?.components??null),m=x(()=>e.components===void 0&&!!e.packet&&!!r($)&&!r($).has(e.packet.trim()));function h(O,S){e.onChange({...n(),[O]:S})}var b=Me(),w=ie(b);{var C=O=>{var S=ek();f(O,S)},A=O=>{var S=tk();f(O,S)},I=O=>{var S=rk(),R=d(l(S)),k=l(R,!0);s(R);var M=d(R);s(S),T(()=>{y(k,e.packet),y(M,`: ${r(o)??""}`)}),f(O,S)},P=O=>{var S=nk(),R=l(S),k=l(R,!0);s(R),ve(),s(S),T(()=>y(k,e.packet)),f(O,S)},F=O=>{var S=ak(),R=l(S),k=l(R,!0);s(R),ve(),s(S),T(()=>y(k,e.packet)),f(O,S)},L=O=>{var S=ik();f(O,S)},B=O=>{var S=sk();de(S,21,()=>r(v),R=>R.name,(R,k)=>{ro(R,{get field(){return r(k)},get value(){return n()[r(k).name]},onChange:M=>h(r(k).name,M)})}),s(S),f(O,S)};z(w,O=>{e.components===void 0&&!e.packet?O(C):r(i)?O(A,1):r(o)?O(I,2):r(m)?O(P,3):e.components===void 0&&r(a)&&!r(a).analyzable?O(F,4):r(v)&&r(v).length===0?O(L,5):r(v)&&O(B,6)})}f(t,b),ce()}var lp=[{id:Et.inject,label:"Inject",detail:"Inject a packet \u2014 direction is derived from the selected packet."},{id:Et.chat,label:"Chat",detail:"Send a system chat message \u2014 body is an expression evaluated per player."},{id:Et.setCustom,label:"Set custom",detail:"Set a custom key on the player state \u2014 value is an expression."},{id:Et.move,label:"Move",detail:'Transfer the player to another server \u2014 the address expression evaluates to "host", "host:port", or "[ipv6]:port".'},{id:Et.sequence,label:"Sequence",detail:"Run multiple actions in order."}],ok=new Set(lp.map(t=>t.id)),cp={[Et.inject]:()=>({type:Et.inject,packet:"",fields:{}}),[Et.chat]:()=>({type:Et.chat,component:""}),[Et.setCustom]:()=>({type:Et.setCustom,key:"",value:""}),[Et.move]:()=>({type:Et.move,address:""}),[Et.sequence]:()=>({type:Et.sequence,actions:[]})};function lk(t){let e=ok.has(t?.type)?t.type:Et.chat;return{...cp[e](),...t,type:e}}function ck(t){let e=t.type;return e===Et.inject?{type:Et.inject,packet:String(t.packet??"").trim(),fields:t.fields||{}}:e===Et.chat?{type:Et.chat,component:t.component??""}:e===Et.setCustom?{type:Et.setCustom,key:t.key||"",value:t.value||""}:e===Et.move?{type:Et.move,address:String(t.address??"")}:e===Et.sequence?{type:Et.sequence,actions:t.actions||[]}:{type:Et.chat}}function cs(t){if(!t)return"(none)";let e=(n,a=40)=>(n||"").slice(0,a);return{[Et.inject]:()=>`inject: ${t.packet||"?"}`,[Et.chat]:()=>typeof t.component=="object"?"chat: [component]":`chat: ${e(String(t.component??""))}`,[Et.setCustom]:()=>`set ${t.key||"?"} = ${t.value||'""'}`,[Et.move]:()=>`move \u2192 ${e(String(t.address??"?"))}`,[Et.sequence]:()=>`sequence (${(t.actions||[]).length} actions)`}[t.type]?.()??String(t.type??"(unknown)")}var dk=_(''),pk=_('
            Fields
            ',1),uk=_(''),fk=_(' ',1),vk=_(''),mk=_('
            '),$k=_('
            Actions run in order.
            ',1),_k=_('
            ');function Ti(t,e){le(e,!0);let n="act-"+Math.random().toString(36).slice(2,9),a=x(()=>lk(e.value)),i=x(()=>lp.find(S=>S.id===r(a).type));function o(S){e.onChange?.(ck(S))}function c(){let S=X(null),R=Wa(async k=>{if(!k.trim()){E(S,null);return}try{await Ge("/expression/compile",{method:"POST",body:{src:k}}),E(S,{kind:"ok",message:"OK"},!0)}catch(M){E(S,Pa(M),!0)}},220);return{get status(){return r(S)},validate:k=>R(k)}}let p=c(),u=c(),$=c();ge(()=>{if(r(a).type===Et.chat){let S=r(a).component;typeof S=="string"&&p.validate(S)}r(a).type===Et.setCustom&&u.validate(String(r(a).value||"")),r(a).type===Et.move&&$.validate(String(r(a).address||""))});function g(S,R){let k=[...r(a).actions||[]];k[S]=R,o({...r(a),actions:k})}function v(){o({...r(a),actions:[...r(a).actions||[],cp[Et.chat]()]})}function m(S){let R=[...r(a).actions||[]];R.splice(S,1),o({...r(a),actions:R})}var h=_k(),b=l(h);de(b,21,()=>lp,S=>S.id,(S,R)=>{var k=dk(),M=l(k);kt(M);var q=d(M,2),j=l(q,!0);s(q),s(k),T(()=>{ne(M,"name",n),Ot(M,r(R).id),da(M,r(a).type===r(R).id),y(j,r(R).label)}),U("change",M,()=>r(a).type===r(R).id?null:o(cp[r(R).id]())),f(S,k)}),s(b);var w=d(b,2),C=l(w,!0);s(w);var A=d(w,2),I=l(A);{var P=S=>{var R=pk(),k=ie(R),M=d(l(k),2);{let H=x(()=>String(r(a).packet||""));Qs(M,{get value(){return r(H)},onChange:D=>o({...r(a),packet:D,fields:{}}),analyzable:!0})}s(k);var q=d(k,2),j=d(l(q),2);{let H=x(()=>String(r(a).packet||"")),D=x(()=>r(a).fields||{});op(j,{get packet(){return r(H)},get fields(){return r(D)},onChange:N=>o({...r(a),fields:N})})}s(q),f(S,R)},F=S=>{let R=x(()=>r(a).component!=null&&typeof r(a).component=="object");var k=uk(),M=l(k),q=l(M,!0);s(M);var j=d(M,2);{let H=x(()=>r(R)?"json":"expression"),D=x(()=>r(R)?JSON.stringify(r(a).component,null,2):String(r(a).component??"")),N=x(()=>r(R)?6:2),G=x(()=>r(R)?'{"text":"hello"}':'"Hello " + name + "!"'),Y=x(()=>r(R)?null:p.status);jr(j,{get language(){return r(H)},get value(){return r(D)},onChange:K=>{if(r(R)||K.trimStart().startsWith("{"))try{o({...r(a),component:JSON.parse(K)});return}catch{}o({...r(a),component:K})},get rows(){return r(N)},get placeholder(){return r(G)},get status(){return r(Y)}})}s(k),T(()=>y(q,r(R)?"Message (component)":"Message (expression)")),f(S,k)},L=S=>{var R=fk(),k=ie(R),M=d(l(k),2);kt(M),s(k);var q=d(k,2),j=d(l(q),2);{let H=x(()=>String(r(a).value||""));jr(j,{language:"expression",get value(){return r(H)},onChange:D=>o({...r(a),value:D}),rows:2,placeholder:"health + food",get status(){return u.status}})}s(q),T(H=>Ot(M,H),[()=>String(r(a).key||"")]),U("change",M,H=>o({...r(a),key:H.currentTarget.value})),f(S,R)},B=S=>{var R=vk(),k=d(l(R),2);{let M=x(()=>String(r(a).address||""));jr(k,{language:"expression",get value(){return r(M)},onChange:q=>o({...r(a),address:q}),rows:1,placeholder:'"play.example.com" or "host:port"',get status(){return $.status}})}s(R),f(S,R)},O=S=>{var R=$k(),k=d(ie(R),2);de(k,17,()=>r(a).actions||[],lt,(q,j,H)=>{var D=mk(),N=l(D),G=l(N);G.textContent=`Step ${H+1}`;var Y=d(G,2);s(N);var K=d(N,2);Ti(K,{get value(){return r(j)},onChange:Z=>g(H,Z)}),s(D),U("click",Y,()=>m(H)),f(q,D)});var M=d(k,2);U("click",M,v),f(S,R)};z(I,S=>{r(a).type===Et.inject?S(P):r(a).type===Et.chat?S(F,1):r(a).type===Et.setCustom?S(L,2):r(a).type===Et.move?S(B,3):r(a).type===Et.sequence&&S(O,4)})}s(A),s(h),T(()=>y(C,r(i)?.detail||"")),f(t,h),ce()}Pe(["change","click"]);var gk=_('
            No registered actions. Create one.
            '),hk=_(""),bk=_(""),xk=_('
            ');function Ci(t,e){le(e,!0);let n="asel-"+Math.random().toString(36).slice(2,9),a=am("/actions"),i=Bt(()=>Jn(e.value)?null:e.value),o=X(tt(Bt(()=>Jn(e.value)?"registered":"inline")));ge(()=>{Jn(e.value)&&r(o)!=="registered"&&E(o,"registered")});function c(P){P!==r(o)&&(E(o,P,!0),P==="inline"?e.onChange?.(i||null):(Jn(e.value)||(i=e.value),e.onChange?.(null)))}function p(P){i=P,e.onChange?.(P)}var u=xk(),$=l(u),g=l($),v=l(g);kt(v),ve(2),s(g);var m=d(g,2),h=l(m);kt(h),ve(2),s(m),s($);var b=d($,2),w=l(b);{var C=P=>{{let F=x(()=>Jn(e.value)?null:e.value);Ti(P,{get value(){return r(F)},onChange:p})}},A=P=>{var F=gk();f(P,F)},I=P=>{var F=bk(),L=l(F);L.value=L.__value="";var B=d(L);de(B,17,()=>a.data,S=>S.id,(S,R)=>{var k=hk(),M=l(k);s(k);var q={};T(j=>{y(M,`${r(R).name??""} \u2014 ${j??""}`),q!==(q=r(R).id)&&(k.value=(k.__value=r(R).id)??"")},[()=>cs(r(R).action)]),f(S,k)}),s(F);var O;Zn(F),T(S=>{O!==(O=S)&&(F.value=(F.__value=S)??"",Sn(F,S))},[()=>ls(e.value)]),U("change",F,S=>{let R=S.currentTarget.value;e.onChange?.(R?{type:Et.ref,id:R}:null)}),f(P,F)};z(w,P=>{r(o)==="inline"?P(C):(a.data?.length??0)===0?P(A,1):P(I,-1)})}s(b),s(u),T(()=>{ne(v,"name",n),da(v,r(o)==="inline"),ne(h,"name",n),da(h,r(o)==="registered")}),U("change",v,()=>c("inline")),U("change",h,()=>c("registered")),f(t,u),ce()}Pe(["change"]);var yk=_(''),wk=_('
             
            '),kk=_(" ",1);function dp(t,e){le(e,!0);let n=X(null),a=X(null);async function i(){if(!r(n)){ft("No action defined","error");return}try{let o=await Ge("/trigger",{method:"POST",body:{query:`name = "${e.p.username}"`,action:r(n)}});E(a,JSON.stringify(o,null,2),!0),ft(`Action fired on ${o.fired}/${o.matched}`)}catch(o){ft("Failed: "+o.message,"error")}}{let o=p=>{var u=yk();U("click",u,i),f(p,u)},c=x(()=>`on ${e.p.username||"this player"}`);et(t,{title:"Run action",get meta(){return r(c)},actions:o,children:(p,u)=>{var $=kk(),g=ie($);Ci(g,{get value(){return r(n)},onChange:h=>E(n,h,!0)});var v=d(g,2);{var m=h=>{var b=wk(),w=l(b,!0);s(b),T(()=>y(w,r(a))),f(h,b)};z(v,h=>{r(a)&&h(m)})}f(p,$)},$$slots:{actions:!0,default:!0}})}ce()}Pe(["click"]);var Ek=_('
            ');function Rl(t,e){le(e,!0);let n;ge(()=>{e.dependency,n&&(n.scrollTop=n.scrollHeight)});var a=Ek(),i=l(a);tr(i,()=>e.children??At),s(a),Tt(a,o=>n=o,()=>n),f(t,a),ce()}function Sk(t){return t==null?"":t<1e3?"prov--hot":t>6e4?"prov--stale":""}var Tk=_(' '),Ck=_(' '),Ak=_(''),Mk=_("");function no(t,e){le(e,!0);let n=I=>{var P=Ck(),F=l(P);{var L=R=>{var k=Me(),M=ie(k);tr(M,()=>e.children),f(R,k)},B=R=>{var k=bt();T(()=>y(k,e.value)),f(R,k)};z(F,R=>{e.children?R(L):R(B,-1)})}var O=d(F,2);{var S=R=>{var k=Tk(),M=l(k,!0);s(k),T(()=>y(M,e.suffix)),f(R,k)};z(O,R=>{e.suffix&&R(S)})}s(P),f(I,P)},a=ae(e,"variant",3,""),i=xo(bl),o=xo(xl),c=x(()=>Xr.now),p=x(()=>e.source?.ts?r(c)-e.source.ts:null),u=x(()=>!!e.field&&!!i),$=x(()=>r(u)&&o?.()===e.field),g=x(()=>["prov",a()&&`prov--${a()}`,Sk(r(p)),r($)&&"is-open",!r(u)&&"prov--static"].filter(Boolean).join(" "));function v(I){r(u)&&(I.preventDefault(),Na.hide(),i?.(e.field,I.currentTarget))}function m(I){!r(u)||r($)||I.currentTarget.closest('[data-traces="off"]')||Na.show(I.currentTarget,{field:e.field,source:e.source??null})}function h(){Na.hide()}var b=Me(),w=ie(b);{var C=I=>{var P=Ak(),F=l(P);n(F),s(P),T(()=>{pe(P,1,St(r(g))),ne(P,"data-prov-field",e.field||void 0)}),U("click",P,v),Rt("pointerenter",P,m),Rt("pointerleave",P,h),Rt("focus",P,m),Rt("blur",P,h),f(I,P)},A=I=>{var P=Mk(),F=l(P);n(F),s(P),T(()=>{pe(P,1,St(r(g))),ne(P,"data-prov-field",e.field||void 0)}),f(I,P)};z(w,I=>{r(u)?I(C):I(A,-1)})}f(t,b),ce()}Pe(["click"]);var Pk=_(' '),Rk=_(' no source yet');function Gt(t,e){le(e,!0);let n=ae(e,"suffix",3,null),a=ae(e,"variant",3,"tight");var i=Me(),o=ie(i);{var c=$=>{var g=Rk(),v=l(g),m=l(v,!0),h=d(m);{var b=w=>{var C=Pk(),A=l(C,!0);s(C),T(()=>y(A,n())),f(w,C)};z(h,w=>{n()&&w(b)})}s(v),ve(2),s(g),T(()=>y(m,e.value)),f($,g)},p=x(()=>!Hs(e.p,e.field)),u=$=>{{let g=x(()=>Hs(e.p,e.field));no($,{get value(){return e.value},get source(){return r(g)},get field(){return e.field},get suffix(){return n()},get variant(){return a()}})}};z(o,$=>{r(p)?$(c):$(u,-1)})}f(t,i),ce()}var Nk=_('
            UUID
            Locale
            Client
            Server
            Address
            tcp-accept
            Protocol
            Compression
            ');function pp(t,e){le(e,!0),et(t,{title:"Identity",children:(n,a)=>{var i=Nk(),o=d(l(i),2),c=l(o);{let L=x(()=>e.p.uuid||"\u2014");Gt(c,{get p(){return e.p},field:"uuid",get value(){return r(L)}})}s(o);var p=d(o,4),u=l(p);{let L=x(()=>e.p.locale||"\u2014");Gt(u,{get p(){return e.p},field:"locale",get value(){return r(L)}})}s(p);var $=d(p,4),g=l($);{let L=x(()=>e.p.clientBrand||"\u2014");Gt(g,{get p(){return e.p},field:"clientBrand",get value(){return r(L)}})}s($);var v=d($,4),m=l(v);{let L=x(()=>e.p.serverBrand||"\u2014");Gt(m,{get p(){return e.p},field:"serverBrand",get value(){return r(L)}})}s(v);var h=d(v,4),b=l(h),w=l(b),C=l(w,!0);s(w),ve(2),s(b),s(h);var A=d(h,4),I=l(A);{let L=x(()=>String(e.p.protocolVersion||"\u2014"));Gt(I,{get p(){return e.p},field:"protocolVersion",get value(){return r(L)}})}s(A);var P=d(A,4),F=l(P);{let L=x(()=>String(e.p.traffic.compressionThreshold));Gt(F,{get p(){return e.p},field:"traffic.compressionThreshold",get value(){return r(L)},suffix:"bytes"})}s(P),s(i),T(()=>y(C,e.p.address||"\u2014")),f(n,i)},$$slots:{default:!0}}),ce()}var Lk=(t,e=At,n=At,a=At)=>{var i=Me(),o=ie(i);de(o,17,()=>Array(Math.max(1,Math.ceil(n()/2))),lt,(c,p,u)=>{let $=x(()=>e()-u*2),g=x(()=>a()?{empty:bi.hcEmpty,full:bi.hcFull,half:bi.hcHalf}:{empty:bi.empty,full:bi.full,half:bi.half}),v=x(()=>r($)>=2?r(g).full:r($)>=1?r(g).half:null);var m=Me(),h=ie(m);{var b=C=>{var A=Ok(),I=l(A);ne(I,"draggable",!1);var P=d(I);ne(P,"draggable",!1),s(A),T(()=>{ne(I,"src",r(g).empty),ne(P,"src",r(v))}),f(C,A)},w=C=>{var A=Dk();ne(A,"draggable",!1),T(()=>ne(A,"src",r(g).empty)),f(C,A)};z(h,C=>{r(v)?C(b):C(w,-1)})}f(c,m)}),f(t,i)},Ik=(t,e=At)=>{var n=Me(),a=ie(n);de(a,16,()=>Array(10),lt,(i,o,c)=>{let p=x(()=>e()-c*2),u=x(()=>r(p)>=2?qs.full:r(p)>=1?qs.half:null);var $=Me(),g=ie($);{var v=h=>{var b=Fk(),w=l(b);ne(w,"draggable",!1);var C=d(w);ne(C,"draggable",!1),s(b),T(()=>{ne(w,"src",qs.empty),ne(C,"src",r(u))}),f(h,b)},m=h=>{var b=Bk();ne(b,"draggable",!1),T(()=>ne(b,"src",qs.empty)),f(h,b)};z(g,h=>{r(u)?h(v):h(m,-1)})}f(i,$)}),f(t,n)},Ok=_(''),Dk=_(''),Fk=_(''),Bk=_(''),zk=_('
            HP
            Food
            XP \xB7 Lvl
            ',1);function up(t,e){le(e,!0),et(t,{title:"Vitals",children:(n,a)=>{var i=zk(),o=ie(i),c=d(l(o),2),p=l(c);{let D=x(()=>(e.p.health??0).toFixed(1)),N=x(()=>`/${(e.p.maxHealth??20).toFixed(0)}`);Gt(p,{get p(){return e.p},field:"health",get value(){return r(D)},get suffix(){return r(N)}})}s(c);var u=d(c,2),$=l(u);Lk($,()=>e.p.health||0,()=>e.p.maxHealth||20,()=>e.p.hardcore),s(u),s(o);var g=d(o,2),v=d(l(g),2),m=l(v);{let D=x(()=>String(e.p.food??0)),N=x(()=>`/20 \xB7 sat ${(e.p.saturation??0).toFixed(1)}`);Gt(m,{get p(){return e.p},field:"food",get value(){return r(D)},get suffix(){return r(N)}})}s(v);var h=d(v,2),b=l(h);Ik(b,()=>e.p.food||0),s(h),s(g);var w=d(g,2),C=l(w),A=d(l(C));{let D=x(()=>String(e.p.xpLevel??0));Gt(A,{get p(){return e.p},field:"xpLevel",get value(){return r(D)}})}s(C);var I=d(C,2),P=l(I);{let D=x(()=>Math.round((e.p.xpBar||0)*100)+"%");Gt(P,{get p(){return e.p},field:"xpBar",get value(){return r(D)}})}s(I);var F=d(I,2);{let D=x(()=>e.p.xpBar??0);Ra(F,{get value(){return r(D)},class:"progress-bar--gauge"})}s(w);var L=d(w,2),B=l(L);{var O=D=>{pr(D,{kind:"on",children:(N,G)=>{ve();var Y=bt("flying");f(N,Y)},$$slots:{default:!0}})};z(B,D=>{e.p.flying&&D(O)})}var S=d(B,2);{var R=D=>{pr(D,{kind:"on",children:(N,G)=>{ve();var Y=bt("invuln");f(N,Y)},$$slots:{default:!0}})};z(S,D=>{e.p.invulnerable&&D(R)})}var k=d(S,2);{var M=D=>{pr(D,{children:(N,G)=>{ve();var Y=bt("may fly");f(N,Y)},$$slots:{default:!0}})};z(k,D=>{e.p.allowFlying&&D(M)})}var q=d(k,2);{var j=D=>{pr(D,{children:(N,G)=>{ve();var Y=bt("grounded");f(N,Y)},$$slots:{default:!0}})},H=D=>{pr(D,{children:(N,G)=>{ve();var Y=bt("airborne");f(N,Y)},$$slots:{default:!0}})};z(q,D=>{e.p.onGround?D(j):D(H,-1)})}s(L),f(n,i)},$$slots:{default:!0}}),ce()}var qk=_('
            Flying
            Invulnerable
            Allow flying
            Fly speed
            Walk speed
            ');function fp(t,e){le(e,!0),et(t,{title:"Abilities",children:(n,a)=>{var i=qk(),o=d(l(i),2),c=l(o);{let w=x(()=>String(!!e.p.flying));Gt(c,{get p(){return e.p},field:"flying",get value(){return r(w)}})}s(o);var p=d(o,4),u=l(p);{let w=x(()=>String(!!e.p.invulnerable));Gt(u,{get p(){return e.p},field:"invulnerable",get value(){return r(w)}})}s(p);var $=d(p,4),g=l($);{let w=x(()=>String(!!e.p.allowFlying));Gt(g,{get p(){return e.p},field:"allowFlying",get value(){return r(w)}})}s($);var v=d($,4),m=l(v);{let w=x(()=>((e.p.flySpeed??0)*1).toFixed(3));Gt(m,{get p(){return e.p},field:"flySpeed",get value(){return r(w)}})}s(v);var h=d(v,4),b=l(h);{let w=x(()=>((e.p.walkSpeed??0)*1).toFixed(3));Gt(b,{get p(){return e.p},field:"walkSpeed",get value(){return r(w)}})}s(h),s(i),f(n,i)},$$slots:{default:!0}}),ce()}var Hk=_('
            No active effects.
            '),jk=_(''),Uk=_('
            '),Vk=_(' '),Yk=_('
            '),Gk=_('
            ');function vp(t,e){le(e,!0);var n=Me(),a=ie(n);{var i=p=>{et(p,{title:"Effects",meta:"none",children:(u,$)=>{var g=Hk();f(u,g)},$$slots:{default:!0}})},o=x(()=>Object.values(e.p.activeEffects||{}).length===0),c=p=>{{let u=x(()=>`${Object.values(e.p.activeEffects).length} active`);et(p,{title:"Effects",get meta(){return r(u)},children:($,g)=>{var v=Gk();de(v,21,()=>Object.values(e.p.activeEffects),lt,(m,h)=>{let b=x(()=>nv(r(h).id)),w=x(()=>Math.round((r(h).durationTicks||0)/20)),C=x(()=>r(w)>9999?"\u221E":Wf(r(w))),A=x(()=>r(h).amplifier?Gf(r(h).amplifier+1):"");var I=Yk(),P=l(I);{var F=k=>{var M=jk();ne(M,"draggable",!1),T(()=>{ne(M,"src",r(b)),ne(M,"alt",r(h).id)}),f(k,M)},L=k=>{var M=Uk(),q=l(M,!0);s(M),T(j=>y(q,j),[()=>(r(h).id||"").replace(/^minecraft:/,"").slice(0,3)]),f(k,M)};z(P,k=>{r(b)?k(F):k(L,-1)})}var B=d(P,2);{var O=k=>{var M=Vk(),q=l(M,!0);s(M),T(()=>y(q,r(A))),f(k,M)};z(B,k=>{r(A)&&k(O)})}var S=d(B,2),R=l(S,!0);s(S),s(I),T(k=>{ne(I,"title",k),y(R,r(C))},[()=>`${Zi(r(h).id)}${r(A)?" "+r(A):""} \xB7 ${r(C)}`]),f(m,I)}),s(v),f($,v)},$$slots:{default:!0}})}};z(a,p=>{r(o)?p(i):p(c,-1)})}f(t,n),ce()}var Wk=_('
            X
            Y
            Z
            Yaw
            Pitch
            On ground
            Bytes in
            Bytes out
            ');function mp(t,e){le(e,!0),et(t,{title:"Position",meta:a=>{{let i=x(()=>(e.p.dimension||"\u2014").replace("minecraft:",""));Gt(a,{get p(){return e.p},field:"dimension",get value(){return r(i)}})}},children:(a,i)=>{var o=Wk(),c=d(l(o),2),p=l(c);{let B=x(()=>(e.p.posX??0).toFixed(2));Gt(p,{get p(){return e.p},field:"posX",get value(){return r(B)}})}s(c);var u=d(c,4),$=l(u);{let B=x(()=>(e.p.posY??0).toFixed(2));Gt($,{get p(){return e.p},field:"posY",get value(){return r(B)}})}s(u);var g=d(u,4),v=l(g);{let B=x(()=>(e.p.posZ??0).toFixed(2));Gt(v,{get p(){return e.p},field:"posZ",get value(){return r(B)}})}s(g);var m=d(g,4),h=l(m);{let B=x(()=>(e.p.yaw??0).toFixed(1)+"\xB0");Gt(h,{get p(){return e.p},field:"yaw",get value(){return r(B)}})}s(m);var b=d(m,4),w=l(b);{let B=x(()=>(e.p.pitch??0).toFixed(1)+"\xB0");Gt(w,{get p(){return e.p},field:"pitch",get value(){return r(B)}})}s(b);var C=d(b,4),A=l(C);{let B=x(()=>String(!!e.p.onGround));Gt(A,{get p(){return e.p},field:"onGround",get value(){return r(B)}})}s(C);var I=d(C,4),P=l(I,!0);s(I);var F=d(I,4),L=l(F,!0);s(F),s(o),T((B,O)=>{y(P,B),y(L,O)},[()=>zt(e.p.traffic.bytesIn),()=>zt(e.p.traffic.bytesOut)]),f(a,o)},$$slots:{meta:!0,default:!0}}),ce()}var Kk=_(' '),Xk=_("
            ");function $p(t,e){le(e,!0);let n=ae(e,"className",3,""),a=ae(e,"withTimestamp",3,!0);var i=Xk(),o=l(i);{var c=u=>{var $=Kk(),g=l($,!0);s($),T(v=>y(g,v),[()=>Nn(e.ts).slice(0,8)]),f(u,$)};z(o,u=>{a()&&e.ts!=null&&u(c)})}var p=d(o,2);ln(p,{get value(){return e.value}}),s(i),T(u=>pe(i,1,u),[()=>St(("chat-line "+n()).trim())]),f(t,i),ce()}var Zk=_("HUD theater",1),Jk=_('
            '),Qk=_('
            '),e0=_(''),t0=_(''),r0=_(' '),n0=_('
            '),a0=_('
            '),i0=_('
            '),s0=_('
            ');function _p(t,e){le(e,!0),et(t,{meta:"live mirror",className:"hud-panel",flush:!0,title:a=>{ve();var i=Zk();ve(),f(a,i)},children:(a,i)=>{var o=s0(),c=l(o);{var p=w=>{var C=Qk();de(C,23,()=>Object.entries(e.p.bossBars||{}).filter(([,A])=>A!=null),([A,I])=>A,(A,I)=>{var P=x(()=>fr(r(I),2));let F=()=>r(P)[0],L=()=>r(P)[1];var B=Jk(),O=l(B),S=l(O);ln(S,{get value(){return L().title}}),s(O);var R=d(O,2);{let k=x(()=>L().progress??0);Ra(R,{variant:"boss",get value(){return r(k)},get color(){return L().color}})}s(B),f(A,B)}),s(C),f(w,C)},u=x(()=>Object.keys(e.p.bossBars||{}).length>0);z(c,w=>{r(u)&&w(p)})}var $=d(c,2);{var g=w=>{var C=a0(),A=l(C),I=l(A);{let F=x(()=>e.p.scoreboard.displayName||e.p.scoreboard.objectiveName||"\u2014");ln(I,{get value(){return r(F)}})}s(A);var P=d(A,2);de(P,17,()=>ev(e.p.scoreboard.rows),F=>F.key,(F,L)=>{var B=n0(),O=l(B),S=l(O);{let j=x(()=>r(L).display??r(L).key);ln(S,{get value(){return r(j)}})}s(O);var R=d(O,2);{var k=j=>{var H=e0(),D=l(H);ln(D,{get value(){return r(L).numberFormat.content}}),s(H),f(j,H)},M=j=>{var H=t0();f(j,H)},q=j=>{var H=r0(),D=l(H,!0);s(H),T(()=>y(D,r(L).score)),f(j,H)};z(R,j=>{r(L).numberFormat?.format==="FIXED"?j(k):r(L).numberFormat?.format==="BLANK"?j(M,1):j(q,-1)})}s(B),f(F,B)}),s(C),f(w,C)};z($,w=>{e.p.scoreboard&&w(g)})}var v=d($,2);{var m=w=>{var C=i0(),A=l(C);ln(A,{get value(){return e.p.lastActionBar}}),s(C),f(w,C)};z(v,w=>{e.p.lastActionBar!=null&&w(m)})}var h=d(v,2),b=l(h);de(b,21,()=>(e.p.recentChat||[]).slice(-12),lt,(w,C)=>{$p(w,{get ts(){return r(C).ts},get value(){return r(C).content},className:"hud-chat-line"})}),s(b),s(h),s(o),f(a,o)},$$slots:{title:!0,default:!0}}),ce()}var _m="/assets/textures/entity/player/wide/steve.png",Ln={headBase:[8,8,8,8],headOverlay:[40,8,8,8],body:[20,20,8,12],bodyOverlay:[20,36,8,12],rArm:[44,20,4,12],rArmOverlay:[44,36,4,12],lArm:[36,52,4,12],lArmOverlay:[52,52,4,12],rLeg:[4,20,4,12],rLegOverlay:[4,36,4,12],lLeg:[20,52,4,12],lLegOverlay:[4,52,4,12]};function gm(t){return new Promise((e,n)=>{if(!t)return n(new Error("no url"));let a=new Image;a.crossOrigin="anonymous",a.onload=()=>e(a),a.onerror=()=>n(new Error("load failed: "+t)),a.src=t})}function o0(t){let e=document.createElement("canvas");e.width=64,e.height=64;let n=e.getContext("2d");n.imageSmoothingEnabled=!1,n.drawImage(t,0,0);let a=(i,o,c,p,u,$)=>{n.save(),n.translate(u+c,$),n.scale(-1,1),n.drawImage(e,i,o,c,p,0,0,c,p),n.restore()};return a(44,20,4,12,36,52),a(4,20,4,12,20,52),e}function l0(t){if(!t)return null;try{let e=t.textures??t.textures,n=e;return e&&typeof e=="object"&&(n=e.value??e.Value),!n||typeof n!="string"?null:JSON.parse(atob(n))?.textures?.SKIN?.url||null}catch{return null}}async function c0(t,e){if(!t)return;let n=l0(e)||_m,a=await gm(n).catch(()=>gm(_m).catch(()=>null));if(!a)return;let i=a.naturalWidth===64&&a.naturalHeight===32?o0(a):a,o=document.createElement("canvas");o.width=16,o.height=32;let c=o.getContext("2d");c.imageSmoothingEnabled=!1;let p=([w,C,A,I],P,F)=>c.drawImage(i,w,C,A,I,P,F,A,I);p(Ln.headBase,4,0),p(Ln.headOverlay,4,0),p(Ln.rArm,0,8),p(Ln.rArmOverlay,0,8),p(Ln.body,4,8),p(Ln.bodyOverlay,4,8),p(Ln.lArm,12,8),p(Ln.lArmOverlay,12,8),p(Ln.rLeg,4,20),p(Ln.rLegOverlay,4,20),p(Ln.lLeg,8,20),p(Ln.lLegOverlay,8,20);let u=window.devicePixelRatio||1,$=t.clientWidth||120,g=t.clientHeight||180;t.width=Math.round($*u),t.height=Math.round(g*u);let v=t.getContext("2d");v.imageSmoothingEnabled=!1,v.clearRect(0,0,t.width,t.height);let m=Math.min(t.width/o.width,t.height/o.height),h=o.width*m,b=o.height*m;v.drawImage(o,(t.width-h)/2,(t.height-b)/2,h,b)}var d0=_("");function gp(t,e){le(e,!0);let n=ae(e,"className",3,""),a=ae(e,"style",3,""),i;ge(()=>{c0(i,e.profileProperties).catch(()=>{})});var o=d0();Tt(o,c=>i=c,()=>i),T(()=>{pe(o,1,St(n())),we(o,a())}),f(t,o),ce()}var hm=(t,e=At)=>{var n=Me(),a=ie(n);{var i=o=>{var c=Me(),p=ie(c);Tc(p,e,u=>{var $=u0();f(u,$)}),f(o,c)};z(a,o=>{e()&&o(i)})}f(t,n)},p0=_(''),u0=_(''),f0=_("
            "),v0=_(' '),m0=_(''),$0=_('
            '),_0=_('
            awaiting first Window-Items packet\u2026
            '),g0=_('
            '),h0=_('
            Open container
            ',1),b0=_('
            '),x0=_('
            '),y0=_('
            '),w0=_('
            ');function hp(t,e){le(e,!0);let n=(V,W=At)=>{let re=x(()=>g(W()));var oe=Me(),ue=ie(oe);{var $e=me=>{var he=p0();ne(he,"draggable",!1),T(()=>ne(he,"src",`/api/material-icon/${r(re)}`)),Rt("error",he,be=>{be.target.replaceWith(Object.assign(document.createElement("span"),{className:"mc-icon-fallback",textContent:r(re).slice(0,3)}))}),xc(he),f(me,he)};z(ue,me=>{r(re)&&me($e)})}f(V,oe)},a=(V,W=At,re=At,oe=At,ue)=>{let $e=wa(()=>ql(ue?.(),"")),me=x(()=>P(re(),oe()));var he=Me(),be=ie(he);{var ye=Re=>{var De=f0(),Be=l(De);hm(Be,()=>r(me)),s(De),T(()=>{pe(De,1,`mc-slot ${r($e)??""}`),ne(De,"data-kind",re()),ne(De,"data-idx",oe())}),f(Re,De)},ze=Re=>{let De=x(()=>m(W()));var Be=$0();let ot;var Ke=l(Be);n(Ke,()=>W().id);var ke=d(Ke,2);{var Ze=Ae=>{var Ce=v0(),Fe=l(Ce,!0);s(Ce),T(()=>y(Fe,W().count)),f(Ae,Ce)};z(ke,Ae=>{W().count>1&&Ae(Ze)})}var je=d(ke,2);{var Le=Ae=>{var Ce=m0();let Fe;T(Ne=>Fe=we(Ce,"",Fe,Ne),[()=>({"--dur":`${(r(De)*100).toFixed(0)}%`,"--dur-color":h(r(De))})]),f(Ae,Ce)};z(je,Ae=>{r(De)!=null&&Ae(Le)})}var qe=d(je,2);hm(qe,()=>r(me)),s(Be),T(Ae=>{ot=pe(Be,1,`mc-slot has-item ${r($e)??""}`,null,ot,Ae),ne(Be,"data-kind",re()),ne(Be,"data-idx",oe())},[()=>({enchanted:v(W())})]),Rt("mouseenter",Be,Ae=>C(W(),Ae)),U("mousemove",Be,Ae=>C(W(),Ae)),Rt("mouseleave",Be,A),Rt("click",Be,Ae=>ol(Ae,W(),"Item JSON copied"),!0),f(Re,Be)};z(be,Re=>{!W()||!W().id?Re(ye):Re(ze,-1)})}f(V,he)},i=ae(e,"armor",19,()=>[]),o=ae(e,"main",19,()=>[]),c=ae(e,"hotbar",19,()=>[]),p=ae(e,"selectedHotbar",3,0),u=ae(e,"openedWindow",3,null),$=ae(e,"recentClicks",19,()=>[]),g=V=>String(V||"").replace(/^minecraft:/,""),v=V=>{let W=V?.components;return!!(W&&(W.enchantments||W["minecraft:enchantments"]||W.stored_enchantments))};function m(V){let W=V?.components,re=W?.damage??W?.["minecraft:damage"],oe=W?.max_damage??W?.["minecraft:max_damage"];return re==null||!oe?null:Math.max(0,Math.min(1,1-re/oe))}let h=V=>V>.66?"var(--acc)":V>.33?"var(--warn)":"var(--danger)";function b(V){let W=V.components||{},re=W.custom_name??W["minecraft:custom_name"]??W.item_name??W["minecraft:item_name"],oe=W.lore||W["minecraft:lore"],ue=W.enchantments||W["minecraft:enchantments"]||W.stored_enchantments;return{id:V.id||"",title:re??Zi(V.id),count:V.count||1,lore:Array.isArray(oe)?oe:[],enchants:ue&&typeof ue=="object"?Object.entries(ue).map(([$e,me])=>`${g($e)} ${me}`):[]}}let w=X(null),C=(V,W)=>{if(W.altKey){E(w,null);return}E(w,{data:b(V),x:W.clientX+12,y:W.clientY+12},!0)},A=()=>{E(w,null)},I=X(null);ge(()=>{let V=$().at(-1);if(!V)return;let W=`${V.seq}:${V.ts}:${V.rawSlot}`;r(I)?.key!==W&&E(I,{kind:V.kind,idx:V.localSlot,key:W},!0)});let P=(V,W)=>r(I)&&r(I).kind===V&&r(I).idx===W?r(I).key:null;function F(V){return V<=0||V%9===0?9:V===5?5:V===3||V===10?3:Math.min(V,9)}var L=w0(),B=l(L),O=l(B);{var S=V=>{let W=x(()=>u().slots||[]),re=x(()=>F(r(W).length)),oe=x(()=>Zi(u().type)||"window");var ue=h0(),$e=ie(ue),me=l($e),he=d(l(me),4),be=l(he);ln(be,{get value(){return u().title}}),s(he);var ye=d(he,2),ze=l(ye),Re=l(ze,!0);s(ze);var De=d(ze,2),Be=l(De);s(De);var ot=d(De,2),Ke=l(ot);s(ot),s(ye),s(me);var ke=d(me,2);{var Ze=Ae=>{var Ce=_0();f(Ae,Ce)},je=Ae=>{var Ce=g0();let Fe;de(Ce,21,()=>r(W),lt,(Ne,Ve,mt)=>{a(Ne,()=>r(Ve),()=>"container",()=>mt)}),s(Ce),T(()=>Fe=we(Ce,"",Fe,{"--w":r(re)})),f(Ae,Ce)};z(ke,Ae=>{r(W).length===0?Ae(Ze):Ae(je,-1)})}s($e);var Le=d($e,2),qe=l(Le);s(Le),T(()=>{y(Re,r(oe)),y(Be,`${r(W).length??""} slot${r(W).length===1?"":"s"}`),y(Ke,`id ${u().id??""}`),y(qe,`Player inventory \xB7 live mirror while ${r(oe)??""} is open`)}),f(V,ue)};z(O,V=>{u()&&V(S)})}var R=d(O,2);let k;var M=l(R);de(M,20,()=>Array(4),lt,(V,W,re)=>{a(V,()=>i()[re],()=>"armor",()=>re)}),s(M);var q=d(M,2),j=l(q);gp(j,{get profileProperties(){return e.profileProperties}}),s(q);var H=d(q,2),D=l(H);a(D,()=>e.offHand,()=>"offhand",()=>0),s(H);var N=d(H,2);de(N,20,()=>Array(27),lt,(V,W,re)=>{a(V,()=>o()[re],()=>"main",()=>re)}),s(N);var G=d(N,2);de(G,20,()=>Array(9),lt,(V,W,re)=>{a(V,()=>c()[re],()=>"hotbar",()=>re,()=>re===p()?"selected":"")}),s(G),s(R),s(B);var Y=d(B,2);let K;var Z=d(l(Y),2),Q=l(Z);{var ee=V=>{a(V,()=>e.cursor,()=>"cursor",()=>0)};z(Q,V=>{e.cursor?.id&&V(ee)})}s(Z),s(Y);var J=d(Y,2);{var te=V=>{var W=y0();let re;var oe=l(W),ue=l(oe);ln(ue,{get value(){return r(w).data.title}}),s(oe);var $e=d(oe,2);de($e,17,()=>r(w).data.lore,lt,(ye,ze)=>{var Re=b0(),De=l(Re);ln(De,{get value(){return r(ze)}}),s(Re),f(ye,Re)});var me=d($e,2);de(me,17,()=>r(w).data.enchants,lt,(ye,ze)=>{var Re=x0(),De=l(Re,!0);s(Re),T(()=>y(De,r(ze))),f(ye,Re)});var he=d(me,2),be=l(he,!0);s(he),s(W),T(()=>{re=we(W,"",re,{left:r(w).x+"px",top:r(w).y+"px"}),y(be,r(w).data.id)}),f(V,W)};z(J,V=>{r(w)?.data&&V(te)})}s(L),T(()=>{k=pe(R,1,"mc-inv-stage",null,k,{"mc-inv-stage--ghosted":!!u()}),K=pe(Y,1,"mc-cursor",null,K,{"mc-cursor--empty":!e.cursor?.id}),ne(Y,"aria-hidden",!e.cursor?.id)}),f(t,L),ce()}Pe(["mousemove"]);function Nl(t,e){le(e,!0);let n=x(()=>e.p.openedWindow?(e.p.openedWindow.slots||[]).length:-1),a=x(()=>r(n)>=0?`open: ${r(n)} slot${r(n)===1?"":"s"}`:`slot ${e.p.selectedHotbar??0}`);et(t,{title:"Inventory",get meta(){return r(a)},children:(i,o)=>{{let c=x(()=>e.p.armor||[]),p=x(()=>e.p.mainInventory||[]),u=x(()=>e.p.hotbar||[]),$=x(()=>e.p.recentClicks||[]);hp(i,{get armor(){return r(c)},get main(){return r(p)},get hotbar(){return r(u)},get offHand(){return e.p.offHand},get cursor(){return e.p.cursor},get selectedHotbar(){return e.p.selectedHotbar},get openedWindow(){return e.p.openedWindow},get recentClicks(){return r($)},get profileProperties(){return e.p.profileProperties}})}},$$slots:{default:!0}}),ce()}var k0=_('
            No attributes reported.
            '),E0=_(' no source'),S0=_(' '),T0=_('
            ');function bp(t,e){le(e,!0);var n=Me(),a=ie(n);{var i=p=>{et(p,{title:"Attributes",meta:"none",children:(u,$)=>{var g=k0();f(u,g)},$$slots:{default:!0}})},o=x(()=>Object.entries(e.p.attributes||{}).length===0),c=p=>{{let u=x(()=>String(Object.entries(e.p.attributes).length));et(p,{title:"Attributes",get meta(){return r(u)},flush:!0,children:($,g)=>{var v=T0(),m=l(v);de(m,21,()=>Object.entries(e.p.attributes),([h,b])=>h,(h,b)=>{var w=x(()=>fr(r(b),2));let C=()=>r(w)[0],A=()=>r(w)[1],I=x(()=>"attributes."+C()),P=x(()=>Hs(e.p,r(I)));var F=S0(),L=l(F),B=l(L,!0);s(L);var O=d(L),S=l(O);{var R=M=>{{let q=x(()=>Number(A()).toFixed(3));no(M,{get value(){return r(q)},get source(){return r(P)},get field(){return r(I)},variant:"tight"})}},k=M=>{var q=E0(),j=l(q),H=l(j),D=l(H,!0);s(H),s(j),ve(2),s(q),T(N=>y(D,N),[()=>Number(A()).toFixed(3)]),f(M,q)};z(S,M=>{r(P)?M(R):M(k,-1)})}s(O),s(F),T(M=>y(B,M),[()=>C().replace(/^minecraft:/,"")]),f(h,F)}),s(m),s(v),f($,v)},$$slots:{default:!0}})}};z(a,p=>{r(o)?p(i):p(c,-1)})}f(t,n),ce()}function xp(t,e){le(e,!0),et(t,{title:"Latency",meta:a=>{{let i=x(()=>String(e.p.traffic.pingMs));Gt(a,{get p(){return e.p},field:"traffic.pingMs",get value(){return r(i)},suffix:"ms"})}},children:(a,i)=>{{let o=x(()=>({ping:e.p.traffic.pingHistory}));es(a,{get series(){return Jf},get data(){return r(o)},yLabel:"ms",yFormat:c=>Math.round(c)+"",gridX:5,gridY:3,showAxes:!0,showLegend:!1,className:"chart-sm"})}},$$slots:{meta:!0,default:!0}}),ce()}function bm(t){return Number.isInteger(t)?String(t):parseFloat(t.toFixed(6)).toString()}function xm(t){return Number.isInteger(t)?t>=-128&&t<=127?"Byte":t>=-32768&&t<=32767?"Short":t>=-2147483648&&t<=2147483647?"Int":"Long":"Float"}function C0(t){let e=Object.keys(t);if(e.length===0)return"{ }";let n=e.slice(0,3).join(", ");return e.length>3?n+", \u2026":n}function A0(t){if(t.length===0)return"[ ]";let e=t.slice(0,4).map(n=>n===null?"null":typeof n=="string"?`"${n.length>12?n.slice(0,12)+"\u2026":n}"`:typeof n=="boolean"?String(n):typeof n=="number"?bm(n):Array.isArray(n)?`[${n.length}]`:typeof n=="object"?`{${Object.keys(n).length}}`:String(n));return t.length>4?e.join(", ")+", \u2026":e.join(", ")}function M0(t){if(t.length===0)return null;let e=null;for(let n of t){let a;if(n===null)a="Null";else if(typeof n=="string")a="String";else if(typeof n=="boolean")a="Bool";else if(typeof n=="number")a=xm(n);else return null;if(e==null)e=a;else if(e!==a)return null}return e}var P0=_('
            '),R0=_(''),N0=_(' '),L0=_('
            '),I0=_('
            '),O0=_('[ ]'),D0=_(' '),F0=_('
            '),B0=_('
            '),z0=_('
            Unknown
            '),q0=_('
            ');function Za(t,e){le(e,!0);let n=A=>{var I=Me(),P=ie(I);{var F=S=>{var R=P0(),k=l(R),M=l(k,!0);s(k);var q=d(k,2),j=l(q,!0);s(q);var H=d(q,2),D=l(H,!0);s(H),s(R),T(()=>{y(M,a()),pe(q,1,"nbt-row__value nbt-v--"+r(u).kind),y(j,r(u).text),y(D,r(u).type)}),f(S,R)},L=S=>{let R=x(()=>Object.entries(e.value));var k=I0(),M=l(k),q=l(M),j=l(q),H=l(j,!0);s(j);var D=d(j);s(q);var N=d(q,2),G=l(N);{var Y=te=>{var V=R0();V.textContent="{ }",f(te,V)},K=te=>{var V=N0(),W=l(V,!0);s(V),T(re=>y(W,re),[()=>C0(e.value)]),f(te,V)};z(G,te=>{r(R).length===0?te(Y):te(K,-1)})}s(N);var Z=d(N,2),Q=l(Z);s(Z),s(M);var ee=d(M,2);{var J=te=>{var V=L0();de(V,21,()=>r(R),([W,re])=>W,(W,re)=>{var oe=x(()=>fr(r(re),2));let ue=()=>r(oe)[0],$e=()=>r(oe)[1];{let me=x(()=>i()+1);Za(W,{get value(){return $e()},get name(){return ue()},get depth(){return r(me)},root:!1,wrap:!1})}}),s(V),f(te,V)};z(ee,te=>{r(v)&&r(R).length>0&&te(J)})}s(k),T(()=>{pe(M,1,"nbt-row nbt-row--toggle"+(r(v)?" is-open":"")),y(H,r(v)?"\u25BE":"\u25B8"),y(D,` ${a()??""}`),y(Q,`Object \xB7 ${r(R).length??""}`)}),U("click",M,m),U("keydown",M,te=>{(te.key==="Enter"||te.key===" ")&&(te.preventDefault(),m())}),f(S,k)},B=S=>{let R=x(()=>M0(e.value));var k=B0(),M=l(k),q=l(M),j=l(q),H=l(j,!0);s(j);var D=d(j);s(q);var N=d(q,2),G=l(N);{var Y=te=>{var V=O0();f(te,V)},K=te=>{var V=D0(),W=l(V,!0);s(V),T(re=>y(W,re),[()=>A0(e.value)]),f(te,V)};z(G,te=>{e.value.length===0?te(Y):te(K,-1)})}s(N);var Z=d(N,2),Q=l(Z);s(Z),s(M);var ee=d(M,2);{var J=te=>{var V=F0();de(V,21,()=>e.value,lt,(W,re,oe)=>{{let ue=x(()=>i()+1);Za(W,{get value(){return r(re)},name:`[${oe}]`,get depth(){return r(ue)},root:!1,wrap:!1})}}),s(V),f(te,V)};z(ee,te=>{r(v)&&e.value.length>0&&te(J)})}s(k),T(()=>{pe(M,1,"nbt-row nbt-row--toggle"+(r(v)?" is-open":"")),y(H,r(v)?"\u25BE":"\u25B8"),y(D,` ${a()??""}`),y(Q,`List${r(R)?"\xB7"+r(R):""} \xB7 ${e.value.length??""}`)}),U("click",M,m),U("keydown",M,te=>{(te.key==="Enter"||te.key===" ")&&(te.preventDefault(),m())}),f(S,k)},O=S=>{var R=z0(),k=l(R),M=l(k,!0);s(k);var q=d(k,2),j=l(q,!0);s(q),ve(2),s(R),T(H=>{y(M,a()),y(j,H)},[()=>String(e.value)]),f(S,R)};z(P,S=>{r(u)?S(F):r(g)?S(L,1):r($)?S(B,2):S(O,-1)})}f(A,I)},a=ae(e,"name",3,"root"),i=ae(e,"depth",3,0),o=ae(e,"root",3,!0),c=ae(e,"wrap",3,!0);function p(A){return A==null?{kind:"null",text:"null",type:"Null"}:typeof A=="string"?{kind:"string",text:`"${A}"`,type:"String"}:typeof A=="boolean"?{kind:"bool",text:String(A),type:"Bool"}:typeof A=="number"?{kind:"num",text:bm(A),type:xm(A)}:null}let u=x(()=>p(e.value)),$=x(()=>!r(u)&&Array.isArray(e.value)),g=x(()=>!r(u)&&!r($)&&typeof e.value=="object"),v=X(tt(Bt(()=>o()||i()<(Array.isArray(e.value)?1:2))));function m(){E(v,!r(v))}var h=Me(),b=ie(h);{var w=A=>{var I=q0(),P=l(I);n(P),s(I),f(A,I)},C=A=>{n(A)};z(b,A=>{c()?A(w):A(C,-1)})}f(t,h),ce()}Pe(["click","keydown"]);var H0=_('
            No server data pushed for this player.
            '),j0=_('

            Available in MQL as server.*.

            ',1);function yp(t,e){le(e,!0);var n=Me(),a=ie(n);{var i=p=>{et(p,{title:"Server data",meta:"0 keys",children:(u,$)=>{var g=H0();f(u,g)},$$slots:{default:!0}})},o=x(()=>Object.keys(e.p.serverData||{}).length===0),c=p=>{{let u=x(()=>`${Object.keys(e.p.serverData).length} keys`);et(p,{title:"Server data",get meta(){return r(u)},children:($,g)=>{var v=j0(),m=ie(v);Za(m,{get value(){return e.p.serverData},name:"root"}),ve(2),f($,v)},$$slots:{default:!0}})}};z(a,p=>{r(o)?p(i):p(c,-1)})}f(t,n),ce()}var U0=t=>{var e=G0();f(t,e)},V0=(t,e=At)=>{var n=Me(),a=ie(n);{var i=c=>{var p=W0();f(c,p)},o=c=>{var p=Me(),u=ie(p);de(u,17,e,lt,($,g)=>{var v=X0();let m;var h=l(v),b=l(h),w=d(b);{var C=P=>{var F=K0(),L=l(F);s(F),T(B=>y(L,`\xB7 ${B??""}`),[()=>on(r(g).sender)]),f(P,F)};z(w,P=>{r(g).sender&&P(C)})}s(h);var A=d(h,2),I=l(A);ln(I,{get value(){return r(g).content}}),s(A),s(v),T(P=>{m=pe(v,1,"chat-msg",null,m,{player:r(g).style==="player",system:r(g).style==="system"}),y(b,`${P??""} `)},[()=>ed(r(g).ts)]),f($,v)}),f(c,p)};z(a,c=>{e().length===0?c(i):c(o,-1)})}f(t,n)},Y0=(t,e=At)=>{var n=Me(),a=ie(n);{var i=c=>{var p=Z0();f(c,p)},o=c=>{var p=Me(),u=ie(p);de(u,17,e,lt,($,g)=>{var v=Q0(),m=l(v),h=l(m,!0);s(m);var b=d(m,2),w=l(b);{var C=I=>{var P=J0();f(I,P)};z(w,I=>{r(g).kind==="command"&&I(C)})}var A=d(w);s(b),s(v),T(I=>{y(h,I),y(A,` ${r(g).text??""}`)},[()=>ed(r(g).ts)]),f($,v)}),f(c,p)};z(a,c=>{e().length===0?c(i):c(o,-1)})}f(t,n)},G0=_('Players'),W0=_('
            No chat received.
            '),K0=_(' '),X0=_('
            '),Z0=_('
            No outgoing chat captured yet.
            '),J0=_('/'),Q0=_('
            '),e1=_('

            Player not found

             
            '),t1=_('
            Loading\u2026
            '),r1=_('Protocol '),n1=_('Locale '),a1=_('
            Frozen \xB7 live state updates and packet streams are paused. Click Resume to continue.
            '),i1=_('live'),s1=_(" "),o1=_(`
            Every traceable value carries a quiet dotted underline. + Hover to peek the source packet \xB7 click to pin the full history.
            `,1),l1=_('
            '),c1=_('
            '),d1=_('
            UUID Session
            ms Ping
            Total i/o
            ');function wp(t,e){le(e,!0);let n=L=>{ve();var B=bt();T(()=>y(B,r(i)?.username||r(i)?.uuid)),f(L,B)},a=ae(e,"tab",3,"overview"),i=X(null),o=X(null),c=X(!0),p=X(null),u=X(!1),$=[];ge(()=>{e.uuid;let L=!0;return $=[],(async()=>{try{await rv();let B=await Ge("/players/"+e.uuid);if(!L)return;let O=$;$=[];for(let S of O)Qc(B,S);E(i,B,!0)}catch(B){L&&E(o,B.message,!0)}})(),()=>{L=!1}}),Zr(()=>e.uuid?Of(e.uuid):null,L=>{if(!(r(u)||!L)){if(!r(i)){$.push(L);return}Qc(r(i),L)}});function g(L,B){E(p,{field:L,anchor:B},!0)}function v(){E(p,null)}yo(bl,g),yo(xl,()=>r(p)?.field??null);let m=x(()=>"/p/"+e.uuid),h=L=>r(m)+(L==="overview"?"":"/"+L),b=x(()=>Xr.now);async function w(){try{await Ge(`/players/${r(i).uuid}/inject`,{method:"POST",body:{class:"DisconnectPacket",fields:{reason:{text:"Kicked by operator",color:"red"}}}}),ft("Kick packet injected")}catch(L){ft("Kick failed: "+L.message,"error")}}var C=Me(),A=ie(C);{var I=L=>{var B=e1(),O=l(B),S=d(l(O),2),R=l(S,!0);s(S),s(O),s(B),T(()=>y(R,r(o))),f(L,B)},P=L=>{var B=t1();f(L,B)},F=L=>{var B=d1(),O=l(B),S=l(O),R=l(S);{let Ee=x(()=>[U0,n]);ki(R,{get steps(){return r(Ee)}})}s(S);var k=d(S,2),M=l(k),q=l(M,!0);s(M);var j=d(M,2),H=d(j,2);s(k),s(O);var D=d(O,2),N=l(D),G=l(N,!0);s(N);var Y=d(N,2),K=l(Y),Z=l(K,!0);s(K);var Q=d(K,2),ee=l(Q);pr(ee,{kind:"on",dot:!0,children:(Ee,Ye)=>{ve();var We=bt();T(()=>y(We,r(i).serverConnectionState||"\u2014")),f(Ee,We)},$$slots:{default:!0}});var J=d(ee,2),te=d(l(J)),V=l(te,!0);s(te),s(J);var W=d(J,2),re=d(l(W)),oe=l(re,!0);s(re),s(W);var ue=d(W,2);{var $e=Ee=>{var Ye=r1(),We=d(l(Ye)),Oe=l(We,!0);s(We),s(Ye),T(()=>y(Oe,r(i).protocolVersion)),f(Ee,Ye)};z(ue,Ee=>{r(i).protocolVersion!=null&&Ee($e)})}var me=d(ue,2);{var he=Ee=>{var Ye=n1(),We=d(l(Ye)),Oe=l(We,!0);s(We),s(Ye),T(()=>y(Oe,r(i).locale)),f(Ee,Ye)};z(me,Ee=>{r(i).locale&&Ee(he)})}s(Q),s(Y);var be=d(Y,2),ye=l(be),ze=l(ye),Re=l(ze,!0);ve(),s(ze),ve(2),s(ye);var De=d(ye,2),Be=l(De),ot=l(Be,!0);s(Be),ve(2),s(De),s(be),s(D);var Ke=d(D,2);{var ke=Ee=>{var Ye=a1();f(Ee,Ye)};z(Ke,Ee=>{r(u)&&Ee(ke)})}var Ze=d(Ke,2);de(Ze,21,()=>Qo,Ee=>Ee.id,(Ee,Ye)=>{var We=s1(),Oe=l(We),st=d(Oe);{var rt=ht=>{var wt=i1();f(ht,wt)};z(st,ht=>{r(Ye).live&&ht(rt)})}s(We),T(ht=>{ne(We,"href",ht),ne(We,"aria-current",a()===r(Ye).id?"page":void 0),y(Oe,`${r(Ye).label??""} `)},[()=>h(r(Ye).id)]),f(Ee,We)}),s(Ze);var je=d(Ze,2);{var Le=Ee=>{var Ye=o1(),We=ie(Ye),Oe=l(We);pr(Oe,{kind:"on",children:(nr,Wt)=>{ve();var Ft=bt("\u24D8 Provenance");f(nr,Ft)},$$slots:{default:!0}});var st=d(Oe,4),rt=l(st,!0);s(st),s(We);var ht=d(We,2),wt=l(ht),dt=l(wt);pp(dt,{get p(){return r(i)}});var Ue=d(dt,2);up(Ue,{get p(){return r(i)}});var Qe=d(Ue,2);fp(Qe,{get p(){return r(i)}});var Se=d(Qe,2);vp(Se,{get p(){return r(i)}});var nt=d(Se,2);mp(nt,{get p(){return r(i)}}),s(wt);var ut=d(wt,2),vt=l(ut);_p(vt,{get p(){return r(i)}});var Ct=d(vt,2);Nl(Ct,{get p(){return r(i)}});var Dt=d(Ct,2);bp(Dt,{get p(){return r(i)}}),s(ut);var Vt=d(ut,2),ct=l(Vt),Mt=l(ct);El(Mt,{get uuid(){return e.uuid},get player(){return r(i)},get paused(){return r(u)}}),s(ct);var It=d(ct,2);xp(It,{get p(){return r(i)}});var qt=d(It,2);yp(qt,{get p(){return r(i)}}),s(Vt),s(ht),T(()=>y(rt,r(c)?"Hide all traces":"Show traces")),U("click",st,()=>E(c,!r(c))),f(Ee,Ye)},qe=Ee=>{Ud(Ee,{get player(){return r(i)},get paused(){return r(u)}})},Ae=Ee=>{Kd(Ee,{get player(){return r(i)}})},Ce=Ee=>{Nl(Ee,{get p(){return r(i)}})},Fe=Ee=>{var Ye=l1(),We=l(Ye);El(We,{get uuid(){return e.uuid},get player(){return r(i)},get paused(){return r(u)}}),s(Ye),f(Ee,Ye)},Ne=Ee=>{Yd(Ee,{get player(){return r(i)},get paused(){return r(u)}})},Ve=Ee=>{Wd(Ee,{get player(){return r(i)}})},mt=Ee=>{dp(Ee,{get p(){return r(i)}})},He=Ee=>{var Ye=c1(),We=l(Ye);{let st=x(()=>`${(r(i).recentChat||[]).slice(-100).length} messages`);et(We,{title:"As the player sees it",get meta(){return r(st)},flush:!0,children:(rt,ht)=>{{let wt=Ue=>{{let Qe=x(()=>(r(i).recentChat||[]).slice(-100));V0(Ue,()=>r(Qe))}},dt=x(()=>(r(i).recentChat||[]).slice(-100).length);Rl(rt,{get dependency(){return r(dt)},children:wt,$$slots:{default:!0}})}},$$slots:{default:!0}})}var Oe=d(We,2);{let st=x(()=>`${(r(i).sentChat||[]).slice(-100).length} captured`);et(Oe,{title:"What they sent",get meta(){return r(st)},flush:!0,children:(rt,ht)=>{{let wt=Ue=>{{let Qe=x(()=>(r(i).sentChat||[]).slice(-100));Y0(Ue,()=>r(Qe))}},dt=x(()=>(r(i).sentChat||[]).slice(-100).length);Rl(rt,{get dependency(){return r(dt)},children:wt,$$slots:{default:!0}})}},$$slots:{default:!0}})}s(Ye),f(Ee,Ye)};z(je,Ee=>{a()==="overview"?Ee(Le):a()==="packets"?Ee(qe,1):a()==="lifecycle"?Ee(Ae,2):a()==="inventory"?Ee(Ce,3):a()==="world"?Ee(Fe,4):a()==="entities"?Ee(Ne,5):a()==="registries"?Ee(Ve,6):a()==="action"?Ee(mt,7):a()==="chat"&&Ee(He,8)})}var Ie=d(je,2);{var Je=Ee=>{{let Ye=x(()=>r(i)?.provenance?.[r(p).field]?.seq??null);Pd(Ee,{get uuid(){return e.uuid},get field(){return r(p).field},get anchor(){return r(p).anchor},valueOf:We=>Qf(r(i),We),get sourceSeq(){return r(Ye)},onClose:v})}};z(Ie,Ee=>{r(p)&&Ee(Je)})}var $t=d(Ie,2);Nd($t,{}),s(B),T((Ee,Ye,We,Oe)=>{ne(B,"data-traces",r(c)?"on":"off"),pe(M,1,St(r(u)?"primary sm":"ghost sm")),ne(M,"title",r(u)?"Resume live updates":"Freeze this profile at the current state"),y(q,r(u)?"\u25B6 Resume":"\u275A\u275A Pause"),y(G,Ee),y(Z,r(i).username||"unknown"),y(V,Ye),y(oe,We),y(Re,r(i).traffic.pingMs),y(ot,Oe)},[()=>(r(i).username||"?").slice(0,2).toUpperCase(),()=>on(r(i).uuid),()=>pa(r(b)-(r(i).connectedAt||r(b))),()=>zt(r(i).traffic.bytesIn+r(i).traffic.bytesOut)]),U("click",M,()=>E(u,!r(u))),U("click",j,()=>{navigator.clipboard.writeText(r(i).uuid||"").catch(()=>{}),ft("UUID copied")}),U("click",H,w),f(L,B)};z(A,L=>{r(o)?L(I):r(i)?L(F,-1):L(P,1)})}f(t,C),ce()}Pe(["click"]);var p1=t=>{ve();var e=bt("Trigger");f(t,e)},u1=t=>{ve();var e=f1();ve(),f(t,e)},f1=_("Ad-hoc trigger",1),v1=_(' ',1),m1=_('1 \xB7 Match \xB7 MQL',1),$1=_('2 \xB7 Then \xB7 action',1),_1=_(" runs this session",1),g1=_('
            No runs yet. Hit \u25B6 Run to fire against the live roster.
            '),h1=_(' '),b1=_('
            TimeActionMatchedFiredErrors
            '),x1=_(" will fire",1),y1=_('
            No matches.
            '),w1=_('
            \u2192
            '),k1=_('
            match all \xB7 leave blank to target every player
            dry run \xB7 pick a chat action to preview what would fire
            recurring \xB7 click "Save as routine" to fire it automatically
            '),E1=_('
            ',1);function kp(t,e){le(e,!0);let n=L=>{var B=v1(),O=ie(B),S=d(O,2),R=d(l(S)),k=l(R,!0);s(R),ve(),s(S),T(()=>y(k,r(o).length)),U("click",O,g),U("click",S,$),f(L,B)},a=X(""),i=X(null),o=X(tt([])),c=X(tt([])),p=X(null),u=Wa(async L=>{try{let B=Tr.list;if(!L.trim())E(o,B,!0),E(p,{kind:"dim",message:`Everyone \xB7 ${B.length} online`},!0);else{let S=(await Ge("/query",{method:"POST",body:{ql:L}})).matches||[],R=new Map(B.map(k=>[k.uuid,k]));E(o,S.map(k=>R.get(k)).filter(Boolean),!0),E(p,{kind:S.length?"ok":"dim",message:`${S.length} matched \xB7 live`},!0)}}catch(B){E(p,Pa(B,"invalid query"),!0),E(o,[],!0)}},220);ge(()=>{u(r(a))}),Zr(gr.players,()=>u(r(a)));async function $(){if(!r(i)){ft("No action defined","error");return}try{let L=await Ge("/trigger",{method:"POST",body:{query:r(a).trim()||null,action:r(i)}});E(c,[{ts:Nn(Date.now()).slice(0,8),action:Jn(r(i))?`(registered ${on(ls(r(i)))})`:r(i).type||"action",matched:L.matched,fired:L.fired,errors:L.errors||[]},...r(c)].slice(0,12),!0),ft(`Fired on ${L.fired}/${L.matched} players`)}catch(L){ft("Failed: "+L.message,"error")}}async function g(){if(!r(i)){ft("Pick an action first","error");return}let L=prompt("Routine name?","Saved trigger "+new Date().toLocaleTimeString());if(L)try{await Ge("/routines",{method:"POST",body:{name:L,ql:r(a).trim(),trigger:{type:lr.onMatch},action:r(i),enabled:!0}}),ft("Saved as routine")}catch(B){ft(B.message,"error")}}var v=E1(),m=ie(v);{let L=x(()=>[p1]);Hr(m,{get crumbs(){return r(L)},get title(){return u1},get actions(){return n}})}var h=d(m,2),b=l(h),w=l(b);et(w,{meta:"who runs this",title:B=>{ve();var O=m1();ve(2),f(B,O)},children:(B,O)=>{jr(B,{get value(){return r(a)},onChange:S=>E(a,S,!0),rows:3,big:!0,placeholder:'gamemode = "SURVIVAL" and ping < 100',get status(){return r(p)},onSubmit:$})},$$slots:{title:!0,default:!0}});var C=d(w,2);et(C,{meta:"run once per match",title:B=>{ve();var O=$1();ve(2),f(B,O)},children:(B,O)=>{Ci(B,{get value(){return r(i)},onChange:S=>E(i,S,!0)})},$$slots:{title:!0,default:!0}});var A=d(C,2);et(A,{title:"History",flush:!0,meta:B=>{var O=_1(),S=ie(O),R=l(S,!0);s(S),ve(),T(()=>y(R,r(c).length)),f(B,O)},children:(B,O)=>{var S=Me(),R=ie(S);{var k=q=>{var j=g1();f(q,j)},M=q=>{var j=b1(),H=d(l(j));de(H,21,()=>r(c),lt,(D,N)=>{var G=h1(),Y=l(G),K=l(Y,!0);s(Y);var Z=d(Y),Q=l(Z,!0);s(Z);var ee=d(Z),J=l(ee,!0);s(ee);var te=d(ee),V=l(te,!0);s(te);var W=d(te),re=l(W,!0);s(W),s(G),T(()=>{y(K,r(N).ts),y(Q,r(N).action),y(J,r(N).matched),y(V,r(N).fired),pe(W,1,"num "+(r(N).errors.length?"dim":"")),y(re,r(N).errors.length)}),f(D,G)}),s(H),s(j),f(q,j)};z(R,q=>{r(c).length===0?q(k):q(M,-1)})}f(B,S)},$$slots:{meta:!0,default:!0}}),s(b);var I=d(b,2),P=l(I);et(P,{title:"Preview",meta:B=>{var O=x1(),S=ie(O),R=l(S,!0);s(S),ve(),T(()=>y(R,r(o).length)),f(B,O)},children:(B,O)=>{var S=Me(),R=ie(S);{var k=q=>{var j=y1();f(q,j)},M=q=>{var j=Me(),H=ie(j);de(H,17,()=>r(o),D=>D.uuid,(D,N)=>{var G=w1(),Y=d(l(G),2),K=l(Y),Z=l(K,!0);s(K);var Q=d(K,2),ee=l(Q);s(Q),s(Y),ve(2),s(G),T((J,te)=>{ne(G,"href","/p/"+r(N).uuid),y(Z,r(N).username||"\u2014"),y(ee,`${J??""} \xB7 HP ${te??""}`)},[()=>(r(N).dimension||"\u2014").replace("minecraft:",""),()=>(r(N).health??0).toFixed(1)]),f(D,G)}),f(q,j)};z(R,q=>{r(o).length===0?q(k):q(M,-1)})}f(B,S)},$$slots:{meta:!0,default:!0}});var F=d(P,2);et(F,{title:"Tips",children:(L,B)=>{var O=k1();f(L,O)},$$slots:{default:!0}}),s(I),s(h),f(t,v),ce()}Pe(["click"]);var S1=_('
            '),T1=_('
            '),C1=_('
            ');function ao(t,e){var n=C1(),a=l(n),i=l(a,!0);s(a);var o=d(a,2);{var c=$=>{var g=S1(),v=l(g,!0);s(g),T(()=>y(v,e.hint)),f($,g)};z(o,$=>{e.hint&&$(c)})}var p=d(o,2);{var u=$=>{var g=T1(),v=l(g);tr(v,()=>e.cta),s(g),f($,g)};z(p,$=>{e.cta&&$(u)})}s(n),T(()=>y(i,e.title)),f(t,n)}var A1=_('
            '),M1=_('
            '),P1=_('
            '),R1=_('
            ');function io(t,e){let n=ae(e,"off",3,!1),a=x(()=>typeof e.detail=="function");var i=R1();let o;var c=l(i);{var p=I=>{var P=A1(),F=l(P);tr(F,()=>e.icon),s(P),f(I,P)};z(c,I=>{e.icon!=null&&I(p)})}var u=d(c,2),$=l(u),g=l($),v=l(g,!0);s(g);var m=d(g,2);{var h=I=>{var P=Me(),F=ie(P);tr(F,()=>e.badges),f(I,P)};z(m,I=>{e.badges&&I(h)})}s($);var b=d($,2);{var w=I=>{var P=M1(),F=l(P);{var L=O=>{var S=Me(),R=ie(S);tr(R,()=>e.detail),f(O,S)},B=O=>{var S=bt();T(()=>y(S,e.detail)),f(O,S)};z(F,O=>{r(a)?O(L):O(B,-1)})}s(P),f(I,P)};z(b,I=>{e.detail!=null&&I(w)})}s(u);var C=d(u,2);{var A=I=>{var P=P1(),F=l(P);tr(F,()=>e.actions),s(P),f(I,P)};z(C,I=>{e.actions!=null&&I(A)})}s(i),T(()=>{o=pe(i,1,"entity-card",null,o,{"is-off":n()}),y(v,e.title)}),f(t,i)}var N1=t=>{ve();var e=bt("Actions");f(t,e)},L1=_(" registered",1),I1=_(''),O1=_(''),D1=_(" "),F1=_(' ',1),B1=_(' '),z1=_(' ',1),q1=_('
            Action
            '),H1=_('

            ',1);function Ep(t,e){le(e,!0);let n=H=>{var D=L1(),N=ie(D),G=l(N,!0);s(N),ve(),T(()=>y(G,r(i).length)),f(H,D)},a=H=>{var D=I1();U("click",D,g),f(H,D)},i=X(tt([])),o=X(null),c=X(""),p=X(null),u;async function $(){try{E(i,await Ge("/actions"),!0)}catch{E(i,[],!0)}}ge(()=>{$()}),ge(()=>{u&&(r(o)?u.showModal():u.close())});function g(){E(c,""),E(p,{type:"chat",component:""},!0),E(o,{id:null},!0)}function v(H){E(c,H.name||"",!0),E(p,H.action,!0),E(o,H,!0)}function m(){E(o,null)}async function h(){try{await Ge("/actions",{method:"POST",body:{id:r(o)?.id||void 0,name:r(c),action:r(p)||{type:"chat",component:""}}}),m(),await $(),ft("Action saved")}catch(H){ft(H.message,"error")}}async function b(H){if(confirm("Delete this action?"))try{await Ge("/actions/"+H,{method:"DELETE"}),await $(),ft("Deleted")}catch(D){ft(D.message,"error")}}var w=H1(),C=ie(w);{let H=x(()=>[N1]);Hr(C,{get crumbs(){return r(H)},get title(){return n},get actions(){return a}})}var A=d(C,2),I=l(A);{var P=H=>{ao(H,{title:"No actions defined yet.",hint:"Actions are reusable side-effects (inject a packet, send chat, mutate state). Register one to reference it from routines and triggers.",cta:N=>{var G=O1();U("click",G,g),f(N,G)},$$slots:{cta:!0}})},F=H=>{var D=Me(),N=ie(D);de(N,17,()=>r(i),G=>G.id,(G,Y)=>{let K=x(()=>r(Y).action?.type||"unknown"),Z=x(()=>r(Y).usedBy?.length??0);io(G,{get title(){return r(Y).name},icon:V=>{var W=D1(),re=l(W,!0);s(W),T(oe=>{ne(W,"title",r(K)),y(re,oe)},[()=>sm(r(Y).action)]),f(V,W)},badges:V=>{var W=F1(),re=ie(W);pr(re,{kind:"on",children:($e,me)=>{ve();var he=bt();T(()=>y(he,r(K))),f($e,he)},$$slots:{default:!0}});var oe=d(re,2),ue=l(oe);s(oe),T(()=>y(ue,`${r(Z)??""} routine${r(Z)===1?"":"s"}`)),f(V,W)},detail:V=>{var W=B1(),re=l(W,!0);s(W),T(oe=>y(re,oe),[()=>cs(r(Y).action)]),f(V,W)},actions:V=>{var W=z1(),re=ie(W),oe=d(re,2);U("click",re,()=>v(r(Y))),U("click",oe,()=>b(r(Y).id)),f(V,W)},$$slots:{icon:!0,badges:!0,detail:!0,actions:!0}})}),f(H,D)};z(I,H=>{r(i).length===0?H(P):H(F,-1)})}s(A);var L=d(A,2),B=l(L),O=l(B),S=l(O,!0);s(O);var R=d(O,2),k=l(R),M=d(k,2);s(R),s(B);var q=d(B,2);{var j=H=>{var D=q1(),N=l(D),G=d(l(N),2);kt(G),s(N);var Y=d(N,2),K=d(l(Y),2);Ti(K,{get value(){return r(p)},onChange:Z=>E(p,Z,!0)}),s(Y),s(D),Sa(G,()=>r(c),Z=>E(c,Z)),f(H,D)};z(q,H=>{r(o)&&H(j)})}s(L),Tt(L,H=>u=H,()=>u),T(()=>y(S,r(o)?.id?"Edit action":"New action")),Rt("close",L,m),U("click",k,m),U("click",M,h),f(t,w),ce()}Pe(["click"]);var j1=_('');function so(t,e){le(e,!0);let n=X(null);ge(()=>{let o=!0;return Vs().then(c=>{o&&E(n,c,!0)}),()=>{o=!1}});let a=x(()=>Ys(Gs,e.src||"",null,r(n)));var i=j1();Ds(i,()=>r(a),!0),s(i),f(t,i),ce()}var U1=_('
            Loading\u2026
            '),V1=_('
            '),Y1=_('
            ');function ds(t,e){le(e,!0);{let n=x(()=>String(e.items.length||"\u2014"));et(t,{get title(){return e.title},get meta(){return r(n)},children:(a,i)=>{var o=Y1(),c=l(o);{var p=$=>{var g=U1();f($,g)},u=$=>{var g=Me(),v=ie(g);de(v,17,()=>e.items,m=>m.name,(m,h)=>{var b=V1(),w=l(b),C=l(w),A=l(C,!0);s(C);var I=d(C,2),P=l(I,!0);s(I),s(w);var F=d(w,2),L=l(F,!0);s(F),s(b),T(()=>{y(A,r(h).name),y(P,r(h).kind),y(L,r(h).detail||"")}),f(m,b)}),f($,g)};z(c,$=>{e.items.length===0?$(p):$(u,-1)})}s(o),f(a,o)},$$slots:{default:!0}})}ce()}var G1=t=>{ve();var e=bt("MQL guide");f(t,e)},W1=t=>{var e=Z1();ve(),f(t,e)},K1=[{tag:"Vitals",desc:"Players in serious trouble \u2014 low health, in survival.",ql:'health < 6 and gamemode = "SURVIVAL"'},{tag:"Network",desc:"High-ping players. Useful for triaging laggy connections live.",ql:"ping > 200"},{tag:"World",desc:"Anyone currently in the overworld dimension.",ql:'dimension = "minecraft:overworld"'},{tag:"Geometry",desc:"Spawn-area campers \u2014 within 100 blocks of the world origin.",ql:"distance(pos, (0, 64, 0)) < 100"},{tag:"Server data",desc:"VIPs as marked by your plugin via the server-data channel.",ql:'server.rank = "vip" and server.kills > 10'},{tag:"Pattern",desc:"Bot accounts \u2014 usernames matching a regex pattern.",ql:'name matches "Bot_.*"'},{tag:"Text search",desc:'Case-insensitive substring search \u2014 finds "Steve", "STEVE_42", \u2026',ql:'name ~ "steve"'},{tag:"Logic",desc:"Compose boolean expressions with and / or / not and parentheses.",ql:'not (gamemode = "CREATIVE") and (flying or health < 10)'},{tag:"Collections",desc:"Membership tests via has \u2014 useful for tags, attributes, lists.",ql:'server.tags has "staff" and not (server.muted)'}],X1=[["expr","or"],["or","and ('or' and)*"],["and","not ('and' not)*"],["not","'not' not | cmp"],["cmp","value op value | value"],["op","= | != | < | <= | > | >= | ~ | matches | contains | has | in"],["value","ident('.'ident)* | number | string | tuple | call"]];var Z1=_("MQL guide & sandbox",1),J1=_(' ',1),Q1=_("press cmd\u21B5 to run",1),e2=_('
            '),t2=_(' '),r2=_('
            '),n2=_('
            Press \u21A9 to accept \xB7 esc to dismiss
            ',1),a2=_(''),i2=_('
            '),s2=_('
            '),o2=_(`
            Minestom Query Language

            A small, total expression language with comparisons, boolean logic, dotted paths, + regex matches, collection membership, and a tiny library of functions. Used by the trigger + page, routine filters, and the in-app evaluators. Browse the examples, grammar, and reference + below \u2014 or paste your own into the sandbox.

            Keyword Field Function String Number Operator
            `,1);function Sp(t,e){le(e,!0);let n=H=>{var D=J1(),N=ie(D),G=d(N,2);U("click",N,()=>{E(a,""),u("")}),U("click",G,()=>u(r(a))),f(H,D)},a=X(""),i=X(tt({kind:"dim",message:"Empty expression"})),o=X(tt([])),c=X(tt(new Map)),p=X(null);ge(()=>{Vs().then(H=>{E(p,H,!0)})});let u=Wa(async H=>{if(!H.trim()){E(o,[],!0),E(i,{kind:"dim",message:"Empty expression"},!0);return}try{let N=(await Ge("/query",{method:"POST",body:{ql:H}})).matches||[];E(o,N,!0),E(i,{kind:N.length?"ok":"dim",message:`Compiled \xB7 ${N.length} match${N.length===1?"":"es"}`},!0),E(c,new Map(Tr.list.map(G=>[G.uuid,G])),!0)}catch(D){E(i,Pa(D),!0),E(o,[],!0)}},220);ge(()=>{u(r(a))}),Zr(gr.players,()=>{r(a).trim()&&u(r(a))});let $=x(()=>r(p)?.fields||[]),g=x(()=>r(p)?.operators||[]),v=x(()=>r(g).filter(H=>["comparison","keyword","arithmetic","pipe"].includes(H.kind))),m=x(()=>r(g).filter(H=>H.kind==="logical")),h=x(()=>r(p)?.functions||[]),b=x(()=>r($).map(H=>({name:H.name,kind:"field",detail:H.detail||"(custom field)"}))),w=x(()=>r(v).map(H=>({name:H.name,kind:H.kind==="keyword"?"kw":"op",detail:H.detail||"(custom operator)"}))),C=x(()=>r(h).map(H=>({name:H.sig||H.name,kind:"fn",detail:H.detail||"(custom function)"}))),A=x(()=>r(m).map(H=>({name:H.name,kind:"kw",detail:H.detail||"(custom keyword)"})));var I=o2(),P=ie(I);{let H=x(()=>[G1]);Hr(P,{get crumbs(){return r(H)},get title(){return W1},get actions(){return n}})}var F=d(P,4),L=l(F),B=l(L);et(B,{title:"Sandbox",meta:D=>{ve();var N=Q1();ve(3),f(D,N)},children:(D,N)=>{var G=n2(),Y=ie(G);jr(Y,{get value(){return r(a)},onChange:W=>E(a,W,!0),rows:3,big:!0,placeholder:'health < 6 and gamemode = "SURVIVAL"',get status(){return r(i)},onSubmit:()=>u(r(a))});var K=d(Y,2),Z=d(l(K),2),Q=l(Z,!0);s(Z),s(K);var ee=d(K,2),J=l(ee);{var te=W=>{var re=e2(),oe=l(re,!0);s(re),T(()=>y(oe,r(i).message)),f(W,re)},V=W=>{var re=r2();de(re,20,()=>r(o),oe=>oe,(oe,ue)=>{let $e=x(()=>r(c).get(ue));var me=t2(),he=l(me);pr(he,{kind:"on",children:(De,Be)=>{ve();var ot=bt();T(Ke=>y(ot,Ke),[()=>r($e)?.username||ue.slice(0,8)]),f(De,ot)},$$slots:{default:!0}});var be=d(he,2),ye=l(be,!0);s(be);var ze=d(be,2),Re=l(ze,!0);s(ze),s(me),T(De=>{ne(me,"href","/p/"+ue),y(ye,ue),y(Re,De)},[()=>(r($e)?.dimension||"\u2014").replace("minecraft:","")]),f(oe,me)}),s(re),f(W,re)};z(J,W=>{r(o).length===0&&r(i)?.kind==="error"?W(te):r(o).length>0&&W(V,1)})}s(ee),T(()=>y(Q,r(o).length===0?"\u2014":`${r(o).length} match${r(o).length===1?"":"es"}`)),f(D,G)},$$slots:{meta:!0,default:!0}});var O=d(B,2);et(O,{title:"Examples",meta:"click to load",children:(H,D)=>{var N=i2();de(N,21,()=>K1,lt,(G,Y)=>{var K=a2(),Z=l(K),Q=l(Z,!0);s(Z);var ee=d(Z,2),J=l(ee,!0);s(ee);var te=d(ee,2),V=l(te);so(V,{get src(){return r(Y).ql}}),s(te),ve(2),s(K),T(()=>{y(Q,r(Y).tag),y(J,r(Y).desc)}),U("click",K,()=>{E(a,r(Y).ql,!0),u(r(Y).ql)}),f(G,K)}),s(N),f(H,N)},$$slots:{default:!0}}),s(L);var S=d(L,2),R=l(S);et(R,{title:"Grammar",meta:"Pratt",children:(H,D)=>{var N=Me(),G=ie(N);de(G,17,()=>X1,lt,(Y,K)=>{var Z=x(()=>fr(r(K),2));let Q=()=>r(Z)[0],ee=()=>r(Z)[1];var J=s2(),te=l(J),V=l(te,!0);s(te);var W=d(te),re=l(W,!0);s(W),s(J),T(()=>{y(V,Q()),y(re,ee())}),f(Y,J)}),f(H,N)},$$slots:{default:!0}});var k=d(R,2);ds(k,{title:"Fields",get items(){return r(b)}});var M=d(k,2);ds(M,{title:"Operators",get items(){return r(w)}});var q=d(M,2);ds(q,{title:"Functions",get items(){return r(C)}});var j=d(q,2);ds(j,{title:"Logic",get items(){return r(A)}}),s(S),s(F),f(t,I),ce()}Pe(["click"]);var Cp=[{id:lr.onMatch,icon:"\u25B6",label:"On match",detail:"Edge \u2014 fires once when a player starts matching the filter."},{id:lr.onUnmatch,icon:"\u25C0",label:"On unmatch",detail:"Edge \u2014 fires once when a player stops matching the filter."},{id:lr.interval,icon:"\u27F3",label:"Interval",detail:"Periodic \u2014 fires every N ms for every matching player."},{id:lr.onPacket,icon:"\u26A1",label:"On packet",detail:"Per-packet \u2014 fires for every decoded packet of the given class (after debounce)."}],ym=[{ms:100,label:"100ms"},{ms:1e3,label:"1s"},{ms:5e3,label:"5s"},{ms:3e4,label:"30s"},{ms:6e4,label:"1m"},{ms:3e5,label:"5m"}],Tp=t=>Number.isInteger(t)?String(t):t.toFixed(1).replace(/\.0$/,"");function l2(t){if(!t||t<0)return"never";if(t<1e3)return`${t} ms`;if(t<6e4){let n=t/1e3;return n===1?"1 second":`${Tp(n)} seconds`}if(t<36e5){let n=t/6e4;return n===1?"once per minute":`every ${Tp(n)} minutes`}let e=t/36e5;return e===1?"once per hour":`every ${Tp(e)} hours`}function c2(t){return t===lr.interval?{type:lr.interval,millis:5e3}:t===lr.onPacket?{type:lr.onPacket,packet:""}:{type:t}}function wm(t){let e=Cp.find(n=>n.id===t?.type)?.id??lr.onMatch;return e===lr.interval?{type:lr.interval,millis:Number(t?.millis)||5e3}:e===lr.onPacket?{type:lr.onPacket,packet:String(t?.packet??"")}:{type:e}}var d2=_(''),p2=_(''),u2=_('
            Fire every
            ms
            '),f2=_('
            Simple class name (e.g. ClientChatMessagePacket) \u2014 matched against every decoded packet.
            ',1),v2=_('
            ');function Ap(t,e){le(e,!0);let n="trig-"+Math.random().toString(36).slice(2,9),a=x(()=>wm(e.value)),i=x(()=>Cp.find(b=>b.id===r(a).type));function o(b){e.onChange?.(wm(b))}var c=v2(),p=l(c);de(p,21,()=>Cp,b=>b.id,(b,w)=>{var C=d2(),A=l(C);kt(A);var I=d(A,2),P=l(I,!0);s(I);var F=d(I,2),L=l(F,!0);s(F),s(C),T(()=>{ne(A,"name",n),Ot(A,r(w).id),da(A,r(a).type===r(w).id),y(P,r(w).icon),y(L,r(w).label)}),U("change",A,()=>r(a).type===r(w).id?null:o(c2(r(w).id))),f(b,C)}),s(p);var u=d(p,2),$=l(u,!0);s(u);var g=d(u,2),v=l(g);{var m=b=>{let w=x(()=>r(a).millis),C=x(()=>ym.some(S=>S.ms===r(w)));var A=u2(),I=d(l(A),2),P=l(I);de(P,17,()=>ym,S=>S.ms,(S,R)=>{var k=p2(),M=l(k,!0);s(k),T(()=>{pe(k,1,"trig-interval__chip"+(r(R).ms===r(w)?" is-on":"")),y(M,r(R).label)}),U("click",k,()=>o({...r(a),millis:r(R).ms})),f(S,k)});var F=d(P,2),L=l(F);kt(L),ne(L,"min",100),ne(L,"step",100),ve(2),s(F),s(I);var B=d(I,2),O=l(B);s(B),s(A),T(S=>{pe(F,1,"trig-interval__custom"+(r(C)?"":" is-on")),Ot(L,r(w)),y(O,`\u2248 ${S??""}`)},[()=>l2(r(w))]),U("change",L,S=>o({...r(a),millis:Math.max(0,Number(S.currentTarget.value)||0)})),f(b,A)},h=b=>{var w=f2(),C=ie(w),A=d(l(C),2);Qs(A,{get value(){return r(a).packet},onChange:I=>o({...r(a),packet:I})}),s(C),ve(2),f(b,w)};z(v,b=>{r(a).type===lr.interval?b(m):r(a).type===lr.onPacket&&b(h,1)})}s(g),s(c),T(()=>y($,r(i)?.detail||"")),f(t,c),ce()}Pe(["change","click"]);var m2=t=>{ve();var e=bt("Routines");f(t,e)},$2=_(" ",1),_2=_(''),g2=_(''),h2=_(" "),b2=_(' ',1),x2=_('(empty)'),y2=_(' \u2192 ',1),w2=_(' ',1),k2=_('
            Match (MQL)
            Trigger
            Action
            '),E2=_('

            ',1);function Mp(t,e){le(e,!0);let n=N=>{var G=$2(),Y=ie(G),K=l(Y,!0);s(Y);var Z=d(Y);T(()=>{y(K,r(C)),y(Z,` / ${r(i).length??""} active`)}),f(N,G)},a=N=>{var G=_2();U("click",G,$),f(N,G)},i=X(tt([])),o=X(null),c=X(null),p;async function u(){try{E(i,await Ge("/routines"),!0)}catch{E(i,[],!0)}}ge(()=>{u()}),ge(()=>{p&&(r(o)?p.showModal():p.close())});function $(){E(c,{name:"",ql:"",trigger:{type:"onMatch"},action:null,enabled:!0},!0),E(o,{id:null},!0)}function g(N){E(c,{name:N.name||"",ql:N.ql||"",trigger:N.trigger||{type:"onMatch"},action:N.action||null,enabled:N.enabled??!0},!0),E(o,N,!0)}function v(){E(o,null)}async function m(){try{let N=await Ge("/routines",{method:"POST",body:{id:r(o)?.id||void 0,name:r(c).name,ql:(r(c).ql||"").trim(),trigger:r(c).trigger,action:r(c).action||{type:"chat",component:""}}});await w(N.id,r(c).enabled),v(),await u(),ft("Routine saved")}catch(N){ft(N.message,"error")}}async function h(N){if(confirm("Delete this routine?"))try{await Ge("/routines/"+N,{method:"DELETE"}),await u(),ft("Deleted")}catch(G){ft(G.message,"error")}}async function b(N){try{await w(N.id,!N.enabled),await u()}catch(G){ft(G.message,"error")}}function w(N,G){return Ge("/routines/"+N+"/enabled",{method:"PUT",body:{enabled:G}})}let C=x(()=>r(i).filter(N=>N.enabled).length);var A=E2(),I=ie(A);{let N=x(()=>[m2]);Hr(I,{get crumbs(){return r(N)},get title(){return n},get actions(){return a}})}var P=d(I,2),F=l(P);{var L=N=>{ao(N,{title:"No routines defined yet.",hint:"Routines fire actions automatically when a query matches, on packet decode, or on a timer.",cta:Y=>{var K=g2();U("click",K,$),f(Y,K)},$$slots:{cta:!0}})},B=N=>{var G=Me(),Y=ie(G);de(Y,17,()=>r(i),K=>K.id,(K,Z)=>{let Q=x(()=>r(Z).action),ee=x(()=>Jn(r(Q))?"ref":r(Q)?.type||"inline"),J=x(()=>Jn(r(Q))?`(registered ${ls(r(Q))})`:cs(r(Q)));{let te=ue=>{var $e=h2(),me=l($e,!0);s($e),T((he,be)=>{ne($e,"title",he),y(me,be)},[()=>Xd(r(Z).trigger),()=>im(r(Z).trigger)]),f(ue,$e)},V=ue=>{var $e=b2(),me=ie($e);pr(me,{children:(ye,ze)=>{ve();var Re=bt();T(De=>y(Re,De),[()=>Xd(r(Z).trigger)]),f(ye,Re)},$$slots:{default:!0}});var he=d(me,2),be=l(he,!0);s(he),T(()=>y(be,r(Z).enabled?"enabled":"disabled")),f(ue,$e)},W=ue=>{var $e=y2(),me=ie($e),he=l(me);{var be=Ke=>{so(Ke,{get src(){return r(Z).ql}})},ye=Ke=>{var ke=x2();f(Ke,ke)};z(he,Ke=>{r(Z).ql?Ke(be):Ke(ye,-1)})}s(me);var ze=d(me,4),Re=l(ze),De=l(Re,!0);s(Re);var Be=d(Re),ot=l(Be,!0);s(Be),s(ze),T(()=>{y(De,r(ee)),y(ot,r(J))}),f(ue,$e)},re=ue=>{var $e=w2(),me=ie($e);yi(me,{get on(){return r(Z).enabled},onchange:()=>b(r(Z))});var he=d(me,2),be=d(he,2);U("click",he,()=>g(r(Z))),U("click",be,()=>h(r(Z).id)),f(ue,$e)},oe=x(()=>!r(Z).enabled);io(K,{get off(){return r(oe)},get title(){return r(Z).name},icon:te,badges:V,detail:W,actions:re,$$slots:{icon:!0,badges:!0,detail:!0,actions:!0}})}}),f(N,G)};z(F,N=>{r(i).length===0?N(L):N(B,-1)})}s(P);var O=d(P,2),S=l(O),R=l(S),k=l(R,!0);s(R);var M=d(R,2),q=l(M),j=d(q,2);s(M),s(S);var H=d(S,2);{var D=N=>{var G=k2(),Y=l(G),K=d(l(Y),2);kt(K),s(Y);var Z=d(Y,2),Q=d(l(Z),2);jr(Q,{get value(){return r(c).ql},onChange:oe=>r(c).ql=oe,rows:2,placeholder:'health < 6 and gamemode = "SURVIVAL"',onSubmit:m}),s(Z);var ee=d(Z,2),J=d(l(ee),2);Ap(J,{get value(){return r(c).trigger},onChange:oe=>r(c).trigger=oe}),s(ee);var te=d(ee,2),V=d(l(te),2);Ci(V,{get value(){return r(c).action},onChange:oe=>r(c).action=oe}),s(te);var W=d(te,2),re=l(W);kt(re),ve(2),s(W),s(G),Sa(K,()=>r(c).name,oe=>r(c).name=oe),Fs(re,()=>r(c).enabled,oe=>r(c).enabled=oe),f(N,G)};z(H,N=>{r(c)&&N(D)})}s(O),Tt(O,N=>p=N,()=>p),T(()=>y(k,r(o)?.id?"Edit routine":"New routine")),Rt("close",O,v),U("click",q,v),U("click",j,m),f(t,A),ce()}Pe(["click"]);var S2=t=>{ve();var e=bt("Terminal");f(t,e)},T2=t=>{ve();var e=C2();ve(),f(t,e)},km=2e3,C2=_("Server terminal",1),A2=_(' '),M2=_('
            CPU
            Heap
            TPS
            MSPT
            Threads
            Uptime
            Players
            '),P2=_(" lines",1),R2=_('
            Waiting for output\u2026
            '),N2=_('
            '),L2=_('
            '),I2=_('
            No data pushed yet.
            Queryable via global.<path>.
            '),O2=_('
            '),D2=_('
            ',1);function Pp(t,e){le(e,!0);let n=S=>{var R=A2(),k=l(R);s(R),T(()=>y(k,`${r(a).length??""} lines \xB7 live tail`)),f(S,R)},a=X(tt([])),i=X(null),o=X(null),c=X(""),p=X(!1),u=X(void 0),$=!0;ge(()=>{let S=!1;return(async()=>{try{let[R,k,M]=await Promise.all([Ge("/console/history"),Ge("/metrics/latest").catch(()=>null),Ge("/global").catch(()=>null)]);if(S)return;Array.isArray(R)&&E(a,R,!0),k&&typeof k=="object"&&E(i,k,!0),M!==null&&typeof M=="object"&&E(o,M,!0)}catch{}})(),()=>{S=!0}}),Zr(gr.console,S=>{let R=r(a).length>=km?r(a).slice(r(a).length-km+1):r(a);E(a,[...R,{ts:S.ts,level:S.level,message:S.message}],!0)}),Zr(gr.metrics,S=>{E(i,S,!0)}),Zr(gr.global,S=>{E(o,S.data??null,!0)}),ge(()=>{r(a),!(!r(u)||!$)&&(r(u).scrollTop=r(u).scrollHeight)});function g(S){let R=S.currentTarget;$=R.scrollHeight-R.clientHeight-R.scrollTop<24}async function v(S){S?.preventDefault();let R=r(c).trim();if(R){E(p,!0);try{await Ge("/console/command",{method:"POST",body:{command:R}}),E(c,"")}catch(k){ft("Command failed: "+k.message,"error")}finally{E(p,!1)}}}var m=D2(),h=ie(m);{let S=x(()=>[S2]);Hr(h,{get crumbs(){return r(S)},get title(){return T2},get actions(){return n}})}var b=d(h,2);{var w=S=>{let R=x(()=>r(i));var k=M2(),M=l(k),q=d(l(M)),j=l(q);s(q),s(M);var H=d(M,2),D=d(l(H)),N=l(D,!0);s(D),s(H);var G=d(H,2),Y=d(l(G)),K=l(Y,!0);s(Y),s(G);var Z=d(G,2),Q=d(l(Z)),ee=l(Q);s(Q),s(Z);var J=d(Z,2),te=d(l(J)),V=l(te,!0);s(te),s(J);var W=d(J,2),re=d(l(W)),oe=l(re,!0);s(re),s(W);var ue=d(W,2),$e=d(l(ue)),me=l($e,!0);s($e),s(ue),s(k),T((he,be,ye,ze,Re)=>{y(j,`${he??""}%`),y(N,be),y(K,ye),y(ee,`${ze??""} ms`),y(V,r(R).threadCount),y(oe,Re),y(me,r(R).playerCount)},[()=>(r(R).processCpu*100).toFixed(1),()=>`${zt(r(R).heapUsed)} / ${zt(r(R).heapMax)}`,()=>r(R).tps.toFixed(1),()=>r(R).mspt.toFixed(2),()=>pa(r(R).uptimeMs)]),f(S,k)};z(b,S=>{r(i)&&S(w)})}var C=d(b,2),A=l(C),I=l(A);et(I,{title:"Output",flush:!0,meta:R=>{var k=P2(),M=ie(k),q=l(M,!0);s(M),ve(),T(()=>y(q,r(a).length)),f(R,k)},children:(R,k)=>{var M=L2(),q=l(M);{var j=D=>{var N=R2();f(D,N)},H=D=>{var N=Me(),G=ie(N);de(G,17,()=>r(a),lt,(Y,K)=>{var Z=N2(),Q=l(Z),ee=l(Q,!0);s(Q);var J=d(Q,2),te=l(J,!0);s(J);var V=d(J,2),W=l(V,!0);s(V),s(Z),T((re,oe,ue)=>{pe(Z,1,re),y(ee,oe),pe(J,1,ue),y(te,r(K).level),y(W,r(K).message)},[()=>"console-line lvl-"+(r(K).level||"info").toLowerCase(),()=>Nn(r(K).ts).slice(0,8),()=>"console-level lvl-"+(r(K).level||"info").toLowerCase()]),f(Y,Z)}),f(D,N)};z(q,D=>{r(a).length===0?D(j):D(H,-1)})}s(M),Tt(M,D=>E(u,D),()=>r(u)),Rt("scroll",M,g),f(R,M)},$$slots:{meta:!0,default:!0}});var P=d(I,2),F=l(P);F.textContent=">";var L=d(F,2);kt(L);var B=d(L,2);s(P),s(A);var O=d(A,2);{let S=x(()=>r(o)?`${Object.keys(r(o)).length} keys`:"empty");et(O,{title:"Global NBT",get meta(){return r(S)},flush:!0,children:(R,k)=>{var M=O2(),q=l(M);{var j=N=>{Za(N,{get value(){return r(o)},name:"global"})},H=x(()=>r(o)&&Object.keys(r(o)).length>0),D=N=>{var G=I2();f(N,G)};z(q,N=>{r(H)?N(j):N(D,-1)})}s(M),f(R,M)},$$slots:{default:!0}})}s(C),T(S=>{L.disabled=r(p),B.disabled=S},[()=>r(p)||!r(c).trim()]),Rt("submit",P,v),Sa(L,()=>r(c),S=>E(c,S)),f(t,m),ce()}var mn={latencyMs:0,jitterMs:0,bandwidthBytesPerSec:0,direction:null},Ai=16*1024*1024,Rp=t=>t<=0?0:Math.min(1,Math.log10(1+t)/Math.log10(1+Ai)),Em=t=>t<=0?0:t>=1?Ai:Math.max(1,Math.round(Math.pow(10,t*Math.log10(1+Ai))-1)),Sm=t=>t<=0?"unlimited":t<1024?t+" B/s":t<1024*1024?(t/1024).toFixed(t<10240?1:0)+" KB/s":(t/(1024*1024)).toFixed(t<10*1024*1024?2:1)+" MB/s",Qn=t=>!!t&&(t.latencyMs>0||t.jitterMs>0||t.bandwidthBytesPerSec>0);function Np(t,e){return t.latencyMs===e.latencyMs&&t.jitterMs===e.jitterMs&&t.bandwidthBytesPerSec===e.bandwidthBytesPerSec&&t.direction===e.direction}function F2(t){let e=Math.sin(t*12.9898)*43758.5453;return e-Math.floor(e)}function Tm(t){let i=Qn(t),o=i?t:mn,c=Math.min(12,i?o.latencyMs/200+o.jitterMs/50:.5),p=.05+(i?o.bandwidthBytesPerSec/Ai:0)*.15,u=i?Math.floor((o.latencyMs+o.jitterMs*3+o.bandwidthBytesPerSec/1e3)%9973):0,$="";for(let g=0;g<=200;g+=2){let v=i&&o.jitterMs?(F2(g*7+u)-.5)*(o.jitterMs/40):0,m=16+Math.sin(g*p)*c+v;$+=(g===0?"M":" L")+g.toFixed(0)+" "+m.toFixed(2)}return $}var B2=t=>{ve();var e=bt("Throttle");f(t,e)},z2=t=>{ve();var e=q2();ve(),f(t,e)},Lp=(t,e=At)=>{var n=U2();de(n,20,e,a=>a,(a,i)=>{var o=j2();let c;T(()=>c=pe(o,1,"",null,c,{maj:i%5===0})),f(a,o)}),s(n),f(t,n)},Ip=(t,e=At)=>{var n=Y2();de(n,20,e,a=>a,(a,i)=>{var o=V2(),c=l(o,!0);s(o),T(()=>y(c,i)),f(a,o)}),s(n),f(t,n)},q2=_("Traffic shaper",1),H2=_(' ',1),j2=_(""),U2=_('
            '),V2=_(" "),Y2=_('
            '),G2=_(""),W2=_('target \u2192 '),K2=_(' '),X2=_(''),Z2=_('
            No matching connections.
            '),J2=_('
            '),Q2=_('
            Live (target)
            '),eE=_("\xB7 unlimited"),tE=_(" engaged",1),rE=_('
            '),nE=_('
            PlayerUUIDThrottle
            '),aE=_('
            SHAPER \xB7
            Global: Targeted:
            Direction
            Draft
            01 Latency ms
            ms \xB7 base delay
            fixed ms added per packet \u2014 both ends feel it
            02 Jitter \xB1 ms
            \xB1 ms \xB7 random variance
            uniform [0\u2026N) extra latency, picked per packet
            03 Bandwidth cap
            per-direction outgoing cap \xB7 log-scale
            ',1),iE={hash:"svelte-bmh03o",code:` + @layer pages { + /* ---- Throttle page --------------------------------------------- */.thr-deck {display:flex;flex-direction:column;gap:var(--pad-4);}.thr-status {position:relative;display:grid;grid-template-columns:auto 1fr auto;grid-template-rows:auto auto;column-gap:var(--pad-5);row-gap:6px;padding:var(--pad-4) var(--pad-5);background:repeating-linear-gradient(0deg, transparent 0, transparent 2px, color-mix(in oklab, var(--ink-4) 4%, transparent) 2px, color-mix(in oklab, var(--ink-4) 4%, transparent) 3px), + var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);color:var(--ink-3);overflow:hidden;}.thr-status.engaged {color:var(--acc);}.thr-status__lights {display:grid;grid-template-columns:repeat(6, 6px);gap:4px;align-self:center;grid-row:1 / span 2;}.thr-status__lights i {width:6px;height:6px;background:var(--line-2);box-shadow:var(--bevel-sunk);}.thr-status__lights i.on {background:var(--acc); + animation: thr-blink 1.2s ease-in-out infinite;animation-delay:var(--d, 0ms);box-shadow:inset 0 1px 0 0 color-mix(in oklab, white 25%, transparent), + 0 0 6px var(--acc-line);} + @keyframes thr-blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } + }.thr-status__head {display:flex;align-items:center;gap:10px;font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-3);}.thr-status__label {color:var(--ink);}.thr-status.engaged .thr-status__state {color:var(--acc);}.thr-status__sep {color:var(--ink-4);}.thr-status__body {grid-column:2;grid-row:2;display:flex;gap:var(--pad-5);flex-wrap:wrap;font-size:var(--t-sm);color:var(--ink-3);}.thr-status__sum em {color:var(--ink);font-style:normal;}.thr-scope {grid-column:3;grid-row:1 / span 2;width:200px;height:56px;align-self:center;color:currentColor;}.thr-target {background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);padding:var(--pad-4);display:flex;flex-direction:column;gap:var(--pad-3);}.thr-target__modes {grid-template-columns:1fr 1fr;}.thr-mode__bar {position:absolute;left:0;top:0;bottom:0;width:4px;background:var(--line);pointer-events:none;}.seg-control--cards > .thr-mode.on .thr-mode__bar, + .thr-mode.on .thr-mode__bar {background:var(--acc);}.thr-mode__lbl {font-size:var(--t-md);text-transform:uppercase;color:var(--ink);}.thr-mode__hint {font-size:var(--t-xs);color:var(--ink-4);}.thr-roster {display:flex;flex-direction:column;gap:var(--pad-2);}.thr-roster__bar {display:flex;align-items:center;gap:var(--pad-3);}.thr-roster__filter {max-width:360px;}.thr-roster__sel {font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-3);}.thr-roster__sel em {color:var(--acc);font-style:normal;}.thr-roster__grid {display:grid;grid-template-columns:repeat(auto-fill, minmax(180px, 1fr));gap:6px;max-height:220px;overflow-y:auto;padding:var(--pad-2);background:var(--sunk);border:1px solid var(--line);box-shadow:var(--bevel-sunk);}.thr-chip {display:grid;grid-template-columns:auto 1fr;align-items:center;gap:var(--pad-2);padding:6px 10px;background:var(--bg-2);border:1px solid var(--line);font-size:var(--t-sm);color:var(--ink-2);text-transform:none;text-align:left;cursor:pointer;box-shadow:var(--bevel);}.thr-chip__dot {width:6px;height:6px;background:var(--ink-4);display:inline-block;}.thr-chip.lit .thr-chip__dot {background:var(--warn); animation: thr-blink 1.6s linear infinite;}.thr-chip.on {color:var(--ink);border-color:var(--acc-line);background:var(--bg-3);}.thr-chip.on .thr-chip__dot {background:var(--acc);}.thr-chip__name {white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}.thr-chip__tag {grid-column:2;font-size:var(--t-xs);color:var(--warn);text-transform:uppercase;}.thr-strip {display:flex;flex-wrap:wrap;gap:var(--pad-5);align-items:stretch;padding:var(--pad-3) var(--pad-4);background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);}.thr-strip.off {opacity:0.7;}.thr-strip__cell {display:flex;flex-direction:column;gap:6px;}.thr-strip__cell.wide {flex:1;min-width:240px;}.thr-strip__k {font-size:var(--t-xs);text-transform:uppercase;color:var(--ink-3);}.thr-strip__live {font-size:var(--t-sm);color:var(--warn);}.thr-strip__live.dim {color:var(--ink-4);}.thr-dir {display:inline-flex;border:1px solid var(--line);background:var(--bg-2);box-shadow:var(--bevel);.thr-dir__opt {display:inline-flex;align-items:center;gap:6px;padding:var(--pad-2) var(--pad-3);background:transparent;border:0;border-left:1px solid var(--line);border-radius:0;box-shadow:none;color:var(--ink-3);font-size:var(--t-xs);text-transform:uppercase;cursor:pointer;&:first-child {border-left:0;}&:hover {color:var(--ink);background:var(--bg-3);}&:active {box-shadow:none;}&.on {color:var(--ink);background:var(--bg-3);box-shadow:inset 0 0 0 1px var(--acc-line);}i {width:6px;height:6px;display:inline-block;}i.cb {background:var(--dir-cb);}i.sb {background:var(--dir-sb);}}}.thr-rack {display:grid;grid-template-columns:repeat(auto-fit, minmax(320px, 1fr));gap:var(--pad-4);}.thr-mod {position:relative;display:grid;grid-template-rows:auto auto auto auto;gap:var(--pad-3);padding:var(--pad-4);background:var(--bg-1);border:1px solid var(--line);box-shadow:var(--bevel);transition:border-color var(--motion);overflow:hidden;}.thr-mod.lit {border-color:var(--acc-line);}.thr-mod.lit::before {content:"";position:absolute;left:0;top:0;bottom:0;width:3px;background:var(--acc);}.thr-mod > header {display:flex;align-items:baseline;gap:var(--pad-3);}.thr-mod__idx {font-size:var(--t-xs);color:var(--ink-4);}.thr-mod__lbl {font-size:var(--t-md);color:var(--ink);text-transform:uppercase;}.thr-mod__unit {margin-left:auto;font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.thr-mod__readout {display:flex;align-items:baseline;gap:var(--pad-2);min-width:0;padding:var(--pad-3) var(--pad-4);background:var(--sunk);border:1px solid var(--line);box-shadow:var(--bevel-sunk);color:var(--ink);line-height:1;font-variant-numeric:tabular-nums;}.thr-mod.lit .thr-mod__readout {color:var(--acc);}.thr-mod__prefix {font-size:var(--t-2xl);color:inherit;}.thr-mod__num {flex:1 1 0;min-width:0;width:100%;padding:0;background:transparent;border:0;box-shadow:none;font-size:var(--t-2xl);line-height:1;color:inherit;font-variant-numeric:tabular-nums;text-align:left;-moz-appearance:textfield;}.thr-mod__num:focus {outline:none;color:var(--ink);}.thr-mod__num::-webkit-outer-spin-button, + .thr-mod__num::-webkit-inner-spin-button {-webkit-appearance:none;margin:0;}.thr-mod__readout small {flex:0 0 auto;font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;white-space:nowrap;}.thr-mod__unit-tag {flex:0 0 auto;font-size:var(--t-md);color:var(--ink-3);text-transform:uppercase;white-space:nowrap;font-variant-numeric:tabular-nums;}.thr-mod.lit .thr-mod__unit-tag {color:var(--acc);}.thr-fader {position:relative;display:flex;flex-direction:column;gap:4px;}.thr-fader__ticks {display:grid;grid-auto-flow:column;grid-auto-columns:1fr;align-items:end;height:12px;padding:0 6px;}.thr-fader__ticks i {width:1px;height:4px;background:var(--ink-4);opacity:0.5;justify-self:center;}.thr-fader__ticks i.maj {height:8px;opacity:1;background:var(--ink-3);}.thr-fader__scale {display:flex;justify-content:space-between;font-size:var(--t-xs);color:var(--ink-4);padding:0 2px;}.thr-fader input[type="range"] {-webkit-appearance:none;appearance:none;width:100%;height:18px;padding:0;background:transparent;border:0;box-shadow:none;cursor:pointer;}.thr-fader input[type="range"]::-webkit-slider-runnable-track {height:6px;background:var(--acc) 0 0 / var(--pct, 0%) 100% no-repeat, + var(--sunk);border:1px solid var(--line);box-shadow:var(--bevel-sunk);}.thr-fader input[type="range"]::-moz-range-track {height:6px;background:var(--sunk);border:1px solid var(--line);box-shadow:var(--bevel-sunk);}.thr-fader input[type="range"]::-moz-range-progress {height:6px;background:var(--acc);}.thr-fader input[type="range"]::-webkit-slider-thumb {-webkit-appearance:none;width:12px;height:20px;margin-top:-8px;background:var(--bg-3);border:1px solid var(--line-2);cursor:grab;}.thr-fader input[type="range"]::-moz-range-thumb {width:12px;height:20px;background:var(--bg-3);border:1px solid var(--line-2);border-radius:0;cursor:grab;}.thr-fader input[type="range"]:focus {outline:none;}.thr-fader input[type="range"]:focus::-webkit-slider-thumb {border-color:var(--acc);background:var(--bg-2);}.thr-fader input[type="range"]:focus::-moz-range-thumb {border-color:var(--acc);background:var(--bg-2);}.thr-mod > footer {font-size:var(--t-xs);color:var(--ink-4);}.thr-overrides td.right {text-align:right;} + @media (max-width: 720px) {.thr-status {grid-template-columns:auto 1fr;}.thr-scope {display:none;} + } + + @media (prefers-reduced-motion: reduce) {.thr-status__lights i.on, + .thr-chip.lit .thr-chip__dot { animation: none;} + } + }`};function Op(t,e){le(e,!0),Ut(t,iE);let n=xe=>{var _t=H2(),Xt=ie(_t),cr=d(Xt,2),dr=d(cr,2);T(()=>{Xt.disabled=!r(q),cr.disabled=!r(M),dr.disabled=!r(b)}),U("click",Xt,B),U("click",cr,P),U("click",dr,I),f(xe,_t)},a=X("global"),i=X(null),o=X(tt({})),c=X(tt({...mn})),p=X(tt({...mn})),u=X(null),$=X(""),g=X(!1);Tr.boot();let v=x(()=>Tr.list),m=x(()=>r(a)==="global"?r(c):r(p)),h=x(()=>r(i)?r(o)[r(i)]??null:null),b=x(()=>r(a)==="global"?!Np(r(c),r(u)??mn):r(i)?!Np(r(p),r(h)??mn):!1);async function w(){try{let xe=await Ge("/throttle");E(u,xe.global?C(xe.global):null,!0),E(o,Object.fromEntries(Object.entries(xe.players||{}).map(([_t,Xt])=>[_t,C(Xt)])),!0),r(g)||(E(c,{...r(u)??mn},!0),E(g,!0))}catch(xe){ft("Failed to load throttles: "+xe.message,"error")}}ge(()=>{w()});function C(xe){return{latencyMs:Number(xe?.latencyMs??0),jitterMs:Number(xe?.jitterMs??0),bandwidthBytesPerSec:Number(xe?.bandwidthBytesPerSec??0),direction:xe?.direction??null}}function A(xe){let _t=xe&&xe===r(i)?null:xe;E(i,_t,!0),E(p,_t?{...r(o)[_t]??mn}:{...mn},!0)}async function I(){try{if(r(a)==="global"){let xe=C(await Ge("/throttle/global",{method:"PUT",body:r(c)}));E(u,Qn(xe)?xe:null,!0),E(c,{...xe},!0),ft(Qn(xe)?"Global throttle engaged":"Global throttle stored (no-op)")}else{if(!r(i)){ft("Select a player first","error");return}let xe=C(await Ge("/throttle/players/"+r(i),{method:"PUT",body:r(p)}));if(Qn(xe))E(o,{...r(o),[r(i)]:xe},!0);else{let _t={...r(o)};delete _t[r(i)],E(o,_t,!0)}E(p,{...xe},!0),ft(Qn(xe)?`Throttle engaged for ${O(r(i))}`:`Throttle stored for ${O(r(i))} (no-op)`)}}catch(xe){ft("Failed to apply: "+xe.message,"error")}}async function P(){try{if(r(a)==="global")await Ge("/throttle/global",{method:"DELETE"}),E(u,null),E(c,{...mn},!0),ft("Global throttle disengaged");else{if(!r(i))return;await Ge("/throttle/players/"+r(i),{method:"DELETE"});let xe={...r(o)};delete xe[r(i)],E(o,xe,!0),E(p,{...mn},!0),ft("Throttle cleared for "+O(r(i)))}}catch(xe){ft(xe.message,"error")}}function F(xe){r(a)==="global"?E(c,{...r(c),direction:xe},!0):E(p,{...r(p),direction:xe},!0)}function L(xe){r(a)==="global"?E(c,{...r(c),...xe},!0):E(p,{...r(p),...xe},!0)}function B(){r(a)==="global"?E(c,{...mn},!0):E(p,{...mn},!0)}function O(xe){return r(v).find(Xt=>Xt.uuid===xe)?.username||on(xe)}let S=x(()=>{let xe=r($).trim().toLowerCase();return xe?r(v).filter(_t=>(_t.username||"").toLowerCase().includes(xe)||(_t.uuid||"").toLowerCase().includes(xe)):r(v)}),R=x(()=>Object.values(r(o)).filter(Qn).length),k=x(()=>Qn(r(u))),M=x(()=>r(a)==="global"?r(k):!!(r(i)&&r(h))),q=x(()=>Qn(r(m))),j=x(()=>r(k)&&r(R)?`GLOBAL + ${r(R)} TARGETED`:r(k)?"GLOBAL ENGAGED":r(R)?`${r(R)} TARGETED`:"IDLE"),H=x(()=>r(k)||r(R)>0);function D(xe){if(!xe)return"pass-through";let _t=[];return xe.latencyMs&&_t.push(xe.latencyMs+"ms"),xe.jitterMs&&_t.push("\xB1"+xe.jitterMs+"ms"),xe.bandwidthBytesPerSec&&_t.push(Sm(xe.bandwidthBytesPerSec)),xe.direction&&_t.push(xe.direction==="CLIENTBOUND"?"S\u2192C":"C\u2192S"),_t.length?_t.join(" \xB7 "):"no-op"}let N=Array.from({length:11},(xe,_t)=>_t),G=Array.from({length:21},(xe,_t)=>_t);function Y(xe){return xe>=1024*1024?"MB/s":xe>=1024?"KB/s":"B/s"}let K=xe=>xe==="MB/s"?1024*1024:xe==="KB/s"?1024:1,Z=xe=>xe==="MB/s"?"0.01":xe==="KB/s"?"0.1":"1",Q=(xe,_t)=>_t==="B/s"?xe:+(xe/K(_t)).toFixed(_t==="MB/s"?2:1),ee=X("B/s"),J=X(!1);ge(()=>{r(J)||E(ee,Y(r(m).bandwidthBytesPerSec),!0)});function te(xe,_t,Xt){let cr=xe.currentTarget.value;if(cr==="")return 0;let dr=Number(cr);return Number.isFinite(dr)?Math.max(_t,Math.min(Xt,Math.round(dr))):0}var V=aE(),W=ie(V);{let xe=x(()=>[B2]);Hr(W,{get crumbs(){return r(xe)},get title(){return z2},get actions(){return n}})}var re=d(W,2),oe=l(re);let ue;var $e=l(oe);de($e,20,()=>Array(6),lt,(xe,_t,Xt)=>{var cr=G2();we(cr,`--d:${Xt*80}ms`);let dr;T(()=>dr=pe(cr,1,"",null,dr,{on:r(H)})),f(xe,cr)}),s($e);var me=d($e,2),he=d(l(me),4),be=l(he,!0);s(he),s(me);var ye=d(me,2),ze=l(ye),Re=d(l(ze)),De=l(Re,!0);s(Re),s(ze);var Be=d(ze,2),ot=d(l(Be)),Ke=l(ot);s(ot),s(Be),s(ye);var ke=d(ye,2),Ze=l(ke);s(ke),s(oe);var je=d(oe,2),Le=l(je),qe=l(Le);let Ae;var Ce=d(qe,2);let Fe;s(Le);var Ne=d(Le,2);{var Ve=xe=>{var _t=J2(),Xt=l(_t),cr=l(Xt);kt(cr);var dr=d(cr,2);{var Dn=Fr=>{var se=W2(),fe=d(l(se)),it=l(fe,!0);s(fe),s(se),T(pt=>y(it,pt),[()=>O(r(i))]),f(Fr,se)};z(dr,Fr=>{r(i)&&Fr(Dn)})}s(Xt);var ea=d(Xt,2),ta=l(ea);de(ta,17,()=>r(S),Fr=>Fr.uuid,(Fr,se)=>{let fe=x(()=>r(i)===r(se).uuid),it=x(()=>r(o)[r(se).uuid]);var pt=X2();let Lt;var ar=d(l(pt),2),Fn=l(ar,!0);s(ar);var Bn=d(ar,2);{var ei=ti=>{var ra=K2(),Ol=l(ra,!0);s(ra),T(Dl=>y(Ol,Dl),[()=>D(r(it))]),f(ti,ra)},lo=x(()=>Qn(r(it)));z(Bn,ti=>{r(lo)&&ti(ei)})}s(pt),T((ti,ra)=>{Lt=pe(pt,1,"thr-chip",null,Lt,ti),ne(pt,"title",r(se).uuid),y(Fn,ra)},[()=>({on:r(fe),lit:Qn(r(it))}),()=>r(se).username||on(r(se).uuid)]),U("click",pt,()=>A(r(se).uuid)),f(Fr,pt)});var Fa=d(ta,2);{var gn=Fr=>{var se=Z2();f(Fr,se)};z(Fa,Fr=>{r(S).length===0&&Fr(gn)})}s(ea),s(_t),Sa(cr,()=>r($),Fr=>E($,Fr)),f(xe,_t)};z(Ne,xe=>{r(a)==="player"&&xe(Ve)})}s(je);var mt=d(je,2);let He;var Ie=l(mt),Je=d(l(Ie),2),$t=l(Je);let Ee;var Ye=d($t,2);let We;var Oe=d(Ye,2);let st;s(Je),s(Ie);var rt=d(Ie,2),ht=d(l(rt),2);let wt;var dt=l(ht,!0);s(ht),s(rt);var Ue=d(rt,2);{var Qe=xe=>{var _t=Q2(),Xt=d(l(_t),2),cr=l(Xt,!0);s(Xt),s(_t),T(dr=>y(cr,dr),[()=>D(r(h))]),f(xe,_t)};z(Ue,xe=>{r(a)==="player"&&r(i)&&r(h)&&xe(Qe)})}s(mt);var Se=d(mt,2),nt=l(Se);let ut;var vt=d(l(nt),2),Ct=l(vt);kt(Ct),ve(2),s(vt);var Dt=d(vt,2),Vt=l(Dt);Lp(Vt,()=>G);var ct=d(Vt,2);kt(ct);let Mt;var It=d(ct,2);Ip(It,()=>["0","500","1k","1.5k","2k"]),s(Dt),ve(2),s(nt);var qt=d(nt,2);let nr;var Wt=d(l(qt),2),Ft=d(l(Wt),2);kt(Ft),ve(2),s(Wt);var Pt=d(Wt,2),Kt=l(Pt);Lp(Kt,()=>N);var Ht=d(Kt,2);kt(Ht);let cn;var Qr=d(Ht,2);Ip(Qr,()=>["0","125","250","375","500"]),s(Pt),ve(2),s(qt);var $n=d(qt,2);let en;var Ur=d(l($n),2),rr=l(Ur);kt(rr);var _n=d(rr,2),Ja=l(_n,!0);s(_n);var In=d(_n,2);{var Mi=xe=>{var _t=eE();f(xe,_t)};z(In,xe=>{r(m).bandwidthBytesPerSec||xe(Mi)})}s(Ur);var Qa=d(Ur,2),Da=l(Qa);Lp(Da,()=>G);var On=d(Da,2);kt(On);let Pi;var Ll=d(On,2);Ip(Ll,()=>["0","1K","32K","1M","16M"]),s(Qa),ve(2),s($n),s(Se);var Il=d(Se,2);{var ps=xe=>{et(xe,{title:"Active overrides",flush:!0,meta:Xt=>{var cr=tE(),dr=ie(cr),Dn=l(dr,!0);s(dr),ve(),T(()=>y(Dn,r(R))),f(Xt,cr)},children:(Xt,cr)=>{var dr=nE(),Dn=d(l(dr));de(Dn,21,()=>Object.entries(r(o)),([ea,ta])=>ea,(ea,ta)=>{var Fa=x(()=>fr(r(ta),2));let gn=()=>r(Fa)[0],Fr=()=>r(Fa)[1];var se=rE(),fe=l(se),it=l(fe,!0);s(fe);var pt=d(fe),Lt=l(pt,!0);s(pt);var ar=d(pt),Fn=l(ar,!0);s(ar);var Bn=d(ar),ei=l(Bn),lo=l(ei),ti=d(lo,2);s(ei),s(Bn),s(se),T((ra,Ol,Dl)=>{y(it,ra),y(Lt,Ol),y(Fn,Dl)},[()=>O(gn()),()=>on(gn()),()=>D(Fr())]),U("click",lo,()=>{E(a,"player"),A(gn())}),U("click",ti,async()=>{await Ge("/throttle/players/"+gn(),{method:"DELETE"});let ra={...r(o)};delete ra[gn()],E(o,ra,!0),r(i)===gn()&&E(p,{...mn},!0),ft("Cleared "+O(gn()))}),f(ea,se)}),s(Dn),s(dr),f(Xt,dr)},$$slots:{meta:!0,default:!0}})},oo=x(()=>Object.keys(r(o)).length>0);z(Il,xe=>{r(oo)&&xe(ps)})}s(re),T((xe,_t,Xt,cr,dr,Dn,ea,ta,Fa,gn)=>{ue=pe(oe,1,"thr-status",null,ue,{engaged:r(H),idle:!r(H)}),y(be,r(j)),y(De,xe),y(Ke,`${r(R)??""} ${r(R)===1?"player":"players"}`),ne(Ze,"d",_t),ne(qe,"aria-selected",r(a)==="global"),Ae=pe(qe,1,"thr-mode seg-control__item",null,Ae,{on:r(a)==="global"}),ne(Ce,"aria-selected",r(a)==="player"),Fe=pe(Ce,1,"thr-mode seg-control__item",null,Fe,{on:r(a)==="player"}),He=pe(mt,1,"thr-strip",null,He,{off:!r(q)}),Ee=pe($t,1,"thr-dir__opt",null,Ee,{on:r(m).direction===null}),We=pe(Ye,1,"thr-dir__opt",null,We,{on:r(m).direction==="CLIENTBOUND"}),st=pe(Oe,1,"thr-dir__opt",null,st,{on:r(m).direction==="SERVERBOUND"}),wt=pe(ht,1,"thr-strip__live",null,wt,{dim:!r(q)}),y(dt,Xt),ut=pe(nt,1,"thr-mod",null,ut,{lit:r(m).latencyMs>0}),Ot(Ct,r(m).latencyMs),Ot(ct,r(m).latencyMs),Mt=we(ct,"",Mt,cr),nr=pe(qt,1,"thr-mod",null,nr,{lit:r(m).jitterMs>0}),Ot(Ft,r(m).jitterMs),Ot(Ht,r(m).jitterMs),cn=we(Ht,"",cn,dr),en=pe($n,1,"thr-mod",null,en,{lit:r(m).bandwidthBytesPerSec>0}),ne(rr,"max",Dn),ne(rr,"step",ea),Ot(rr,ta),y(Ja,r(ee)),Ot(On,Fa),Pi=we(On,"",Pi,gn)},[()=>D(r(u)),()=>Tm(r(q)?r(m):null),()=>D(r(m)),()=>({"--pct":Math.min(r(m).latencyMs,2e3)/2e3*100+"%"}),()=>({"--pct":Math.min(r(m).jitterMs,500)/500*100+"%"}),()=>Ai/K(r(ee)),()=>Z(r(ee)),()=>Q(r(m).bandwidthBytesPerSec,r(ee)),()=>Math.round(Rp(r(m).bandwidthBytesPerSec)*1e3),()=>({"--pct":Rp(r(m).bandwidthBytesPerSec)*100+"%"})]),U("click",qe,()=>E(a,"global")),U("click",Ce,()=>E(a,"player")),U("click",$t,()=>F(null)),U("click",Ye,()=>F("CLIENTBOUND")),U("click",Oe,()=>F("SERVERBOUND")),U("input",Ct,xe=>L({latencyMs:te(xe,0,6e4)})),U("input",ct,xe=>L({latencyMs:+xe.currentTarget.value})),U("input",Ft,xe=>L({jitterMs:te(xe,0,1e4)})),U("input",Ht,xe=>L({jitterMs:+xe.currentTarget.value})),Rt("focus",rr,()=>E(J,!0)),Rt("blur",rr,()=>E(J,!1)),U("input",rr,xe=>{let _t=parseFloat(xe.currentTarget.value);!Number.isFinite(_t)||_t<0||L({bandwidthBytesPerSec:Math.min(Ai,Math.round(_t*K(r(ee))))})}),U("input",On,xe=>L({bandwidthBytesPerSec:Em(+xe.currentTarget.value/1e3)})),f(t,V),ce()}Pe(["click","input"]);function sE(t){let{root:e,segs:n}=t;return e?e==="p"&&n[1]?"p":e:"dashboard"}var oE=_('
            '),lE=_('
            ',1),cE=_(" ",1);function Dp(t,e){le(e,!0);let n=x(()=>sE(hi.current)),a=x(()=>r(n)==="p"?"players":r(n)),i=x(()=>r(n)==="p"?hi.current.segs[1]:null),o=x(()=>r(n)==="p"?hi.current.segs[2]||"overview":null),c=x(()=>ur.mode==="replay"&&!ur.scope),p=x(()=>ur.mode==="replay");ge(()=>{ur.scope&&Fp()}),ge(()=>(document.addEventListener("click",Kc),()=>document.removeEventListener("click",Kc)));let u=X(!1);var $=cE(),g=ie($);{var v=w=>{var C=oE(),A=l(C);fd(A,{}),s(C),f(w,C)},m=w=>{var C=lE(),A=ie(C);td(A,{get navKey(){return r(a)},get currentUuid(){return r(i)},get profileTab(){return r(o)},get isReplay(){return r(p)},onTweaks:()=>E(u,!r(u))});var I=d(A,2),P=l(I);{var F=K=>{dl(K,{})},L=K=>{gd(K,{})},B=K=>{Sd(K,{})},O=K=>{Pp(K,{})},S=K=>{kp(K,{})},R=K=>{Ep(K,{})},k=K=>{Sp(K,{})},M=K=>{Mp(K,{})},q=K=>{Op(K,{})},j=K=>{wp(K,{get uuid(){return r(i)},get tab(){return r(o)}})},H=K=>{dl(K,{})};z(P,K=>{r(n)==="dashboard"?K(F):r(n)==="players"?K(L,1):r(n)==="packets"?K(B,2):r(n)==="terminal"&&!r(p)?K(O,3):r(n)==="trigger"&&!r(p)?K(S,4):r(n)==="actions"&&!r(p)?K(R,5):r(n)==="query"?K(k,6):r(n)==="routines"&&!r(p)?K(M,7):r(n)==="throttle"&&!r(p)?K(q,8):r(n)==="p"?K(j,9):K(H,-1)})}s(I);var D=d(I,2);{var N=K=>{id(K,{onClose:()=>E(u,!1)})};z(D,K=>{r(u)&&K(N)})}var G=d(D,2);ld(G,{});var Y=d(G,2);ud(Y,{}),f(w,C)};z(g,w=>{r(c)?w(v):w(m,-1)})}var h=d(g,2);ad(h,{});var b=d(h,2);dd(b,{}),f(t,$),ce()}async function dE(){await ur.boot(),(ur.mode==="live"||ur.scope)&&Fp()}var Cm=!1;function Fp(){Cm||(Cm=!0,or.connect(),Tr.boot(),Ca.boot())}var Am=document.getElementById("app");if(!Am)throw new Error("Missing #app mount target");Yi(Dp,{target:Am});dE();export{Fp as ensureBoot}; diff --git a/web/src/main/resources/web/index.html b/web/src/main/resources/web/index.html new file mode 100644 index 00000000000..5ea6a0cfdd8 --- /dev/null +++ b/web/src/main/resources/web/index.html @@ -0,0 +1,20 @@ + + + + + + + + Minestom · Console + + + + + + + +
            + + + + diff --git a/web/src/main/resources/web/style.css b/web/src/main/resources/web/style.css new file mode 100644 index 00000000000..1e6390f0035 --- /dev/null +++ b/web/src/main/resources/web/style.css @@ -0,0 +1,4529 @@ +@font-face { + font-family: 'Monocraft'; + src: url('https://cdn.jsdelivr.net/gh/IdreesInc/Monocraft@main/dist/Monocraft-ttf/Monocraft.ttf') format('truetype'); + font-display: swap; +} + +/* Layer order — later wins on equal specificity. Tokens flow down to every layer; component + * rules can be overridden by per-page rules. */ +@layer tokens, base, layout, components, util, chrome, pages; + +/* ============================================================================== TOKENS == */ + +/* Dynamic custom properties set inline by Svelte (`style:--gc={c}`) or by JS. Declared + * formally with @property so the browser knows their type and tooling doesn't flag them + * as unknown. Declarations live outside @layer because @property cannot be nested. */ +@property --gc { syntax: ''; inherits: true; initial-value: transparent; } +@property --em-c { syntax: ''; inherits: true; initial-value: transparent; } +@property --wp-c { syntax: ''; inherits: true; initial-value: transparent; } +@property --chip-c { syntax: ''; inherits: true; initial-value: transparent; } +@property --dur-color { syntax: ''; inherits: true; initial-value: transparent; } +@property --fill { syntax: '*'; inherits: true; } +@property --class-c { syntax: '*'; inherits: true; } +@property --phase { syntax: '*'; inherits: true; } +@property --dur { syntax: ''; inherits: true; initial-value: 100%; } +@property --pct { syntax: ''; inherits: true; initial-value: 0%; } +@property --heat { syntax: ''; inherits: true; initial-value: 0; } +@property --cols { syntax: ''; inherits: true; initial-value: 3; } +@property --depth { syntax: ''; inherits: true; initial-value: 0; } +@property --w { syntax: ''; inherits: true; initial-value: 9; } +@property --pt-inspector-w { syntax: '*'; inherits: true; } +@property --pt-inspector-h { syntax: '*'; inherits: true; } +@property --d { syntax: '