From 30da5a70c8b70081e971a01279480a48b8ec5ff0 Mon Sep 17 00:00:00 2001 From: TheMode Date: Thu, 14 May 2026 11:49:18 +0200 Subject: [PATCH 1/5] Proxy --- .gitignore | 6 + demo/build.gradle.kts | 9 + demo/src/main/java/module-info.java | 3 + .../src/main/java/net/minestom/demo/Main.java | 6 +- .../java/net/minestom/demo/WebInterface.java | 208 + demo/src/main/resources/logback.xml | 14 + settings.gradle.kts | 1 + .../net/minestom/server/item/ItemStack.java | 4 + .../server/item/ItemStackHashImpl.java | 5 + .../minestom/server/item/ItemStackImpl.java | 25 +- .../net/minestom/server/item/Material.java | 5 + .../server/registry/DynamicRegistry.java | 4 + .../server/registry/DynamicRegistryImpl.java | 52 +- .../minestom/server/registry/Registries.java | 5 + .../server/registry/RegistriesImpl.java | 10 + .../minestom/server/scoreboard/Sidebar.java | 2 +- .../server/utils/mojang/MojangUtils.java | 37 + .../registry/RegistryIntegrationTest.java | 31 +- web/CLAUDE.md | 80 + web/build.gradle.kts | 382 ++ web/frontend/.gitignore | 3 + web/frontend/esbuild.config.mjs | 37 + web/frontend/package-lock.json | 726 +++ web/frontend/package.json | 19 + web/frontend/src/App.svelte | 83 + web/frontend/src/components/Minimap.svelte | 393 ++ web/frontend/src/components/Sidebar.svelte | 240 + .../components/editors/ActionEditor.svelte | 213 + .../components/editors/ActionSelector.svelte | 79 + .../editors/ComponentBuilder.svelte | 146 + .../src/components/editors/ElementSlot.svelte | 78 + .../src/components/editors/FieldRow.svelte | 67 + .../src/components/editors/ItemBuilder.svelte | 213 + .../editors/LibraryRecallPopover.svelte | 56 + .../src/components/editors/ListEditor.svelte | 109 + .../src/components/editors/MapEditor.svelte | 103 + .../editors/MaterialPickerPopover.svelte | 67 + .../editors/PacketFieldsEditor.svelte | 95 + .../components/editors/RecordEditor.svelte | 29 + .../components/editors/TriggerEditor.svelte | 118 + .../src/components/mctext/ChatLine.svelte | 13 + .../mctext/ChatListScrollBottom.svelte | 13 + .../components/mctext/MinecraftText.svelte | 20 + .../mctext/MinecraftTextNode.svelte | 53 + .../components/overlay/ContextMenuHost.svelte | 104 + .../overlay/EntityTooltipHost.svelte | 68 + .../overlay/McJsonTooltipHost.svelte | 48 + .../src/components/overlay/ProvBadge.svelte | 79 + .../components/overlay/ProvTooltipHost.svelte | 172 + .../overlay/ProvenancePopover.svelte | 113 + .../packet-trace/PacketTrace.svelte | 1356 +++++ .../packet-trace/PacketTraceFacets.svelte | 329 ++ .../packet-trace/PacketTraceHelp.svelte | 66 + .../packet-trace/PacketTraceInspector.svelte | 312 ++ .../packet-trace/PacketTraceMinimap.svelte | 160 + .../packet-trace/PacketTraceStream.svelte | 194 + .../packet-trace/PacketTraceTopBar.svelte | 122 + .../packet-trace/PacketTraceTweaks.svelte | 56 + .../src/components/packet-trace/types.ts | 25 + .../src/components/packets/CodeEditor.svelte | 219 + .../src/components/packets/Heatmap.svelte | 84 + .../src/components/packets/Leaderboard.svelte | 117 + .../src/components/packets/MqlSnippet.svelte | 17 + .../packets/PacketAggregatePanels.svelte | 31 + .../components/packets/PacketSelector.svelte | 99 + .../src/components/packets/SwimlaneRow.svelte | 88 + .../components/profile/AbilitiesPanel.svelte | 16 + .../components/profile/AttributesPanel.svelte | 35 + .../components/profile/DashboardStats.svelte | 203 + .../components/profile/EffectsPanel.svelte | 31 + .../src/components/profile/EntityCard.svelte | 39 + .../src/components/profile/HudPanel.svelte | 52 + .../components/profile/IdentityPanel.svelte | 23 + .../components/profile/InventoryPanel.svelte | 25 + .../src/components/profile/NbtTree.svelte | 163 + .../src/components/profile/PingPanel.svelte | 23 + .../components/profile/PlayerEntities.svelte | 563 ++ .../components/profile/PlayerInventory.svelte | 217 + .../components/profile/PlayerLifecycle.svelte | 127 + .../components/profile/PlayerPackets.svelte | 396 ++ .../profile/PlayerRegistries.svelte | 788 +++ .../components/profile/PositionPanel.svelte | 21 + .../src/components/profile/ProvValue.svelte | 22 + .../components/profile/ServerDataPanel.svelte | 17 + .../src/components/profile/SkinCanvas.svelte | 101 + .../src/components/profile/VitalsPanel.svelte | 64 + web/frontend/src/components/ui/Chart.svelte | 33 + web/frontend/src/components/ui/Crumbs.svelte | 14 + .../src/components/ui/EmptyState.svelte | 15 + web/frontend/src/components/ui/Panel.svelte | 49 + web/frontend/src/components/ui/Pill.svelte | 18 + .../src/components/ui/ProgressBar.svelte | 39 + .../src/components/ui/ReferenceList.svelte | 25 + .../src/components/ui/RunActionPanel.svelte | 27 + .../src/components/ui/Sparkline.svelte | 19 + web/frontend/src/components/ui/Toasts.svelte | 9 + web/frontend/src/components/ui/Toggle.svelte | 24 + .../src/components/ui/TweaksPanel.svelte | 70 + .../src/components/ui/ViewHead.svelte | 26 + web/frontend/src/lib/api.ts | 150 + web/frontend/src/lib/assets.ts | 54 + web/frontend/src/lib/charts.ts | 289 ++ web/frontend/src/lib/comboboxPopover.ts | 107 + web/frontend/src/lib/expression.ts | 202 + .../src/lib/floatingPopover.svelte.ts | 101 + web/frontend/src/lib/libraryDrop.svelte.ts | 53 + web/frontend/src/lib/minecraftText.ts | 320 ++ web/frontend/src/lib/minimap-camera.ts | 392 ++ web/frontend/src/lib/minimap-input.ts | 96 + web/frontend/src/lib/minimap-templates.ts | 115 + web/frontend/src/lib/minimap.ts | 871 ++++ web/frontend/src/lib/mql.ts | 123 + web/frontend/src/lib/nav.ts | 37 + web/frontend/src/lib/packetAgg.ts | 203 + web/frontend/src/lib/packetLibrary.svelte.ts | 102 + web/frontend/src/lib/packetSchema.ts | 70 + web/frontend/src/lib/packetTape.ts | 163 + web/frontend/src/lib/packetTrace.ts | 224 + web/frontend/src/lib/packetTraceDsl.ts | 100 + web/frontend/src/lib/playerDomain.ts | 17 + web/frontend/src/lib/profile.ts | 52 + web/frontend/src/lib/provenance.ts | 6 + web/frontend/src/lib/routineWire.ts | 59 + web/frontend/src/lib/statePatch.ts | 88 + web/frontend/src/lib/throttle.ts | 70 + web/frontend/src/lib/topics.ts | 83 + web/frontend/src/lib/types.ts | 76 + web/frontend/src/lib/util.ts | 93 + web/frontend/src/main.ts | 33 + web/frontend/src/state/api.svelte.ts | 11 + web/frontend/src/state/bus.svelte.ts | 40 + web/frontend/src/state/contextMenu.svelte.ts | 51 + .../src/state/entityTooltip.svelte.ts | 29 + .../src/state/mcJsonTooltip.svelte.ts | 66 + web/frontend/src/state/mode.svelte.ts | 80 + .../src/state/packetAggregate.svelte.ts | 195 + web/frontend/src/state/players.svelte.ts | 78 + web/frontend/src/state/provTooltip.svelte.ts | 35 + web/frontend/src/state/route.svelte.ts | 19 + web/frontend/src/state/throughput.svelte.ts | 51 + web/frontend/src/state/toasts.svelte.ts | 21 + web/frontend/src/state/tweaks.svelte.ts | 54 + web/frontend/src/views/Actions.svelte | 121 + web/frontend/src/views/Dashboard.svelte | 184 + web/frontend/src/views/GlobalPackets.svelte | 185 + web/frontend/src/views/Landing.svelte | 163 + web/frontend/src/views/Players.svelte | 127 + web/frontend/src/views/Profile.svelte | 267 + web/frontend/src/views/Query.svelte | 161 + web/frontend/src/views/Routines.svelte | 157 + web/frontend/src/views/Terminal.svelte | 139 + web/frontend/src/views/Throttle.svelte | 864 ++++ web/frontend/src/views/Trigger.svelte | 142 + web/frontend/tsconfig.json | 22 + web/src/main/java/module-info.java | 13 + .../main/java/net/minestom/web/Action.java | 35 + .../java/net/minestom/web/BackendRouter.java | 46 + .../java/net/minestom/web/BackendTarget.java | 32 + .../java/net/minestom/web/ControlBridge.java | 154 + .../java/net/minestom/web/ControlPacket.java | 42 + .../main/java/net/minestom/web/Direction.java | 10 + .../java/net/minestom/web/LifecycleEvent.java | 55 + .../java/net/minestom/web/MojangAuth.java | 34 + .../java/net/minestom/web/PacketEvent.java | 18 + .../java/net/minestom/web/PacketRecord.java | 23 + .../java/net/minestom/web/PlayerState.java | 452 ++ .../java/net/minestom/web/PlayerWorld.java | 126 + .../java/net/minestom/web/Provenance.java | 19 + .../java/net/minestom/web/ProxyConfig.java | 50 + .../java/net/minestom/web/ProxyServer.java | 261 + web/src/main/java/net/minestom/web/Query.java | 8 + .../net/minestom/web/RegisteredAction.java | 6 + .../net/minestom/web/RegisteredRoutine.java | 3 + .../main/java/net/minestom/web/Routine.java | 30 + .../java/net/minestom/web/StatePatch.java | 42 + .../main/java/net/minestom/web/Throttle.java | 33 + .../main/java/net/minestom/web/cli/Main.java | 343 ++ .../net/minestom/web/cli/MicrosoftAuth.java | 258 + .../web/internal/AddressResolver.java | 173 + .../web/internal/codec/MinimapCodec.java | 111 + .../web/internal/codec/PacketDecoder.java | 163 + .../web/internal/codec/PatchValue.java | 220 + .../web/internal/codec/PlayerSnapshot.java | 305 ++ .../web/internal/codec/RoutineCodecs.java | 146 + .../web/internal/codec/WebCodecs.java | 429 ++ .../minestom/web/internal/codec/WebJson.java | 44 + .../web/internal/codec/WebJsonBuilders.java | 105 + .../web/internal/codec/WebPayloads.java | 162 + .../web/internal/expression/Builtins.java | 138 + .../web/internal/expression/Expr.java | 139 + .../web/internal/expression/ExprValue.java | 98 + .../internal/expression/ExpressionEngine.java | 103 + .../web/internal/expression/Lexer.java | 86 + .../web/internal/expression/MqlConstants.java | 76 + .../web/internal/expression/QueryEngine.java | 85 + .../web/internal/expression/ValueParser.java | 124 + .../web/internal/http/DashboardServer.java | 327 ++ .../web/internal/http/JsonSerialization.java | 36 + .../web/internal/http/MetricsSampler.java | 55 + .../web/internal/http/PacketCatalog.java | 350 ++ .../web/internal/http/PacketCodec.java | 232 + .../web/internal/http/PacketSchema.java | 191 + .../web/internal/http/RateLimiter.java | 47 + .../minestom/web/internal/http/Topics.java | 26 + .../internal/http/routes/ConsoleRoutes.java | 39 + .../internal/http/routes/InjectRoutes.java | 29 + .../web/internal/http/routes/MiscRoutes.java | 112 + .../web/internal/http/routes/ModeRoutes.java | 53 + .../internal/http/routes/PacketRoutes.java | 104 + .../internal/http/routes/PlayerRoutes.java | 68 + .../web/internal/http/routes/QueryRoutes.java | 33 + .../internal/http/routes/RouteResponses.java | 214 + .../internal/http/routes/RoutineRoutes.java | 80 + .../web/internal/http/routes/ScopeRouter.java | 76 + .../internal/http/routes/ThrottleRoutes.java | 57 + .../web/internal/persist/HistoryFile.java | 225 + .../net/minestom/web/internal/persist/Op.java | 68 + .../internal/persist/PersistentHistory.java | 515 ++ .../web/internal/persist/RunMetadata.java | 23 + .../web/internal/proxy/ConnectionWorker.java | 395 ++ .../web/internal/proxy/JourneyTracker.java | 100 + .../minestom/web/internal/proxy/LoginIo.java | 105 + .../web/internal/proxy/LoginPipeline.java | 359 ++ .../web/internal/proxy/ProxyMetrics.java | 44 + .../web/internal/proxy/TcpAcceptor.java | 308 ++ .../web/internal/proxy/ThrottleManager.java | 102 + .../internal/renderer/BlockModelResolver.java | 84 + .../web/internal/renderer/IconCanvas.java | 114 + .../web/internal/renderer/IconCatalog.java | 235 + .../web/internal/renderer/IconConstants.java | 11 + .../web/internal/renderer/IconRecipe.java | 59 + .../internal/renderer/IconResourceIds.java | 39 + .../internal/renderer/ItemIconRenderer.java | 258 + .../internal/renderer/MinimapRasterizer.java | 44 + .../web/internal/renderer/SpriteIcons.java | 52 + .../internal/renderer/TextureResources.java | 32 + .../internal/replay/PacketSeqResolver.java | 235 + .../web/internal/replay/ReplaySource.java | 181 + .../web/internal/scope/DashboardScope.java | 359 ++ .../internal/scope/ScopeSessionBridge.java | 188 + .../web/internal/session/ActionRunner.java | 146 + .../internal/session/LifecycleHistory.java | 32 + .../internal/session/MailboxException.java | 31 + .../web/internal/session/PacketTimeline.java | 92 + .../web/internal/session/PlayerView.java | 83 + .../web/internal/session/Session.java | 529 ++ .../web/internal/session/SessionEvent.java | 40 + .../web/internal/session/SessionListener.java | 7 + .../web/internal/session/SessionMessage.java | 17 + .../web/internal/session/SessionRegistry.java | 316 ++ .../web/internal/state/BlockColors.java | 37 + .../web/internal/state/ChatHudUpdaters.java | 192 + .../web/internal/state/EntityGroups.java | 71 + .../web/internal/state/EntityUpdaters.java | 86 + .../web/internal/state/InventoryUpdaters.java | 185 + .../internal/state/SessionWorldUpdaters.java | 121 + .../web/internal/state/StateApplier.java | 175 + .../web/internal/state/VitalsUpdaters.java | 82 + .../web/internal/state/WorldUpdaters.java | 272 + .../java/net/minestom/web/package-info.java | 21 + web/src/main/resources/logback.xml | 14 + web/src/main/resources/web/app.js | 403 ++ web/src/main/resources/web/index.html | 19 + web/src/main/resources/web/style.css | 4538 +++++++++++++++++ .../net/minestom/web/EntityTrackingTest.java | 107 + .../minestom/web/InventoryUpdatersTest.java | 86 + .../minestom/web/ItemIconRendererTest.java | 25 + .../minestom/web/MinimapRasterizerTest.java | 41 + .../net/minestom/web/PacketDecoderTest.java | 29 + .../minestom/web/PacketSeqResolverTest.java | 79 + .../net/minestom/web/PacketTimelineTest.java | 56 + .../java/net/minestom/web/PatchValueTest.java | 204 + .../minestom/web/PersistentHistoryTest.java | 157 + .../net/minestom/web/QueryEngineTest.java | 137 + .../net/minestom/web/ReplaySourceTest.java | 80 + .../net/minestom/web/SessionRegistryTest.java | 96 + .../java/net/minestom/web/StatePatchTest.java | 199 + .../test/java/net/minestom/web/WorldTest.java | 173 + 278 files changed, 38647 insertions(+), 21 deletions(-) create mode 100644 demo/src/main/java/net/minestom/demo/WebInterface.java create mode 100644 demo/src/main/resources/logback.xml create mode 100644 web/CLAUDE.md create mode 100644 web/build.gradle.kts create mode 100644 web/frontend/.gitignore create mode 100644 web/frontend/esbuild.config.mjs create mode 100644 web/frontend/package-lock.json create mode 100644 web/frontend/package.json create mode 100644 web/frontend/src/App.svelte create mode 100644 web/frontend/src/components/Minimap.svelte create mode 100644 web/frontend/src/components/Sidebar.svelte create mode 100644 web/frontend/src/components/editors/ActionEditor.svelte create mode 100644 web/frontend/src/components/editors/ActionSelector.svelte create mode 100644 web/frontend/src/components/editors/ComponentBuilder.svelte create mode 100644 web/frontend/src/components/editors/ElementSlot.svelte create mode 100644 web/frontend/src/components/editors/FieldRow.svelte create mode 100644 web/frontend/src/components/editors/ItemBuilder.svelte create mode 100644 web/frontend/src/components/editors/LibraryRecallPopover.svelte create mode 100644 web/frontend/src/components/editors/ListEditor.svelte create mode 100644 web/frontend/src/components/editors/MapEditor.svelte create mode 100644 web/frontend/src/components/editors/MaterialPickerPopover.svelte create mode 100644 web/frontend/src/components/editors/PacketFieldsEditor.svelte create mode 100644 web/frontend/src/components/editors/RecordEditor.svelte create mode 100644 web/frontend/src/components/editors/TriggerEditor.svelte create mode 100644 web/frontend/src/components/mctext/ChatLine.svelte create mode 100644 web/frontend/src/components/mctext/ChatListScrollBottom.svelte create mode 100644 web/frontend/src/components/mctext/MinecraftText.svelte create mode 100644 web/frontend/src/components/mctext/MinecraftTextNode.svelte create mode 100644 web/frontend/src/components/overlay/ContextMenuHost.svelte create mode 100644 web/frontend/src/components/overlay/EntityTooltipHost.svelte create mode 100644 web/frontend/src/components/overlay/McJsonTooltipHost.svelte create mode 100644 web/frontend/src/components/overlay/ProvBadge.svelte create mode 100644 web/frontend/src/components/overlay/ProvTooltipHost.svelte create mode 100644 web/frontend/src/components/overlay/ProvenancePopover.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTrace.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceFacets.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceHelp.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceInspector.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceMinimap.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceStream.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceTopBar.svelte create mode 100644 web/frontend/src/components/packet-trace/PacketTraceTweaks.svelte create mode 100644 web/frontend/src/components/packet-trace/types.ts create mode 100644 web/frontend/src/components/packets/CodeEditor.svelte create mode 100644 web/frontend/src/components/packets/Heatmap.svelte create mode 100644 web/frontend/src/components/packets/Leaderboard.svelte create mode 100644 web/frontend/src/components/packets/MqlSnippet.svelte create mode 100644 web/frontend/src/components/packets/PacketAggregatePanels.svelte create mode 100644 web/frontend/src/components/packets/PacketSelector.svelte create mode 100644 web/frontend/src/components/packets/SwimlaneRow.svelte create mode 100644 web/frontend/src/components/profile/AbilitiesPanel.svelte create mode 100644 web/frontend/src/components/profile/AttributesPanel.svelte create mode 100644 web/frontend/src/components/profile/DashboardStats.svelte create mode 100644 web/frontend/src/components/profile/EffectsPanel.svelte create mode 100644 web/frontend/src/components/profile/EntityCard.svelte create mode 100644 web/frontend/src/components/profile/HudPanel.svelte create mode 100644 web/frontend/src/components/profile/IdentityPanel.svelte create mode 100644 web/frontend/src/components/profile/InventoryPanel.svelte create mode 100644 web/frontend/src/components/profile/NbtTree.svelte create mode 100644 web/frontend/src/components/profile/PingPanel.svelte create mode 100644 web/frontend/src/components/profile/PlayerEntities.svelte create mode 100644 web/frontend/src/components/profile/PlayerInventory.svelte create mode 100644 web/frontend/src/components/profile/PlayerLifecycle.svelte create mode 100644 web/frontend/src/components/profile/PlayerPackets.svelte create mode 100644 web/frontend/src/components/profile/PlayerRegistries.svelte create mode 100644 web/frontend/src/components/profile/PositionPanel.svelte create mode 100644 web/frontend/src/components/profile/ProvValue.svelte create mode 100644 web/frontend/src/components/profile/ServerDataPanel.svelte create mode 100644 web/frontend/src/components/profile/SkinCanvas.svelte create mode 100644 web/frontend/src/components/profile/VitalsPanel.svelte create mode 100644 web/frontend/src/components/ui/Chart.svelte create mode 100644 web/frontend/src/components/ui/Crumbs.svelte create mode 100644 web/frontend/src/components/ui/EmptyState.svelte create mode 100644 web/frontend/src/components/ui/Panel.svelte create mode 100644 web/frontend/src/components/ui/Pill.svelte create mode 100644 web/frontend/src/components/ui/ProgressBar.svelte create mode 100644 web/frontend/src/components/ui/ReferenceList.svelte create mode 100644 web/frontend/src/components/ui/RunActionPanel.svelte create mode 100644 web/frontend/src/components/ui/Sparkline.svelte create mode 100644 web/frontend/src/components/ui/Toasts.svelte create mode 100644 web/frontend/src/components/ui/Toggle.svelte create mode 100644 web/frontend/src/components/ui/TweaksPanel.svelte create mode 100644 web/frontend/src/components/ui/ViewHead.svelte create mode 100644 web/frontend/src/lib/api.ts create mode 100644 web/frontend/src/lib/assets.ts create mode 100644 web/frontend/src/lib/charts.ts create mode 100644 web/frontend/src/lib/comboboxPopover.ts create mode 100644 web/frontend/src/lib/expression.ts create mode 100644 web/frontend/src/lib/floatingPopover.svelte.ts create mode 100644 web/frontend/src/lib/libraryDrop.svelte.ts create mode 100644 web/frontend/src/lib/minecraftText.ts create mode 100644 web/frontend/src/lib/minimap-camera.ts create mode 100644 web/frontend/src/lib/minimap-input.ts create mode 100644 web/frontend/src/lib/minimap-templates.ts create mode 100644 web/frontend/src/lib/minimap.ts create mode 100644 web/frontend/src/lib/mql.ts create mode 100644 web/frontend/src/lib/nav.ts create mode 100644 web/frontend/src/lib/packetAgg.ts create mode 100644 web/frontend/src/lib/packetLibrary.svelte.ts create mode 100644 web/frontend/src/lib/packetSchema.ts create mode 100644 web/frontend/src/lib/packetTape.ts create mode 100644 web/frontend/src/lib/packetTrace.ts create mode 100644 web/frontend/src/lib/packetTraceDsl.ts create mode 100644 web/frontend/src/lib/playerDomain.ts create mode 100644 web/frontend/src/lib/profile.ts create mode 100644 web/frontend/src/lib/provenance.ts create mode 100644 web/frontend/src/lib/routineWire.ts create mode 100644 web/frontend/src/lib/statePatch.ts create mode 100644 web/frontend/src/lib/throttle.ts create mode 100644 web/frontend/src/lib/topics.ts create mode 100644 web/frontend/src/lib/types.ts create mode 100644 web/frontend/src/lib/util.ts create mode 100644 web/frontend/src/main.ts create mode 100644 web/frontend/src/state/api.svelte.ts create mode 100644 web/frontend/src/state/bus.svelte.ts create mode 100644 web/frontend/src/state/contextMenu.svelte.ts create mode 100644 web/frontend/src/state/entityTooltip.svelte.ts create mode 100644 web/frontend/src/state/mcJsonTooltip.svelte.ts create mode 100644 web/frontend/src/state/mode.svelte.ts create mode 100644 web/frontend/src/state/packetAggregate.svelte.ts create mode 100644 web/frontend/src/state/players.svelte.ts create mode 100644 web/frontend/src/state/provTooltip.svelte.ts create mode 100644 web/frontend/src/state/route.svelte.ts create mode 100644 web/frontend/src/state/throughput.svelte.ts create mode 100644 web/frontend/src/state/toasts.svelte.ts create mode 100644 web/frontend/src/state/tweaks.svelte.ts create mode 100644 web/frontend/src/views/Actions.svelte create mode 100644 web/frontend/src/views/Dashboard.svelte create mode 100644 web/frontend/src/views/GlobalPackets.svelte create mode 100644 web/frontend/src/views/Landing.svelte create mode 100644 web/frontend/src/views/Players.svelte create mode 100644 web/frontend/src/views/Profile.svelte create mode 100644 web/frontend/src/views/Query.svelte create mode 100644 web/frontend/src/views/Routines.svelte create mode 100644 web/frontend/src/views/Terminal.svelte create mode 100644 web/frontend/src/views/Throttle.svelte create mode 100644 web/frontend/src/views/Trigger.svelte create mode 100644 web/frontend/tsconfig.json create mode 100644 web/src/main/java/module-info.java create mode 100644 web/src/main/java/net/minestom/web/Action.java create mode 100644 web/src/main/java/net/minestom/web/BackendRouter.java create mode 100644 web/src/main/java/net/minestom/web/BackendTarget.java create mode 100644 web/src/main/java/net/minestom/web/ControlBridge.java create mode 100644 web/src/main/java/net/minestom/web/ControlPacket.java create mode 100644 web/src/main/java/net/minestom/web/Direction.java create mode 100644 web/src/main/java/net/minestom/web/LifecycleEvent.java create mode 100644 web/src/main/java/net/minestom/web/MojangAuth.java create mode 100644 web/src/main/java/net/minestom/web/PacketEvent.java create mode 100644 web/src/main/java/net/minestom/web/PacketRecord.java create mode 100644 web/src/main/java/net/minestom/web/PlayerState.java create mode 100644 web/src/main/java/net/minestom/web/PlayerWorld.java create mode 100644 web/src/main/java/net/minestom/web/Provenance.java create mode 100644 web/src/main/java/net/minestom/web/ProxyConfig.java create mode 100644 web/src/main/java/net/minestom/web/ProxyServer.java create mode 100644 web/src/main/java/net/minestom/web/Query.java create mode 100644 web/src/main/java/net/minestom/web/RegisteredAction.java create mode 100644 web/src/main/java/net/minestom/web/RegisteredRoutine.java create mode 100644 web/src/main/java/net/minestom/web/Routine.java create mode 100644 web/src/main/java/net/minestom/web/StatePatch.java create mode 100644 web/src/main/java/net/minestom/web/Throttle.java create mode 100644 web/src/main/java/net/minestom/web/cli/Main.java create mode 100644 web/src/main/java/net/minestom/web/cli/MicrosoftAuth.java create mode 100644 web/src/main/java/net/minestom/web/internal/AddressResolver.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/MinimapCodec.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/PacketDecoder.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/PatchValue.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/PlayerSnapshot.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/RoutineCodecs.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/WebCodecs.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/WebJson.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/WebJsonBuilders.java create mode 100644 web/src/main/java/net/minestom/web/internal/codec/WebPayloads.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/Builtins.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/Expr.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/ExprValue.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/ExpressionEngine.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/Lexer.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/MqlConstants.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/QueryEngine.java create mode 100644 web/src/main/java/net/minestom/web/internal/expression/ValueParser.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/DashboardServer.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/JsonSerialization.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/MetricsSampler.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/PacketCatalog.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/PacketCodec.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/PacketSchema.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/RateLimiter.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/Topics.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/ConsoleRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/InjectRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/MiscRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/ModeRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/PacketRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/PlayerRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/QueryRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/RouteResponses.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/RoutineRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/ScopeRouter.java create mode 100644 web/src/main/java/net/minestom/web/internal/http/routes/ThrottleRoutes.java create mode 100644 web/src/main/java/net/minestom/web/internal/persist/HistoryFile.java create mode 100644 web/src/main/java/net/minestom/web/internal/persist/Op.java create mode 100644 web/src/main/java/net/minestom/web/internal/persist/PersistentHistory.java create mode 100644 web/src/main/java/net/minestom/web/internal/persist/RunMetadata.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/ConnectionWorker.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/JourneyTracker.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/LoginIo.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/LoginPipeline.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/ProxyMetrics.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/TcpAcceptor.java create mode 100644 web/src/main/java/net/minestom/web/internal/proxy/ThrottleManager.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/BlockModelResolver.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/IconCanvas.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/IconCatalog.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/IconConstants.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/IconRecipe.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/IconResourceIds.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/ItemIconRenderer.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/MinimapRasterizer.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/SpriteIcons.java create mode 100644 web/src/main/java/net/minestom/web/internal/renderer/TextureResources.java create mode 100644 web/src/main/java/net/minestom/web/internal/replay/PacketSeqResolver.java create mode 100644 web/src/main/java/net/minestom/web/internal/replay/ReplaySource.java create mode 100644 web/src/main/java/net/minestom/web/internal/scope/DashboardScope.java create mode 100644 web/src/main/java/net/minestom/web/internal/scope/ScopeSessionBridge.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/ActionRunner.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/LifecycleHistory.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/MailboxException.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/PacketTimeline.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/PlayerView.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/Session.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/SessionEvent.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/SessionListener.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/SessionMessage.java create mode 100644 web/src/main/java/net/minestom/web/internal/session/SessionRegistry.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/BlockColors.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/ChatHudUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/EntityGroups.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/EntityUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/InventoryUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/SessionWorldUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/StateApplier.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/VitalsUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/internal/state/WorldUpdaters.java create mode 100644 web/src/main/java/net/minestom/web/package-info.java create mode 100644 web/src/main/resources/logback.xml create mode 100644 web/src/main/resources/web/app.js create mode 100644 web/src/main/resources/web/index.html create mode 100644 web/src/main/resources/web/style.css create mode 100644 web/src/test/java/net/minestom/web/EntityTrackingTest.java create mode 100644 web/src/test/java/net/minestom/web/InventoryUpdatersTest.java create mode 100644 web/src/test/java/net/minestom/web/ItemIconRendererTest.java create mode 100644 web/src/test/java/net/minestom/web/MinimapRasterizerTest.java create mode 100644 web/src/test/java/net/minestom/web/PacketDecoderTest.java create mode 100644 web/src/test/java/net/minestom/web/PacketSeqResolverTest.java create mode 100644 web/src/test/java/net/minestom/web/PacketTimelineTest.java create mode 100644 web/src/test/java/net/minestom/web/PatchValueTest.java create mode 100644 web/src/test/java/net/minestom/web/PersistentHistoryTest.java create mode 100644 web/src/test/java/net/minestom/web/QueryEngineTest.java create mode 100644 web/src/test/java/net/minestom/web/ReplaySourceTest.java create mode 100644 web/src/test/java/net/minestom/web/SessionRegistryTest.java create mode 100644 web/src/test/java/net/minestom/web/StatePatchTest.java create mode 100644 web/src/test/java/net/minestom/web/WorldTest.java 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..bab00d9a941 100644 --- a/src/main/java/net/minestom/server/item/ItemStackHashImpl.java +++ b/src/main/java/net/minestom/server/item/ItemStackHashImpl.java @@ -63,6 +63,11 @@ record Item( Map, Integer> addedComponents, Set> removedComponents ) implements ItemStack.Hash { + @Override + public ItemStack asItemStack() { + return ItemStack.of(material, amount); + } + private static final int MAX_COMPONENTS = 256; public static final NetworkBuffer.Type NETWORK_TYPE = NetworkBufferTemplate.template( Material.NETWORK_TYPE, Item::material, diff --git a/src/main/java/net/minestom/server/item/ItemStackImpl.java b/src/main/java/net/minestom/server/item/ItemStackImpl.java index 1227ea59c88..ca4d7a068b3 100644 --- a/src/main/java/net/minestom/server/item/ItemStackImpl.java +++ b/src/main/java/net/minestom/server/item/ItemStackImpl.java @@ -10,6 +10,7 @@ import net.minestom.server.item.component.CustomData; import net.minestom.server.item.component.TooltipDisplay; import net.minestom.server.network.NetworkBuffer; +import net.minestom.server.registry.Registries; import net.minestom.server.registry.RegistryTranscoder; import net.minestom.server.tag.Tag; import org.jetbrains.annotations.Contract; @@ -45,13 +46,22 @@ public ItemStack read(NetworkBuffer buffer) { if (amount <= 0) return ItemStack.AIR; final Material material = buffer.read(Material.NETWORK_TYPE); final DataComponentMap components = buffer.read(componentPatchType); - return ItemStackImpl.create(material, amount, components); + return ItemStackImpl.create(material, amount, components, buffer.registries()); } }; } static ItemStack create(Material material, int amount, DataComponentMap components) { if (amount <= 0 || material == Material.AIR) return AIR; + return create(material, amount, components, null); + } + + static ItemStack create(Material material, int amount, DataComponentMap components, @Nullable Registries registries) { + if (amount <= 0 || material == Material.AIR) return AIR; + if (components != DataComponentMap.EMPTY) { + final DataComponentMap prototype = registries == null ? material.prototype() : material.prototype(registries); + components = DataComponentMap.diff(prototype, components); + } return new ItemStackImpl(material, amount, components); } @@ -62,19 +72,6 @@ static ItemStack create(Material material, int amount) { public ItemStackImpl { Objects.requireNonNull(material, "Material cannot be null"); - // It is relevant to create the minimal diff of the prototype so that #isSimilar returns consistent - // results for ItemStacks which would resolve to the same thing. For example, consider two items - // (name indicating prototype, brackets showing the components given during construction): - // 1: apple[max_stack_size=64, custom_name=Hello] - // 2: apple[custom_name=Hello] - // After resolution the first set of components would turn into the second one because apple already has a - // max stack size of 64. If we did not do this, #isSimilar would return false for these two items because of - // their different patches. - // It is worth noting that the client would handle both cases perfectly fine. - if (components != DataComponentMap.EMPTY) { - components = DataComponentMap.diff(material.prototype(), components); - } - // Having items with amount being 0 and material not being air kicks players if (amount == 0) material = Material.AIR; } diff --git a/src/main/java/net/minestom/server/item/Material.java b/src/main/java/net/minestom/server/item/Material.java index e83d4da3530..d8d99e9ff2a 100644 --- a/src/main/java/net/minestom/server/item/Material.java +++ b/src/main/java/net/minestom/server/item/Material.java @@ -10,6 +10,7 @@ import net.minestom.server.network.NetworkBuffer; import net.minestom.server.registry.Registry; import net.minestom.server.registry.RegistryData; +import net.minestom.server.registry.Registries; import net.minestom.server.registry.StaticProtocolObject; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; @@ -50,6 +51,10 @@ default DataComponentMap prototype() { return registry().prototype(); } + default DataComponentMap prototype(Registries registries) { + return registry().prototype(registries); + } + default boolean isArmor() { return registry().isArmor(); } 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..9b0894d27bd --- /dev/null +++ b/web/frontend/src/components/editors/ListEditor.svelte @@ -0,0 +1,109 @@ + + +
+
+ + +
+ {#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..da018da04c0 --- /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..4e97e07cfec --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTrace.svelte @@ -0,0 +1,1356 @@ + + + + +
+ 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..8295e6c3c0a --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceHelp.svelte @@ -0,0 +1,66 @@ + + +
{ 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..2e55b981ab5 --- /dev/null +++ b/web/frontend/src/components/packet-trace/PacketTraceInspector.svelte @@ -0,0 +1,312 @@ + + + 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..8282a1c7bd5 --- /dev/null +++ b/web/frontend/src/components/packets/Leaderboard.svelte @@ -0,0 +1,117 @@ + + + + +{#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..eb5cd3939c3 --- /dev/null +++ b/web/frontend/src/components/profile/PlayerPackets.svelte @@ -0,0 +1,396 @@ + + + + +
+
+
+ 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..ff1dc92ab97 --- /dev/null +++ b/web/frontend/src/views/Query.svelte @@ -0,0 +1,161 @@ + + + + +{#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..aaf65f6e76c --- /dev/null +++ b/web/frontend/src/views/Trigger.svelte @@ -0,0 +1,142 @@ + + +{#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..d89b0db1637 --- /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; callers can resolve +/// them via [net.minestom.web.cli.MicrosoftAuth#fetchProfile] (the `--login` CLI flow and the bundled `Main` do +/// this automatically 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..8749059b995 --- /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 nanos at capture time +/// @param direction CLIENTBOUND (server → client) or SERVERBOUND (client → server) +/// @param state the connection state at decode time +/// @param className fully-qualified 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..8c29d7f5538 --- /dev/null +++ b/web/src/main/java/net/minestom/web/PlayerState.java @@ -0,0 +1,452 @@ +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. Not serialized. + public transient 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..ecf81573bad --- /dev/null +++ b/web/src/main/java/net/minestom/web/PlayerWorld.java @@ -0,0 +1,126 @@ +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(); + } +} 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..cf864dd0670 --- /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#proxy] `.movePlayer(uuid, host, port)`. `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..af3a1c36481 --- /dev/null +++ b/web/src/main/java/net/minestom/web/StatePatch.java @@ -0,0 +1,42 @@ +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) {} + + /// Convenience builder for tests + the rare hand-rolled caller. Production patches 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..60807ce4bca --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/AddressResolver.java @@ -0,0 +1,173 @@ +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] / [#resolve] — 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] / [#resolveMinecraft] — 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. + public 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. + public 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. + public 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); + } + + /// Resolve `host` with SRV, falling back to [#DEFAULT_PORT] when no SRV record exists. + public static InetSocketAddress resolveMinecraft(String host) { + return resolveMinecraft(host, DEFAULT_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/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..1d42e532b68 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/codec/PatchValue.java @@ -0,0 +1,220 @@ +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 instanceof Result.Error> err + ? err.cast() : new Result.Error<>("decode failed"); + } + 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 instanceof Result.Error err ? err.cast() : new Result.Error<>("decode failed"); + } + Result decoded = CODEC.decode(coder, item); + if (decoded instanceof Result.Ok(Object object)) { + out.put(key, object); + } else { + return decoded instanceof Result.Error err ? err.cast() : new Result.Error<>("decode failed"); + } + } + 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()); + } + Result encoded = Codec.RAW_VALUE.encode(coder, RawValue.of(Transcoder.JAVA, value)); + if (!(encoded instanceof Result.Ok(D boxed)) || !(boxed instanceof RawValue raw)) { + return encoded instanceof Result.Error err ? err : new Result.Error<>("encode failed"); + } + return raw.convertTo(coder); + } + + 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)) { + builder.add(item); + } else { + return encoded instanceof Result.Error err ? err : new Result.Error<>("encode failed"); + } + } + 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)) { + object.add(entry.getKey(), (JsonElement) item); + } else { + return encoded instanceof Result.Error err ? err : new Result.Error<>("encode failed"); + } + } + @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)) { + builder.put(entry.getKey(), item); + } else { + return encoded instanceof Result.Error err ? err : new Result.Error<>("encode failed"); + } + } + return new Result.Ok<>(builder.build()); + } + + @Override + public Result decode(Transcoder coder, D value) { + Result decoded = Codec.RAW_VALUE.decode(coder, value); + if (!(decoded instanceof Result.Ok(RawValue raw))) { + return decoded instanceof Result.Error err ? err.cast() : new Result.Error<>("decode failed"); + } + Result converted = raw.convertTo(Transcoder.JAVA); + return converted.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..c35a84b2eb1 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/Builtins.java @@ -0,0 +1,138 @@ +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.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())), + unary("lower(s)", "Lowercase a string.", (v, s) -> new ExprValue.Str(v.str().toLowerCase())), + 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) return ExprValue.NULL; + 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..4cf85691e1a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/expression/ExpressionEngine.java @@ -0,0 +1,103 @@ +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.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(); + + private final ControlBridge control; + + 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) { + ValueParser p = newParser(src); + Expr ast = p.parseExpr(); + if (p.peek().kind() != Lexer.Kind.EOF) + throw new IllegalArgumentException("Trailing tokens at " + p.peek()); + 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..9ebc1bba65a --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/DashboardServer.java @@ -0,0 +1,327 @@ +package net.minestom.web.internal.http; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.javalin.Javalin; +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 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 = "/"; + }); + 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; + + 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())); + + 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); + } + + 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; + String key = config.token() == null ? ctx.ip() : config.token(); + if (!postLimiter.tryAcquire(key)) { + 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..3c89db9072c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/PacketCatalog.java @@ -0,0 +1,350 @@ +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); + + 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 + put(m, PacketCatalog::entitySubject, + SpawnEntityPacket.class, EntityPositionPacket.class, EntityPositionAndRotationPacket.class, + EntityRotationPacket.class, EntityPositionSyncPacket.class, EntityTeleportPacket.class, + EntityMetaDataPacket.class, EntityHeadLookPacket.class, EntityVelocityPacket.class, + EntityAnimationPacket.class, EntityStatusPacket.class, EntityEquipmentPacket.class, + DestroyEntitiesPacket.class); + // 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 + put(m, PacketCatalog::windowSubject, + OpenWindowPacket.class, CloseWindowPacket.class, SetSlotPacket.class, + SetPlayerInventorySlotPacket.class, WindowItemsPacket.class, SetCursorItemPacket.class, + HeldItemChangePacket.class, ClientHeldItemChangePacket.class, WindowPropertyPacket.class); + // 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 Integer id = switch (packet) { + case SpawnEntityPacket p -> p.entityId(); + case EntityPositionPacket p -> p.entityId(); + case EntityPositionAndRotationPacket p -> p.entityId(); + case EntityRotationPacket p -> p.entityId(); + case EntityPositionSyncPacket p -> p.entityId(); + case EntityTeleportPacket p -> p.entityId(); + case EntityMetaDataPacket p -> p.entityId(); + case EntityHeadLookPacket p -> p.entityId(); + case EntityVelocityPacket p -> p.entityId(); + case EntityAnimationPacket p -> p.entityId(); + case EntityStatusPacket p -> p.entityId(); + case EntityEquipmentPacket p -> p.entityId(); + case DestroyEntitiesPacket p -> p.entityIds().size() == 1 ? p.entityIds().getFirst() : null; + default -> null; + }; + return id == null ? SUBJ_ENT_ALL : new Subject("ent." + id, "Entity #" + id, Group.ENT); + } + + private static Subject windowSubject(Packet packet) { + final int id = switch (packet) { + case OpenWindowPacket p -> p.windowId(); + case CloseWindowPacket p -> p.windowId(); + case SetSlotPacket p -> p.windowId(); + case WindowItemsPacket p -> p.windowId(); + case WindowPropertyPacket p -> p.windowId(); + // always the player inventory + case HeldItemChangePacket _, ClientHeldItemChangePacket _, + SetPlayerInventorySlotPacket _, SetCursorItemPacket _ -> 0; + default -> -1; + }; + if (id == 0 || id == -1) return SUBJ_WIN_INV; + return new Subject("win." + id, "Window #" + id, Group.WIN); + } + + 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..b036f944a92 --- /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; } + + public 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..08c98c1a7b9 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/RateLimiter.java @@ -0,0 +1,47 @@ +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(); + 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; + } + } + + private static final class Bucket { + long tokens; + long lastRefillNanos = 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..32023744975 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/ConsoleRoutes.java @@ -0,0 +1,39 @@ +package net.minestom.web.internal.http.routes; + +import com.google.gson.JsonObject; +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) -> { + JsonObject body = parseJsonBody(ctx); + String command = body.get("command").getAsString(); + if (command.isBlank()) { + badRequest(ctx, new IllegalArgumentException("empty command")); + 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..1e5c1bd807b --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/InjectRoutes.java @@ -0,0 +1,29 @@ +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 = body.get("class").getAsString(); + 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..07b52738b3e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/MiscRoutes.java @@ -0,0 +1,112 @@ +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; + try { uuid = UUID.fromString(ctx.pathParam("uuid")); } + catch (IllegalArgumentException e) { badRequest(ctx, e); return; } + final com.google.gson.JsonObject body = parseJsonBody(ctx); + if (!body.has("address") || body.get("address").isJsonNull()) { + badRequest(ctx, new IllegalArgumentException("address required")); + return; + } + final InetSocketAddress target; + try { target = AddressResolver.parseMinecraft(body.get("address").getAsString()); } + catch (IllegalArgumentException e) { badRequest(ctx, e); return; } + 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..c7076d2d9f5 --- /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 = (int) parseLong(ctx.queryParam("limit"), 200); + 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 = (int) parseLong(ctx.queryParam("limit"), 5000); + 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..941137bc515 --- /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 = session.readState(player -> WebJsonBuilders.visibleEntityJson(player, eid)); + if (snap == null) { notFound(ctx, "not visible"); return; } + int limit = (int) parseLong(ctx.queryParam("limit"), 200); + 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, session.readState(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..53f38df4376 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/QueryRoutes.java @@ -0,0 +1,33 @@ +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) -> { + scope.expressions.compile(parseJsonBody(ctx).get("src").getAsString()); + jsonRaw(ctx, OK_JSON); + })); + + app.post("/api/query", scoped((ctx, scope) -> { + var q = scope.queries.compile(parseJsonBody(ctx).get("ql").getAsString()); + 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..e1e15e3d7da --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/RouteResponses.java @@ -0,0 +1,214 @@ +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, session.readState(extractor::apply)); + }); + } + + // ---- 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 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) { + return JsonParser.parseString(ctx.body()).getAsJsonObject(); + } + + 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..69da8c56e6b --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/http/routes/RoutineRoutes.java @@ -0,0 +1,80 @@ +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; + JsonObject body = parseJsonBody(ctx); + RegisteredRoutine routine = scope.registry.setRoutineEnabled(id, body.get("enabled").getAsBoolean()); + 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); + Action action = scope.registry.resolveAction(body.getAsJsonObject("action")); + 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..0de98e128a5 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/HistoryFile.java @@ -0,0 +1,225 @@ +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.ByteBuffer; +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(); + } + + // ---------------------------------------------------------------- UUID <-> BLOB(16) + + public static byte[] uuidBytes(UUID uuid) { + final ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(uuid.getMostSignificantBits()); + buf.putLong(uuid.getLeastSignificantBits()); + return buf.array(); + } + + public static UUID uuidFromBytes(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()); + } + + // ---------------------------------------------------------------- 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..c1e4ca7a5c0 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/persist/PersistentHistory.java @@ -0,0 +1,515 @@ +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.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++, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(id)); + insertConnect.setLong(2, sid); + if (journeyId == null) insertConnect.setNull(3, Types.BLOB); + else insertConnect.setBytes(3, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(journeyId)); + if (playerUuid == null) insertJourney.setNull(2, Types.BLOB); + else insertJourney.setBytes(2, HistoryFile.uuidBytes(playerUuid)); + insertJourney.setLong(3, ts); + insertJourney.addBatch(); + } + case Op.JourneyPlayerUuid(UUID journeyId, UUID playerUuid) -> { + updateJourneyPlayer.setBytes(1, HistoryFile.uuidBytes(playerUuid)); + updateJourneyPlayer.setBytes(2, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(id)); + updateConnectInit.addBatch(); + } + case Op.CloseConnection(UUID id, long ts) -> { + updateDisconnect.setLong(1, ts); + updateDisconnect.setBytes(2, HistoryFile.uuidBytes(id)); + updateDisconnect.addBatch(); + } + case Op.Io(UUID id, long seq, long ts, Direction dir, byte[] payload) -> { + insertIo.setBytes(1, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(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..c3aa6ce39f2 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/ConnectionWorker.java @@ -0,0 +1,395 @@ +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; + +/// 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(); + 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) { + if (!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); + if (delayNanos == 0L) { + var cipher = writeCipher(direction); + PacketDecoder.encryptInPlace(writeBuffer, cipher == null ? null : cipher.encrypt()); + return writeFully(sink, writeBuffer, direction); + } + // AES-CFB8 is stateful — cipher state must advance in wire-write order. Copy plain bytes + // and defer encryption to writeDelayed (drained on this same worker thread), otherwise a + // later delay=0 packet's encryption would advance the cipher while its bytes jump ahead + // of this still-pending frame, corrupting the receiver's decrypt stream. + final byte[] frame = new byte[frameBytes]; + writeBuffer.copyTo(0L, frame, 0L, frameBytes); + ThrottleManager.schedule(delayNanos, () -> { + if (!isOpen()) return; + if (tasks.offer(() -> writeDelayed(sink, frame, direction))) selector.wakeup(); + else 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; + } + } + + 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) { + 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(); + } + + 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 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..36ea4e2900c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/JourneyTracker.java @@ -0,0 +1,100 @@ +package net.minestom.web.internal.proxy; + +import org.jetbrains.annotations.Nullable; + +import java.net.InetSocketAddress; +import java.nio.ByteBuffer; +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 + /// `cookieBytes(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; + try { + final ByteBuffer buf = ByteBuffer.wrap(cookieBytes); + id = new UUID(buf.getLong(), buf.getLong()); + } catch (RuntimeException _) { + return null; + } + 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` on `journeyId`. Called when a new + /// connection (LOGIN or post-TRANSFER) finishes login and is about to flow PLAY traffic. + public void recordAssignment(UUID playerUuid, UUID journeyId, InetSocketAddress address) { + if (playerUuid == null || journeyId == 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); + } + + /// Encode a cookie id as the 16-byte payload that goes on the wire. + public static byte[] cookieBytes(UUID id) { + final ByteBuffer buf = ByteBuffer.allocate(16); + buf.putLong(id.getMostSignificantBits()); + buf.putLong(id.getLeastSignificantBits()); + return buf.array(); + } + + 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..7097ccc9a14 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/LoginPipeline.java @@ -0,0 +1,359 @@ +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); + 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..dea0eb61fa7 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/proxy/ProxyMetrics.java @@ -0,0 +1,44 @@ +package net.minestom.web.internal.proxy; + +import net.minestom.server.codec.Codec; +import net.minestom.server.codec.StructCodec; + +import java.util.concurrent.atomic.LongAdder; + +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..f1f708188be --- /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.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, + JourneyTracker.cookieBytes(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)))); + }, new java.util.concurrent.CompletableFuture<>())); + } + + 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), + new java.util.concurrent.CompletableFuture<>())); + } + + 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..e1293fc555e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/IconCanvas.java @@ -0,0 +1,114 @@ +package net.minestom.web.internal.renderer; + +import org.jetbrains.annotations.Nullable; + +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; + + for (int y = minY; y <= maxY; y++) { + for (int x = minX; x <= maxX; x++) { + double[] uv = barycentric(x + 0.5, y + 0.5, x0, y0, x1, y1, x2, y2, x3, y3); + if (uv == null) 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 { + BufferedImage hi = new BufferedImage(RENDER, RENDER, BufferedImage.TYPE_INT_ARGB); + hi.setRGB(0, 0, RENDER, RENDER, pixels, 0, RENDER); + + 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, hi.getRGB(x * RENDER / OUT, y * 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); + } + + private static double @Nullable [] barycentric(double px, double py, + double x0, double y0, double x1, double y1, + double x2, double y2, double x3, double y3) { + double[] uv = tri(px, py, x0, y0, x1, y1, x3, y3); + if (uv != null) return uv; + double d = (y2 - y3) * (x1 - x3) + (x3 - x2) * (y1 - y3); + if (Math.abs(d) < 1e-6) return null; + 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 null; + return new double[]{w0 + w1, w1 + w2}; + } + + private static double @Nullable [] tri(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 null; + 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 null; + return new double[]{w1, w2}; + } +} 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..c6a680edbf1 --- /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) { + String s = raw; + if (s.startsWith("minecraft:")) s = s.substring("minecraft:".length()); + if (s.startsWith("block/")) s = s.substring("block/".length()); + if (s.startsWith("item/")) s = s.substring("item/".length()); + return s.isEmpty() ? null : s; + } + + /// 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. + static @Nullable String modelPath(String id) { + String s = id; + if (s.startsWith("minecraft:")) s = s.substring("minecraft:".length()); + if (s.startsWith("block/")) s = s.substring("block/".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..4be193eb300 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/renderer/MinimapRasterizer.java @@ -0,0 +1,44 @@ +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 short h = heights[idx]; + if (h == UNKNOWN) { + out[o] = 16; + out[o + 1] = 20; + out[o + 2] = 24; + out[o + 3] = (byte) 255; + continue; + } + final int packed = colors == null || colors[idx] == UNKNOWN_COLOR ? BlockColors.UNKNOWN : colors[idx]; + final int r = (packed >> 16) & 0xFF; + final int g = (packed >> 8) & 0xFF; + final int b = packed & 0xFF; + out[o] = (byte) r; + out[o + 1] = (byte) g; + out[o + 2] = (byte) b; + 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..6067004af5d --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/replay/PacketSeqResolver.java @@ -0,0 +1,235 @@ +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.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, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(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, HistoryFile.uuidBytes(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..6e926ec1a81 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/replay/ReplaySource.java @@ -0,0 +1,181 @@ +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.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 = HistoryFile.uuidFromBytes(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 = HistoryFile.uuidFromBytes(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..cdc21de38b0 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/scope/DashboardScope.java @@ -0,0 +1,359 @@ +package net.minestom.web.internal.scope; + +import com.google.gson.JsonElement; +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; + } + + /// Fan a message out to every subscriber of `topic` in this scope. No-op when nobody is + /// listening so the caller can avoid building the payload (also gated upstream). + public void broadcast(String topic, JsonElement message) { + if (!hasSubscriber(topic)) return; + final String body = message.toString(); + for (Subscriber sub : subscribers.values()) { + if (sub.topics.contains(topic)) sub.enqueue(body); + } + } + + /// Stamp `topic` onto the payload and broadcast. No-op if nobody is listening. + public void publish(String topic, JsonObject payload) { + if (!hasSubscriber(topic)) return; + payload.addProperty("topic", topic); + broadcast(topic, payload); + } + + 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; + final List rows = new ArrayList<>(pendingSummary.values()); + pendingSummary.clear(); + 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 { + com.google.gson.JsonArray arr = new com.google.gson.JsonArray(batch.size()); + for (String body : batch) arr.add(com.google.gson.JsonParser.parseString(body)); + com.google.gson.JsonObject wrap = new com.google.gson.JsonObject(); + wrap.add("batch", arr); + ctx.send(wrap.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..c9987ec058c --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/scope/ScopeSessionBridge.java @@ -0,0 +1,188 @@ +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 so SELECT … WHERE player_uuid = ? can find every connection on the + // player's journey. + if (scope.persistence != null && session.journeyId() != null) { + scope.persistence.recordJourneyPlayerUuid(session.journeyId(), ev.playerUuid()); + } + publishPlayers("add", session); + } + if (timelineEvent != null && scope.hasSubscriber(Topics.PACKETS_AGGREGATE)) { + scope.notePacketAggregate(buildPlayerEvent(ev, timelineEvent)); + } + pushPacketEvent(ev, timelineEvent); + } + + private void pushPacketEvent(SessionEvent.PacketSeen ev, PacketEvent timelineEvent) { + if (timelineEvent == null) return; + final UUID uuid = ev.playerUuid(); + if (uuid == null) return; + final String topic = Topics.playerPackets(uuid); + if (!scope.hasSubscriber(topic)) return; + scope.publish(topic, WebJson.encodeAsObject(WebCodecs.PLAYER_PACKET_EVENT, buildPlayerEvent(ev, timelineEvent))); + } + + 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..8e6b014418e --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/Session.java @@ -0,0 +1,529 @@ +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 void removeListener(SessionListener listener) { + listeners.remove(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, new CompletableFuture<>())); + } + + public void setJourneyId(UUID id) { + this.journeyId = id; + if (ownerThread == null) player.journeyId = id; + else send(new SessionMessage.Mutate(p -> p.journeyId = id, new CompletableFuture<>())); + } + + 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) { + return stateTasks.offer(adapt(message)); + } + + private StateTask adapt(SessionMessage message) { + return switch (message) { + case SessionMessage.Mutate m -> new StateTask<>(p -> { + try { m.body().accept(p); m.ack().complete(null); } + catch (Throwable t) { m.ack().completeExceptionally(t); } + 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) { + if (routines.isEmpty()) 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 { + 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..bdde9361560 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionMessage.java @@ -0,0 +1,17 @@ +package net.minestom.web.internal.session; + +import net.minestom.web.PlayerState; +import net.minestom.web.RegisteredRoutine; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/// Typed messages a producer sends into a [Session] mailbox. Each carries its own ack/reply +/// future. Futures complete on the session worker thread — don't block them. +public sealed interface SessionMessage { + + record Mutate(Consumer body, CompletableFuture ack) 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..ba314029e44 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/session/SessionRegistry.java @@ -0,0 +1,316 @@ +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.journeyId(), 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"); + try { + return session.readState(query::matches); + } 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) 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..51da8564d5f --- /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.clear(); + 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..064a68d5716 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/VitalsUpdaters.java @@ -0,0 +1,82 @@ +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()))); + } + }), + // 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..b47c6e5e2f4 --- /dev/null +++ b/web/src/main/java/net/minestom/web/internal/state/WorldUpdaters.java @@ -0,0 +1,272 @@ +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); + 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, 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) return; + + 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..3bf68758634 --- /dev/null +++ b/web/src/main/resources/web/app.js @@ -0,0 +1,403 @@ +var Rm=Object.defineProperty;var Bp=(t,e)=>{for(var n in e)Rm(t,n,{get:e[n],enumerable:!0})};var zp=globalThis.process?.env?.NODE_ENV,_e=zp&&!zp.toLowerCase().startsWith("prod");var ia=Array.isArray,qp=Array.prototype.indexOf,zn=Array.prototype.includes,Ri=Array.from,Fl=Object.keys,Hr=Object.defineProperty,pn=Object.getOwnPropertyDescriptor,uo=Object.getOwnPropertyDescriptors,Bl=Object.prototype,Hp=Array.prototype,ni=Object.getPrototypeOf,zl=Object.isExtensible;var At=()=>{};function jp(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 ur(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 kr=Symbol("$state"),vs=Symbol("legacy props"),Up=Symbol(""),vo=Symbol("proxy path"),mo=Symbol("attributes"),ms=Symbol("class"),$s=Symbol("style"),_s=Symbol("text"),Ha=Symbol("form reset"),Hl=Symbol("hmr anchor"),ja=new class extends Error{name="StaleReactionError";message="The reaction that called `getAbortSignal()` was re-run or destroyed"},ai=!!globalThis.document?.contentType&&globalThis.document.contentType.includes("xml");var ii=3,gn=8;function Vp(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 gs(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 Gp(){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 Wp(){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 Kp(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 Xp(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 Zp(){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 Jp(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 Qp(){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 eu(){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 tu(){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 ru(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 nu(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 au(){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 iu(){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 su(){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 ou(){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 lu(){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 sa={};var Xt=Symbol(),un=Symbol("filename"),cu=Symbol("hmr"),$o="http://www.w3.org/1999/xhtml",hs="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 du(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 pu(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 uu(){_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 fu(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 vu(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 Ua(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 mu(){_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 $u(){_e?console.warn("%c[svelte] select_multiple_invalid_value\n%cThe `value` property of a ` `),j_={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,j_);let n=X(!1),a=X(!1),i=X(null),o=X(!0),d;async function p(k){if(!r(a)){E(a,!0),E(i,null);try{let M=await k.arrayBuffer();await pr.uploadReplay(M,k.name,r(o)),Xa("/")}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(){d.click()}function m(k){let M=k.target.files?.[0];M&&p(M)}var h=H_(),x=c(l(h),2),w=c(l(x),2),C=l(w,!0);s(w);var A=c(w,2),I=l(A);s(A);var P=c(A,2);Ct(P,k=>d=k,()=>d),s(x);var D=c(x,2),N=l(D);wt(N),me(2),s(D);var F=c(D,2);{var L=k=>{var M=z_(),q=l(M,!0);s(M),T(()=>y(q,r(i))),f(k,M)};B(F,k=>{r(i)&&k(L)})}var S=c(F,2);{var R=k=>{var M=q_(),q=c(l(M),2),V=l(q,!0);s(q);var H=c(q,4);s(M),T(()=>y(V,pr.scope.label)),Y("click",H,()=>pr.deleteCurrentScope()),f(k,M)};B(S,k=>{pr.scope&&k(R)})}s(h),T(()=>{ue(x,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${pr.protocolVersion??"?"??""}).`)}),Mt("dragover",x,$),Mt("dragleave",x,g),Mt("drop",x,u),Y("click",x,v),Y("keydown",x,k=>{(k.key==="Enter"||k.key===" ")&&v()}),Y("change",P,m),zs(N,()=>r(o),k=>E(o,k)),f(t,h),ce()}Pe(["click","keydown","change"]);function dl(t){return t.disconnectedAt?"ghost":t.serverConnectionState==="PLAY"?"on":t.serverConnectionState==="CONFIGURATION"?"warn":"ghost"}function pl(t,e){let n=t.disconnectedAt||e,a=t.connectedAt||e;return n-a}var U_=_('/'),V_=_(' '),Y_=_('

    ');function Ei(t,e){le(e,!0);let n=ne(e,"steps",19,()=>[]);var a=Y_();de(a,21,n,lt,(i,o,d)=>{var p=V_(),u=l(p);er(u,()=>r(o));var $=c(u,2);{var g=v=>{var m=U_();f(v,m)};B($,v=>{d"),W_=_(''),K_=_('
    '),X_=_("
    ");function et(t,e){let n=ne(e,"title",3,void 0),a=ne(e,"meta",3,void 0),i=ne(e,"actions",3,void 0),o=ne(e,"flush",3,!1),d=ne(e,"headless",3,!1),p=ne(e,"className",3,""),u=ne(e,"children",3,void 0),$=b(()=>typeof n()=="function"),g=b(()=>typeof a()=="function");var v=X_(),m=l(v);{var h=A=>{var I=K_(),P=l(I);{var D=k=>{var M=G_(),q=l(M);{var V=G=>{var O=Ce(),j=ie(O);er(j,n),f(G,O)},H=G=>{var O=bt();T(()=>y(O,n())),f(G,O)};B(q,G=>{r($)?G(V):G(H,-1)})}s(M),f(k,M)};B(P,k=>{n()!=null&&k(D)})}var N=c(P,2),F=l(N);{var L=k=>{var M=W_(),q=l(M);{var V=G=>{var O=Ce(),j=ie(O);er(j,a),f(G,O)},H=G=>{var O=bt();T(()=>y(O,a())),f(G,O)};B(q,G=>{r(g)?G(V):G(H,-1)})}s(M),f(k,M)};B(F,k=>{a()!=null&&k(L)})}var S=c(F,2);{var R=k=>{var M=Ce(),q=ie(M);er(q,i),f(k,M)};B(S,k=>{i()&&k(R)})}s(N),s(I),f(A,I)};B(m,A=>{d()||A(h)})}var x=c(m,2);let w;var C=l(x);er(C,()=>u()??At),s(x),s(v),T(()=>{ue(v,1,`panel ${p()??""}`),w=ue(x,1,"panel-body",null,w,{flush:o()})}),f(t,v)}var Z_=_(''),J_=_(" ");function dr(t,e){let n=ne(e,"kind",3,"ghost"),a=ne(e,"dot",3,!1);var i=J_(),o=l(i);{var d=u=>{var $=Z_();f(u,$)};B(o,u=>{a()&&u(d)})}var p=c(o,2);er(p,()=>e.children??At),s(i),T(()=>ue(i,1,`pill ${n()??""}`)),f(t,i)}var Q_=on(''),eg=on(''),tg=_('\xB7 ',1),rg=_('
    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),d=X(0);Lf(()=>{je("/metrics/latest").then(Me=>{Me&&(E(i,Me.mspt??null,!0),E(o,Me.tps??null,!0))}).catch(()=>{})}),en(gr.metrics,Me=>{typeof Me.mspt=="number"&&E(i,Me.mspt,!0),typeof Me.tps=="number"&&E(o,Me.tps,!0)});let p=b(()=>Ma.series),u=b(()=>r(p).connections);ge(()=>{for(let Me of r(u))Me>r(d)&&E(d,Me,!0)});let $=b(()=>r(u).at(-1)??0),g=b(()=>r(u).length>=6?r(u).at(-6)??0:r(u)[0]??0),v=b(()=>r($)-r(g)),m=b(()=>(r(p).bytesIn.at(-1)??0)+(r(p).bytesOut.at(-1)??0)),h=b(()=>{let Me=r(p).bytesIn,He=r(p).bytesOut,Xe=Math.min(Me.length,He.length,60);if(Xe===0)return[];let ct=new Array(Xe);for(let Et=0;Et{if(!r(h).length)return 0;let Me=0;for(let He of r(h))Me+=He;return Me/r(h).length}),w=b(()=>r(x)<=0?0:Math.round((r(m)-r(x))/r(x)*100)),C=b(()=>Math.round(r(p).packetsIn.at(-1)??0)),A=b(()=>Math.round(r(p).packetsOut.at(-1)??0)),I=b(()=>r(C)+r(A)),P=b(()=>r(i)==null?"\u2014":r(i)<10?r(i).toFixed(1):Math.round(r(i)).toString()),D=b(()=>r(i)==null?0:Math.max(0,Math.min(100,r(i)/a*100))),N=b(()=>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 F(Me){if(!Me||Me.length<2)return null;let He=1/0,Xe=-1/0;for(let ot of Me)otXe&&(Xe=ot);if(!Number.isFinite(He)||!Number.isFinite(Xe))return null;let ct=Xe-He||1,Et=100/(Me.length-1),It="";for(let ot=0;otF(r(u))),S=b(()=>F(r(h)));function R(Me){return Me>0?"\u25B2":Me<0?"\u25BC":"\xB7"}function k(Me){return Me>0?"up":Me<0?"down":""}var M=rg(),q=l(M),V=c(l(q),2),H=l(V),G=l(H,!0);s(H);var O=c(H,2);O.textContent="/ 256",s(V);var j=c(V,2),z=l(j),W=l(z),Z=l(W,!0);s(W);var ee=c(W);s(z);var ae=c(z,4),J=l(ae);s(ae),s(j);var Q=c(j,2);{var U=Me=>{var He=Q_(),Xe=l(He),ct=c(Xe);s(He),T(()=>{re(Xe,"d",r(L).area),re(ct,"d",r(L).line)}),f(Me,He)};B(Q,Me=>{r(L)&&Me(U)})}s(q);var K=c(q,2),te=c(l(K),2),se=l(te),pe=l(se,!0);s(se);var $e=c(se,2),ve=l($e,!0);s($e),me(2),s(te);var he=c(te,2),be=l(he),xe=l(be),Be=l(xe,!0);s(xe);var Re=c(xe);s(be),s(he);var Oe=c(he,2);{var De=Me=>{var He=eg(),Xe=l(He),ct=c(Xe);s(He),T(()=>{re(Xe,"d",r(S).area),re(ct,"d",r(S).line)}),f(Me,He)};B(Oe,Me=>{r(S)&&Me(De)})}s(K);var it=c(K,2),Je=c(l(it),2),we=l(Je),Qe=l(we,!0);s(we),me(2),s(Je);var Ye=c(Je,2),Le=l(Ye),ze=c(l(Le));s(Le);var Ae=c(Le,4),Se=c(l(Ae));s(Ae),s(Ye);var Fe=c(Ye,2),Ne=l(Fe);let Ue;var mt=c(Ne,2);let Ve;s(Fe),s(it);var Ie=c(it,2),We=c(l(Ie),2),$t=l(We),Ee=l($t,!0);s($t),me(2),s(We);var Ge=c(We,2),Ke=l(Ge);Ke.textContent="budget 50";var st=c(Ke,4),St=l(st,!0);s(st);var qe=c(st,2);{var pt=Me=>{var He=tg(),Xe=c(ie(He),2),ct=l(Xe);s(Xe),T(Et=>y(ct,`${Et??""} tps`),[()=>r(o).toFixed(0)]),f(Me,He)};B(qe,Me=>{r(o)!=null&&Me(pt)})}s(Ge);var ft=c(Ge,2),ht=l(ft);let at;var dt=c(ht,2);ke(dt,"",{},{left:"80%"}),s(ft),s(Ie),s(M),T((Me,He,Xe,ct,Et,It,qt,ot,Pt,Ot,Ht)=>{y(G,r($)),ue(z,1,Me),y(Z,He),y(ee,` ${r(v)>0?"+":""}${r(v)??""} in last 5s`),y(J,`\u03B5 ${r(d)??""} ever`),y(pe,Xe),y(ve,ct),ue(be,1,Et),y(Be,It),y(Re,` ${r(w)>0?"+":""}${r(w)??""}% vs 1m avg`),y(Qe,qt),y(ze,` ${ot??""}`),y(Se,` ${Pt??""}`),Ue=ke(Ne,"",Ue,Ot),Ve=ke(mt,"",Ve,Ht),ue(Ie,1,"stat-card stat-card--tick tone-"+r(N).tone),y(Ee,r(P)),ue(st,1,"stat-card__mood "+r(N).tone),y(St,r(N).word),at=ke(ht,"",at,{width:r(D)+"%"})},[()=>"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)),()=>Fr(r(I)),()=>Fr(r(C)),()=>Fr(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 ng=_("
    ");function es(t,e){le(e,!0);let n=ne(e,"xValues",3,null),a=ne(e,"className",3,""),i=ne(e,"style",3,""),o,d=null;ge(()=>(d=new nl(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}),()=>d?.destroy())),ge(()=>{d?.set(e.data,n())});var p=ng();Ct(p,u=>o=u,()=>o),T(()=>{ue(p,1,Tt(a())),ke(p,i())}),f(t,p),ce()}var ag=t=>{me();var e=bt("Overview");f(t,e)},ig=[{key:"in",label:"Ingress",color:"var(--ink-2)"},{key:"out",label:"Egress",color:"var(--acc)",area:!0}],sg=[{key:"in",label:"Inbound",color:"var(--ink-2)"},{key:"out",label:"Outbound",color:"var(--acc)",area:!0}],og=_("
    ",1),lg=_('
    Live counters have stopped updating.
    '),cg=_(' '),dg=_(' '),pg=_('
    '),ug=_("Active Sessions",1),fg=_('View all \u2192'),vg=_(' '),mg=_('
    No sessions. The proxy is listening; clients have yet to arrive.
    '),$g=_('
    PlayerStateDimensionModeHealthPingSession
    ',1),_g=_('

    Live Operations

    ',1),gg={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 ul(t,e){le(e,!0),Ut(t,gg);let n=b(()=>Ma.series),a=b(()=>ln.visible),i=b(()=>Qr.now),o=X(null),d=X(!1),p=X(null);ge(()=>{je("/persistence").then(V=>E(o,V,!0)).catch(()=>E(o,{enabled:!1},!0))});async function u(){if(!r(d)){E(d,!0),E(p,null);try{let V={},H=sessionStorage.getItem("mw-token");H&&(V["X-Auth-Token"]=H);let G=await fetch("/api/export.sqlite",{headers:V});if(!G.ok)throw new Error(`HTTP ${G.status}`);let O=await G.blob(),j=document.createElement("a");j.href=URL.createObjectURL(O),j.download=`sessions-${new Date().toISOString().replace(/[:.]/g,"-")}.sqlite`,document.body.appendChild(j),j.click(),j.remove(),URL.revokeObjectURL(j.href)}catch(V){E(p,String(V.message??V),!0)}finally{E(d,!1)}}}let $=b(()=>({in:r(n).bytesIn,out:r(n).bytesOut})),g=b(()=>({in:r(n).packetsIn,out:r(n).packetsOut})),v=b(()=>pr.scope?.status),m=b(()=>!!r(v)&&Ki.has(r(v))),h=b(()=>pr.scope?.endedAt),x=b(()=>pr.scope?.error);var w=_g(),C=ie(w),A=l(C),I=l(A);{let V=b(()=>[ag]);Ei(I,{get steps(){return r(V)}})}me(2),s(A);var P=c(A,2);{var D=V=>{var H=lg(),G=l(H);let O;var j=c(G,2),z=l(j),W=l(z,!0);s(z);var Z=c(z,2),ee=l(Z);{var ae=U=>{var K=bt();T(te=>y(K,`Frozen at ${te??""}.`),[()=>Ln(r(h))]),f(U,K)};B(ee,U=>{r(h)&&U(ae)})}var J=c(ee,2);{var Q=U=>{var K=og(),te=c(ie(K),1,!0);T(()=>y(te,r(x))),f(U,K)};B(J,U=>{r(v)==="error"&&r(x)&&U(Q)})}s(Z),s(j),s(H),T(()=>{O=ue(G,1,"replay-ended__dot",null,O,{"replay-ended__dot--err":r(v)==="error"}),y(W,r(v)==="error"?"Replay failed.":"Replay finished.")}),f(V,H)};B(P,V=>{r(m)&&V(D)})}var N=c(P,2);{var F=V=>{var H=pg(),G=l(H),O=l(G,!0);s(G);var j=c(G,2);{var z=Z=>{var ee=cg(),ae=l(ee,!0);s(ee),T(()=>y(ae,r(p))),f(Z,ee)},W=Z=>{var ee=dg(),ae=l(ee);s(ee),T(()=>y(ae,`protocol v${r(o).protocolVersion??""}`)),f(Z,ee)};B(j,Z=>{r(p)?Z(z):Z(W,-1)})}s(H),T(()=>{G.disabled=r(d),y(O,r(d)?"Exporting\u2026":"Export history \u21E3")}),Y("click",G,u),f(V,H)};B(N,V=>{r(o)?.enabled&&V(F)})}s(C);var L=c(C,2);vd(L,{});var S=c(L,2),R=l(S);et(R,{title:"Network Throughput",meta:"bytes / second",children:(V,H)=>{es(V,{get series(){return ig},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=c(R,2);et(k,{title:"Packet Rate",meta:"packets / second",children:(V,H)=>{es(V,{get series(){return sg},get data(){return r(g)},get xValues(){return r(n).ts},yLabel:"/s",yFormat:G=>Fr(Math.round(G)),className:"chart-md"})},$$slots:{default:!0}}),s(S);var M=c(S,2),q=l(M);{let V=O=>{me();var j=ug();me(),f(O,j)},H=O=>{var j=fg();f(O,j)},G=b(()=>`${r(a).length} online`);et(q,{get meta(){return r(G)},flush:!0,className:"table-scroll",title:V,actions:H,children:(O,j)=>{var z=$g(),W=ie(z),Z=c(l(W));de(Z,21,()=>r(a),J=>J.uuid,(J,Q)=>{let U=b(()=>!!r(Q).disconnectedAt),K=b(()=>r(U)?"OFFLINE":r(Q).serverConnectionState||"\u2014");var te=vg(),se=l(te),pe=l(se,!0);s(se);var $e=c(se),ve=l($e);{let Ue=Ve=>{me();var Ie=bt();T(()=>y(Ie,r(K))),f(Ve,Ie)},mt=b(()=>dl(r(Q)));dr(ve,{get kind(){return r(mt)},dot:!0,children:Ue,$$slots:{default:!0}})}s($e);var he=c($e),be=l(he,!0);s(he);var xe=c(he),Be=l(xe);dr(Be,{children:mt=>{me();var Ve=bt();T(()=>y(Ve,r(Q).gamemode||"\u2014")),f(mt,Ve)},$$slots:{default:!0}}),s(xe);var Re=c(xe),Oe=l(Re),De=c(Oe),it=l(De);s(De),s(Re);var Je=c(Re),we=l(Je,!0),Qe=c(we),Ye=l(Qe,!0);s(Qe),s(Je);var Le=c(Je),ze=l(Le,!0);s(Le);var Ae=c(Le),Se=l(Ae),Fe=l(Se),Ne=c(Fe,2);s(Se),s(Ae),s(te),T((Ue,mt,Ve,Ie)=>{ue(te,1,Tt(r(U)?"row-offline":"")),y(pe,r(Q).username||"\u2014"),y(be,Ue),y(Oe,`${mt??""} `),y(it,`/ ${Ve??""}`),y(we,r(U)?"\u2014":r(Q).traffic.pingMs),y(Ye,r(U)?"":"ms"),y(ze,Ie),re(Fe,"href","/p/"+r(Q).uuid),re(Ne,"href","/p/"+r(Q).uuid+"/packets")},[()=>(r(Q).dimension||"\u2014").replace("minecraft:",""),()=>(r(Q).health??0).toFixed(1),()=>(r(Q).maxHealth??20).toFixed(0),()=>fa(pl(r(Q),r(i)))]),f(J,te)}),s(Z),s(W);var ee=c(W,2);{var ae=J=>{var Q=mg();f(J,Q)};B(ee,J=>{r(a).length===0&&J(ae)})}f(O,z)},$$slots:{title:!0,actions:!0,default:!0}})}s(M),f(t,w),ce()}Pe(["click"]);var Ks={};Bp(Ks,{complete:()=>Ag,errorPos:()=>_v,loadSchema:()=>Gs,mqlError:()=>Na,renderTokens:()=>Ws,tokenize:()=>gv});var bl={};Bp(bl,{appendArithAndPipeOps:()=>ml,complete:()=>Sg,contextAt:()=>hl,finalize:()=>gl,isInString:()=>_l,loadSchema:()=>$d,operatorFor:()=>rs,operatorNames:()=>_a,schemaOrDefault:()=>ts,tokenize:()=>$l});var hg={fields:[],functions:[],operators:[],literals:[]},vl=null,md=null,ts=t=>t??vl??hg,bg=(t,e)=>rs(t,e)?.detail??"",rs=(t,e)=>ts(e).operators.find(n=>n.name===t),_a=(t,...e)=>ts(t).operators.filter(n=>e.includes(n.kind??"")).map(n=>n.name);function ml(t,e){for(let a of _a(e,"arithmetic"))t.push({label:a,kind:"op",insert:" "+a+" ",detail:bg(a,e)});let n=rs("|",e);n&&t.push({label:"|",kind:"op",insert:" | ",detail:n.detail||""})}async function $d(){return vl||(md??=je("/mql/constants").then(t=>vl=xg(t)).catch(t=>{throw md=null,t}),md)}function xg(t){let e=fl(t?.functions).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),sig:n.sig,detail:n.detail,pipe:!!n.pipe});return{fields:fl(t?.fields).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),detail:n.detail}),functions:e,operators:fl(t?.operators).map(n=>typeof n=="string"?{name:n}:{name:String(n.name),detail:n.detail,kind:n.kind}),literals:fl(t?.literals).map(String)}}var fl=t=>Array.isArray(t)?t:[],yg=[["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 $l(t){let e=[];e:for(let n=0;nd.pipe))i.push({label:o.name,kind:"transform",insert:o.name,detail:o.detail||"transform"});else a.wants==="op"&&ml(i,n);return gl(i,a)}function gl(t,e){let n=e.partial.toLowerCase();if(!n)return[];let a=t.map(o=>{let d=o.label.toLowerCase(),p=d.startsWith(n)?0:d.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,d)=>o.score-d.score||o.label.localeCompare(d.label)).slice(0,12)}function hl(t,e,n={}){let{isKeyword:a=()=>!1,valueStartKw:i=$v,cmpBoundaryKw:o=$v}=n,d=null,p=-1;for(let w=0;w=e&&kg.has(d.kind)),$=u?d.text.slice(0,e-d.start):"",g=u?[d.start,d.end]:[e,e],v=-1,m=null,h=u?p:d?p+1:t.length;for(let w=h-1;w>=0;w--)if(t[w].kind!=="ws"){v=w,m=t[w];break}let x={partial:$,range:g,prev:m,prevIdx:v};return m?m.kind==="dot"?{...x,wants:"path"}:m.kind==="pipe"?{...x,wants:"transform"}:m.kind==="op"||m.kind==="comma"||m.kind==="paren"&&m.text==="("||a(m)&&(i.has(m.text)||o.has(m.text))?{...x,wants:"value"}:Eg.has(m.kind)||m.kind==="paren"&&m.text===")"?{...x,wants:$?"value":"op"}:{...x,wants:"value"}:{...x,wants:"value"}}var Gs=$d,Ys=(t,e)=>`${Ar(e)}`;function Ws(t,e,n,a=null){let i=Number.isInteger(n),o=t.tokenize(e,a),d="";for(let p of o){if(!i||n=p.end){d+=Ys(p.kind,p.text);continue}let u=n-p.start;u>0&&(d+=Ys(p.kind,p.text.slice(0,u))),d+=Ys("error",p.text.slice(u,u+1)),u+1=e.length&&(d+=Ys("error"," ")),d}function _v(t){let e=/ at (\d+)\b/.exec(t??"");return e?Number(e[1]):null}function Na(t,e="invalid expression"){let n=t?.message||e;return{kind:"error",message:n,position:_v(n)}}function gv(t,e=null){let n=new Set(_a(e,"keyword","logical")),a=$l(t);for(let i of a)(i.kind==="root"||i.kind==="function")&&n.has(i.text)&&(i.kind="keyword");return a}var Tg=t=>e=>e.kind==="keyword"||e.kind==="root"&&t.has(e.text);function Cg(t,e,n,a,i){let o=new Set(_a(n,"arithmetic")),d=0;for(let p=e-1;p>=0;p--){let u=t[p];if(u.kind!=="ws"){if(u.kind==="paren"){if(u.text===")"){d++;continue}if(d===0)return!1;d--;continue}if(!(d>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 Ag(t,e,n){if(_l(t,e))return[];n=ts(n);let a=new Set(_a(n,"keyword")),i=new Set(_a(n,"logical")),o=new Set([...a,...i]),d=new Set([...a,...i]),p=gv(t,n),u=hl(p,e,{isKeyword:Tg(o),valueStartKw:d,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(!Cg(p,u.prevIdx,n,a,i)){for(let v of _a(n,"comparison"))$.push({label:v,kind:"op",insert:v+" ",detail:g(v)});for(let v of _a(n,"keyword"))$.push({label:v,kind:"keyword",insert:v+" ",detail:g(v)})}ml($,n);for(let v of _a(n,"logical").filter(m=>m!=="not"))$.push({label:v,kind:"keyword",insert:v+" ",detail:g(v)})}return gl($,u)}var Mg=_('
    '),Pg=_('

    ');function Vr(t,e){let n=ne(e,"crumbs",19,()=>[]);var a=Pg(),i=l(a),o=l(i);Ei(o,{get steps(){return n()}});var d=c(o,2),p=l(d);er(p,()=>e.title),s(d);var u=c(d,2);{var $=m=>{var h=Ce(),x=ie(h);er(x,()=>e.subtitle),f(m,h)};B(u,m=>{e.subtitle&&m($)})}s(i);var g=c(i,2);{var v=m=>{var h=Mg(),x=l(h);er(x,()=>e.actions),s(h),f(m,h)};B(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 Rg={field:"\u25C6",function:"\u0192",keyword:"\xB7",op:"=",literal:"\u220E",hint:"\u2026"},Ng=["boxSizing","height","borderTopWidth","borderRightWidth","borderBottomWidth","borderLeftWidth","paddingTop","paddingRight","paddingBottom","paddingLeft","fontStyle","fontWeight","fontSize","lineHeight","fontFamily","letterSpacing","tabSize"],ga=null;function bv(t){ga||(ga=document.createElement("div"),ga.className="mql-mirror",document.body.appendChild(ga));let e=window.getComputedStyle(t);for(let n of Ng)ga.style[n]=e[n];return ga.style.width=`${t.clientWidth}px`,e}function Lg(t,e){return bv(t),ga.textContent=e.endsWith(` +`)?e+` + `:e,ga.scrollHeight}function Ig(t,e){let n=bv(t);ga.textContent=t.value.slice(0,e);let a=document.createElement("span");return a.textContent="\u200B",ga.appendChild(a),{left:a.offsetLeft-t.scrollLeft,top:a.offsetTop-t.scrollTop,lineH:parseFloat(n.lineHeight)||parseFloat(n.fontSize)*1.2}}var hv=new WeakMap;var Og=_('?'),Dg=_('
    '),Fg=_('
    ');function Yr(t,e){le(e,!0);let n=ne(e,"value",3,""),a=ne(e,"language",3,"mql"),i=ne(e,"placeholder",3,'gamemode = "SURVIVAL" and ping < 100'),o=ne(e,"rows",3,3),d=ne(e,"status",3,null),p=ne(e,"className",3,""),u=ne(e,"big",3,!1),$=ne(e,"compact",3,!1),g=ne(e,"focus",15,null),v=b(()=>a()==="expression"?bl:Ks),m,h,x,w=X(null),C=X(null);ge(()=>{let O=hv.get(r(v));O||(O=r(v).loadSchema(),hv.set(r(v),O));let j=!0;return O.then(z=>{j&&E(C,z,!0)}),()=>{j=!1}}),ge(()=>{h&&(h.innerHTML=Ws(r(v),n(),r(w),r(C))+` +`)}),ge(()=>(x=new ns("mql-pop",(O,j,z)=>` +
  • + ${Rg[O.kind]||"\xB7"} + ${Ar(O.label)} + ${Ar(O.kind)} + ${Ar(O.detail||"")} +
  • `,P),x.mount(),()=>x.destroy())),ge(()=>{d()?.kind==="error"&&Number.isInteger(d().position)?E(w,d().position,!0):E(w,null)}),ge(()=>{let O=j=>{x?.open&&(x?.contains(j.target)||m?.contains(j.target)||A())};return document.addEventListener("pointerdown",O,!0),()=>document.removeEventListener("pointerdown",O,!0)}),ge(()=>{g()&&typeof g()=="object"&&g(g().focus=()=>m?.focus(),!0)});function A(){x&&x.hide()}function I(){if(!m||!x)return;let O=r(v).complete(m.value,m.selectionStart,r(C));if(O.length===0){A();return}x.setItems(O);let j=m.closest("dialog")||document.body;x.ensureParent(j),x.show();let{left:z,top:W,lineH:Z}=Ig(m,m.selectionStart),ee=m.getBoundingClientRect();x.position(ee.left+z,ee.top+W+Z+2)}function P(O){let j=x?.items[O];if(!m||!j)return;let[z,W]=j.range,Z=m.value.slice(0,z)+j.insert+m.value.slice(W),ee=z+j.insert.length;m.value=Z,m.setSelectionRange(ee,ee),A(),E(w,null),e.onChange?.(Z),queueMicrotask(I)}function D(O){if(!x?.handleKey(O)){if(O.key==="Enter"&&(O.metaKey||O.ctrlKey)){e.onSubmit?.(m.value),O.preventDefault();return}O.key===" "&&(O.ctrlKey||O.metaKey)&&(I(),O.preventDefault())}}function N(){!m||!$()||(m.style.height=`${Lg(m,m.value||m.placeholder||"\u200B")}px`)}function F(O){E(w,null),e.onChange?.(O.target.value),N(),I()}function L(){h&&m&&(h.scrollTop=m.scrollTop,h.scrollLeft=m.scrollLeft)}ge(()=>{if(n(),i(),$(),!m||!$())return;N();let O=new ResizeObserver(N);return O.observe(m),()=>O.disconnect()});let S=b(()=>["mql-editor",p(),u()&&"big",$()&&"compact"].filter(Boolean).join(" "));var R=Fg(),k=l(R);Ct(k,O=>h=O,()=>h);var M=c(k,2);vc(M),Ct(M,O=>m=O,()=>m);var q=c(M,2);{var V=O=>{var j=Og();f(O,j)};B(q,O=>{a()==="mql"&&O(V)})}var H=c(q,2);{var G=O=>{var j=Dg(),z=l(j,!0);s(j),T(()=>{ue(j,1,"mql-status "+(d().kind||"")),y(z,d().message)}),f(O,j)};B(H,O=>{d()&&O(G)})}s(R),T(()=>{ue(R,1,Tt(r(S))),re(M,"rows",o()),re(M,"placeholder",i()),Dt(M,n())}),Y("input",M,F),Y("keydown",M,D),Mt("scroll",M,L),Y("click",M,I),f(t,R),ce()}Pe(["input","keydown","click"]);var Bg=t=>{me();var e=bt("Players");f(t,e)},_d=t=>t.traffic.pingMs,xv={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)},zg=_(" connected",1),qg=_(""),Hg=_(' /20 ms '),jg=_('
    No connected players.
    '),Ug=_('
    PlayerUUIDBackendStateDimensionModePosHealthFoodXPPingLatency 60sIn \xB7 OutSession
    ',1),Vg=_('
    ',1);function gd(t,e){le(e,!0);let n=I=>{var P=zg(),D=ie(P),N=l(D,!0);s(D),me(),T(()=>y(N,r(v).length)),f(I,P)},a=I=>{var P=qg(),D=l(P);D.value=D.__value="connectedAt";var N=c(D);N.value=N.__value="ping";var F=c(N);F.value=F.__value="name";var L=c(F);L.value=L.__value="health",s(P);var S;Zn(P),T(()=>{S!==(S=r(d))&&(P.value=(P.__value=r(d))??"",En(P,r(d)))}),Y("change",P,R=>E(d,R.target.value,!0)),f(I,P)},i=b(()=>ln.visible),o=X(null),d=X("connectedAt"),p=X(""),u=X(null),$=b(()=>Qr.now),g=Za(async I=>{if(!I.trim()){E(o,null),E(u,null);return}try{let P=await je("/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,Na(P,"invalid query"),!0)}},220);ge(()=>{r(p),r(i),g(r(p))});let v=b(()=>{let I=r(i);if(r(o)){let D=new Set(r(o));I=I.filter(N=>D.has(N.uuid))}let P=xv[r(d)]||xv.connectedAt;return[...I].sort(P)}),m=b(()=>r(u)??{kind:"dim",message:`${r(i).length} / ${r(i).length} matched`});var h=Vg(),x=ie(h);{let I=b(()=>[Bg]);Vr(x,{get crumbs(){return r(I)},get title(){return n},get actions(){return a}})}var w=c(x,2),C=l(w);Yr(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=c(w,2);et(A,{headless:!0,flush:!0,className:"table-scroll",children:(I,P)=>{var D=Ug(),N=ie(D),F=c(l(N));de(F,21,()=>r(v),R=>R.uuid,(R,k)=>{let M=b(()=>[r(k).posX??0,r(k).posY??0,r(k).posZ??0]),q=b(()=>!!r(k).disconnectedAt);var V=Hg(),H=l(V),G=l(H,!0);s(H);var O=c(H),j=l(O,!0);s(O);var z=c(O),W=l(z,!0);s(z);var Z=c(z),ee=l(Z);{let Ne=b(()=>dl(r(k)));dr(ee,{get kind(){return r(Ne)},dot:!0,children:(Ue,mt)=>{me();var Ve=bt();T(()=>y(Ve,r(q)?"OFFLINE":r(k).serverConnectionState||"\u2014")),f(Ue,Ve)},$$slots:{default:!0}})}s(Z);var ae=c(Z),J=l(ae,!0);s(ae);var Q=c(ae),U=l(Q);dr(U,{children:(Ne,Ue)=>{me();var mt=bt();T(()=>y(mt,r(k).gamemode||"\u2014")),f(Ne,mt)},$$slots:{default:!0}}),s(Q);var K=c(Q),te=l(K,!0);s(K);var se=c(K),pe=l(se,!0),$e=c(pe),ve=l($e);s($e),s(se);var he=c(se),be=l(he,!0);me(),s(he);var xe=c(he),Be=l(xe,!0);s(xe);var Re=c(xe),Oe=l(Re,!0);me(),s(Re);var De=c(Re),it=l(De),Je=l(it);yi(Je,{get data(){return r(k).traffic.pingHistory},color:"var(--acc)",fill:"transparent"}),s(it),s(De);var we=c(De),Qe=l(we);s(we);var Ye=c(we),Le=l(Ye,!0);s(Ye);var ze=c(Ye),Ae=l(ze),Se=l(Ae),Fe=c(Se,2);s(Ae),s(ze),s(V),T((Ne,Ue,mt,Ve,Ie,We,$t,Ee,Ge)=>{ue(V,1,Tt(r(q)?"row-offline":"")),y(G,r(k).username||"\u2014"),y(j,Ne),y(W,r(k).backendAddress||"\u2014"),y(J,Ue),y(te,mt),y(pe,Ve),y(ve,`/${Ie??""}`),y(be,r(k).food??0),y(Be,r(k).xpLevel??0),y(Oe,We),y(Qe,`${$t??""}\xB7${Ee??""}`),y(Le,Ge),re(Se,"href","/p/"+r(k).uuid),re(Fe,"href","/p/"+r(k).uuid+"/packets")},[()=>cn(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),()=>fa(pl(r(k),r($)))]),f(R,V)}),s(F),s(N);var L=c(N,2);{var S=R=>{var k=jg();f(R,k)};B(L,R=>{r(v).length===0&&R(S)})}f(I,D)},$$slots:{default:!0}}),f(t,h),ce()}Pe(["change"]);var bd=["self","ent","world","hud","win","net","chat"];var tn=t=>String(t).toUpperCase().startsWith("CLIENT"),wv=t=>(t||"").replace(/^Clientbound|^Client/,"").replace(/Packet$/,""),yr=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:Kg()},anomaly:{prev:new Map,seen:new Set}}}function kv(){return{count:0,cbBytes:0,sbBytes:0,buckets:hd(),cb:hd(),bucketTs:yd()}}function as(t){return{seq:Number(t.seq)||0,ts:Wg(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 Ev(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 d=tn(e.direction);d?o.cb++:o.sb++;let p=(d?"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=xl(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=kv(),t.byPlayer.set(n,v)),v.count++,d?v.cbBytes+=e.sizeBytes:v.sbBytes+=e.sizeBytes,v.bucketTs=xl(v.bucketTs,$,v.buckets,v.cb),v.buckets[g]++,d&&v.cb[g]++}function Yg(t){let e=yd();t.window.bucketTs=xl(t.window.bucketTs,e,t.window.buckets,t.window.byteBuckets);for(let n of t.byPlayer.values())n.bucketTs=xl(n.bucketTs,e,n.buckets,n.cb)}function Sv(t,e,n){if(Yg(t),!n)return e;let a=Gg(t);return a.length?[...a,...e].slice(0,8):e}function Tv(t,e){let n=0;for(let[d,p]of t.byHeatmap)d.startsWith("cb|")&&(n+=p.bytes);let a=t.total.bytes,i=null;for(let[d,p]of t.byClass)(!i||p.count>i.count)&&(i={k:d,count:p.count});let o={totalCount:t.total.count,totalBytes:a,cbBytes:n,sbBytes:a-n,cbPct:a?n/a*100:50,pps:yv(t.window.buckets)/3,bps:yv(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(d=>({p:d,lane:t.byPlayer.get(d.uuid)??kv()})).sort((d,p)=>(p.lane.buckets[19]||0)-(d.lane.buckets[19]||0));for(let{lane:d}of o.lanes)for(let p of d.buckets)p>o.gmax&&(o.gmax=p);return o}function Gg(t,e=8){let{prev:n,seen:a}=t.anomaly,i=[],o=Date.now();for(let[d,p]of t.byClass){let u=n.get(d)??0,$=yr(d);!a.has(d)&&p.count>=5?(a.add(d),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(([d,p])=>[d,p.count])),i.slice(0,e)}var yd=()=>Math.floor(Date.now()/1e3),hd=()=>new Uint16Array(20);function Wg(t){let e=Number(t);return!Number.isFinite(e)||e<=0||e>1e14?Date.now():e<1e11?e*1e3:e}var Kg=()=>new Uint32Array(20);function xl(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 yv(t){let e=0;for(let n=Math.max(0,t.length-3);n{e||(e=!0,requestAnimationFrame(()=>{e=!1,t()}))}}function Cv(t){let e=xd(),n=X(0),a=X(tt(Date.now())),i=X(tt([])),o=new Set,d=t.lanes!==!1,p=Xg(()=>{ya(n)}),u=g=>{let v=as(g),m=`${v.connectionId||v.uuid}:${v.seq}`;return o.has(m)?{row:v,fresh:!1}:(o.add(m),Ev(e,v,String(g.uuid??v.uuid??""),d),{row:v,fresh:!0})},$=()=>{Object.assign(e,xd()),o.clear(),E(i,[],!0),E(n,0)};return ge(()=>{let g=setInterval(()=>{E(a,Date.now(),!0),E(i,Sv(e,r(i),!!t.anomalies),!0),p()},1e3);return()=>clearInterval(g)}),{get agg(){return e},get version(){return r(n)},get now(){return r(a)},get anomalies(){return r(i)},bump:p,tryIngest:u,reset:$}}function Av(t,e={}){let n=Cv({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()},d=()=>{a.clear(),n.reset()};return ge(()=>{e.resetKey?.()!=null&&d()}),ge(()=>{let p=t().filter($=>$.uuid);if(!p.length)return;let u=p.map($=>sr.subscribe(Jo($.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=[],x=0;for(;;){let w=await je(`/connections/${m.connectionId}/packets?since=${x}&limit=${g}`);if(!w.length||(h.push(...w),x=Number(w[w.length-1]?.seq)||x,$>0||w.lengthv(m).catch(()=>({source:m,recs:[]})))).then(m=>{if(p){for(let{source:h,recs:x}of m){a.add(h.connectionId);for(let w of x)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:d}}function Mv(t={}){let e=Cv({anomalies:t.anomalies});return ge(()=>sr.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 Jg=on(''),Qg=on(''),eh=on(""),th=_('
    pkt
    \u2193 in
    \u2191 out
    '),rh={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,rh);var n=th(),a=l(n),i=l(a,!0);s(a);var o=c(a,2),d=l(o),p=c(d),u=l(p);s(p),s(o);var $=c(o,2),g=l($);de(g,21,()=>Array.from(e.lane.buckets),lt,(N,F,L)=>{var S=Ce(),R=ie(S);{var k=M=>{let q=b(()=>e.lane.cb[L]||0),V=b(()=>r(F)-r(q)),H=b(()=>Math.max(2,r(q)/e.gmax*92)),G=b(()=>Math.max(0,r(V)/e.gmax*92));var O=eh(),j=l(O);{var z=ee=>{var ae=Jg();re(ae,"x",L+.05),T(()=>{re(ae,"y",100-r(H)),re(ae,"height",r(H))}),f(ee,ae)};B(j,ee=>{r(H)>0&&ee(z)})}var W=c(j);{var Z=ee=>{var ae=Qg();re(ae,"x",L+.05),T(()=>{re(ae,"y",100-r(H)-r(G)),re(ae,"height",r(G))}),f(ee,ae)};B(W,ee=>{r(G)>0&&ee(Z)})}s(O),f(M,O)};B(R,M=>{r(F)&&M(k)})}f(N,S)}),s(g),s($);var v=c($,2),m=l(v),h=c(l(m)),x=l(h,!0);s(h),s(m);var w=c(m,2),C=c(l(w)),A=l(C,!0);s(C),s(w);var I=c(w,2),P=c(l(I)),D=l(P,!0);s(P),s(I),s(v),s(n),T((N,F,L,S,R,k)=>{y(i,N),y(d,`${F??""} `),y(u,`${L??""} \xB7 ${(e.player.gamemode||"\u2014")??""}`),re(g,"viewBox",`0 0 ${20} 100`),y(x,S),y(A,R),y(D,k)},[()=>(e.player.username||"?").slice(0,2).toUpperCase(),()=>e.player.username||e.player.uuid.slice(0,8),()=>(e.player.dimension||"").replace("minecraft:",""),()=>Fr(e.lane.count),()=>zt(e.lane.cbBytes),()=>zt(e.lane.sbBytes)]),Y("click",n,function(...N){e.onclick?.apply(this,N)}),Y("keydown",n,N=>{(N.key==="Enter"||N.key===" ")&&(N.preventDefault(),e.onclick())}),f(t,n),ce()}Pe(["click","keydown"]);var nh=_('
    '),ah=_('
    ');function La(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=ne(e,"value",3,0),i=ne(e,"variant",3,"spectrum"),o=ne(e,"class",3,""),d=b(()=>Math.max(0,Math.min(1,a()??0))),p=b(()=>i()==="boss"&&e.color?n[e.color.toUpperCase()]??n.PINK:void 0);var u=ah();re(u,"aria-valuemin",0),re(u,"aria-valuemax",100);var $=l(u),g=l($);let v;s($);var m=c($,2);{var h=x=>{var w=nh(),C=l(w);er(C,()=>e.children),s(w),f(x,w)};B(m,x=>{e.children&&x(h)})}s(u),T(x=>{ue(u,1,`progress-bar progress-bar--${i()??""} ${o()??""}`),re(u,"aria-valuenow",x),v=ke(g,"",v,{width:r(d)*100+"%","--fill":r(p)})},[()=>Math.round(r(d)*100)]),f(t,u),ce()}function Pv(t){let e=String(t??"").match(/^([\d.,]+)\s*(\S*)$/);return e?[e[1],e[2]]:[String(t??""),""]}var ih=_('
    No packets yet.
    '),sh=_('
    '),oh=_('
    '),lh={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,lh);let n=ne(e,"max",3,14),a=ne(e,"version",3,0),i=b(()=>{a(),e.sortBy,n();let $=[...e.agg.byClass.entries()];$.sort((m,h)=>e.sortBy==="bytes"?h[1].bytes-m[1].bytes:h[1].count-m[1].count);let g=$.slice(0,n()),v=e.sortBy==="bytes"?g[0]?.[1]?.bytes||1:g[0]?.[1]?.count||1;return g.map(([m,h])=>({cls:m,info:h,pct:(e.sortBy==="bytes"?h.bytes:h.count)/v*100}))});var o=Ce(),d=ie(o);{var p=$=>{var g=ih();f($,g)},u=$=>{var g=oh();de(g,23,()=>r(i),v=>v.cls,(v,m,h)=>{let x=b(()=>r(m).info.cb>r(m).info.sb?"cb":"sb"),w=b(()=>r(x)==="cb"?"\u2193":"\u2191"),C=b(()=>{let[ee,ae]=Pv(e.sortBy==="bytes"?zt(r(m).info.bytes):Fr(r(m).info.count));return{pN:ee,pU:ae}}),A=b(()=>{let[ee,ae]=Pv(e.sortBy==="bytes"?Fr(r(m).info.count):zt(r(m).info.bytes));return{sN:ee,sU:ae}});var I=sh(),P=l(I),D=l(P,!0);s(P);var N=c(P,2),F=l(N,!0);s(N);var L=c(N,2);{let ee=b(()=>r(m).pct/100);La(L,{get value(){return r(ee)}})}var S=c(L,2),R=l(S),k=l(R,!0);s(R);var M=c(R),q=l(M,!0);s(M),s(S);var V=c(S,2),H=l(V),G=l(H,!0);s(H);var O=c(H,2),j=l(O),z=l(j,!0);s(j);var W=c(j),Z=l(W,!0);s(W),s(O),s(V),s(I),T(ee=>{y(D,r(h)+1),y(F,ee),y(k,r(C).pN),y(q,r(C).pU),ue(V,1,"leaderboard__chip "+r(x)),y(G,r(w)),y(z,r(A).sN),y(Z,r(A).sU)},[()=>yr(r(m).cls)]),f(v,I)}),s(g),f($,g)};B(d,$=>{r(i).length===0?$(p):$(u,-1)})}f(t,o),ce()}var ch=_(' '),dh=_(' '),ph=_(' '),uh=_('
    \u2193 Inbound \u2191 Outbound
    '),fh={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,fh);let n=ne(e,"version",3,0),a=b(()=>{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},x=e.sortBy==="bytes"?h.bytes:h.count;return{s:m,val:x,pct:Math.min(1,x/$)}});return{cb:g("cb"),sb:g("sb")}}),i=$=>$?e.sortBy==="bytes"?zt($):Fr($):"\xB7";var o=uh(),d=c(l(o),2);de(d,16,()=>bd,$=>$,($,g)=>{var v=ch(),m=l(v,!0);s(v),T(()=>y(m,g)),f($,v)});var p=c(d,4);de(p,17,()=>r(a).cb,$=>$.s,($,g)=>{var v=dh(),m=l(v);let h;var x=c(m,2),w=l(x,!0);s(x),s(v),T(C=>{h=ke(m,"",h,{"--heat":r(g).pct}),y(w,C)},[()=>i(r(g).val)]),f($,v)});var u=c(p,4);de(u,17,()=>r(a).sb,$=>$.s,($,g)=>{var v=ph(),m=l(v);let h;var x=c(m,2),w=l(x,!0);s(x),s(v),T(C=>{h=ke(m,"",h,{"--heat":r(g).pct}),y(w,C)},[()=>i(r(g).val)]),f($,v)}),s(o),f(t,o),ce()}var vh=_('
    '),mh=_(" ",1);function Xs(t,e){le(e,!0);let n=g=>{var v=vh(),m=l(v);let h;var x=c(m,2);let w;s(v),T(()=>{h=ue(m,1,"",null,h,{"is-on":e.sortBy==="count"}),w=ue(x,1,"",null,w,{"is-on":e.sortBy==="bytes"})}),Y("click",m,()=>e.onSortBy("count")),Y("click",x,()=>e.onSortBy("bytes")),f(g,v)},a=ne(e,"version",3,0),i=ne(e,"topMeta",3,""),o=ne(e,"heatmapMeta",19,()=>e.sortBy),d=ne(e,"max",3,14);var p=mh(),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 d()},get version(){return a()}})},$$slots:{actions:!0,default:!0}});var $=c(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 _h=t=>{me();var e=bt("Packets");f(t,e)},gh=t=>{me();var e=bt("Global");f(t,e)},hh=t=>{me();var e=bh();me(),f(t,e)},bh=_("Global packet analysis",1),xh=_('

    Aggregate across

    '),yh=_(''),wh=_('top \xB7 ',1),kh=_('
    No active sessions.
    '),Eh=_('
    '),Sh=_('
    No anomalies detected.
    '),Th=_('
    '),Ch=_('
    '),Ah=_('
    Throughput
    /s
    Sessions
    Classes seen
    Total packets
    ',1),Mh={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,Mh);let n=te=>{var se=xh(),pe=c(l(se)),$e=l(pe,!0);s(pe);var ve=c(pe);s(se),T(()=>{y($e,r(o).length),y(ve,` sessions \xB7 ${3}s rate window \xB7 click a swimlane for detail`)}),f(te,se)},a=te=>{var se=yh();Y("click",se,()=>Xa(r(o)[0]?"/p/"+r(o)[0].uuid+"/packets":"/players")),f(te,se)},i=X("count"),o=b(()=>ln.list),d=Mv({anomalies:!0}),p=b(()=>(d.version,Tv(d.agg,r(o))));var u=Ah(),$=ie(u);{let te=b(()=>[_h,gh]);Vr($,{get crumbs(){return r(te)},get title(){return hh},get subtitle(){return n},get actions(){return a}})}var g=c($,2),v=l(g),m=c(l(v),2),h=l(m,!0);me(),s(m);var x=c(m,2),w=l(x);s(x);var C=c(x,2),A=l(C);let I;var P=c(A,2);let D;s(C),s(v);var N=c(v,2),F=c(l(N),2),L=l(F,!0);s(F);var S=c(F,2),R=l(S);s(S),s(N);var k=c(N,2),M=c(l(k),2),q=l(M,!0);s(M);var V=c(M,2),H=l(V);{var G=te=>{var se=wh(),pe=c(ie(se)),$e=l(pe,!0);s(pe),T(ve=>y($e,ve),[()=>yr(r(p).topClass.k)]),f(te,se)},O=te=>{var se=bt("\u2014");f(te,se)};B(H,te=>{r(p).topClass?te(G):te(O,-1)})}s(V),s(k);var j=c(k,2),z=c(l(j),2),W=l(z,!0);s(z);var Z=c(z,2),ee=l(Z);s(Z),s(j),s(g);var ae=c(g,2),J=l(ae);{let te=b(()=>`${r(o).length} active`);et(J,{title:"Per-player swimlanes",get meta(){return r(te)},flush:!0,children:(se,pe)=>{var $e=Ce(),ve=ie($e);{var he=xe=>{var Be=kh();f(xe,Be)},be=xe=>{var Be=Eh();de(Be,21,()=>r(p).lanes,({p:Re,lane:Oe})=>Re.uuid,(Re,Oe)=>{let De=()=>r(Oe).p,it=()=>r(Oe).lane;wd(Re,{get player(){return De()},get lane(){return it()},get gmax(){return r(p).gmax},onclick:()=>Xa("/p/"+De().uuid+"/packets")})}),s(Be),f(xe,Be)};B(ve,xe=>{r(p).lanes.length?xe(be,-1):xe(he)})}f(se,$e)},$$slots:{default:!0}})}var Q=c(J,2);et(Q,{title:"Anomalies",meta:"1s sampling",flush:!0,children:(te,se)=>{var pe=Ce(),$e=ie(pe);{var ve=be=>{var xe=Sh();f(be,xe)},he=be=>{var xe=Ch();de(xe,23,()=>d.anomalies,(Be,Re)=>Be.ts+":"+Re,(Be,Re)=>{var Oe=Th(),De=c(l(Oe),2),it=l(De,!0);s(De);var Je=c(De,2),we=l(Je);s(Je),s(Oe),T(Qe=>{ue(Oe,1,"anomaly data-row data-row--panel data-row--interactive "+r(Re).kind),y(it,r(Re).msg),y(we,`${Qe??""} ago`)},[()=>mn(d.now-r(Re).ts)]),f(Be,Oe)}),s(xe),f(be,xe)};B($e,be=>{d.anomalies.length?be(he,-1):be(ve)})}f(te,pe)},$$slots:{default:!0}}),s(ae);var U=c(ae,2),K=l(U);Xs(K,{get agg(){return d.agg},get sortBy(){return r(i)},get version(){return d.version},topMeta:"all players",get heatmapMeta(){return r(i)},onSortBy:te=>E(i,te,!0)}),s(U),T((te,se,pe,$e,ve,he,be)=>{y(h,te),y(w,`${se??""} pkt/s \xB7 ${pe??""} in view`),re(C,"title",$e),I=ke(A,"",I,{width:r(p).cbPct+"%"}),D=ke(P,"",D,{left:r(p).cbPct+"%",width:100-r(p).cbPct+"%"}),y(L,r(o).length),y(R,`tracking ${r(p).streamCount??""} streams`),y(q,r(p).classCount),y(W,ve),y(ee,`${he??""} \u2B07 \xB7 ${be??""} \u2B06`)},[()=>zt(r(p).bps),()=>Fr(Math.round(r(p).pps)),()=>zt(r(p).totalBytes),()=>`${r(p).cbPct.toFixed(0)}% server\u2192client`,()=>Fr(r(p).totalCount),()=>zt(r(p).cbBytes),()=>zt(r(p).sbBytes)]),f(t,u),ce()}Pe(["click"]);var yl=Symbol("prov-open"),wl=Symbol("prov-open-field");var Ph=(t,e=At)=>{var n=Ce(),a=ie(n);{var i=u=>{var $=Ce(),g=ie($);{var v=h=>{var x=Nh();T(()=>re(x,"title",e().title)),f(h,x)},m=h=>{var x=Lh(),w=l(x);re(w,"draggable",!1),s(x),T(()=>{re(x,"title",e().title),re(w,"src",`/api/material-icon/${e().id}`)}),f(h,x)};B(g,h=>{e().head?h(v):h(m,-1)})}f(u,$)},o=u=>{var $=Ih(),g=l($,!0),v=c(g);{var m=h=>{Rh(h,()=>e().hover)};B(v,h=>{e().hover&&h(m)})}s($),T((h,x,w)=>{ue($,1,h),ke($,x),re($,"title",w),y(g,e().text)},[()=>Tt(uv(e().style,e().hover,e().click)),()=>fv(e().style),()=>e().click?vv(e().click):void 0]),f(u,$)},d=b(()=>pv(e())),p=u=>{var $=bt();T(()=>y($,e().text)),f(u,$)};B(a,u=>{e().kind==="icon"?u(i):r(d)?u(o,1):u(p,-1)})}f(t,n)},Rh=(t,e=At)=>{let n=b(()=>mv(e()));var a=qh(),i=l(a);{var o=m=>{is(m,{get node(){return r(n)}})},d=b(()=>e().action==="show_text"||typeof r(n)=="string"||ki(r(n))?.text!=null||ki(r(n))?.translate!=null||Array.isArray(r(n))),p=m=>{let h=b(()=>ki(r(n)));var x=Dh(),w=ie(x),C=l(w,!0);s(w);var A=c(w,2);{var I=P=>{var D=Oh(),N=l(D);s(D),T(()=>y(N,`\xD7${r(h).count??""}`)),f(P,D)};B(A,P=>{(r(h).count??1)>1&&P(I)})}T(P=>y(C,P),[()=>String(r(h).id??"?")]),f(m,x)},u=b(()=>e().action==="show_item"||ki(r(n))?.id!=null),$=m=>{let h=b(()=>ki(r(n)));var x=Bh(),w=ie(x),C=l(w,!0);s(w);var A=c(w,2);{var I=P=>{var D=Fh(),N=l(D);is(N,{get node(){return r(h).name}}),s(D),f(P,D)};B(A,P=>{r(h).name&&P(I)})}T(P=>y(C,P),[()=>String(r(h).type??"entity")]),f(m,x)},g=b(()=>e().action==="show_entity"||ki(r(n))?.type!=null),v=m=>{var h=zh(),x=l(h,!0);s(h),T(w=>y(x,w),[()=>JSON.stringify(r(n),null,2)]),f(m,h)};B(i,m=>{r(d)?m(o):r(u)?m(p,1):r(g)?m($,2):m(v,-1)})}s(a),f(t,a)},Nh=_(''),Lh=_(''),Ih=_(" "),Oh=_('
    '),Dh=_('
    ',1),Fh=_('
    '),Bh=_('
    ',1),zh=_('
     
    '),qh=_('');function is(t,e){le(e,!0);let n=b(()=>dv(e.node));var a=Ce(),i=ie(a);de(i,17,()=>r(n),lt,(o,d)=>{Ph(o,()=>r(d))}),f(t,a),ce()}var Hh=_('');function dn(t,e){le(e,!0);let n=ne(e,"className",3,""),a=b(()=>("mc-component "+n()).trim());var i=Ce(),o=ie(i);{var d=p=>{var u=Hh(),$=l(u);is($,{get node(){return e.value}}),s(u),T(()=>ue(u,1,Tt(r(a)))),Mt("pointerenter",u,g=>$a.track(e.value,g)),Y("pointermove",u,g=>$a.track(e.value,g)),Mt("pointerleave",u,()=>$a.track(null,null)),Mt("click",u,g=>cl(g,e.value,"Text JSON copied"),!0),f(p,u)};B(o,p=>{e.value!=null&&e.value!==""&&p(d)})}f(t,i),ce()}Pe(["pointermove"]);var jh=` +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); +} +`,kl=class{constructor(e,n,a,i,o,d){this.width=e;this.height=n;this.centerX=a;this.centerZ=i;this.zoom=o;this.rotation=d;this.cosRot=Math.cos(d),this.sinRot=Math.sin(d),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,d=-n*this.zoom;return[a*o-i*d,i*o+a*d]}},El=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},(d,p)=>this.layerCapacity-1-p);let o=n.createShaderModule({code:jh});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),d=Math.floor((e.centerZ-a)/16),p=Math.ceil((e.centerZ+a)/16),u=new Set,$=[];for(let g=d;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
    • +
    + +
    `,Lv=()=>` +
    +

    Minimap

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

    Waypoints \xB70

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

      New waypoint

      +
      + +
      + + + +
      + + +
      +
      + + +
      +
      `;var Uh=340,Vh=500,Yh=5;function Gh(t){return t?(t.startsWith("minecraft:")?t.slice(10):t).split("_").map(n=>n&&n[0].toUpperCase()+n.slice(1)).join(" "):"Entity"}function Dv(t){let e=t._els.viewport,n=new Map,a=0,i={t:0,x:0,y:0},o=!1,d=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))},Vh)}else if(n.size===2){let[m,h]=[...n.values()];d={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,x=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)>Yh){o=!0,u(),t._clearHover(),t.startManualPan();let w=t.buildCamera(e.clientWidth,e.clientHeight),[C,A]=w.screenPanDelta(h,x);t.panX+=C,t.panZ+=A,t.requestRender()}}else if(n.size===2&&d){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-d.angle,d.angle=I,t.setZoom(d.zoom*(d.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&&(d=null);try{e.releasePointerCapture(g.pointerId)}catch{}if(!v||o)return;let m=t._entityHitAt(v.x,v.y);if(m){vt(`${Gh(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,Fv=8,Wh=10,Bv=.01,Kh=.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)"}},Si=Object.keys(Md),Xh=64,Hv=3.5;function Zh(t,e,n){let a=Hv;switch(e){case"diamond":for(let i=0;i[a,!0])),this.waypoints=tb(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(sr.subscribe(Of(this.uuid),e=>this.applyFrame(e)))}async fetchInitial(){try{this.applySnapshot(await je(`/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,Rv(e.tile))}renderShell(){if(this.host.innerHTML=this.enabled?Lv():Nv(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(),Dv(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 El.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;vt("WebGPU required for minimap"),this.toggleEnabled(!1)}}onMarkersClick(e){let n=Zs(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",()=>vt("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=Zs(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=Zs(e,".mm-wp-item");if(!n)return;if(Zs(e,".mm-wp-x")){e.stopPropagation(),this.waypoints=this.waypoints.filter(i=>i.id!==n.dataset.id),qv(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=rb(e,Ad,Fv),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,eb(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 kl(e,n,a,i,this.zoom,this.mapRotation())}tickInterpolators(e){let n=1-Math.exp(-Wh*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)>Bv||Math.abs($)>Bv?(this.displayX+=u*n,this.displayZ+=$*n,a=!0):(this.displayX=i,this.displayZ=o)}let d=this.player.rotation[0]||0,p=nb(this.displayYaw,d);Math.abs(p)>Kh?(this.displayYaw=(this.displayYaw+p*n+360)%360,a=!0):this.displayYaw=d}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 d=n.getContext("2d");d.setTransform(o,0,0,o,0,0),d.imageSmoothingEnabled=!1,d.clearRect(0,0,a,i),this.showGrid&&this.drawGrid(d,e,a,i),this.drawEntities(d,e,a,i)}drawGrid(e,n,a,i){let o=16;for(;o/n.zoom<8;)o*=2;let d=Math.SQRT2*Math.max(a,i)*n.zoom/2+o,p=Math.floor((n.centerX-d)/o)*o,u=Math.ceil((n.centerX+d)/o)*o,$=Math.floor((n.centerZ-d)/o)*o,g=Math.ceil((n.centerZ+d)/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,$),[x,w]=n.worldToScreen(v,g);e.moveTo(m,h),e.lineTo(x,w)}for(let v=$;v<=g;v+=o){let[m,h]=n.worldToScreen(p,v),[x,w]=n.worldToScreen(u,v);e.moveTo(m,h),e.lineTo(x,w)}e.stroke()}drawEntities(e,n,a,i){let o=this.entities;if(o.length===0)return;let d=a/2,p=i/2,u=Hv+1,$=this._selfUuidLower,g=this.filters,v=this._entityBuckets;if(!v){v=this._entityBuckets={};for(let m of Si)v[m]=[]}for(let m of Si)v[m].length=0;for(let m=0,h=o.length;md+u||I<-p-u||I>p+u||w.push(d+A,p+I)}e.lineWidth=1,e.strokeStyle="rgba(0,0,0,0.6)";for(let m of Si){let h=v[m];if(h.length===0)continue;let x=Md[m];e.fillStyle=this._resolveColor(x.color),e.beginPath(),Zh(e,x.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,[d,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}${Ar($.name)}`,this._markerEls.set(m,h),n.appendChild(h)),h.style.transform=`translate(${g.toFixed(2)}px,${v.toFixed(2)}px)`;let x=Math.round(Math.hypot($.x-d,$.z-p))+"m",w=h.querySelector(".mm-wp-d");w.textContent!==x&&(w.textContent=x)}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,d=a.height/2,p=e-o,u=n-d,$=this._selfUuidLower,g=this.filters,v=null,m=Xh;for(let h=0,x=i.length;h${zv[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(Fv)-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),d=this.displayYaw*Math.PI/180,p=-Math.sin(d),u=Math.cos(d),$=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(Si.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 Si){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 Si){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,d=Math.hypot(i,o),p=(Math.atan2(i,-o)*180/Math.PI+360)%360,u=Jh[Math.floor((p+22.5)/45)%8];return`
    • + ${a.icon} + + ${Ar(a.name)} + ${a.x} ${a.y} ${a.z} + + ${u}${Math.round(d)}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=Iv(a),document.body.appendChild(i),requestAnimationFrame(()=>{let d=i.getBoundingClientRect(),p=8;i.style.left=Math.max(p,Math.min(innerWidth-d.width-p,e))+"px",i.style.top=Math.max(p,Math.min(innerHeight-d.height-p,n))+"px"}),i.onclick=d=>{let p=d.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])}`),vt("Coordinates copied")),this.closeContextMenu()};let o=d=>{Zs(d,".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=Ov(n),document.body.appendChild(a),a.addEventListener("click",o=>{o.target===a&&a.remove()});let i=(o,d)=>a.querySelectorAll(`[${o}]`).forEach(p=>p.onclick=()=>{n[d]=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),qv(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 ab=_("
      "),ib={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 Tl(t,e){le(e,!0),Ut(t,ib);let n=ne(e,"paused",3,!1),a,i=null;ge(()=>(e.uuid,i=new Sl(a,e.uuid),i.boot(),()=>i?.destroy())),ge(()=>{i?.setPaused(n())}),ge(()=>{i?.updatePlayer(e.player)});var o=ab();Ct(o,d=>a=d,()=>a),f(t,o),ce()}var sb=_('
      '),ob=_('
      Loading\u2026
      '),lb=_('
      No mutation history yet.
      '),cb=_(' '),db=_('
    • '),pb=_('
        '),ub=_('');function Pd(t,e){le(e,!0);let n=X(null),a=X(null),i=X(null),o=null;async function d(){try{let k=await je(`/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,d()}),ge(()=>{let k=e.sourceSeq;k==null||k===o||(o=k,d())});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=b(()=>r(a)?r(a).slice().reverse():null),$=b(()=>Qr.now);var g=ub();let v;var m=l(g),h=l(m),x=c(l(h),2),w=l(x,!0);s(x),s(h);var C=c(h,2);s(m);var A=c(m,2),I=c(l(A),2),P=l(I,!0);s(I),s(A);var D=c(A,2),N=l(D);{var F=k=>{var M=sb(),q=l(M,!0);s(M),T(()=>y(q,r(i))),f(k,M)},L=k=>{var M=ob();f(k,M)},S=k=>{var M=lb();f(k,M)},R=k=>{var M=pb();de(M,21,()=>r(u),lt,(q,V,H)=>{let G=b(()=>r(V).source||{}),O=b(()=>r(G).ts?r($)-r(G).ts:null),j=b(()=>r(G).seq!=null?`/p/${encodeURIComponent(e.uuid)}/packets?seq=${r(G).seq}`:`/p/${encodeURIComponent(e.uuid)}/packets`);var z=db();ue(z,1,"prov-pop__step",null,{},{"prov-pop__step--latest":H===0});var W=l(z),Z=c(l(W),2),ee=l(Z),ae=l(ee,!0);s(ee);var J=c(ee,2),Q=l(J);s(J),s(Z);var U=c(Z,2),K=l(U),te=l(K,!0);s(K);var se=c(K,2);{var pe=he=>{var be=cb(),xe=l(be);s(be),T(Be=>y(xe,`was ${Be??""}`),[()=>String(r(V).prev)]),f(he,be)};B(se,he=>{r(V).prev!=null&&he(pe)})}s(U);var $e=c(U,2),ve=l($e);s($e),s(W),s(z),T((he,be,xe)=>{re(W,"href",r(j)),y(ae,he),y(Q,`${be??""} ago`),y(te,xe),y(ve,`#${r(G).seq??"\u2014"??""}`)},[()=>va(r(G).packetClass||"")||"unknown",()=>mn(r(O)),()=>String(r(V).value)]),Y("click",W,()=>e.onClose?.()),f(q,z)}),s(M),f(k,M)};B(N,k=>{r(i)?k(F):r(u)==null?k(L,1):r(u).length===0?k(S,2):k(R,-1)})}s(D),s(g),Ct(g,k=>E(n,k),()=>r(n)),T(k=>{re(g,"aria-label",`Provenance for ${e.field??""}`),v=ke(g,"",v,{left:`${p.left??""}px`,top:`${p.top??""}px`}),re(x,"title",e.field),y(w,e.field),y(P,k)},[()=>String(e.valueOf?.(e.field)??"\u2014")]),Y("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}},Ia=new Rd;var fb=_(' '),vb=_(' '),mb=_('
        ',1),$b=_('
        no source recorded
        ',1),_b=_('
        Click to pin history
        '),gb={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,gb);let n=X(null),a=X(tt({left:0,top:0,caret:16,below:!1,ready:!1})),i=b(()=>Ia.state),o=b(()=>r(i)?.source??null),d=b(()=>r(o)?.ts?Qr.now-r(o).ts:null),p=b(()=>(r(o)?.direction||"").toUpperCase()),u=b(()=>tn(r(p))?"\u25C0":r(p).startsWith("SERVER")?"\u25B6":""),$=b(()=>tn(r(p))?"cb":r(p)?"sb":"");ge(()=>{let h=Ia.state,x=r(n);if(!h||!x){r(a).ready=!1;return}let w=x.offsetWidth,C=x.offsetHeight,A=8,I=!1,P=h.anchor.top-A-C;P<8&&(I=!0,P=h.anchor.bottom+A);let D=Math.min(Math.max(8,h.anchor.cx-w/2),window.innerWidth-w-8),N=Math.min(Math.max(12,h.anchor.cx-D),w-12);E(a,{left:D,top:P,caret:N,below:I,ready:!0},!0)}),ge(()=>{if(!Ia.state)return;let h=()=>Ia.hide();return window.addEventListener("scroll",h,!0),window.addEventListener("resize",h),()=>{window.removeEventListener("scroll",h,!0),window.removeEventListener("resize",h)}});var g=Ce(),v=ie(g);{var m=h=>{var x=_b();let w,C;var A=l(x);{var I=F=>{var L=mb(),S=ie(L),R=l(S);{var k=Z=>{var ee=fb(),ae=l(ee,!0);s(ee),T(()=>{re(ee,"data-dir",r($)),y(ae,r(u))}),f(Z,ee)};B(R,Z=>{r(u)&&Z(k)})}var M=c(R,2),q=l(M,!0);s(M);var V=c(M,2);{var H=Z=>{var ee=vb(),ae=l(ee);s(ee),T(()=>y(ae,`#${r(o).seq??""}`)),f(Z,ee)};B(V,Z=>{r(o).seq!=null&&Z(H)})}s(S);var G=c(S,2),O=l(G),j=l(O);s(O);var z=c(O,2),W=l(z,!0);s(z),s(G),T((Z,ee)=>{y(q,Z),y(j,`${ee??""} ago`),y(W,r(i).field)},[()=>va(r(o).packetClass).replace(/Packet$/,"")||"unknown",()=>mn(r(d))]),f(F,L)},P=F=>{var L=$b(),S=c(ie(L),2),R=l(S),k=l(R,!0);s(R),s(S),T(()=>y(k,r(i).field)),f(F,L)};B(A,F=>{r(o)?F(I):F(P,-1)})}var D=c(A,4);let N;s(x),Ct(x,F=>E(n,F),()=>r(n)),T(()=>{w=ue(x,1,"prov-tip",null,w,{"prov-tip--below":r(a).below,"prov-tip--ready":r(a).ready}),C=ke(x,"",C,{left:`${r(a).left??""}px`,top:`${r(a).top??""}px`}),N=ke(D,"",N,{left:`${r(a).caret??""}px`})}),f(h,x)};B(v,h=>{r(i)&&h(m)})}f(t,g),ce()}var Vv=/(!?)([a-zA-Z]+):((?:"[^"]*")|[^\s]+)|(!?)([^\s]+)/g;function Cl(t){let e=[],n;for(Vv.lastIndex=0;n=Vv.exec(t);)if(n[2]){let d=!!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:d,raw:n[0]})}else n[5]&&e.push({kind:"text",val:n[5].toLowerCase(),neg:!!n[4],raw:n[0]});function a(d,p){let u=p.val,$;switch(p.key){case"class":case"c":return d.className.toLowerCase().includes(u);case"dir":return u==="cb"||u==="in"||u==="clientbound"?tn(d.direction):u==="sb"||u==="out"||u==="serverbound"?d.direction==="SERVERBOUND":!1;case"state":case"s":return(d.state||"").toLowerCase().startsWith(u);case"subject":case"subj":return(d.subjectLabel||"").toLowerCase().includes(u)||String(d.subject).toLowerCase()===u;case"group":case"g":return(d.subjectGroup||"").toLowerCase()===u;case"size":return $=Number(u),Number.isNaN($)?!1:p.op===">"?d.sizeBytes>$:p.op==="<"?d.sizeBytes<$:p.op===">="?d.sizeBytes>=$:p.op==="<="?d.sizeBytes<=$:d.sizeBytes===$;case"seq":return $=Number(u),Number.isNaN($)?!1:p.op===">"?d.seq>$:p.op==="<"?d.seq<$:p.op===">="?d.seq>=$:p.op==="<="?d.seq<=$:d.seq===$;case"has":return u==="bookmark"?!!d._bookmarked:!1;default:return!1}}function i(d,p){let u=p.val;return d.className.toLowerCase().includes(u)||(d.subjectLabel||"").toLowerCase().includes(u)||(d.summary||"").toLowerCase().includes(u)}function o(d){for(let p of e){let u=p.kind==="kv"?a(d,p):i(d,p);if(p.neg?u:!u)return!1}return!0}return{tokens:e,match:o}}function Al(t){let e=0;for(let a=0;a28?n.slice(0,26)+"\u2026":n}var bb=400,Oa=new Map;function Ld(t,e){return Oa.get(`${t}:${e}`)}function Gv(t,e,n){let a=`${t}:${e}`;if(Oa.size>=bb&&!Oa.has(a)){let i=Oa.keys().next().value;i&&Oa.delete(i)}Oa.set(a,n)}function Wv(t){if(!t){Oa.clear();return}let e=t+":";for(let n of[...Oa.keys()])n.startsWith(e)&&Oa.delete(n)}var Qs={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??=Js(t),n._bookmarked=e.has(t.seq),n}function Kv(t){return Object.entries(t).map(([e,n])=>{let a=new Set,i=new Set;for(let[o,d]of Object.entries(n))d==="include"?a.add(o):d==="exclude"&&i.add(o);return{field:e,includes:a,excludes:i}})}function Xv(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 Zv(t,e,n){for(let{field:a,includes:i,excludes:o}of e){let d=t[a];if(o.has(d)||i.size&&!i.has(d))return!1}return!(n.excludes.has(t.className)||n.includes.size&&!n.includes.has(t.className))}function Jv(t,e,n,a){let i=new Map;for(let u of e)i.set(u.seq,u);let o=[],d=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}),d=t[g-1].ts,p=g;continue}}o.push({kind:"row",p:u,delta:d==null?null:u.ts-d,bookmark:a.get(u.seq)}),d=u.ts,p++}return o}function Qv(t,e,n){if(!t)return[];let a=n?.indexOfSeq(t.seq)??-1;if(a<0)return[];let i=[],o=50,d=Math.max(0,a-o),p=Math.min(e.length,a+o);for(let u=d;u({...e,matcher:e.enabled?Cl(e.match).match:null}))}function tm(t,e,n){return t.map(a=>{let{matcher:i,...o}=a;if(!i)return{...o,matchedSeqs:[],hitCount:o.hitCount??0};let d=[],p=0;for(let u of e)i(Id(u,n))&&(p++,d.length<6&&d.push(u.seq));return{...o,matchedSeqs:d,hitCount:p}})}var Ml=class{rows=[];pending=[];flushScheduled=!1;onFlush;minSeq=0;maxSeq=0;constructor(e){this.onFlush=e}get length(){return this.rows.length}clear(){this.rows.length=0,this.pending=[],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.onFlush({added:n,minSeq:this.minSeq,maxSeq:this.maxSeq,length:this.rows.length})}upsert(e){let n=this.lowerBound(e.seq);return nthis.maxSeq&&(this.maxSeq=e.seq),!0)}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 d of this.rows)n(d);return}let i=a-1,o=Math.max(1,e-1);for(let d=0;dthis.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 d=n(a-1).seq;return Math.abs(e-d)<=Math.abs(e-o)?d: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'),yb=_(" "),wb=_('
        '),kb=_('
        \u2315
        #seq
        p/s
        pkts
        ');function Od(t,e){le(e,!0);let n=ne(e,"query",3,""),a=ne(e,"parsed",3,null),i=ne(e,"live",3,!1),o=ne(e,"paused",3,!1),d=ne(e,"rate",3,0),p=ne(e,"totalPackets",3,0),u=ne(e,"jump",3,""),$=ne(e,"breakOn",3,!1),g=ne(e,"searchRef",15,null),v=ne(e,"onQuery",3,()=>{}),m=ne(e,"onPaused",3,()=>{}),h=ne(e,"onStep",3,()=>{}),x=ne(e,"onLive",3,()=>{}),w=ne(e,"onJump",3,()=>{}),C=ne(e,"onJumpChange",3,()=>{}),A=ne(e,"onHelp",3,()=>{}),I=ne(e,"onTweaks",3,()=>{});var P=kb(),D=l(P),N=c(l(D),2);wt(N),re(N,"placeholder",'filter \u2014 try class:Position dir:sb size:>20 or just "chest"'),re(N,"spellcheck",!1),Ct(N,pe=>g(pe),()=>g());var F=c(N,2);{var L=pe=>{var $e=xb();Y("click",$e,()=>v()("")),f(pe,$e)};B(F,pe=>{n()&&pe(L)})}s(D);var S=c(D,2);{var R=pe=>{var $e=wb();de($e,21,()=>a().tokens,lt,(ve,he)=>{var be=yb();let xe;var Be=l(be);s(be),T(()=>{xe=ue(be,1,"chip-filter chip-filter--sm",null,xe,{"is-exclude":r(he).neg,"is-include":!r(he).neg}),re(be,"title",r(he).kind==="kv"?`${r(he).key} ${r(he).op} ${r(he).val}`:"text"),y(Be,`${r(he).neg?"\u2212":"+"} ${r(he).raw??""}`)}),f(ve,be)}),s($e),f(pe,$e)};B(S,pe=>{a()&&a().tokens.length&&pe(R)})}var k=c(S,2),M=l(k);let q;var V=l(M,!0);s(M);var H=c(M,2),G=c(H,2),O=c(G,2);let j;var z=c(O,4),W=c(l(z),2);wt(W),s(z);var Z=c(z,4),ee=l(Z),ae=l(ee,!0);s(ee),me(2),s(Z);var J=c(Z,2),Q=l(J),U=l(Q,!0);s(Q),me(2),s(J);var K=c(J,4);let te;var se=c(K,2);s(k),s(P),T(pe=>{Dt(N,n()),q=ue(M,1,"btn sm icon",null,q,{"is-on":!o()}),re(M,"title",o()?"Resume (space)":"Pause (space)"),y(V,o()?"\u25B6":"\u25AE\u25AE"),j=ue(O,1,"btn sm",null,j,{"is-on":i()}),Dt(W,u()),y(ae,d()),y(U,pe),te=ue(K,1,"btn sm icon",null,te,{danger:$(),"is-on":$()})},[()=>p().toLocaleString()]),Y("input",N,pe=>v()(pe.currentTarget.value)),Y("click",M,()=>m()(!o())),Y("click",H,()=>h()(-1)),Y("click",G,()=>h()(1)),Y("click",O,function(...pe){x()?.apply(this,pe)}),Y("input",W,pe=>C()(pe.currentTarget.value)),Y("keydown",W,pe=>{pe.key==="Enter"&&w()()}),Y("click",K,function(...pe){I()?.apply(this,pe)}),Y("click",se,function(...pe){A()?.apply(this,pe)}),f(t,P),ce()}Pe(["input","click","keydown"]);var Eb=_('
        '),Sb=_('
        '),Tb=_('
        \u25C6
        '),Cb=_('
        \u2605
        '),Ab=_('
        \u23FB
        '),Mb=_('
        '),Pb=_('
        '),Rb=_('
        ');function Dd(t,e){le(e,!0);let n=ne(e,"tape",3,null),a=ne(e,"tapeVersion",3,0),i=ne(e,"bookmarks",19,()=>[]),o=ne(e,"breakpoints",19,()=>[]),d=ne(e,"lifecycle",19,()=>[]),p=ne(e,"playhead",3,null),u=ne(e,"viewStart",3,null),$=ne(e,"viewEnd",3,null),g=ne(e,"related",19,()=>[]),v=ne(e,"onSeek",3,()=>{}),m=180,h=20,x=6e3,w=X(void 0),C=X(800);ge(()=>{if(!r(w))return;E(C,r(w).clientWidth||r(C),!0);let z=new ResizeObserver(W=>{for(let Z of W)E(C,Z.contentRect.width,!0)});return z.observe(r(w)),()=>z.disconnect()});let A=b(()=>{a();let z=Array.from({length:m},()=>({cb:0,sb:0}));if(!n()||n().length===0)return{a:z,max:1,minSeq:1,maxSeq:1,span:1};let W=n().minSeq,Z=n().maxSeq,ee=Math.max(1,Z-W),ae=0;return n().forEachSampled(x,J=>{let Q=Math.min(m-1,Math.floor((J.seq-W)/ee*m));tn(J.direction)?z[Q].cb++:z[Q].sb++;let U=z[Q].cb+z[Q].sb;U>ae&&(ae=U)}),{a:z,max:Math.max(1,ae),minSeq:W,maxSeq:Z,span:ee}}),I=z=>(z-r(A).minSeq)/r(A).span*r(C),P=z=>Math.round(z/Math.max(1,r(C))*r(A).span+r(A).minSeq);function D(z){if(!r(w))return;let W=r(w).getBoundingClientRect(),Z=ae=>v()(P(ae.clientX-W.left));Z(z);let ee=()=>{window.removeEventListener("mousemove",Z),window.removeEventListener("mouseup",ee)};window.addEventListener("mousemove",Z),window.addEventListener("mouseup",ee)}let N=b(()=>r(C)/m);var F=Rb(),L=c(l(F),4);de(L,17,()=>r(A).a,lt,(z,W,Z)=>{let ee=b(()=>r(W).cb/r(A).max*h),ae=b(()=>r(W).sb/r(A).max*h);var J=Eb();let Q;var U=l(J);let K;var te=c(U,2);let se;s(J),T(pe=>{Q=ke(J,"",Q,pe),K=ke(U,"",K,{bottom:"50%",height:`${r(ee)??""}px`}),se=ke(te,"",se,{top:"50%",height:`${r(ae)??""}px`})},[()=>({left:`${Z*r(N)}px`,width:`${Math.max(1,r(N)-.5)}px`})]),f(z,J)});var S=c(L,2);{var R=z=>{var W=Sb();let Z;T(ee=>Z=ke(W,"",Z,ee),[()=>({left:`${I(u())??""}px`,width:`${Math.max(2,I($())-I(u()))}px`})]),f(z,W)};B(S,z=>{u()!=null&&$()!=null&&z(R)})}var k=c(S,2);de(k,17,d,lt,(z,W)=>{var Z=Tb();let ee;var ae=l(Z);s(Z),T(J=>{re(Z,"title",r(W).label),ee=ke(Z,"",ee,J)},[()=>({left:`${I(r(W).seq)??""}px`})]),Y("click",ae,J=>{J.stopPropagation(),v()(r(W).seq)}),Y("keydown",ae,J=>{J.key==="Enter"&&(J.stopPropagation(),v()(r(W).seq))}),f(z,Z)});var M=c(k,2);de(M,17,i,lt,(z,W)=>{var Z=Cb();let ee;var ae=l(Z);s(Z),T(J=>{re(Z,"title",r(W).label),ee=ke(Z,"",ee,J)},[()=>({left:`${I(r(W).seq)??""}px`})]),Y("click",ae,J=>{J.stopPropagation(),v()(r(W).seq)}),Y("keydown",ae,J=>{J.key==="Enter"&&(J.stopPropagation(),v()(r(W).seq))}),f(z,Z)});var q=c(M,2);de(q,17,o,z=>z.id,(z,W)=>{var Z=Ce(),ee=ie(Z);de(ee,17,()=>r(W).matchedSeqs??[],lt,(ae,J)=>{var Q=Ab();let U;T(K=>{re(Q,"title",r(W).label),U=ke(Q,"",U,K)},[()=>({left:`${I(r(J))??""}px`})]),f(ae,Q)}),f(z,Z)});var V=c(q,2);de(V,17,g,lt,(z,W)=>{var Z=Mb();let ee;T(ae=>ee=ke(Z,"",ee,ae),[()=>({left:`${I(r(W))??""}px`,background:"var(--ink-3)"})]),f(z,Z)});var H=c(V,2);{var G=z=>{var W=Pb();let Z;T(ee=>Z=ke(W,"",Z,ee),[()=>({left:`${I(p())??""}px`})]),f(z,W)};B(H,z=>{p()!=null&&z(G)})}var O=c(H,2),j=l(O);s(O),s(F),Ct(F,z=>E(w,z),()=>r(w)),T((z,W)=>{re(F,"aria-valuemin",r(A).minSeq),re(F,"aria-valuemax",r(A).maxSeq),re(F,"aria-valuenow",p()??r(A).maxSeq),y(j,`#${z??""} \u2014 #${W??""}`)},[()=>r(A).minSeq.toLocaleString(),()=>r(A).maxSeq.toLocaleString()]),Y("mousedown",F,D),f(t,F),ce()}Pe(["mousedown","click","keydown"]);var Nb=_(''),Lb=_(' '),Ib=_(''),Ob=_('
        '),Db=_(''),Fb=_(' '),Bb=_(''),zb=_('
        Class
        '),qb=_('
        No bookmarks yet.
        Press B on any packet to add.
        '),Hb=_(''),jb=_('
        '),Ub=_('
        Pause when a packet matches a DSL filter.

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

        \xB7 click any row to open
        \xB7 shift-click to multi-select / diff
        \xB7 right-click for context actions
        \xB7 press B to bookmark playhead
        '),sx=_('
        Inspector
        ',1),ox=_(' '),lx=_(''),cx=_('
        Loading\u2026
        '),dx=_('
        '),px=_('
        No decoded record in buffer.
        '),ux=_("
        "),fx=_('
        '),vx=_('
        This class does not mutate player state directly.
        '),mx=_(' \u2192',1),$x=_('
        '),_x=_('
        '),gx=_('
        No related packets in the current view.
        '),hx=_(''),bx=_(''),xx=_(''),yx=_('
        '),wx=_('
        \u2192
        '),kx=_('
        \u2192
        '),Ex=_('
        subj size ts
        ',1),Sx=_('');function zd(t,e){le(e,!0);let n=ne(e,"row",3,null),a=ne(e,"seq",3,0),i=ne(e,"record",3,null),o=ne(e,"prevSameClass",3,null),d=ne(e,"prevRecord",3,null),p=ne(e,"related",19,()=>[]),u=ne(e,"multi",19,()=>new Set),$=ne(e,"getRow",3,()=>null),g=ne(e,"isBookmarked",3,!1),v=ne(e,"onClose",3,()=>{}),m=ne(e,"onJumpSeq",3,()=>{}),h=ne(e,"onStep",3,()=>{}),x=ne(e,"onToggleBookmark",3,()=>{}),w=ne(e,"onCopyClass",3,()=>{}),C=ne(e,"onBreakOnClass",3,()=>{}),A=X("decoded"),I=b(()=>i()?.full),P=b(()=>r(I)?.record??null),D=b(()=>{if(!n())return null;if(u().size===2){let j=[...u()].sort((Z,ee)=>Z-ee),z=$()(j[0]),W=$()(j[1]);if(z&&W)return z.seq===n().seq?W:z}return o()});function N(j,z){let W=j||{},Z=z||{},ee=new Set([...Object.keys(W),...Object.keys(Z)]),ae=[];for(let J of ee){let Q=JSON.stringify(W[J]),U=JSON.stringify(Z[J]);ae.push({k:J,a:W[J],b:Z[J],changed:Q!==U})}return ae}let F=b(()=>{if(!r(D)||!r(P))return null;let j=(r(D)===o()?d():null)||null;return j?new Set(N(j,r(P)).filter(z=>z.changed).map(z=>z.k)):null}),L=b(()=>[{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}]),S=b(()=>{if(!r(P))return[];let j=[];return k(r(P),0,j,r(F)),j});function R(j){return j===null?'null':typeof j=="boolean"?`${j}`:typeof j=="number"?`${Number.isInteger(j)?j:j.toFixed(3)}`:typeof j=="string"?`"${Ar(j)}"`:`${Ar(String(j))}`}function k(j,z,W,Z,ee,ae=!1){let J=ee==null?"":`${Ar(ee)}: `,Q=ae?',':"",U=ee!=null&&Z?.has(ee);if(Array.isArray(j)){if(j.length===0){W.push({depth:z,html:`${J}[]${Q}`,changed:U});return}W.push({depth:z,html:`${J}[`,changed:U}),j.forEach((se,pe)=>k(se,z+1,W,Z,void 0,pe]${Q}`});return}if(j===null||typeof j!="object"){W.push({depth:z,html:`${J}${R(j)}${Q}`,changed:U});return}let K=j,te=Object.keys(K);if(te.length===0){W.push({depth:z,html:`${J}{}${Q}`,changed:U});return}W.push({depth:z,html:`${J}{`,changed:U}),te.forEach((se,pe)=>k(K[se],z+1,W,Z,se,pe}${Q}`})}function M(j){let z={"Same subject":[],"Same class":[]};for(let W of j)z[W.reason].push(W);return z}function q(j){return j==null?"\u2014":typeof j=="number"?Number.isInteger(j)?String(j):j.toFixed(2):String(j)}var V=Sx(),H=l(V);{var G=j=>{var z=sx(),W=c(ie(z),2);{var Z=J=>{var Q=nx(),U=l(Q);s(Q),T(()=>y(U,`Loading seq #${a()??""}\u2026`)),f(J,Q)},ee=J=>{var Q=ax(),U=l(Q);s(Q),T(()=>y(U,`Error \xB7 ${i().error??""}`)),f(J,Q)},ae=J=>{var Q=ix();f(J,Q)};B(W,J=>{i()?.loading?J(Z):i()?.error?J(ee,1):J(ae,-1)})}f(j,z)},O=j=>{let z=b(()=>tn(n().direction));var W=Ex(),Z=ie(W),ee=l(Z),ae=l(ee);let J;var Q=l(ae,!0);s(ae);var U=c(ae,2),K=l(U,!0);s(U);var te=c(U,2),se=l(te);let pe;var $e=c(se);s(te);var ve=c(te,2),he=l(ve);s(ve);var be=c(ve,2);s(ee);var xe=c(ee,2);ke(xe,"",{},{"margin-top":"8px"});var Be=l(xe),Re=l(Be,!0);s(Be),s(xe);var Oe=c(xe,2),De=l(Oe),it=c(l(De)),Je=l(it,!0);s(it),s(De);var we=c(De,2),Qe=c(l(we)),Ye=l(Qe,!0);s(Qe),s(we);var Le=c(we,2),ze=c(l(Le)),Ae=l(ze,!0);s(ze),s(Le),s(Oe),s(Z);var Se=c(Z,2),Fe=l(Se),Ne=c(Fe,2),Ue=c(Ne,4);let mt;var Ve=c(Ue,2),Ie=c(Ve,2);s(Se);var We=c(Se,2);de(We,21,()=>r(L),qe=>qe.id,(qe,pt)=>{var ft=lx();let ht;var at=l(ft,!0),dt=c(at);{var Me=He=>{var Xe=ox(),ct=l(Xe,!0);s(Xe),T(()=>y(ct,r(pt).badge)),f(He,Xe)};B(dt,He=>{r(pt).badge!=null&&He(Me)})}s(ft),T(()=>{ht=ue(ft,1,"",null,ht,{"is-on":r(A)===r(pt).id}),y(at,r(pt).label)}),Y("click",ft,()=>{E(A,r(pt).id,!0)}),f(qe,ft)}),s(We);var $t=c(We,2),Ee=l($t);{var Ge=qe=>{var pt=Ce(),ft=ie(pt);{var ht=He=>{var Xe=cx();f(He,Xe)},at=He=>{var Xe=dx(),ct=l(Xe);s(Xe),T(()=>y(ct,`Error \xB7 ${i().error??""}`)),f(He,Xe)},dt=He=>{var Xe=px();f(He,Xe)},Me=He=>{var Xe=fx();de(Xe,21,()=>r(S),lt,(ct,Et)=>{var It=ux();let qt,ot;Bs(It,()=>r(Et).html,!0),s(It),T(()=>{qt=ue(It,1,"row",null,qt,{changed:r(Et).changed}),ot=ke(It,"",ot,{"--depth":r(Et).depth})}),f(ct,It)}),s(Xe),f(He,Xe)};B(ft,He=>{i()?.loading?He(ht):i()?.error?He(at,1):r(P)==null?He(dt,2):He(Me,-1)})}f(qe,pt)},Ke=qe=>{let pt=b(()=>ss(n().className));var ft=Ce(),ht=ie(ft);{var at=Me=>{var He=vx();f(Me,He)},dt=Me=>{var He=_x();de(He,20,()=>r(pt),Xe=>Xe,(Xe,ct)=>{let Et=b(()=>r(P)??{}),It=b(()=>d()??{}),qt=b(()=>r(Et)[ct.split(".").pop()??ct]??r(Et)[ct]),ot=b(()=>r(It)[ct.split(".").pop()??ct]??r(It)[ct]);var Pt=$x(),Ot=l(Pt),Ht=l(Ot,!0);s(Ot);var tr=c(Ot,2),Vt=l(tr);{var Ft=Lt=>{var hr=mx(),wr=ie(hr),Br=l(wr,!0);s(wr),me(2),T(zr=>y(Br,zr),[()=>q(r(ot))]),f(Lt,hr)};B(Vt,Lt=>{r(ot)!=null&&Lt(Ft)})}var Wt=c(Vt,2),fr=l(Wt,!0);s(Wt),s(tr),s(Pt),T(Lt=>{y(Ht,ct),y(fr,Lt)},[()=>q(r(qt))]),f(Xe,Pt)}),s(He),f(Me,He)};B(ht,Me=>{r(pt).length===0?Me(at):Me(dt,-1)})}f(qe,ft)},st=qe=>{var pt=Ce(),ft=ie(pt);{var ht=dt=>{var Me=gx();f(dt,Me)},at=dt=>{let Me=b(()=>M(p()));var He=xx();de(He,21,()=>Object.entries(r(Me)),([Xe,ct])=>Xe,(Xe,ct)=>{var Et=b(()=>ur(r(ct),2));let It=()=>r(Et)[0],qt=()=>r(Et)[1];var ot=Ce(),Pt=ie(ot);{var Ot=Ht=>{var tr=bx(),Vt=l(tr),Ft=l(Vt);s(Vt);var Wt=c(Vt,2);de(Wt,17,qt,fr=>fr.row.seq,(fr,Lt)=>{let hr=b(()=>tn(r(Lt).row.direction));var wr=hx();let Br;var zr=l(wr),Gr=l(zr);s(zr);var rr=c(zr,2),ea=l(rr,!0);s(rr);var On=c(rr,2);ke(On,"",{},{"white-space":"nowrap",overflow:"hidden","text-overflow":"ellipsis"});var Tn=l(On);ke(Tn,"",{},{color:"var(--ink)"});var Ba=l(Tn,!0);s(Tn);var ta=c(Tn,2);ke(ta,"",{},{color:"var(--ink-3)","margin-left":"6px","font-size":"var(--t-xs)"});var za=l(ta,!0);s(ta),s(On);var Cn=c(On,2),Pi=l(Cn);s(Cn),s(wr),T((ps,us)=>{Br=ue(wr,1,"pt-related__row",null,Br,{"is-cb":r(hr),"is-sb":!r(hr)}),y(Gr,`#${r(Lt).row.seq??""}`),y(ea,r(hr)?"\u2193":"\u2191"),y(Ba,ps),y(za,r(Lt).row.subjectLabel||""),y(Pi,`${r(Lt).dt<0?"":"+"}${us??""}ms`)},[()=>yr(r(Lt).row.className),()=>Math.round(r(Lt).dt)]),Y("click",wr,()=>m()(r(Lt).row.seq)),f(fr,wr)}),s(tr),T(()=>y(Ft,`${It()??""} \xB7 ${qt().length??""}`)),f(Ht,tr)};B(Pt,Ht=>{qt().length&&Ht(Ot)})}f(Xe,ot)}),s(He),f(dt,He)};B(ft,dt=>{p().length===0?dt(ht):dt(at,-1)})}f(qe,pt)},St=qe=>{var pt=Ce(),ft=ie(pt);{var ht=dt=>{var Me=yx(),He=l(Me);s(Me),T(Xe=>y(He,`No prior ${Xe??""} packet in the buffer.`),[()=>yr(n().className)]),f(dt,Me)},at=dt=>{let Me=b(()=>N(d(),r(P))),He=b(()=>r(Me).filter(Lt=>Lt.changed));var Xe=kx(),ct=l(Xe),Et=l(ct),It=l(Et),qt=l(It);s(It);var ot=c(It);s(Et);var Pt=c(Et,2);ke(Pt,"",{},{color:"var(--ink-4)"});var Ot=c(Pt,2),Ht=l(Ot),tr=l(Ht);s(Ht);var Vt=c(Ht),Ft=c(Vt);ke(Ft,"",{},{color:"var(--acc)"});var Wt=l(Ft);s(Ft),s(Ot),s(ct);var fr=c(ct,2);de(fr,17,()=>r(Me),Lt=>Lt.k,(Lt,hr)=>{var wr=wx();let Br;var zr=l(wr),Gr=l(zr,!0);s(zr);var rr=c(zr,2),ea=l(rr,!0);s(rr);var On=c(rr,4),Tn=l(On,!0);s(On),s(wr),T((Ba,ta)=>{Br=ue(wr,1,"pt-diff__row",null,Br,{changed:r(hr).changed}),y(Gr,r(hr).k),y(ea,Ba),y(Tn,ta)},[()=>JSON.stringify(r(hr).a),()=>JSON.stringify(r(hr).b)]),f(Lt,wr)}),s(Xe),T((Lt,hr)=>{y(qt,`#${r(D).seq??""}`),y(ot,` ${Lt??""}`),y(tr,`#${n().seq??""}`),y(Vt,` ${hr??""} \xB7 `),y(Wt,`${r(He).length??""} of ${r(Me).length??""} changed`)},[()=>Ln(r(D).ts).slice(0,12),()=>Ln(n().ts).slice(0,12)]),f(dt,Xe)};B(ft,dt=>{r(D)?dt(at,-1):dt(ht)})}f(qe,pt)};B(Ee,qe=>{r(A)==="decoded"?qe(Ge):r(A)==="mutates"?qe(Ke,1):r(A)==="related"?qe(st,2):r(A)==="diff"&&qe(St,3)})}s($t),T((qe,pt,ft,ht)=>{J=ue(ae,1,"pt-tag",null,J,{cb:r(z),sb:!r(z)}),y(Q,r(z)?"\u2193 CB":"\u2191 SB"),y(K,n().state),pe=ke(se,"",pe,{width:"6px",height:"6px",background:`var(--sub-${n().subjectGroup})`}),y($e,` ${n().subjectGroup??""}`),y(he,`#${qe??""}`),y(Re,pt),y(Je,n().subjectLabel||"\u2014"),y(Ye,ft),y(Ae,ht),mt=ue(Ue,1,"btn sm",null,mt,{"is-on":g()})},[()=>n().seq.toLocaleString(),()=>yr(n().className),()=>zt(n().sizeBytes),()=>Ln(n().ts)]),Y("click",be,function(...qe){v()?.apply(this,qe)}),Y("click",Fe,()=>h()(-1)),Y("click",Ne,()=>h()(1)),Y("click",Ue,function(...qe){x()?.apply(this,qe)}),Y("click",Ve,function(...qe){w()?.apply(this,qe)}),Y("click",Ie,function(...qe){C()?.apply(this,qe)}),f(j,W)};B(H,j=>{n()?j(O,-1):j(G)})}s(V),f(t,V),ce()}Pe(["click"]);var Tx=_('
        ');function qd(t,e){le(e,!0);var n=Tx(),a=l(n),i=l(a),o=c(i,10);ke(o,"",{},{color:"var(--ink-3)",margin:"0 0 10px"});var d=c(o,6);ke(d,"",{},{color:"var(--ink-3)",margin:"0"}),s(a),s(n),Y("click",n,function(...p){e.onClose?.apply(this,p)}),Y("keydown",n,p=>{(p.key==="Escape"||p.key==="Enter")&&e.onClose()}),Y("click",a,p=>p.stopPropagation()),Y("keydown",a,p=>p.stopPropagation()),Y("click",i,function(...p){e.onClose?.apply(this,p)}),f(t,n),ce()}Pe(["click","keydown"]);var Cx=_(''),Ax=_(''),Mx=_('');function Hd(t,e){le(e,!0);var n=Mx(),a=l(n),i=c(l(a),2);s(a);var o=c(a,2),d=c(l(o),2);de(d,20,()=>Object.keys(Qs),x=>x,(x,w)=>{var C=Cx();let A;T(()=>{ue(C,1,Tt(e.accent===w?"is-on":"")),re(C,"title",w),re(C,"aria-label",w),A=ke(C,"",A,{background:Qs[w].acc})}),Y("click",C,()=>e.onAccent(w)),f(x,C)}),s(d),s(o);var p=c(o,2),u=c(l(p),2);de(u,20,()=>["compact","normal","roomy"],x=>x,(x,w)=>{var C=Ax(),A=l(C,!0);s(C),T(()=>{ue(C,1,Tt(e.density===w?"is-on":"")),y(A,w)}),Y("click",C,()=>e.onDensity(w)),f(x,C)}),s(u),s(p);var $=c(p,2),g=c(l($),2),v=l(g);s(g),s($);var m=c($,2),h=l(m);s(m),s(n),T(()=>ue(v,1,Tt(e.collapse?"is-on":""))),Y("click",i,function(...x){e.onClose?.apply(this,x)}),Y("click",v,function(...x){e.onToggleCollapse?.apply(this,x)}),Y("click",h,()=>{e.onReset(),e.onClose()}),f(t,n),ce()}Pe(["click"]);var Px=_(' '),Rx=_('
        shown \xB7 CB SB \xB7 bw \xB7 sel \xB7 marks breaks ? help / search space \u2190\u2192 step B bookmark
        '),Nx={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,Nx);let n=ne(e,"player",3,null),a=ne(e,"paused",3,!1),i=ne(e,"streamLive",3,!0),o=ne(e,"onHistory",3,()=>{}),d=ne(e,"onPlayheadChange",3,()=>{}),p=ne(e,"onResetFeed",3,()=>{}),u=b(()=>n()?.uuid??null),$=b(()=>n()?.connectionId??null),g=X(null),v=X(0),m=X(0),h=X(""),x=X(tt({})),w=X(tt({})),C=X(""),A=X(null),I=X(tt(new Set)),P=X(!1),D=X(!0),N=X(!1),F=X("filters"),L=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),V=X("phosphor"),H=X("normal"),G=X(0),O=X(300),j=X(360),z=X(null),W=X(null),Z=X(tt([])),ee=X(0),ae=0,J=Date.now(),Q=X(null),U=X(void 0);function K(){return new Ml(oe=>{E(m,oe.maxSeq,!0),ae+=oe.added;let fe=Date.now();fe-J>=1e3&&(E(ee,ae,!0),ae=0,J=fe),ya(v)})}function te(){r(g)?.clear(),E(A,null),E(m,0),E(L,[],!0),E(N,!1),E(ee,0),ae=0,J=Date.now(),E(z,null),E(W,null),E(Z,[],!0),ya(v),p()()}let se=b(()=>Cl(r(h))),pe=b(()=>{let oe=new Map;for(let fe of r(L))oe.set(fe.seq,fe);return oe}),$e=b(()=>(r(v),r(g)?.snapshot()??[])),ve=b(()=>oe=>Id(oe,r(pe))),he=b(()=>{let oe=new Map;for(let fe of r($e))oe.set(fe.className,(oe.get(fe.className)||0)+1);return oe}),be=b(()=>{let oe=new Map;for(let fe of r(he).keys())oe.set(fe,Al(fe));return oe}),xe=b(()=>Kv(r(x))),Be=b(()=>Xv(r(w))),Re=b(()=>{let oe=[],fe=0,nt=0;for(let ut of r($e)){let Nt=r(ve)(ut);r(se).match(Nt)&&Zv(ut,r(xe),r(Be))&&(oe.push(ut),fe+=ut.sizeBytes,tn(ut.direction)&&nt++)}return{rows:oe,totalBytes:fe,cbCount:nt,range:oe.length?[oe[0].seq,oe[oe.length-1].seq]:[null,null]}}),Oe=b(()=>r(Re).rows),De=b(()=>r(Re).totalBytes),it=b(()=>r(Re).cbCount),Je=b(()=>r(Re).range),we=b(()=>em(r(S))),Qe=b(()=>tm(r(we),r($e),r(pe))),Ye=b(()=>r(Z).filter(oe=>oe.packetSeq>0).map(oe=>({seq:oe.packetSeq,label:(Yv[oe.kind]??"\xB7")+" "+oe.kind}))),Le=b(()=>Jv(r(Oe),r(Ye),r(D)&&!r(N),r(pe)));function ze(oe){return r(v),r(g)?.rowAtSeq(oe)??null}let Ae=b(()=>r(A)!=null?ze(r(A)):null),Se=b(()=>r(Ae)&&r(g)?r(g).findPrevSameClass(r(Ae).seq,r(Ae).className):null),Fe=b(()=>Qv(r(Ae),r($e),r(g))),Ne=b(()=>new Set(r(Fe).map(oe=>oe.row.seq))),Ue=b(()=>r(Ae)?r(pe).has(r(Ae).seq):!1),mt=b(()=>r(A)==null&&!r(P));function Ve(oe){if(!r(u))return;let{segs:fe}=bi.current;if(fe[0]!=="p"||fe[1]!==r(u)||fe[2]!=="packets")return;let nt=`/p/${r(u)}/packets`;Uf(oe!=null?`${nt}?seq=${oe}`:nt)}function Ie(oe,fe={}){let{keepMulti:nt=!1,expand:ut=!1,syncUrl:Nt=!0}=fe;ut&&E(N,!0),E(A,oe,!0),nt||E(I,new Set,!0),ya(G),Nt&&Ve(oe),d()(ze(oe))}function We(oe){let fe=new Set(r(I));fe.has(oe)?fe.delete(oe):fe.add(oe),fe.size>2?E(I,new Set([...fe].slice(-2)),!0):E(I,fe,!0),r(A)==null&&E(A,oe,!0)}function $t(oe,fe,nt){if(oe==null){E(x,{},!0);return}if(fe==null){let Nt={...r(x)};delete Nt[oe],E(x,Nt,!0);return}let ut={...r(x)[oe]||{}};nt==null?delete ut[fe]:ut[fe]=nt,E(x,{...r(x),[oe]:ut},!0)}function Ee(oe){let fe=[];for(let Nt of r(Le))Nt.kind==="row"&&fe.push(Nt.p);if(fe.length===0)return;let nt=fe.findIndex(Nt=>Nt.seq===r(A)),ut=fe[Math.max(0,Math.min(fe.length-1,(nt<0?0:nt)+oe))];ut&&Ie(ut.seq)}function Ge(){let oe=Number(r(k));!Number.isFinite(oe)||oe<=0||Ie(oe)}function Ke(){E(P,!1),E(A,null),E(z,null),E(I,new Set,!0),Ve(null),d()(null)}function st(){if(!r(Ae))return;let oe=r(L).findIndex(fe=>fe.seq===r(Ae).seq);oe>=0?E(L,r(L).filter((fe,nt)=>nt!==oe),!0):E(L,[...r(L),{seq:r(Ae).seq,label:yr(r(Ae).className)+" \xB7 "+Js(r(Ae)).slice(0,30)}],!0)}function St(){E(A,null),E(z,null),E(W,null),E(I,new Set,!0),Ve(null),d()(null)}function qe(oe,fe){let nt=r(w)[fe.className],ut=nt==="include"?"exclude":nt==="exclude"?null:"include",Nt={...r(w)};ut==null?delete Nt[fe.className]:Nt[fe.className]=ut,E(w,Nt,!0)}function pt(){if(!r(Ae))return;let oe=r(Ae).className;navigator.clipboard?.writeText(oe).then(()=>vt(oe+" copied","ok")).catch(()=>vt("Copy failed","error"))}function ft(){if(!r(Ae))return;let oe="pause on "+yr(r(Ae).className);E(S,[...r(S),{id:"b"+Date.now(),match:"class:"+yr(r(Ae).className),label:oe,enabled:!0}],!0),E(F,"breaks")}function ht(oe){let fe=r(U)?.clientWidth??1100,nt=Math.max(260,Math.min(560,fe-520));return Math.max(240,Math.min(nt,Math.round(oe)))}function at(oe){let fe=r(U)?.clientHeight??800,nt=Math.max(240,fe-320);return Math.max(180,Math.min(nt,Math.round(oe)))}function dt(oe,fe){if(!r(U))return;fe.preventDefault();let nt=Nt=>{let nr=r(U).getBoundingClientRect();oe==="x"?E(O,ht(nr.right-Nt.clientX),!0):E(j,at(nr.bottom-Nt.clientY),!0)},ut=()=>{window.removeEventListener("pointermove",nt),window.removeEventListener("pointerup",ut),window.removeEventListener("pointercancel",ut)};nt(fe),window.addEventListener("pointermove",nt),window.addEventListener("pointerup",ut,{once:!0}),window.addEventListener("pointercancel",ut,{once:!0})}ge(()=>{r(u),E(g,K(),!0),E(A,null),E(m,0),E(Z,[],!0),E(z,null),E(W,null),E(v,0),r(u)&&Wv(r(u))}),ge(()=>{let oe=r(u),fe=r($),nt=r(g);if(!oe||!fe||!nt)return;let ut=!0;return(async()=>{let Nt=[],nr=0;for(;ut;){let Fn=await je(`/connections/${fe}/packets?since=${nr}&limit=5000`).catch(()=>[]);if(!ut||!Fn.length)break;for(let Bn of Fn)Nt.push(as({...Bn,uuid:oe,connectionId:fe}));if(nr=Number(Fn[Fn.length-1]?.seq)||nr,Fn.length<5e3)break}ut&&Nt.length&&(nt.loadHistory(Nt),o()(Nt))})(),()=>{ut=!1}}),ge(()=>{let oe=r(u),fe=r(g);if(!(!oe||!fe))return sr.subscribe(Jo(oe),nt=>{!i()||a()||r(P)||fe.push(as(nt))})}),ge(()=>{let oe=r(u);if(!oe)return;let fe=!0;return je(`/players/${oe}/lifecycle`).then(nt=>{fe&&E(Z,nt||[],!0)}).catch(()=>{fe&&E(Z,[],!0)}),()=>{fe=!1}}),ge(()=>{let oe=r(u);if(oe)return en(()=>Zo(oe),fe=>{fe?.seq&&E(Z,r(Z).some(nt=>nt.seq===fe.seq)?r(Z):[...r(Z),fe],!0)})});function Me(oe){E(z,{full:oe},!0);let fe=as(oe);fe.seq===r(A)&&d()(fe)}async function He(oe,fe,nt,ut){let Nt=Ld(oe,nt);if(Nt)return Nt;let nr=await je(`/connections/${fe}/packets/${nt}`,{signal:ut});return Gv(oe,nt,nr),nr}ge(()=>{let oe=r(A),fe=r(u),nt=r($);if(oe==null||!fe||!nt)return;let ut=Ld(fe,oe);if(ut){Me(ut);return}let Nt=!0;E(z,{loading:!0},!0);let nr=new AbortController,Fn=setTimeout(()=>{He(fe,nt,oe,nr.signal).then(Bn=>{Nt&&Me(Bn)}).catch(Bn=>{if(!Nt||nr.signal.aborted)return;let ti=Bn;E(z,{error:ti.status===404?`Packet #${oe} not in memory or archive`:ti.message||String(Bn)},!0)})},80);return()=>{Nt=!1,nr.abort(),clearTimeout(Fn)}}),ge(()=>{E(W,null);let oe=r(Se),fe=r(u),nt=r($);if(!oe||!fe||!nt)return;let ut=!0,Nt=new AbortController;return He(fe,nt,oe.seq,Nt.signal).then(nr=>{ut&&E(W,nr.record??null,!0)}).catch(()=>{}),()=>{ut=!1,Nt.abort()}}),ge(()=>{let oe=Number(bi.current.query?.seq);!Number.isFinite(oe)||oe<=0||oe===r(A)||Ie(oe,{expand:!0,syncUrl:!1})}),ge(()=>{if(r(P)||!r(m))return;let oe=ze(r(m));if(!oe)return;let fe=r(ve)(oe);for(let nt of r(we))if(nt.matcher?.(fe)){E(P,!0),vt("Breakpoint hit at #"+r(m),"warn");break}});function Xe(oe){let fe=oe.target;if(!(fe instanceof HTMLInputElement||fe instanceof HTMLTextAreaElement))switch(oe.key){case" ":oe.preventDefault(),E(P,!r(P));break;case"ArrowDown":case"j":oe.preventDefault(),Ee(1);break;case"ArrowUp":case"k":oe.preventDefault(),Ee(-1);break;case"ArrowRight":oe.preventDefault(),Ee(oe.shiftKey?10:1);break;case"ArrowLeft":oe.preventDefault(),Ee(oe.shiftKey?-10:-1);break;case"f":case"F":Ke();break;case"b":case"B":st();break;case"c":case"C":E(N,!r(N));break;case"Escape":St(),E(M,!1);break;case"?":E(M,!r(M));break;case"/":oe.preventDefault(),r(Q)?.focus();break}}ge(()=>{let oe=Qs[r(V)];if(!r(U))return;let fe=r(U).style;fe.setProperty("--acc",oe.acc),fe.setProperty("--acc-deep",oe.deep),fe.setProperty("--acc-soft",`color-mix(in oklab, ${oe.acc} 14%, transparent)`),fe.setProperty("--acc-line",`color-mix(in oklab, ${oe.acc} 35%, transparent)`),fe.setProperty("--acc-glow",`color-mix(in oklab, ${oe.acc} 55%, transparent)`)});var ct=Rx();Mt("keydown",Rs,Xe);var Et=l(ct);{let oe=b(()=>r(S).some(fe=>fe.enabled));Od(Et,{get query(){return r(h)},get parsed(){return r(se)},get live(){return r(mt)},get paused(){return r(P)},get rate(){return r(ee)},get totalPackets(){return r($e).length},get jump(){return r(k)},get breakOn(){return r(oe)},onQuery:fe=>{E(h,fe,!0)},onPaused:fe=>{E(P,fe,!0)},onStep:Ee,onLive:Ke,onJump:Ge,onJumpChange:fe=>{E(k,fe,!0)},onHelp:()=>{E(M,!0)},onTweaks:()=>{E(q,!r(q))},get searchRef(){return r(Q)},set searchRef(fe){E(Q,fe,!0)}})}var It=c(Et,2);{let oe=b(()=>[...r(Ne)]);Dd(It,{get tape(){return r(g)},get tapeVersion(){return r(v)},get bookmarks(){return r(L)},get breakpoints(){return r(Qe)},get lifecycle(){return r(Ye)},get playhead(){return r(A)},get viewStart(){return r(Je)[0]},get viewEnd(){return r(Je)[1]},get related(){return r(oe)},onSeek:fe=>{Ie(r(g)?.nearestSeq(fe)??fe),E(P,!0)}})}var qt=c(It,2);let ot;var Pt=l(qt);{let oe=b(()=>r(Ae)?.seq??null);Fd(Pt,{get tab(){return r(F)},get rows(){return r($e)},get filters(){return r(x)},get classCounts(){return r(he)},get classFilter(){return r(w)},get classQuery(){return r(C)},get bookmarks(){return r(L)},get breakpoints(){return r(Qe)},get saved(){return r(R)},get currentSeq(){return r(oe)},get currentQuery(){return r(h)},onSetTab:fe=>{E(F,fe,!0)},onSetFilter:$t,onSetClassFilter:fe=>{E(w,fe,!0)},onSetClassQuery:fe=>{E(C,fe,!0)},onJumpBookmark:Ie,onAddBookmark:fe=>{E(L,[...r(L),fe],!0)},onRemoveBookmark:fe=>{E(L,r(L).filter((nt,ut)=>ut!==fe),!0)},onToggleBreakpoint:fe=>{E(S,r(S).map((nt,ut)=>ut===fe?{...nt,enabled:!nt.enabled}:nt),!0)},onAddBreakpoint:fe=>{E(S,[...r(S),{...fe,id:"b"+Date.now()}],!0)},onRemoveBreakpoint:fe=>{E(S,r(S).filter((nt,ut)=>ut!==fe),!0)},onLoadSaved:fe=>{E(h,fe,!0)},onAddSaved:fe=>{E(R,[...r(R),fe],!0)},onRemoveSaved:fe=>{E(R,r(R).filter((nt,ut)=>ut!==fe),!0)}})}var Ot=c(Pt,2),Ht=l(Ot);{let oe=b(()=>r(H)==="compact"?22:r(H)==="roomy"?32:26);Bd(Ht,{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(G)},get rowHeight(){return r(oe)},onSelect:Ie,onShiftSelect:We,onContext:qe,onExpandGroup:fe=>{E(N,!0),Ie(fe)}})}s(Ot);var tr=c(Ot,2),Vt=c(tr,2),Ft=c(Vt,2);{let oe=b(()=>r(A)??0);zd(Ft,{get row(){return r(Ae)},get seq(){return r(oe)},get record(){return r(z)},get prevSameClass(){return r(Se)},get prevRecord(){return r(W)},get related(){return r(Fe)},get multi(){return r(I)},getRow:ze,get isBookmarked(){return r(Ue)},onClose:St,onJumpSeq:Ie,onStep:Ee,onToggleBookmark:st,onCopyClass:pt,onBreakOnClass:ft})}s(qt);var Wt=c(qt,2),fr=l(Wt),Lt=c(l(fr)),hr=l(Lt,!0);s(Lt);var wr=c(Lt);s(fr);var Br=c(fr,4),zr=c(l(Br)),Gr=l(zr,!0);s(zr),s(Br);var rr=c(Br,2),ea=c(l(rr)),On=l(ea,!0);s(ea),s(rr);var Tn=c(rr,4),Ba=c(l(Tn)),ta=l(Ba,!0);s(Ba),s(Tn);var za=c(Tn,4),Cn=c(l(za),2),Pi=l(Cn,!0);s(Cn);var ps=c(Cn,2);{var us=oe=>{var fe=Px(),nt=l(fe);s(fe),T(()=>y(nt,`(+${r(I).size-1} multi)`)),f(oe,fe)};B(ps,oe=>{r(I).size>1&&oe(us)})}s(za);var fs=c(za,4),co=c(l(fs)),ye=l(co,!0);s(co),s(fs);var _t=c(fs,2),Kt=c(l(_t)),lr=l(Kt,!0);s(Kt),s(_t);var cr=c(_t,2),Dn=c(l(cr),4),ra=c(l(Dn));s(Dn),me(4),s(cr),s(Wt);var na=c(Wt,2);{var qa=oe=>{qd(oe,{onClose:()=>{E(M,!1)}})};B(na,oe=>{r(M)&&oe(qa)})}var _n=c(na,2);{var qr=oe=>{Hd(oe,{get accent(){return r(V)},get density(){return r(H)},get collapse(){return r(D)},onAccent:fe=>{E(V,fe,!0)},onDensity:fe=>{E(H,fe,!0)},onToggleCollapse:()=>{E(D,!r(D)),E(N,!1)},onReset:te,onClose:()=>{E(q,!1)}})};B(_n,oe=>{r(q)&&oe(qr)})}s(ct),Ct(ct,oe=>E(U,oe),()=>r(U)),T((oe,fe,nt,ut,Nt,nr)=>{re(ct,"data-density",r(H)),ot=ke(qt,"",ot,{"--pt-inspector-w":r(O)+"px","--pt-inspector-h":r(j)+"px"}),y(hr,oe),y(wr,` / ${fe??""}`),y(Gr,nt),y(On,ut),y(ta,Nt),y(Pi,r(Ae)?"#"+r(Ae).seq:"\u2014"),y(ye,r(L).length),y(lr,nr),y(ra,` ${r(P)?"resume":"pause"}`)},[()=>r(Oe).length.toLocaleString(),()=>r($e).length.toLocaleString(),()=>r(it).toLocaleString(),()=>(r(Oe).length-r(it)).toLocaleString(),()=>el(r(De)),()=>r(S).filter(oe=>oe.enabled).length]),Y("pointerdown",tr,oe=>dt("x",oe)),Y("pointerdown",Vt,oe=>dt("y",oe)),f(t,ct),ce()}Pe(["pointerdown"]);var Lx=[{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"}]}],rm={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},Ix=_('
        '),Ox=_('
        '),Dx=_('
        '),Fx=_('
        '),Bx={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;}}.ppk-state-mirror__composite {padding:var(--pad-2) var(--pad-3);border-bottom:1px solid var(--line);}.ppk-state-mirror__label {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;margin-bottom:4px;}} + + @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; } + }.ppk-vitals {display:grid;gap:1px;background:var(--line);.ppk-vitals__row {display:grid;grid-template-columns:50px 1fr 1fr;align-items:center;gap:var(--pad-2);padding:6px var(--pad-3);background:var(--bg-1);&.flashed { animation: ppk-flash 700ms ease-out;}}.ppk-vitals__lbl {font-size:var(--t-xs);color:var(--ink-4);text-transform:uppercase;}.ppk-vitals__val {font-size:var(--t-md);color:var(--ink);font-variant-numeric:tabular-nums;&.acc {color:var(--acc);}&.warn {color:var(--warn);}&.danger {color:var(--danger);}}.ppk-vitals__spark {height:18px;min-width:0;display:block;canvas {width:100%;height:100%;display:block;}}}.ppk-hotbar {display:grid;grid-template-columns:repeat(9, 1fr);gap:2px;padding:var(--pad-3);.ppk-hotbar__slot {aspect-ratio:1;display:grid;place-items:center;background:var(--bg-2);border:1px solid var(--line);color:var(--ink-3);font-size:var(--t-xs);position:relative;overflow:hidden;&.selected {border-color:var(--acc);box-shadow:0 0 0 1px var(--acc) inset;}&.flashed { animation: ppk-flash 700ms ease-out;}.lbl {text-align:center;color:var(--ink-2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}.count {position:absolute;right:2px;bottom:1px;font-size:var(--t-xs);color:var(--acc);}}}.ppk-scope {position:relative;aspect-ratio:1;background:var(--sunk);box-shadow:var(--bevel-sunk);overflow:hidden;.ppk-scope__grid {position:absolute;inset:0;background:linear-gradient(to right, color-mix(in oklab, var(--acc) 8%, transparent) 1px, transparent 1px), + linear-gradient(to bottom, color-mix(in oklab, var(--acc) 8%, transparent) 1px, transparent 1px);background-size:25% 25%;}.ppk-scope__trail {position:absolute;inset:0;pointer-events:none;svg {width:100%;height:100%;display:block;}}.ppk-scope__player {position:absolute;width:6px;height:6px;background:var(--acc);transform:translate(-50%, -50%);box-shadow:0 0 8px var(--acc);}.ppk-scope__north {position:absolute;top:4px;right:4px;color:var(--ink-4);font-size:var(--t-xs);}.ppk-scope__coord {position:absolute;bottom:4px;left:4px;color:var(--ink-3);font-size:var(--t-xs);background:color-mix(in oklab, var(--bg-0) 80%, transparent);padding:1px 4px;}} + }`};function Ud(t,e){le(e,!0),Ut(t,Bx);let n=ne(e,"paused",3,!1),a=X({}),i=X({}),o=X(tt(Date.now())),d=X(""),p=X("count"),u=X(!0),$=X(!0);ge(()=>{let D=e.player?.uuid;if(E($,!e.player?.disconnectedAt),!!D)return sr.subscribe(gr.players,N=>{N.uuid===D&&N.event==="disconnect"&&E($,!1)})});let g=Av(()=>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:D=>v(D)});function v(D){for(let N of ss(D.className))E(a,{...r(a),[N]:Date.now()});E(d,D.className,!0)}let m=b(()=>new Set(ss(r(d))));ge(()=>{let D=setInterval(()=>{if(n())return;let N=e.player;if(!N)return;let F={...r(i)};for(let L in rm){let S=rm[L](N);if(typeof S!="number")continue;let R=(F[L]||[]).slice();R.push(S),R.length>60&&R.shift(),F[L]=R}E(i,F)},500);return()=>clearInterval(D)}),ge(()=>{let D=setInterval(()=>{n()||E(o,Date.now(),!0)},300);return()=>clearInterval(D)});var h=Fx(),x=l(h),w=l(x),C=l(w);Xs(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:D=>E(p,D,!0)}),s(w);var A=c(w,2);et(A,{title:"Packet trace",meta:"seq tape \xB7 Space pause \xB7 \u2190\u2192 step",flush:!0,headless:!0,children:(D,N)=>{jd(D,{get player(){return e.player},get paused(){return n()},get streamLive(){return r($)},onHistory:F=>g.ingestRows(F,e.player?.uuid??""),onPlayheadChange:F=>{F&&E(d,F.className,!0)},onResetFeed:()=>g.reset()})},$$slots:{default:!0}}),s(x);var I=c(x,2),P=l(I);{let D=F=>{var L=Ix(),S=l(L),R=c(S,2);s(L),T(()=>{ue(S,1,Tt(r(u)?"ghost sm":"primary sm")),ue(R,1,Tt(r(u)?"primary sm":"ghost sm"))}),Y("click",S,()=>E(u,!1)),Y("click",R,()=>E(u,!0)),f(F,L)},N=b(()=>r(u)?"trails on":"flashes only");et(P,{title:"State at playhead",get meta(){return r(N)},flush:!0,actions:D,children:(F,L)=>{var S=Ce(),R=ie(S);de(R,17,()=>Lx,k=>k.group,(k,M)=>{var q=Dx(),V=l(q),H=l(V,!0);s(V);var G=c(V,2);de(G,17,()=>r(M).items,O=>O.key,(O,j)=>{let z=b(()=>r(a)[r(j).key]),W=b(()=>r(z)&&r(o)-r(z)<700),Z=b(()=>r(i)[r(j).key]),ee=b(()=>r(u)&&r(Z)&&r(Z).length>4),ae=b(()=>r(m).has(r(j).key)),J=b(()=>e.player?.provenance?.[r(j).key]),Q=b(()=>r(j).get(e.player));var U=Ox(),K=l(U),te=l(K,!0);s(K);var se=c(K,2),pe=l(se);{var $e=be=>{{let xe=b(()=>r(W)?"var(--acc)":"var(--ink-3)");yi(be,{get data(){return r(Z)},get color(){return r(xe)}})}};B(pe,be=>{r(ee)&&be($e)})}s(se);var ve=c(se,2),he=l(ve,!0);s(ve),s(U),T(be=>{ue(U,1,"ppk-state-mirror__field"+(r(W)?" flashed":"")+(r(ae)?" linked":"")),re(U,"title",be),y(te,r(j).label),y(he,r(Q)==null||r(Q)===""?"\xB7":r(Q))},[()=>r(J)?`${va(r(J).packetClass||"").replace(/Packet$/,"")} #${r(J).seq} \xB7 ${mn(r(o)-r(J).ts)} ago`:"no source"]),f(O,U)}),s(q),T(()=>y(H,r(M).group)),f(k,q)}),f(F,S)},$$slots:{actions:!0,default:!0}})}s(I),s(h),f(t,h),ce()}Pe(["click"]);var eo=[{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)"}],nm=Object.fromEntries(eo.map(t=>[t.id,t.color])),zx=Object.fromEntries(eo.map(t=>[t.id,t.glyph])),qx=[32,64,128,256];function Vd(t,e,n){let a=t.x-e,i=t.z-n;return Math.hypot(a,i)}function Hx(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",d=Math.abs(i),p=Number.isInteger(i)?d.toString():d.toFixed(2).replace(/\.?0+$/,"");return{text:(i>0?"+":"\u2212")+p,sign:o}}var jx=_('
        '),Ux=_(''),Vx=_('
        '),Yx=_(""),Gx=_('
        '),Wx=on(''),Kx=on(' '),Xx=on('',1),Zx=on(""),Jx=on(''),Qx=on('',1),ey=_(' '),ty=_('
        N
        '),ry=_('
        '),ny=_('
        No entities match.
        '),ay=_('
        '),iy=_(''),sy=_('
        Loading detail\u2026
        '),oy=_('
        Loading detail\u2026
        '),ly=_('
        '),cy=_('
        Entity is no longer in view.
        '),dy=_(' '),py=_('
        '),uy=_('
        No fields tracked yet.
        '),fy=_('
        \u2192
        '),vy=_('
        No mutations yet.
        '),my=_('
        Field state
        '),$y=_('
        '),_y={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,_y);let n=ne(e,"paused",3,!1),a=b(()=>e.player?.uuid),i=b(()=>{let k=String(r(a)||"").toLowerCase();return(e.player?.visibleEntities||[]).filter(M=>!M.uuid||String(M.uuid).toLowerCase()!==k)}),o=b(()=>e.player?.posX??0),d=b(()=>e.player?.posZ??0),p=X(null),u=X(""),$=X("distance"),g=X(null),v=X(128),m=X(null),h=b(()=>{let k=Object.fromEntries(eo.map(M=>[M.id,0]));for(let M of r(i))k[M.group]!==void 0&&k[M.group]++;return k}),x=b(()=>{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,V)=>Vd(q,r(o),r(d))-Vd(V,r(o),r(d))):r($)==="type"?M.sort((q,V)=>(q.type||"").localeCompare(V.type||"")):r($)==="id"&&M.sort((q,V)=>q.id-V.id),M});ge(()=>{if(r(g)==null||!r(a)){E(m,null);return}let k=!0,M=!0,q=async()=>{try{let H=await je(`/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 V=n()?null:setInterval(q,1e3);return()=>{k=!1,V&&clearInterval(V)}});function w(k){let M=k.x-r(o),q=k.z-r(d),V=Math.hypot(M,q);return V>r(v)?null:{x:M/r(v)*95,y:q/r(v)*95,d:V}}let C=b(()=>e.player?.yaw||0),A=b(()=>e.player?.posX!=null),I=b(()=>Qr.now);var P=$y(),D=l(P);{let k=q=>{var V=jx(),H=l(V),G=c(H,2),O=c(G,2);s(V),T(()=>{ue(H,1,Tt(r($)==="distance"?"primary sm":"ghost sm")),ue(G,1,Tt(r($)==="type"?"primary sm":"ghost sm")),ue(O,1,Tt(r($)==="id"?"primary sm":"ghost sm"))}),Y("click",H,()=>E($,"distance")),Y("click",G,()=>E($,"type")),Y("click",O,()=>E($,"id")),f(q,V)},M=b(()=>`${r(i).length} tracked`);et(D,{title:"Entities in view",get meta(){return r(M)},flush:!0,actions:k,children:(q,V)=>{var H=Vx(),G=l(H);de(G,17,()=>eo,j=>j.id,(j,z)=>{var W=Ux();let Z;var ee=l(W),ae=l(ee,!0);s(ee);var J=c(ee,2),Q=l(J,!0);s(J);var U=c(J,2),K=l(U,!0);s(U),s(W),T(()=>{ue(W,1,"ent-filter"+(r(p)===r(z).id?" on":"")),Z=ke(W,"",Z,{"--gc":r(z).color}),y(ae,r(z).glyph),y(Q,r(z).label),y(K,r(h)[r(z).id]||0)}),Y("click",W,()=>E(p,r(p)===r(z).id?null:r(z).id,!0)),f(j,W)});var O=c(G,2);s(H),Y("input",O,j=>E(u,j.target.value,!0)),f(q,H)},$$slots:{actions:!0,default:!0}})}var N=c(D,2),F=l(N);{let k=q=>{var V=Gx();de(V,20,()=>qx,H=>H,(H,G)=>{var O=Yx(),j=l(O,!0);s(O),T(()=>{ue(O,1,Tt(r(v)===G?"primary sm":"ghost sm")),y(j,G)}),Y("click",O,()=>E(v,G,!0)),f(H,O)}),s(V),f(q,V)},M=b(()=>`${r(v)} blocks`);et(F,{title:"Radar",get meta(){return r(M)},flush:!0,actions:k,children:(q,V)=>{var H=ty(),G=l(H),O=l(G);de(O,16,()=>[25,50,75,100],ee=>ee,(ee,ae)=>{var J=Wx();T(()=>re(J,"r",ae)),f(ee,J)});var j=c(O,3);de(j,16,()=>[25,50,75,100],ee=>ee,(ee,ae)=>{var J=Kx(),Q=l(J,!0);s(J),T(U=>{re(J,"y",-ae-1.5),y(Q,U)},[()=>Math.round(ae/100*r(v))]),f(ee,J)});var z=c(j);{var W=ee=>{var ae=Qx(),J=ie(ae);de(J,17,()=>r(x),U=>U.id,(U,K)=>{let te=b(()=>w(r(K)));var se=Ce(),pe=ie(se);{var $e=ve=>{let he=b(()=>nm[r(K).group]||"var(--ink-3)"),be=b(()=>r(g)===r(K).id);var xe=Jx(),Be=l(xe);{var Re=De=>{var it=Xx(),Je=ie(it),we=c(Je);re(we,"r",3),T(()=>{re(Je,"cx",r(te).x),re(Je,"cy",r(te).y),re(Je,"stroke",r(he)),re(we,"cx",r(te).x),re(we,"cy",r(te).y),re(we,"fill",r(he))}),f(De,it)},Oe=De=>{var it=Zx();re(it,"r",1.8),re(it,"opacity",.85),T(()=>{re(it,"cx",r(te).x),re(it,"cy",r(te).y),re(it,"fill",r(he))}),f(De,it)};B(Be,De=>{r(be)?De(Re):De(Oe,-1)})}s(xe),T(()=>re(xe,"aria-label","Entity "+(r(K).type||"unknown")+(r(be)?" (selected)":""))),Y("click",xe,()=>E(g,r(K).id,!0)),Y("keydown",xe,De=>{(De.key==="Enter"||De.key===" ")&&(De.preventDefault(),E(g,r(K).id,!0))}),Mt("pointerenter",xe,De=>il({...r(K),distance:r(te).d},De)),Y("pointermove",xe,function(...De){sl?.apply(this,De)}),Mt("pointerleave",xe,function(...De){Ji?.apply(this,De)}),f(ve,xe)};B(pe,ve=>{r(te)&&ve($e)})}f(U,se)});var Q=c(J);T(()=>re(Q,"transform",`rotate(${r(C)+180})`)),f(ee,ae)};B(z,ee=>{r(A)&&ee(W)})}me(),s(G);var Z=c(G,2);de(Z,21,()=>eo,ee=>ee.id,(ee,ae)=>{var J=ey(),Q=l(J);let U;var K=c(Q);s(J),T(()=>{U=ke(Q,"",U,{background:r(ae).color}),y(K,` ${r(ae).label??""}`)}),f(ee,J)}),s(Z),s(H),f(q,H)},$$slots:{actions:!0,default:!0}})}var L=c(F,2);{let k=b(()=>`${r(x).length} ${r(p)?"\xB7 "+r(p):""}`);et(L,{title:"Constellation",get meta(){return r(k)},flush:!0,children:(M,q)=>{var V=ay(),H=l(V);de(H,17,()=>r(x),j=>j.id,(j,z)=>{let W=b(()=>Vd(r(z),r(o),r(d))),Z=b(()=>Math.max(.4,1-r(W)/256)),ee=b(()=>nm[r(z).group]||"var(--ink-3)");var ae=ry();let J;var Q=l(ae),U=l(Q,!0);s(Q);var K=c(Q,2),te=l(K),se=l(te),pe=l(se,!0);s(se);var $e=c(se,2),ve=l($e);s($e),s(te);var he=c(te,2),be=l(he),xe=l(be);s(be);var Be=c(be,2),Re=l(Be,!0);s(Be),s(he),s(K),s(ae),T((Oe,De,it,Je,we)=>{ue(ae,1,"ent-card"+(r(g)===r(z).id?" on":"")),J=ke(ae,"",J,{"--gc":r(ee),opacity:r(Z)}),y(U,zx[r(z).group]||"\xB7"),y(pe,Oe),y(ve,`#${r(z).id??""}`),y(xe,`${De??""} ${it??""} ${Je??""}`),y(Re,we)},[()=>Vs(r(z).type),()=>r(z).x.toFixed(0),()=>r(z).y.toFixed(0),()=>r(z).z.toFixed(0),()=>r(W)<1?"\xB7":Math.round(r(W))+"m"]),Y("click",ae,()=>E(g,r(g)===r(z).id?null:r(z).id,!0)),Y("keydown",ae,Oe=>{(Oe.key==="Enter"||Oe.key===" ")&&(Oe.preventDefault(),E(g,r(g)===r(z).id?null:r(z).id,!0))}),f(j,ae)});var G=c(H,2);{var O=j=>{var z=ny();f(j,z)};B(G,j=>{r(x).length===0&&j(O)})}s(V),f(M,V)},$$slots:{default:!0}})}s(N);var S=c(N,2);{var R=k=>{et(k,{title:"Detail",meta:"live",flush:!0,actions:q=>{var V=iy();Y("click",V,()=>E(g,null)),f(q,V)},children:(q,V)=>{var H=Ce(),G=ie(H);{var O=ee=>{var ae=sy();f(ee,ae)},j=ee=>{var ae=oy();f(ee,ae)},z=ee=>{var ae=ly(),J=l(ae,!0);s(ae),T(()=>y(J,r(m).error)),f(ee,ae)},W=ee=>{var ae=cy();f(ee,ae)},Z=ee=>{let ae=b(()=>r(m).data),J=b(()=>Object.entries(r(ae).provenance||{})),Q=b(()=>(r(ae).changeLog||[]).slice().reverse().slice(0,20));var U=my(),K=l(U),te=l(K),se=l(te,!0);s(te);var pe=c(te,2),$e=l(pe);s(pe);var ve=c(pe,2);{var he=Ve=>{var Ie=dy(),We=l(Ie,!0);s(Ie),T($t=>y(We,$t),[()=>String(r(ae).uuid).slice(0,8)]),f(Ve,Ie)};B(ve,Ve=>{r(ae).uuid&&Ve(he)})}var be=c(ve,2),xe=l(be);s(be);var Be=c(be,2),Re=l(Be);s(Be);var Oe=c(Be,2),De=l(Oe);s(Oe),s(K);var it=c(K,2),Je=l(it),we=c(l(Je),2),Qe=l(we);de(Qe,17,()=>r(J),([Ve,Ie])=>Ve,(Ve,Ie)=>{var We=b(()=>ur(r(Ie),2));let $t=()=>r(We)[0],Ee=()=>r(We)[1];var Ge=py(),Ke=l(Ge),st=l(Ke,!0);s(Ke);var St=c(Ke,2),qe=l(St),pt=l(qe,!0);s(qe);var ft=c(qe);s(St),s(Ge),T((ht,at)=>{y(st,$t()),y(pt,ht),y(ft,` #${Ee().seq??""} \xB7 ${at??""} ago`)},[()=>va(Ee().packetClass||"").replace(/Packet$/,""),()=>mn(r(I)-Ee().ts)]),f(Ve,Ge)});var Ye=c(Qe,2);{var Le=Ve=>{var Ie=uy();f(Ve,Ie)};B(Ye,Ve=>{r(J).length===0&&Ve(Le)})}s(we),s(Je);var ze=c(Je,2),Ae=l(ze),Se=l(Ae);s(Ae);var Fe=c(Ae,2),Ne=l(Fe);de(Ne,17,()=>r(Q),lt,(Ve,Ie)=>{let We=b(()=>Hx(r(Ie).prev,r(Ie).value));var $t=fy(),Ee=l($t),Ge=l(Ee);s(Ee);var Ke=c(Ee,2),st=l(Ke,!0);s(Ke);var St=c(Ke,2),qe=l(St),pt=l(qe,!0);s(qe);var ft=c(qe,4),ht=l(ft,!0);s(ft),s(St);var at=c(St,2),dt=l(at,!0);s(at),s($t),T((Me,He,Xe)=>{y(Ge,`${Me??""} ago`),y(st,r(Ie).field),y(pt,He),y(ht,Xe),ue(at,1,"delta"+(r(We)?.sign?" "+r(We).sign:"")),y(dt,r(We)?.text??"")},[()=>mn(r(I)-(r(Ie).source?.ts||0)),()=>String(r(Ie).prev??"\u2014"),()=>String(r(Ie).value??"\u2014")]),f(Ve,$t)});var Ue=c(Ne,2);{var mt=Ve=>{var Ie=vy();f(Ve,Ie)};B(Ue,Ve=>{r(Q).length===0&&Ve(mt)})}s(Fe),s(ze),s(it),s(U),T((Ve,Ie,We,$t)=>{y(se,Ve),y($e,`#${r(ae).id??""}`),y(xe,`${Ie??""} \xB7 ${We??""} \xB7 ${$t??""}`),y(Re,`spawn #${r(ae).spawnSeq??""}`),y(De,`${r(ae).packetCount??""} packets`),y(Se,`Recent changes \xB7 ${r(Q).length??""}`)},[()=>Vs(r(ae).type),()=>r(ae).x?.toFixed(1),()=>r(ae).y?.toFixed(1),()=>r(ae).z?.toFixed(1)]),f(ee,U)};B(G,ee=>{r(m)?r(m).loading?ee(j,1):r(m).error?ee(z,2):r(m).data?ee(Z,-1):ee(W,3):ee(O)})}f(q,H)},$$slots:{actions:!0,default:!0}})};B(S,k=>{r(g)!=null&&k(R)})}s(P),f(t,P),ce()}Pe(["click","input","keydown","pointermove"]);var gy=(t,e=At)=>{let n=b(()=>e()*100);var a=by(),i=l(a);let o;s(a),T(d=>{re(a,"title",d),o=ke(i,"",o,{"--pct":r(n)+"%"})},[()=>`${r(n).toFixed(1)}% custom`]),f(t,a)},hy=(t,e=At,n=At)=>{let a=b(()=>os(e().id)||"minecraft"),i=b(()=>Gd(e().id));var o=ky();let d;var p=c(l(o),2),u=l(p,!0);s(p);var $=c(p,2),g=l($),v=l(g,!0);s(g);var m=c(g,2),h=l(m,!0);s(m),s($);var x=c($,2),w=l(x,!0);s(x),s(o),T(C=>{d=ue(o,1,"reg-entry",null,d,{"reg-entry--custom":!e().vanilla,"reg-entry--vanilla":e().vanilla}),y(u,C),ue(g,1,Tt(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 by=_(''),xy=_(' :',1),yy=_(' /',1),wy=_(''),ky=_('
      1. :
      2. '),Ey=_('
         
        '),Sy=_('
        Reading per-connection registry tables\u2026
        '),Ty=_(' ',1),Cy=_(`
        This connection runs only stock Mojang registries \u2014 every entry below is + baseline content. Custom registrations made via Minestom will surface here.
        `),Ay=_('
        '),My=_('
        '),Py=_('
        Ambient to inspect them.
        '),Ry=_('
        Select a registry on the left.
        '),Ny=_(' :',1),Ly=_(' :',1),Iy=_('No custom additions in this registry. Every entry here is baseline Mojang content \u2014 switch to All to inspect vanilla entries.',1),Oy=_(' Clear the filter or switch to All to widen the search.',1),Dy=_("No entries."),Fy=_('
        '),By=_(''),zy=_(''),qy=_('
        '),Hy=_('
          ',1),jy=_('
          registry / custom of total
          ',1),Uy=_('
          Registries
          Entries
          Custom
          Vanilla
          ',1),Vy=_('
          '),Yy={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,Yy);let n=(L,S=At)=>{let R=b(()=>S().id===r(d)),k=b(()=>Gd(S().id)),M=b(()=>os(S().id));var q=wy();let V;var H=l(q),G=l(H);{var O=pe=>{var $e=bt("\u25A0");f(pe,$e)},j=pe=>{var $e=bt("\u25A2");f(pe,$e)};B(G,pe=>{S().customCount>0?pe(O):pe(j,-1)})}s(H);var z=c(H,2),W=l(z);{var Z=pe=>{var $e=xy(),ve=ie($e),he=l(ve,!0);s(ve),me(),T(()=>y(he,r(M))),f(pe,$e)};B(W,pe=>{r(M)&&r(M)!=="minecraft"&&pe(Z)})}var ee=c(W,2),ae=l(ee,!0);s(ee),s(z);var J=c(z,2),Q=l(J);{var U=pe=>{var $e=yy(),ve=ie($e),he=l(ve,!0);s(ve),me(2),T(()=>y(he,S().customCount)),f(pe,$e)};B(Q,pe=>{S().customCount>0&&pe(U)})}var K=c(Q,2),te=l(K,!0);s(K),s(J);var se=c(J,2);gy(se,()=>S().customRatio),s(q),T(()=>{V=ue(q,1,"reg-rail__row",null,V,{on:r(R),dim:S().customCount===0}),re(q,"aria-current",r(R)?"true":void 0),y(ae,r(k)),y(te,S().total)}),Y("click",q,()=>{E(d,S().id,!0)}),f(L,q)},a=b(()=>e.player?.uuid),i=X(null),o=X(null),d=X(null),p=X(!0),u=X(!0),$=X("");ge(()=>{if(r(a),!r(a))return;let L=!0;return E(i,null),E(o,null),(async()=>{try{let S=await je("/players/"+r(a)+"/registries");if(!L)return;E(i,S.registries,!0)}catch(S){if(!L)return;E(o,S.message||"failed to load registries",!0)}})(),()=>{L=!1}});let g=b(()=>r(i)?r(i).map(L=>{let S=0;for(let k of L.entries)k.vanilla||S++;let R=L.entries.length;return{...L,total:R,customCount:S,vanillaCount:R-S,customRatio:R===0?0:S/R}}):[]),v=b(()=>r(g).filter(L=>L.customCount>0).sort((L,S)=>S.customCount-L.customCount||L.id.localeCompare(S.id))),m=b(()=>r(g).filter(L=>L.customCount===0).sort((L,S)=>S.total-L.total||L.id.localeCompare(S.id))),h=b(()=>{let L=r(g).length,S=0,R=0,k=0,M=0;for(let V of r(g))S+=V.total,R+=V.customCount,k+=V.vanillaCount,V.customCount>0&&M++;let q=S===0?0:R/S;return{regs:L,entries:S,custom:R,vanilla:k,customRegs:M,ratio:q}});ge(()=>{r(d)===null&&(r(v).length>0?E(d,r(v)[0].id,!0):r(g).length>0&&E(d,r(g)[0].id,!0))});let x=b(()=>r(g).find(L=>L.id===r(d))||null),w=b(()=>{if(!r(x))return[];let L=r($).trim().toLowerCase(),S=r(x).entries.slice();return r(p)&&(S=S.filter(R=>!R.vanilla)),L&&(S=S.filter(R=>R.id.toLowerCase().includes(L))),S.sort((R,k)=>R.vanilla!==k.vanilla?R.vanilla?1:-1:R.id.localeCompare(k.id)),S}),C=b(()=>r(w).length),A=b(()=>r(x)?r(x).total-r(C):0);var I=Vy(),P=l(I);{var D=L=>{et(L,{title:"Registries",meta:"error",children:(S,R)=>{var k=Ey(),M=l(k),q=l(M,!0);s(M),s(k),T(()=>y(q,r(o))),f(S,k)},$$slots:{default:!0}})},N=L=>{et(L,{title:"Registries",meta:"loading",children:(S,R)=>{var k=Sy();f(S,k)},$$slots:{default:!0}})},F=L=>{var S=Uy(),R=ie(S),k=l(R),M=l(k),q=l(M,!0);s(M),me(2),s(k);var V=c(k,2),H=l(V),G=l(H,!0);s(H),me(2),s(V);var O=c(V,2),j=l(O),z=c(l(j),1,!0);s(j),me(2),s(O);var W=c(O,2),Z=l(W),ee=l(Z,!0);s(Z),me(2),s(W);var ae=c(W,2);La(ae,{get value(){return r(h).ratio},class:"reg-horizon__spectrum",children:(Le,ze)=>{var Ae=Ty(),Se=ie(Ae),Fe=l(Se);s(Se);var Ne=c(Se,2),Ue=l(Ne);s(Ne),T(mt=>{y(Fe,`${mt??""}%`),y(Ue,`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=c(R,2),Q=l(J),U=l(Q),K=c(l(U),4),te=l(K,!0);s(K),s(U);var se=c(U,2);{var pe=Le=>{var ze=Cy();f(Le,ze)},$e=Le=>{var ze=Ay();de(ze,21,()=>r(v),Ae=>Ae.id,(Ae,Se)=>{n(Ae,()=>r(Se))}),s(ze),f(Le,ze)};B(se,Le=>{r(v).length===0?Le(pe):Le($e,-1)})}var ve=c(se,2),he=l(ve),be=c(l(he),4),xe=l(be,!0);s(be);var Be=c(be,2),Re=l(Be,!0);s(Be),s(he),s(ve);var Oe=c(ve,2);{var De=Le=>{var ze=My();de(ze,21,()=>r(m),Ae=>Ae.id,(Ae,Se)=>{n(Ae,()=>r(Se))}),s(ze),f(Le,ze)},it=Le=>{var ze=Py(),Ae=l(ze);me(2),s(ze),T(()=>y(Ae,`${r(m).length??""} vanilla-only registries hidden \u2014 click `)),f(Le,ze)};B(Oe,Le=>{r(u)?Le(it,-1):Le(De)})}s(Q);var Je=c(Q,2),we=l(Je);{var Qe=Le=>{var ze=Ry();f(Le,ze)},Ye=Le=>{let ze=b(()=>r(x));var Ae=jy(),Se=ie(Ae),Fe=l(Se),Ne=l(Fe),Ue=l(Ne);{var mt=ot=>{var Pt=Ny(),Ot=ie(Pt),Ht=l(Ot,!0);s(Ot),me(),T(tr=>y(Ht,tr),[()=>os(r(ze).id)]),f(ot,Pt)},Ve=b(()=>os(r(ze).id)&&os(r(ze).id)!=="minecraft"),Ie=ot=>{var Pt=Ly(),Ot=ie(Pt),Ht=l(Ot,!0);s(Ot),me(),T(tr=>y(Ht,tr),[()=>os(r(ze).id)||"minecraft"]),f(ot,Pt)};B(Ue,ot=>{r(Ve)?ot(mt):ot(Ie,-1)})}var We=c(Ue,2),$t=l(We,!0);s(We),s(Ne);var Ee=c(Ne,2),Ge=c(l(Ee),2);dr(Ge,{kind:"on",dot:!0,children:(ot,Pt)=>{me();var Ot=bt("client registry");f(ot,Ot)},$$slots:{default:!0}});var Ke=c(Ge,4),st=l(Ke),St=l(st,!0);s(st);var qe=c(st,4),pt=l(qe,!0);s(qe),me(2),s(Ke),s(Ee),s(Fe);var ft=c(Fe,2),ht=l(ft),at=l(ht),dt=c(at,2);s(ht);var Me=c(ht,2);wt(Me),s(ft),s(Se);var He=c(Se,2),Xe=l(He);let ct;s(He);var Et=c(He,2);{var It=ot=>{var Pt=Fy(),Ot=l(Pt);{var Ht=Ft=>{var Wt=Iy();me(2),f(Ft,Wt)},tr=Ft=>{var Wt=Oy(),fr=ie(Wt),Lt=l(fr);s(fr),me(2),T(()=>y(Lt,`No entries match \u201C${r($)??""}\u201D.`)),f(Ft,Wt)},Vt=Ft=>{var Wt=Dy();f(Ft,Wt)};B(Ot,Ft=>{r(p)&&r(ze).customCount===0?Ft(Ht):r($)?Ft(tr,1):Ft(Vt,-1)})}s(Pt),f(ot,Pt)},qt=ot=>{var Pt=Hy(),Ot=ie(Pt);de(Ot,23,()=>r(w),Vt=>Vt.id,(Vt,Ft,Wt)=>{hy(Vt,()=>r(Ft),()=>r(Wt))}),s(Ot);var Ht=c(Ot,2);{var tr=Vt=>{var Ft=qy(),Wt=l(Ft),fr=l(Wt,!0);s(Wt);var Lt=c(Wt,2),hr=l(Lt);s(Lt);var wr=c(Lt,2);{var Br=Gr=>{var rr=By();Y("click",rr,()=>E(p,!1)),f(Gr,rr)},zr=Gr=>{var rr=zy();Y("click",rr,()=>E($,"")),f(Gr,rr)};B(wr,Gr=>{r(p)?Gr(Br):r($)&&Gr(zr,1)})}s(Ft),T(()=>{y(fr,r(A)),y(hr,`${r(p)?"vanilla":"filtered"} ${r(A)===1?"entry":"entries"} hidden`)}),f(Vt,Ft)};B(Ht,Vt=>{r(A)>0&&Vt(tr)})}f(ot,Pt)};B(Et,ot=>{r(C)===0?ot(It):ot(qt,-1)})}T((ot,Pt)=>{y($t,ot),y(St,r(ze).customCount),y(pt,r(ze).total),ue(at,1,Tt(r(p)?"primary sm":"ghost sm")),ue(dt,1,Tt(r(p)?"ghost sm":"primary sm")),Dt(Me,r($)),ct=ke(Xe,"",ct,Pt)},[()=>Gd(r(ze).id),()=>({"--pct":(r(ze).customRatio*100).toFixed(3)+"%"})]),Y("click",at,()=>E(p,!0)),Y("click",dt,()=>E(p,!1)),Y("input",Me,ot=>E($,ot.target.value,!0)),f(Le,Ae)};B(we,Le=>{r(x)?Le(Ye,-1):Le(Qe)})}s(Je),s(J),T((Le,ze,Ae)=>{y(q,r(h).regs),y(G,Le),y(z,ze),y(ee,Ae),y(te,r(v).length),re(he,"aria-expanded",!r(u)),y(xe,r(m).length),y(Re,r(u)?"\u25B8":"\u25BE")},[()=>r(h).entries.toLocaleString(),()=>r(h).custom.toLocaleString(),()=>r(h).vanilla.toLocaleString()]),Y("click",he,()=>E(u,!r(u))),f(L,S)};B(P,L=>{r(o)?L(D):r(i)===null?L(N,1):L(F,-1)})}s(I),f(t,I),ce()}Pe(["click","input"]);var am=(t,e=At,n=At,a=At)=>{var i=Zy(),o=ie(i),d=l(o,!0);s(o);var p=c(o,2),u=l(p),$=l(u),g=l($,!0);s($);var v=c($,2);{var m=I=>{var P=Wy(),D=l(P);s(P),T(N=>y(D,`+${N??""}`),[()=>mn(a())]),f(I,P)};B(v,I=>{a()!=null&&I(m)})}var h=c(v,2);{var x=I=>{var P=Ky(),D=l(P);s(P),T(()=>y(D,`#${e().packetSeq??""}`)),f(I,P)};B(h,I=>{e().packetSeq>0&&I(x)})}s(u);var w=c(u,2);de(w,17,()=>im(e().data),([I,P])=>I,(I,P)=>{var D=b(()=>ur(r(P),2));let N=()=>r(D)[0],F=()=>r(D)[1];var L=Xy(),S=l(L),R=l(S,!0);s(S);var k=c(S,2),M=l(k,!0);s(k),s(L),T(()=>{y(R,N()),y(M,F())}),f(I,L)}),s(p);var C=c(p,2),A=l(C,!0);s(C),T((I,P)=>{y(d,n().glyph),y(g,n().label),re(C,"title",I),y(A,P)},[()=>new Date(e().ts).toISOString(),()=>new Date(e().ts).toLocaleTimeString("en-GB",{hour12:!1})]),f(t,i)},Gy={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 im(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 d=e?`${e}.${i}`:i;a.push(...im(o,d,n+1))}return a}return[[e||"\xB7",String(t)]]}var Wy=_(' '),Ky=_(' '),Xy=_('
          '),Zy=_('
          ',1),Jy=_('
          '),Qy=_('
          No lifecycle events captured yet.
          '),ew=_(''),tw=_('
          '),rw=_('
        1. '),nw=_('
            ');function Kd(t,e){le(e,!0);let n=b(()=>e.player?.uuid),a=X(tt([])),i=X(null);ge(()=>{if(!r(n))return;let p=!0;return je(`/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}}),en(()=>r(n)?Zo(r(n)):null,p=>{!p||p.seq==null||r(a).some(u=>u.seq===p.seq)||E(a,[...r(a),p],!0)});let o=b(()=>r(a)[0]?.ts??null),d=b(()=>r(a)[r(a).length-1]?.ts??null);{let p=b(()=>r(a).length===0?"\u2014":`${r(a).length} events${r(o)&&r(d)?` \xB7 ${mn(r(d)-r(o))} span`:""}`);et(t,{title:"Connection lifecycle",get meta(){return r(p)},flush:!0,children:(u,$)=>{var g=Ce(),v=ie(g);{var m=w=>{var C=Jy(),A=l(C);s(C),T(()=>y(A,`Error \xB7 ${r(i)??""}`)),f(w,C)},h=w=>{var C=Qy();f(w,C)},x=w=>{var C=nw();de(C,23,()=>r(a),A=>A.seq,(A,I,P)=>{let D=b(()=>Gy[r(I).kind]||{label:r(I).kind,glyph:"\xB7",accent:"var(--ink-3)"}),N=b(()=>r(P)===0?null:r(I).ts-r(a)[r(P)-1].ts);var F=rw(),L=l(F);{var S=k=>{var M=ew();let q;var V=l(M);am(V,()=>r(I),()=>r(D),()=>r(N)),s(M),T(()=>q=ke(M,"",q,{"--phase":r(D).accent})),Y("click",M,()=>Xa(`/p/${r(n)}/packets?seq=${r(I).packetSeq}`)),f(k,M)},R=k=>{var M=tw();let q;var V=l(M);am(V,()=>r(I),()=>r(D),()=>r(N)),s(M),T(()=>q=ke(M,"",q,{"--phase":r(D).accent})),f(k,M)};B(L,k=>{r(I).packetSeq>0?k(S):k(R,-1)})}s(F),f(A,F)}),s(C),f(w,C)};B(v,w=>{r(i)?w(m):r(a).length===0?w(h,1):w(x,-1)})}f(u,g)},$$slots:{default:!0}})}ce()}Pe(["click"]);function sm(t){let e=tt({data:null});return t&&je(t).then(n=>{e.data=n}).catch(()=>{}),e}var or={onMatch:"onMatch",onUnmatch:"onUnmatch",onPacket:"onPacket",interval:"interval"},kt={inject:"inject",chat:"chat",setCustom:"setCustom",move:"move",sequence:"sequence",ref:"ref"};function Jn(t){return t?.type===kt.ref}function ls(t){return t?.id??""}var aw={[or.onMatch]:"\u2295",[or.onUnmatch]:"\u2296",[or.onPacket]:"\u25C7",[or.interval]:"\u25F4"};function om(t){return aw[t?.type??""]??"\xB7"}function Xd(t){return t?.type?t.type===or.interval?`every ${fa(t.millis??0)}`:t.type===or.onPacket?`on ${va(t.packet)||"(unset)"}`:t.type:or.onMatch}var iw={[kt.inject]:"\u25C7",[kt.chat]:"#",[kt.setCustom]:"\u2699",[kt.move]:"\u2192",[kt.sequence]:"\u21B3"};function lm(t){return iw[t?.type??""]??"\xB7"}var sw={client:"\u25C0 client",server:"\u25B6 server"},cm={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]=je(n).catch(()=>[])}return Zd[e]}var ow=_('
            ');function to(t,e){le(e,!0);let n=ne(e,"value",3,""),a=ne(e,"placeholder",3,"ClientChatMessagePacket"),i=ne(e,"analyzable",3,!1),o,d,p=[];ge(()=>(d=new ns("ps-pop",(h,x,w)=>` +
          1. + ${Ar(h.simple)} + + ${Ar(sw[h.side]||h.side)} + ${Ar(h.state.toLowerCase())} + +
          2. `,g),d.mount(),()=>d.destroy())),ge(()=>{Jd(i()).then(h=>{p=h})});function u(){if(!o)return;let h=o.value.trim().toLowerCase(),x=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||(cm[w.p.state]??9)-(cm[C.p.state]??9)||w.p.simple.localeCompare(C.p.simple)).map(w=>w.p);if(x.length===0){d?.hide();return}d.setItems(x),$(),d.show()}function $(){if(!d||!o)return;let h=o.closest("dialog")||document.body;d.ensureParent(h);let x=o.getBoundingClientRect();d.position(x.left,x.bottom+2,x.width)}function g(h){let x=d?.items[h];x&&(e.onChange?.(x.simple),d.hide())}var v=ow(),m=l(v);wt(m),Ct(m,h=>o=h,()=>o),s(v),T(()=>{Dt(m,n()),re(m,"placeholder",a())}),Y("input",m,h=>{e.onChange?.(h.target.value),u()}),Mt("focus",m,u),Y("click",m,u),Mt("blur",m,()=>setTimeout(()=>d?.hide(),100)),Y("keydown",m,h=>d?.handleKey(h)),f(t,v),ce()}Pe(["input","click","keydown"]);var lw=new Set(["byte","short","int","long","float","double","char","string","uuid"]),cw=new Set(["record","list","map","item","component"]);function Qd(t){return lw.has(t)}function dm(t){return cw.has(t)}function pm(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 Da(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]=Da(n);return e}return t.kind==="list"?[]:t.kind==="map"?{}:t.kind==="item"?{id:"minecraft:stone",count:1}:t.kind==="component"?{text:""}:""}var Pl=new Map;function um(t){return Pl.has(t)||Pl.set(t,je("/packet/describe/"+encodeURIComponent(t)).catch(e=>{throw Pl.delete(t),e})),Pl.get(t)}var dw=_(''),pw=_('
            MATERIAL
            ');function ep(t,e){le(e,!0);let n=X(tt([])),a=X(""),i=X(null);ge(()=>{fm().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()),d=b(()=>r(a).toLowerCase().trim()),p=b(()=>r(d)?r(n).filter(A=>A.includes(r(d))):r(n)),u=b(()=>Hs(e.value));var $=pw();let g;var v=l($),m=c(l(v),2);wt(m),s(v);var h=c(v,2);de(h,20,()=>r(p),A=>A,(A,I)=>{let P=b(()=>Hs(I));var D=dw(),N=l(D);re(N,"draggable",!1),s(D),T(()=>{ue(D,1,`mat-grid__cell ${r(P)===r(u)?"is-on":""}`),re(D,"title",r(P)),re(N,"src",`/api/material-icon/${r(P)}`)}),Y("click",D,()=>{e.onPick(I),e.onClose()}),f(A,D)}),s(h);var x=c(h,2),w=l(x);s(x);var C=c(x,2);wt(C),s($),Ct($,A=>E(i,A),()=>r(i)),T(()=>{g=ke($,"",g,{left:`${o.left??""}px`,top:`${o.top??""}px`}),Dt(m,r(a)),y(w,`${r(p).length??""} of ${r(n).length??""}`),Dt(C,e.value)}),Y("input",m,A=>E(a,A.currentTarget.value,!0)),Y("input",C,A=>e.onPick(A.currentTarget.value)),f(t,$),ce()}Pe(["input","click"]);var vm="mn.packet.library.v1",uw={items:[],components:[],records:[]};function fw(){try{let t=localStorage.getItem(vm);if(t){let e=JSON.parse(t);return{items:e.items??[],components:e.components??[],records:e.records??[]}}}catch{}return structuredClone(uw)}var tp=class{#e=X(tt(fw()));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(vm,JSON.stringify(this.state))}catch(e){vt("Library save failed: "+(e.message||"storage error"),"error",4e3)}}},ro=new tp;function Rl(t){return t==="item"?"items":t==="component"?"components":t==="record"?"records":null}var vw="application/x-mn-lib-",mm=t=>vw+t;function rp(t,e){return!!t&&t.types.includes(mm(e))}function $m(t,e){if(!rp(t,e))return null;try{let n=t.getData(mm(e));return n?JSON.parse(n):null}catch{return null}}function Qa(t,e){let n=tt({over:!1}),a=0,i=()=>typeof t=="function"?t():t;function o(d){let p=i();return!p||!rp(d.dataTransfer,p)?!1:(d.preventDefault(),d.dataTransfer&&(d.dataTransfer.dropEffect="copy"),!0)}return{get over(){return n.over},handlers:{ondragenter:d=>{o(d)&&(a+=1,n.over=!0)},ondragover:d=>{o(d)},ondragleave:()=>{a=Math.max(0,a-1),a===0&&(n.over=!1)},ondrop:d=>{let p=i();if(!p)return;let u=$m(d.dataTransfer,p);u!=null&&(d.preventDefault(),d.stopPropagation(),a=0,n.over=!1,e(u))}}}}var mw=["black","dark_blue","dark_green","dark_aqua","dark_red","dark_purple","gold","gray","dark_gray","blue","green","aqua","red","light_purple","yellow","white"],_m={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"},$w=["bold","italic","underlined","strikethrough","obfuscated"],_w=_(' '),gw=_(''),hw=_(''),bw=_('
            '),xw=_('
            text component
            text color
            style
            extra
            ');function Ti(t,e){le(e,!0);let n=ne(e,"embedded",3,!1),a=b(()=>e.value&&typeof e.value=="object"?e.value:{}),i=b(()=>typeof r(a).text=="string"?r(a).text:""),o=b(()=>typeof r(a).color=="string"?r(a).color:""),d=b(()=>Array.isArray(r(a).extra)?r(a).extra:[]);function p(M,q){let V={...r(a)};q==null||q===""||q===!1?delete V[M]:V[M]=q,e.onChange(V)}function u(M,q){let V=[...r(d)];V[M]=q,p("extra",V)}function $(M){let q=r(d).filter((V,H)=>H!==M);p("extra",q.length?q:null)}function g(){p("extra",[...r(d),{text:""}])}let v=Qa("components",M=>e.onChange(M));var m=xw();Ta(m,()=>({class:`cb-builder builder ${v.over?"drop-over":""}`,role:"region",...v.handlers}));var h=l(m),x=c(l(h),2);{var w=M=>{var q=_w(),V=l(q);s(q),T(()=>y(V,`+${r(d).length??""} extra`)),f(M,q)};B(x,M=>{r(d).length>0&&M(w)})}s(h);var C=c(h,2),A=l(C),I=c(l(A),2);Yr(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=c(I,4),D=l(P),N=c(D,2);de(N,16,()=>mw,M=>M,(M,q)=>{var V=gw();let H;T(()=>{ue(V,1,`cb-swatch ${r(o)===q?"is-on":""}`),re(V,"title",q),re(V,"aria-label",q),H=ke(V,"",H,{background:_m[q]})}),Y("click",V,()=>p("color",q)),f(M,V)});var F=c(N,2);wt(F),s(P);var L=c(P,4);de(L,20,()=>$w,M=>M,(M,q)=>{var V=hw(),H=l(V),G=l(H,!0);s(H);var O=c(H,2),j=l(O,!0);s(O),s(V),T(z=>{ue(V,1,`cb-deco__chip ${r(a)[q]===!0?"is-on":""}`),ue(H,1,`cb-deco__chip-glyph cb-deco__chip-glyph--${q??""}`),y(G,z),y(j,q)},[()=>q[0].toUpperCase()]),Y("click",V,()=>p(q,r(a)[q]!==!0)),f(M,V)}),s(L);var S=c(L,4),R=l(S);de(R,17,()=>r(d),lt,(M,q,V)=>{var H=bw(),G=l(H);G.textContent=V;var O=c(G,2),j=l(O);Ti(j,{get value(){return r(q)},onChange:W=>u(V,W),embedded:!0}),s(O);var z=c(O,2);s(H),Y("click",z,()=>$(V)),f(M,H)});var k=c(R,2);s(S),s(A),s(C),s(m),T(()=>{ue(D,1,`cb-swatch cb-swatch--none ${r(o)?"":"is-on"}`),Dt(F,r(o)&&!_m[r(o)]?r(o):"")}),Y("click",D,()=>p("color",null)),Y("input",F,M=>p("color",M.currentTarget.value||null)),Y("click",k,g),f(t,m),ce()}Pe(["click","input"]);var yw=_('
            '),ww=_(" "),kw=_('
            '),Ew=_('
            Empty list. Add an entry below.
            '),Sw=_('
            '),Tw=_('
            '),Cw=_('
            \u22EE\u22EE
            '),Aw=_('
            ',1),Mw=_('
            ');function no(t,e){le(e,!0);let n=b(()=>Array.isArray(e.value)?e.value:[]),a=X(!0),i=X(null),o=b(()=>r(i)??e.element.kind==="record"),d=Qa(()=>Rl(e.element.kind),G=>e.onChange([...r(n),G]));function p(){e.onChange([...r(n),Da(e.element)])}function u(){e.onChange([])}function $(){r(n).length!==0&&e.onChange([...r(n),structuredClone(r(n)[r(n).length-1])])}function g(G,O){e.onChange(r(n).map((j,z)=>z===G?O:j))}function v(G){e.onChange(r(n).filter((O,j)=>j!==G))}let m=b(()=>e.element.kind==="record"&&e.element.components?e.element.components:[]);var h=Mw();Ta(h,()=>({class:`coll ${r(o)&&e.element.kind==="record"?"coll--table":""} ${d.over?"drop-over":""}`,role:"group",...d.handlers}));var x=l(h),w=l(x),C=l(w),A=l(C),I=l(A,!0);s(A);var P=c(A,2),D=c(l(P)),N=l(D,!0);s(D),me(),s(P);var F=c(P,2),L=l(F,!0);s(F),s(C),s(w);var S=c(w,2),R=l(S);{var k=G=>{var O=yw(),j=l(O),z=c(j,2);s(O),T(()=>{ue(j,1,Tt(r(o)?"":"is-on")),ue(z,1,Tt(r(o)?"is-on":""))}),Y("click",j,()=>E(i,!1)),Y("click",z,()=>E(i,!0)),f(G,O)};B(R,G=>{e.element.kind==="record"&&G(k)})}var M=c(R,2),q=c(M,2);s(S),s(x);var V=c(x,2);{var H=G=>{var O=Aw(),j=ie(O);{var z=U=>{var K=kw();let te;var se=c(l(K),2);de(se,21,()=>r(m),pe=>pe.name,(pe,$e)=>{var ve=ww(),he=l(ve,!0);s(ve),T(()=>y(he,r($e).name)),f(pe,ve)}),s(se),me(2),s(K),T(()=>te=ke(K,"",te,{"--cols":r(m).length})),f(U,K)};B(j,U=>{r(o)&&e.element.kind==="record"&&U(z)})}var W=c(j,2),Z=l(W);{var ee=U=>{var K=Ew();f(U,K)};B(Z,U=>{r(n).length===0&&U(ee)})}var ae=c(Z,2);de(ae,17,()=>r(n),lt,(U,K,te)=>{var se=Cw(),pe=c(l(se),2);{var $e=be=>{var xe=Sw();let Be;de(xe,21,()=>r(m),Re=>Re.name,(Re,Oe)=>{{let De=b(()=>r(K)?.[r(Oe).name]);Fa(Re,{get element(){return r(Oe)},get value(){return r(De)},onChange:it=>g(te,{...r(K)??{},[r(Oe).name]:it})})}}),s(xe),T(()=>Be=ke(xe,"",Be,{"--cols":r(m).length})),f(be,xe)},ve=be=>{var xe=Tw(),Be=l(xe);Fa(Be,{get element(){return e.element},get value(){return r(K)},onChange:Re=>g(te,Re)}),s(xe),f(be,xe)};B(pe,be=>{r(o)&&e.element.kind==="record"?be($e):be(ve,-1)})}var he=c(pe,2);s(se),Y("click",he,()=>v(te)),f(U,se)});var J=c(ae,2),Q=l(J);s(J),s(W),T(()=>y(Q,`+ add ${e.element.kind??""}`)),Y("click",J,p),f(G,O)};B(V,G=>{r(a)&&G(H)})}s(h),T(()=>{re(w,"aria-expanded",r(a)),y(I,r(a)?"\u25BE":"\u25B8"),y(N,e.element.kind),y(L,r(n).length)}),Y("click",w,()=>E(a,!r(a))),Y("click",M,$),Y("click",q,u),f(t,h),ce()}Pe(["click"]);var Nl=null;function fm(){return Nl||(Nl=je("/materials").then(t=>t.map(e=>"minecraft:"+e).sort()).catch(t=>{throw Nl=null,t})),Nl}var gm=[{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 Pw(t){switch(t.kind){case"enum":return t.values?.[0]??"";case"component":return{text:""};case"list-component":return[]}}var Rw=_(' '),Nw=_(''),Lw=_('?'),Iw=_(""),Ow=_(""),Dw=_(' '),Fw=_('
            '),Bw=_('
            '),zw=_(''),qw=_(''),Hw=_('
            item stack
            ',1);function np(t,e){le(e,!0);let n=b(()=>e.value&&typeof e.value=="object"?e.value:{}),a=b(()=>typeof r(n).id=="string"?r(n).id:""),i=b(()=>Hs(r(a))),o=b(()=>Number(r(n).count??1)),d=b(()=>r(n).components??{}),p=b(()=>Object.keys(r(d))),u=b(()=>r(i)?`/api/material-icon/${r(i)}`:""),$=X(null),g=X(!1),v=X(!1);function m(U){let K={...r(n),...U};e.onChange(K)}function h(U){return gm.find(K=>K.key===U)??null}function x(U,K){let te={...r(d)};K==null?delete te[U]:te[U]=K;let se={...r(n)};Object.keys(te).length===0?delete se.components:se.components=te,e.onChange(se)}function w(U){m({count:Math.max(1,Math.min(99,r(o)+U))})}let C=Qa("items",U=>e.onChange(U));var A=Hw(),I=ie(A);Ta(I,()=>({class:`ib-builder builder ${C.over?"drop-over":""}`,role:"region",...C.handlers}));var P=l(I),D=c(l(P),2);{var N=U=>{var K=Rw(),te=l(K);s(K),T(()=>y(te,`+${r(p).length??""} component${r(p).length===1?"":"s"}`)),f(U,K)};B(D,U=>{r(p).length>0&&U(N)})}s(P);var F=c(P,2),L=l(F),S=l(L),R=l(S);{var k=U=>{var K=Nw();re(K,"draggable",!1),T(()=>re(K,"src",r(u))),f(U,K)},M=U=>{var K=Lw();f(U,K)};B(R,U=>{r(u)?U(k):U(M,-1)})}s(S),Ct(S,U=>E($,U),()=>r($));var q=c(S,2);wt(q);var V=c(q,2),H=l(V),G=c(H,2);wt(G);var O=c(G,2);s(V),s(L);var j=c(L,2);{var z=U=>{var K=Bw();de(K,20,()=>r(p),te=>te,(te,se)=>{let pe=b(()=>h(se));var $e=Fw(),ve=l($e),he=l(ve,!0);s(ve);var be=c(ve,2),xe=l(be);{var Be=Je=>{var we=Ow();de(we,20,()=>r(pe).values,Ye=>Ye,(Ye,Le)=>{var ze=Iw(),Ae=l(ze,!0);s(ze);var Se={};T(()=>{y(Ae,Le),Se!==(Se=Le)&&(ze.value=(ze.__value=Le)??"")}),f(Ye,ze)}),s(we);var Qe;Zn(we),T(Ye=>{Qe!==(Qe=Ye)&&(we.value=(we.__value=Ye)??"",En(we,Ye))},[()=>String(r(d)[se]??r(pe).values[0])]),Y("change",we,Ye=>x(se,Ye.currentTarget.value)),f(Je,we)},Re=Je=>{{let we=b(()=>r(d)[se]??null);Ti(Je,{get value(){return r(we)},onChange:Qe=>x(se,Qe)})}},Oe=Je=>{{let we=b(()=>r(d)[se]??[]);no(Je,{get value(){return r(we)},onChange:Qe=>x(se,Qe),element:{name:"line",kind:"component"}})}},De=Je=>{var we=Dw(),Qe=l(we,!0);s(we),T(Ye=>y(Qe,Ye),[()=>JSON.stringify(r(d)[se])]),f(Je,we)};B(xe,Je=>{r(pe)?.kind==="enum"&&r(pe).values?Je(Be):r(pe)?.kind==="component"?Je(Re,1):r(pe)?.kind==="list-component"?Je(Oe,2):Je(De,-1)})}s(be);var it=c(be,2);s($e),T(()=>y(he,r(pe)?.label??se)),Y("click",it,()=>x(se,null)),f(te,$e)}),s(K),f(U,K)};B(j,U=>{r(p).length>0&&U(z)})}var W=c(j,2),Z=l(W),ee=c(Z,2);{var ae=U=>{var K=qw();de(K,21,()=>gm,te=>te.key,(te,se)=>{let pe=b(()=>r(p).includes(r(se).key));var $e=zw(),ve=l($e),he=l(ve,!0);s(ve);var be=c(ve,2),xe=l(be,!0);s(be),s($e),T(()=>{$e.disabled=r(pe),y(he,r(se).label),y(xe,r(se).kind)}),Y("click",$e,()=>{x(r(se).key,Pw(r(se))),E(v,!1)}),f(te,$e)}),s(K),f(U,K)};B(ee,U=>{r(v)&&U(ae)})}s(W),s(F),s(I);var J=c(I,2);{var Q=U=>{ep(U,{get value(){return r(a)},get anchor(){return r($)},onPick:K=>m({id:K}),onClose:()=>E(g,!1)})};B(J,U=>{r(g)&&r($)&&U(Q)})}T(()=>{Dt(q,r(a)),Dt(G,r(o))}),Y("click",S,()=>E(g,!r(g))),Y("input",q,U=>m({id:U.currentTarget.value})),Y("click",H,()=>w(-1)),Y("input",G,U=>m({count:Math.max(1,Math.min(99,Number(U.currentTarget.value)||1))})),Y("click",O,()=>w(1)),Y("click",Z,()=>E(v,!r(v))),f(t,A),ce()}Pe(["click","input","change"]);var jw=_('
            Empty map. Add an entry below.
            '),Uw=_('
            \u2192
            '),Vw=_('
            '),Yw=_('
            ');function ap(t,e){le(e,!0);let n=b(()=>Object.entries(e.value??{})),a=X(!0);function i(M,q){if(r(n).some(([H],G)=>G!==M&&H===q))return;let V={};r(n).forEach(([H,G],O)=>{V[O===M?q:H]=G}),e.onChange(V)}function o(M,q){let V={};r(n).forEach(([H,G],O)=>{V[H]=O===M?q:G}),e.onChange(V)}function d(M){let q={};r(n).forEach(([V,H],G)=>{G!==M&&(q[V]=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,V=`key${q}`;for(;V in(e.value??{});)q+=1,V=`key${q}`;return V}function u(){e.onChange({...e.value??{},[p()]:Da(e.valueField)})}function $(){e.onChange({})}var g=Yw(),v=l(g),m=l(v),h=l(m),x=l(h),w=l(x,!0);s(x);var C=c(x,2),A=c(l(C)),I=l(A,!0);s(A);var P=c(A,2),D=l(P,!0);s(P),me(),s(C);var N=c(C,2),F=l(N,!0);s(N),s(h),s(m);var L=c(m,2),S=l(L);s(L),s(v);var R=c(v,2);{var k=M=>{var q=Vw(),V=l(q);{var H=j=>{var z=jw();f(j,z)};B(V,j=>{r(n).length===0&&j(H)})}var G=c(V,2);de(G,17,()=>r(n),lt,(j,z,W)=>{var Z=b(()=>ur(r(z),2));let ee=()=>r(Z)[0],ae=()=>r(Z)[1];var J=Uw(),Q=l(J),U=l(Q);Fa(U,{get element(){return e.keyField},get value(){return ee()},onChange:pe=>i(W,String(pe))}),s(Q);var K=c(Q,4),te=l(K);Fa(te,{get element(){return e.valueField},get value(){return ae()},onChange:pe=>o(W,pe)}),s(K);var se=c(K,2);s(J),Y("click",se,()=>d(W)),f(j,J)});var O=c(G,2);s(q),Y("click",O,u),f(M,q)};B(R,M=>{r(a)&&M(k)})}s(g),T(()=>{re(m,"aria-expanded",r(a)),y(w,r(a)?"\u25BE":"\u25B8"),y(I,e.keyField.kind),y(D,e.valueField.kind),y(F,r(n).length)}),Y("click",m,()=>E(a,!r(a))),Y("click",S,$),f(t,g),ce()}Pe(["click"]);var Gw=_("
            ");function ip(t,e){le(e,!0);let n=Qa("records",i=>e.onChange(i));var a=Gw();Ta(a,()=>({class:`record-block ${n.over?"drop-over":""}`,role:"group",...n.handlers})),de(a,21,()=>e.components,i=>i.name,(i,o)=>{{let d=b(()=>e.value?.[r(o).name]);ao(i,{get field(){return r(o)},get value(){return r(d)},onChange:p=>e.onChange({...e.value??{},[r(o).name]:p})})}}),s(a),f(t,a),ce()}var Ww=_(''),Kw=_(""),Xw=_("");function Fa(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=Ce(),i=ie(a);{var o=x=>{var w=Ww(),C=l(w);wt(C),s(w),T(()=>ua(C,!!e.value)),Y("change",C,A=>e.onChange(A.currentTarget.checked)),f(x,w)},d=x=>{var w=Xw();de(w,20,()=>e.element.values??[],A=>A,(A,I)=>{var P=Kw(),D=l(P,!0);s(P);var N={};T(()=>{y(D,I),N!==(N=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)??"",En(w,A))},[()=>String(e.value??e.element.values?.[0]??"")]),Y("change",w,A=>e.onChange(A.currentTarget.value)),f(x,w)},p=x=>{{let w=b(()=>String(e.value??"")),C=b(n);Yr(x,{language:"expression",get value(){return r(w)},get onChange(){return e.onChange},rows:1,get placeholder(){return r(C)}})}},u=b(()=>Qd(e.element.kind)),$=x=>{{let w=b(()=>e.value??null);np(x,{get value(){return r(w)},get onChange(){return e.onChange}})}},g=x=>{{let w=b(()=>e.value??null);Ti(x,{get value(){return r(w)},get onChange(){return e.onChange}})}},v=x=>{{let w=b(()=>e.value??{});ip(x,{get components(){return e.element.components},get value(){return r(w)},get onChange(){return e.onChange}})}},m=x=>{{let w=b(()=>e.value??[]);no(x,{get value(){return r(w)},get onChange(){return e.onChange},get element(){return e.element.element}})}},h=x=>{{let w=b(()=>e.value??{});ap(x,{get value(){return r(w)},get onChange(){return e.onChange},get keyField(){return e.element.key},get valueField(){return e.element.value}})}};B(i,x=>{e.element.kind==="boolean"?x(o):e.element.kind==="enum"?x(d,1):r(u)?x(p,2):e.element.kind==="item"?x($,3):e.element.kind==="component"?x(g,4):e.element.kind==="record"&&e.element.components?x(v,5):e.element.kind==="list"&&e.element.element?x(m,6):e.element.kind==="map"&&e.element.key&&e.element.value&&x(h,7)})}f(t,a),ce()}Pe(["change"]);var Zw=_('
            '),Jw=_('
            '),Qw=_('');function sp(t,e){le(e,!0);let n=b(()=>ro.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 d=Qw();let p;var u=l(d),$=l(u);s(u);var g=c(u,2);{var v=h=>{var x=Zw(),w=l(x);s(x),T(()=>y(w,`No saved ${e.bucket??""} yet. Hit \u2606 on a row to save one.`)),f(h,x)},m=h=>{var x=Ce(),w=ie(x);de(w,17,()=>r(n),lt,(C,A,I)=>{var P=Jw(),D=l(P),N=l(D,!0);s(D);var F=c(D,2);s(P),T(()=>y(N,r(A).name)),Y("click",D,()=>o(r(A).value)),Y("click",F,()=>ro.remove(e.bucket,I)),f(C,P)}),f(h,x)};B(g,h=>{r(n).length===0?h(v):h(m,-1)})}s(d),Ct(d,h=>E(a,h),()=>r(a)),T(h=>{p=ke(d,"",p,{left:`${i.left??""}px`,top:`${i.top??""}px`}),y($,`SAVED ${h??""}`)},[()=>e.bucket.toUpperCase()]),f(t,d),ce()}Pe(["click"]);var ek=_(' ',1),tk=_('
            ',1);function ao(t,e){le(e,!0);let n=b(()=>e.field.name),a=b(()=>pm(e.field)),i=b(()=>dm(e.field.kind)),o=b(()=>Rl(e.field.kind)??void 0),d=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){vt("Nothing to save \u2014 fill in the field first.","warn");return}let S=prompt(`Save ${r(o).replace(/s$/,"")} as\u2026`,r(n));S&&(ro.save(r(o),{name:S,value:structuredClone(e.value)}),vt(`Saved to ${r(o)} library`,"ok"))}var $=tk(),g=ie($),v=l(g),m=l(v),h=l(m,!0);s(m);var x=c(m,2),w=l(x,!0);s(x),s(v);var C=c(v,2),A=l(C);Fa(A,{get element(){return e.field},get value(){return e.value},get onChange(){return e.onChange}}),s(C);var I=c(C,2),P=l(I);{var D=S=>{var R=ek(),k=ie(R),M=c(k,2);Ct(M,q=>E(d,q),()=>r(d)),T(()=>{re(k,"title",`Save to ${r(o)??""} library`),ue(M,1,`pkt-field__tool ${r(p)?"is-on":""}`),re(M,"title",`Recall ${r(o)??""} from library`)}),Y("click",k,u),Y("click",M,()=>E(p,!r(p))),f(S,R)};B(P,S=>{r(o)&&S(D)})}var N=c(P,2);s(I),s(g);var F=c(g,2);{var L=S=>{sp(S,{get bucket(){return r(o)},get anchor(){return r(d)},onPick:R=>e.onChange(R),onClose:()=>E(p,!1)})};B(F,S=>{r(p)&&r(o)&&r(d)&&S(L)})}T(()=>{ue(g,1,`pkt-field ${r(i)?"pkt-field--col":""}`),y(h,r(n)),y(w,r(a))}),Y("click",N,()=>e.onChange(Da(e.field))),f(t,$),ce()}Pe(["click"]);var rk=_('
            Select a packet to edit its fields.
            '),nk=_('
            Loading packet schema\u2026
            '),ak=_('
            Failed to describe
            '),ik=_('
            is not in the analyzable packet catalog. Pick a different packet, or fix the name.
            '),sk=_(`
            is not analyzable. It contains components this editor can't break down.
            `),ok=_('
            No fields \u2014 this packet has no components.
            '),lk=_('
            ');function op(t,e){le(e,!0);let n=ne(e,"fields",19,()=>({})),a=X(null),i=X(!1),o=X(null),d="",p=0,u=X(null);ge(()=>{Jd(!0).then(L=>E(u,L,!0)).catch(()=>E(u,[],!0))});let $=b(()=>r(u)?new Set(r(u).map(L=>L.simple)):null);ge(()=>{if(e.components!==void 0){E(a,null);return}let L=(e.packet||"").trim();if(!L){E(a,null),E(o,null),d="";return}if(!r($))return;if(!r($).has(L)){E(a,null),E(o,null),d="";return}if(L===d)return;d=L;let S=++p;E(i,!0),E(o,null),um(L).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),d="")})});function g(L){if(!L?.analyzable||!L.components)return;let S={},R=Object.keys(n()).length!==L.components.length;for(let k of L.components)k.name in n()?S[k.name]=n()[k.name]:(S[k.name]=Da(k),R=!0);R&&e.onChange(S)}let v=b(()=>e.components??r(a)?.components??null),m=b(()=>e.components===void 0&&!!e.packet&&!!r($)&&!r($).has(e.packet.trim()));function h(L,S){e.onChange({...n(),[L]:S})}var x=Ce(),w=ie(x);{var C=L=>{var S=rk();f(L,S)},A=L=>{var S=nk();f(L,S)},I=L=>{var S=ak(),R=c(l(S)),k=l(R,!0);s(R);var M=c(R);s(S),T(()=>{y(k,e.packet),y(M,`: ${r(o)??""}`)}),f(L,S)},P=L=>{var S=ik(),R=l(S),k=l(R,!0);s(R),me(),s(S),T(()=>y(k,e.packet)),f(L,S)},D=L=>{var S=sk(),R=l(S),k=l(R,!0);s(R),me(),s(S),T(()=>y(k,e.packet)),f(L,S)},N=L=>{var S=ok();f(L,S)},F=L=>{var S=lk();de(S,21,()=>r(v),R=>R.name,(R,k)=>{ao(R,{get field(){return r(k)},get value(){return n()[r(k).name]},onChange:M=>h(r(k).name,M)})}),s(S),f(L,S)};B(w,L=>{e.components===void 0&&!e.packet?L(C):r(i)?L(A,1):r(o)?L(I,2):r(m)?L(P,3):e.components===void 0&&r(a)&&!r(a).analyzable?L(D,4):r(v)&&r(v).length===0?L(N,5):r(v)&&L(F,6)})}f(t,x),ce()}var lp=[{id:kt.inject,label:"Inject",detail:"Inject a packet \u2014 direction is derived from the selected packet."},{id:kt.chat,label:"Chat",detail:"Send a system chat message \u2014 body is an expression evaluated per player."},{id:kt.setCustom,label:"Set custom",detail:"Set a custom key on the player state \u2014 value is an expression."},{id:kt.move,label:"Move",detail:'Transfer the player to another server \u2014 the address expression evaluates to "host", "host:port", or "[ipv6]:port".'},{id:kt.sequence,label:"Sequence",detail:"Run multiple actions in order."}],ck=new Set(lp.map(t=>t.id)),cp={[kt.inject]:()=>({type:kt.inject,packet:"",fields:{}}),[kt.chat]:()=>({type:kt.chat,component:""}),[kt.setCustom]:()=>({type:kt.setCustom,key:"",value:""}),[kt.move]:()=>({type:kt.move,address:""}),[kt.sequence]:()=>({type:kt.sequence,actions:[]})};function dk(t){let e=ck.has(t?.type)?t.type:kt.chat;return{...cp[e](),...t,type:e}}function pk(t){let e=t.type;return e===kt.inject?{type:kt.inject,packet:String(t.packet??"").trim(),fields:t.fields||{}}:e===kt.chat?{type:kt.chat,component:t.component??""}:e===kt.setCustom?{type:kt.setCustom,key:t.key||"",value:t.value||""}:e===kt.move?{type:kt.move,address:String(t.address??"")}:e===kt.sequence?{type:kt.sequence,actions:t.actions||[]}:{type:kt.chat}}function cs(t){if(!t)return"(none)";let e=(n,a=40)=>(n||"").slice(0,a);return{[kt.inject]:()=>`inject: ${t.packet||"?"}`,[kt.chat]:()=>typeof t.component=="object"?"chat: [component]":`chat: ${e(String(t.component??""))}`,[kt.setCustom]:()=>`set ${t.key||"?"} = ${t.value||'""'}`,[kt.move]:()=>`move \u2192 ${e(String(t.address??"?"))}`,[kt.sequence]:()=>`sequence (${(t.actions||[]).length} actions)`}[t.type]?.()??String(t.type??"(unknown)")}var uk=_(''),fk=_('
            Fields
            ',1),vk=_(''),mk=_(' ',1),$k=_(''),_k=_('
            '),gk=_('
            Actions run in order.
            ',1),hk=_('
            ');function Ci(t,e){le(e,!0);let n="act-"+Math.random().toString(36).slice(2,9),a=b(()=>dk(e.value)),i=b(()=>lp.find(S=>S.id===r(a).type));function o(S){e.onChange?.(pk(S))}function d(){let S=X(null),R=Za(async k=>{if(!k.trim()){E(S,null);return}try{await je("/expression/compile",{method:"POST",body:{src:k}}),E(S,{kind:"ok",message:"OK"},!0)}catch(M){E(S,Na(M),!0)}},220);return{get status(){return r(S)},validate:k=>R(k)}}let p=d(),u=d(),$=d();ge(()=>{if(r(a).type===kt.chat){let S=r(a).component;typeof S=="string"&&p.validate(S)}r(a).type===kt.setCustom&&u.validate(String(r(a).value||"")),r(a).type===kt.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[kt.chat]()]})}function m(S){let R=[...r(a).actions||[]];R.splice(S,1),o({...r(a),actions:R})}var h=hk(),x=l(h);de(x,21,()=>lp,S=>S.id,(S,R)=>{var k=uk(),M=l(k);wt(M);var q=c(M,2),V=l(q,!0);s(q),s(k),T(()=>{re(M,"name",n),Dt(M,r(R).id),ua(M,r(a).type===r(R).id),y(V,r(R).label)}),Y("change",M,()=>r(a).type===r(R).id?null:o(cp[r(R).id]())),f(S,k)}),s(x);var w=c(x,2),C=l(w,!0);s(w);var A=c(w,2),I=l(A);{var P=S=>{var R=fk(),k=ie(R),M=c(l(k),2);{let H=b(()=>String(r(a).packet||""));to(M,{get value(){return r(H)},onChange:G=>o({...r(a),packet:G,fields:{}}),analyzable:!0})}s(k);var q=c(k,2),V=c(l(q),2);{let H=b(()=>String(r(a).packet||"")),G=b(()=>r(a).fields||{});op(V,{get packet(){return r(H)},get fields(){return r(G)},onChange:O=>o({...r(a),fields:O})})}s(q),f(S,R)},D=S=>{let R=b(()=>r(a).component!=null&&typeof r(a).component=="object");var k=vk(),M=l(k),q=l(M,!0);s(M);var V=c(M,2);{let H=b(()=>r(R)?"json":"expression"),G=b(()=>r(R)?JSON.stringify(r(a).component,null,2):String(r(a).component??"")),O=b(()=>r(R)?6:2),j=b(()=>r(R)?'{"text":"hello"}':'"Hello " + name + "!"'),z=b(()=>r(R)?null:p.status);Yr(V,{get language(){return r(H)},get value(){return r(G)},onChange:W=>{if(r(R)||W.trimStart().startsWith("{"))try{o({...r(a),component:JSON.parse(W)});return}catch{}o({...r(a),component:W})},get rows(){return r(O)},get placeholder(){return r(j)},get status(){return r(z)}})}s(k),T(()=>y(q,r(R)?"Message (component)":"Message (expression)")),f(S,k)},N=S=>{var R=mk(),k=ie(R),M=c(l(k),2);wt(M),s(k);var q=c(k,2),V=c(l(q),2);{let H=b(()=>String(r(a).value||""));Yr(V,{language:"expression",get value(){return r(H)},onChange:G=>o({...r(a),value:G}),rows:2,placeholder:"health + food",get status(){return u.status}})}s(q),T(H=>Dt(M,H),[()=>String(r(a).key||"")]),Y("change",M,H=>o({...r(a),key:H.currentTarget.value})),f(S,R)},F=S=>{var R=$k(),k=c(l(R),2);{let M=b(()=>String(r(a).address||""));Yr(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)},L=S=>{var R=gk(),k=c(ie(R),2);de(k,17,()=>r(a).actions||[],lt,(q,V,H)=>{var G=_k(),O=l(G),j=l(O);j.textContent=`Step ${H+1}`;var z=c(j,2);s(O);var W=c(O,2);Ci(W,{get value(){return r(V)},onChange:Z=>g(H,Z)}),s(G),Y("click",z,()=>m(H)),f(q,G)});var M=c(k,2);Y("click",M,v),f(S,R)};B(I,S=>{r(a).type===kt.inject?S(P):r(a).type===kt.chat?S(D,1):r(a).type===kt.setCustom?S(N,2):r(a).type===kt.move?S(F,3):r(a).type===kt.sequence&&S(L,4)})}s(A),s(h),T(()=>y(C,r(i)?.detail||"")),f(t,h),ce()}Pe(["change","click"]);var bk=_('
            No registered actions. Create one.
            '),xk=_(""),yk=_(""),wk=_('
            ');function Ai(t,e){le(e,!0);let n="asel-"+Math.random().toString(36).slice(2,9),a=sm("/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 d(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=wk(),$=l(u),g=l($),v=l(g);wt(v),me(2),s(g);var m=c(g,2),h=l(m);wt(h),me(2),s(m),s($);var x=c($,2),w=l(x);{var C=P=>{{let D=b(()=>Jn(e.value)?null:e.value);Ci(P,{get value(){return r(D)},onChange:p})}},A=P=>{var D=bk();f(P,D)},I=P=>{var D=yk(),N=l(D);N.value=N.__value="";var F=c(N);de(F,17,()=>a.data,S=>S.id,(S,R)=>{var k=xk(),M=l(k);s(k);var q={};T(V=>{y(M,`${r(R).name??""} \u2014 ${V??""}`),q!==(q=r(R).id)&&(k.value=(k.__value=r(R).id)??"")},[()=>cs(r(R).action)]),f(S,k)}),s(D);var L;Zn(D),T(S=>{L!==(L=S)&&(D.value=(D.__value=S)??"",En(D,S))},[()=>ls(e.value)]),Y("change",D,S=>{let R=S.currentTarget.value;e.onChange?.(R?{type:kt.ref,id:R}:null)}),f(P,D)};B(w,P=>{r(o)==="inline"?P(C):(a.data?.length??0)===0?P(A,1):P(I,-1)})}s(x),s(u),T(()=>{re(v,"name",n),ua(v,r(o)==="inline"),re(h,"name",n),ua(h,r(o)==="registered")}),Y("change",v,()=>d("inline")),Y("change",h,()=>d("registered")),f(t,u),ce()}Pe(["change"]);var kk=_(''),Ek=_('
             
            '),Sk=_(" ",1);function dp(t,e){le(e,!0);let n=X(null),a=X(null);async function i(){if(!r(n)){vt("No action defined","error");return}try{let o=await je("/trigger",{method:"POST",body:{query:`name = "${e.p.username}"`,action:r(n)}});E(a,JSON.stringify(o,null,2),!0),vt(`Action fired on ${o.fired}/${o.matched}`)}catch(o){vt("Failed: "+o.message,"error")}}{let o=p=>{var u=kk();Y("click",u,i),f(p,u)},d=b(()=>`on ${e.p.username||"this player"}`);et(t,{title:"Run action",get meta(){return r(d)},actions:o,children:(p,u)=>{var $=Sk(),g=ie($);Ai(g,{get value(){return r(n)},onChange:h=>E(n,h,!0)});var v=c(g,2);{var m=h=>{var x=Ek(),w=l(x,!0);s(x),T(()=>y(w,r(a))),f(h,x)};B(v,h=>{r(a)&&h(m)})}f(p,$)},$$slots:{actions:!0,default:!0}})}ce()}Pe(["click"]);var Tk=_('
            ');function Ll(t,e){le(e,!0);let n;ge(()=>{e.dependency,n&&(n.scrollTop=n.scrollHeight)});var a=Tk(),i=l(a);er(i,()=>e.children??At),s(a),Ct(a,o=>n=o,()=>n),f(t,a),ce()}function Ck(t){return t==null?"":t<1e3?"prov--hot":t>6e4?"prov--stale":""}var Ak=_(' '),Mk=_(' '),Pk=_(''),Rk=_("");function io(t,e){le(e,!0);let n=I=>{var P=Mk(),D=l(P);{var N=R=>{var k=Ce(),M=ie(k);er(M,()=>e.children),f(R,k)},F=R=>{var k=bt();T(()=>y(k,e.value)),f(R,k)};B(D,R=>{e.children?R(N):R(F,-1)})}var L=c(D,2);{var S=R=>{var k=Ak(),M=l(k,!0);s(k),T(()=>y(M,e.suffix)),f(R,k)};B(L,R=>{e.suffix&&R(S)})}s(P),f(I,P)},a=ne(e,"variant",3,""),i=wo(yl),o=wo(wl),d=b(()=>Qr.now),p=b(()=>e.source?.ts?r(d)-e.source.ts:null),u=b(()=>!!e.field&&!!i),$=b(()=>r(u)&&o?.()===e.field),g=b(()=>["prov",a()&&`prov--${a()}`,Ck(r(p)),r($)&&"is-open",!r(u)&&"prov--static"].filter(Boolean).join(" "));function v(I){r(u)&&(I.preventDefault(),Ia.hide(),i?.(e.field,I.currentTarget))}function m(I){!r(u)||r($)||I.currentTarget.closest('[data-traces="off"]')||Ia.show(I.currentTarget,{field:e.field,source:e.source??null})}function h(){Ia.hide()}var x=Ce(),w=ie(x);{var C=I=>{var P=Pk(),D=l(P);n(D),s(P),T(()=>{ue(P,1,Tt(r(g))),re(P,"data-prov-field",e.field||void 0)}),Y("click",P,v),Mt("pointerenter",P,m),Mt("pointerleave",P,h),Mt("focus",P,m),Mt("blur",P,h),f(I,P)},A=I=>{var P=Rk(),D=l(P);n(D),s(P),T(()=>{ue(P,1,Tt(r(g))),re(P,"data-prov-field",e.field||void 0)}),f(I,P)};B(w,I=>{r(u)?I(C):I(A,-1)})}f(t,x),ce()}Pe(["click"]);var Nk=_(' '),Lk=_(' no source yet');function Gt(t,e){le(e,!0);let n=ne(e,"suffix",3,null),a=ne(e,"variant",3,"tight");var i=Ce(),o=ie(i);{var d=$=>{var g=Lk(),v=l(g),m=l(v,!0),h=c(m);{var x=w=>{var C=Nk(),A=l(C,!0);s(C),T(()=>y(A,n())),f(w,C)};B(h,w=>{n()&&w(x)})}s(v),me(2),s(g),T(()=>y(m,e.value)),f($,g)},p=b(()=>!Us(e.p,e.field)),u=$=>{{let g=b(()=>Us(e.p,e.field));io($,{get value(){return e.value},get source(){return r(g)},get field(){return e.field},get suffix(){return n()},get variant(){return a()}})}};B(o,$=>{r(p)?$(d):$(u,-1)})}f(t,i),ce()}var Ik=_('
            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=Ik(),o=c(l(i),2),d=l(o);{let N=b(()=>e.p.uuid||"\u2014");Gt(d,{get p(){return e.p},field:"uuid",get value(){return r(N)}})}s(o);var p=c(o,4),u=l(p);{let N=b(()=>e.p.locale||"\u2014");Gt(u,{get p(){return e.p},field:"locale",get value(){return r(N)}})}s(p);var $=c(p,4),g=l($);{let N=b(()=>e.p.clientBrand||"\u2014");Gt(g,{get p(){return e.p},field:"clientBrand",get value(){return r(N)}})}s($);var v=c($,4),m=l(v);{let N=b(()=>e.p.serverBrand||"\u2014");Gt(m,{get p(){return e.p},field:"serverBrand",get value(){return r(N)}})}s(v);var h=c(v,4),x=l(h),w=l(x),C=l(w,!0);s(w),me(2),s(x),s(h);var A=c(h,4),I=l(A);{let N=b(()=>String(e.p.protocolVersion||"\u2014"));Gt(I,{get p(){return e.p},field:"protocolVersion",get value(){return r(N)}})}s(A);var P=c(A,4),D=l(P);{let N=b(()=>String(e.p.traffic.compressionThreshold));Gt(D,{get p(){return e.p},field:"traffic.compressionThreshold",get value(){return r(N)},suffix:"bytes"})}s(P),s(i),T(()=>y(C,e.p.address||"\u2014")),f(n,i)},$$slots:{default:!0}}),ce()}var Ok=(t,e=At,n=At,a=At)=>{var i=Ce(),o=ie(i);de(o,17,()=>Array(Math.max(1,Math.ceil(n()/2))),lt,(d,p,u)=>{let $=b(()=>e()-u*2),g=b(()=>a()?{empty:xi.hcEmpty,full:xi.hcFull,half:xi.hcHalf}:{empty:xi.empty,full:xi.full,half:xi.half}),v=b(()=>r($)>=2?r(g).full:r($)>=1?r(g).half:null);var m=Ce(),h=ie(m);{var x=C=>{var A=Fk(),I=l(A);re(I,"draggable",!1);var P=c(I);re(P,"draggable",!1),s(A),T(()=>{re(I,"src",r(g).empty),re(P,"src",r(v))}),f(C,A)},w=C=>{var A=Bk();re(A,"draggable",!1),T(()=>re(A,"src",r(g).empty)),f(C,A)};B(h,C=>{r(v)?C(x):C(w,-1)})}f(d,m)}),f(t,i)},Dk=(t,e=At)=>{var n=Ce(),a=ie(n);de(a,16,()=>Array(10),lt,(i,o,d)=>{let p=b(()=>e()-d*2),u=b(()=>r(p)>=2?js.full:r(p)>=1?js.half:null);var $=Ce(),g=ie($);{var v=h=>{var x=zk(),w=l(x);re(w,"draggable",!1);var C=c(w);re(C,"draggable",!1),s(x),T(()=>{re(w,"src",js.empty),re(C,"src",r(u))}),f(h,x)},m=h=>{var x=qk();re(x,"draggable",!1),T(()=>re(x,"src",js.empty)),f(h,x)};B(g,h=>{r(u)?h(v):h(m,-1)})}f(i,$)}),f(t,n)},Fk=_(''),Bk=_(''),zk=_(''),qk=_(''),Hk=_('
            HP
            Food
            XP \xB7 Lvl
            ',1);function up(t,e){le(e,!0),et(t,{title:"Vitals",children:(n,a)=>{var i=Hk(),o=ie(i),d=c(l(o),2),p=l(d);{let G=b(()=>(e.p.health??0).toFixed(1)),O=b(()=>`/${(e.p.maxHealth??20).toFixed(0)}`);Gt(p,{get p(){return e.p},field:"health",get value(){return r(G)},get suffix(){return r(O)}})}s(d);var u=c(d,2),$=l(u);Ok($,()=>e.p.health||0,()=>e.p.maxHealth||20,()=>e.p.hardcore),s(u),s(o);var g=c(o,2),v=c(l(g),2),m=l(v);{let G=b(()=>String(e.p.food??0)),O=b(()=>`/20 \xB7 sat ${(e.p.saturation??0).toFixed(1)}`);Gt(m,{get p(){return e.p},field:"food",get value(){return r(G)},get suffix(){return r(O)}})}s(v);var h=c(v,2),x=l(h);Dk(x,()=>e.p.food||0),s(h),s(g);var w=c(g,2),C=l(w),A=c(l(C));{let G=b(()=>String(e.p.xpLevel??0));Gt(A,{get p(){return e.p},field:"xpLevel",get value(){return r(G)}})}s(C);var I=c(C,2),P=l(I);{let G=b(()=>Math.round((e.p.xpBar||0)*100)+"%");Gt(P,{get p(){return e.p},field:"xpBar",get value(){return r(G)}})}s(I);var D=c(I,2);{let G=b(()=>e.p.xpBar??0);La(D,{get value(){return r(G)},class:"progress-bar--gauge"})}s(w);var N=c(w,2),F=l(N);{var L=G=>{dr(G,{kind:"on",children:(O,j)=>{me();var z=bt("flying");f(O,z)},$$slots:{default:!0}})};B(F,G=>{e.p.flying&&G(L)})}var S=c(F,2);{var R=G=>{dr(G,{kind:"on",children:(O,j)=>{me();var z=bt("invuln");f(O,z)},$$slots:{default:!0}})};B(S,G=>{e.p.invulnerable&&G(R)})}var k=c(S,2);{var M=G=>{dr(G,{children:(O,j)=>{me();var z=bt("may fly");f(O,z)},$$slots:{default:!0}})};B(k,G=>{e.p.allowFlying&&G(M)})}var q=c(k,2);{var V=G=>{dr(G,{children:(O,j)=>{me();var z=bt("grounded");f(O,z)},$$slots:{default:!0}})},H=G=>{dr(G,{children:(O,j)=>{me();var z=bt("airborne");f(O,z)},$$slots:{default:!0}})};B(q,G=>{e.p.onGround?G(V):G(H,-1)})}s(N),f(n,i)},$$slots:{default:!0}}),ce()}var jk=_('
            Flying
            Invulnerable
            Allow flying
            Fly speed
            Walk speed
            ');function fp(t,e){le(e,!0),et(t,{title:"Abilities",children:(n,a)=>{var i=jk(),o=c(l(i),2),d=l(o);{let w=b(()=>String(!!e.p.flying));Gt(d,{get p(){return e.p},field:"flying",get value(){return r(w)}})}s(o);var p=c(o,4),u=l(p);{let w=b(()=>String(!!e.p.invulnerable));Gt(u,{get p(){return e.p},field:"invulnerable",get value(){return r(w)}})}s(p);var $=c(p,4),g=l($);{let w=b(()=>String(!!e.p.allowFlying));Gt(g,{get p(){return e.p},field:"allowFlying",get value(){return r(w)}})}s($);var v=c($,4),m=l(v);{let w=b(()=>((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=c(v,4),x=l(h);{let w=b(()=>((e.p.walkSpeed??0)*1).toFixed(3));Gt(x,{get p(){return e.p},field:"walkSpeed",get value(){return r(w)}})}s(h),s(i),f(n,i)},$$slots:{default:!0}}),ce()}var Uk=_('
            No active effects.
            '),Vk=_(''),Yk=_('
            '),Gk=_(' '),Wk=_('
            '),Kk=_('
            ');function vp(t,e){le(e,!0);var n=Ce(),a=ie(n);{var i=p=>{et(p,{title:"Effects",meta:"none",children:(u,$)=>{var g=Uk();f(u,g)},$$slots:{default:!0}})},o=b(()=>Object.values(e.p.activeEffects||{}).length===0),d=p=>{{let u=b(()=>`${Object.values(e.p.activeEffects).length} active`);et(p,{title:"Effects",get meta(){return r(u)},children:($,g)=>{var v=Kk();de(v,21,()=>Object.values(e.p.activeEffects),lt,(m,h)=>{let x=b(()=>av(r(h).id)),w=b(()=>Math.round((r(h).durationTicks||0)/20)),C=b(()=>r(w)>9999?"\u221E":Kf(r(w))),A=b(()=>r(h).amplifier?Wf(r(h).amplifier+1):"");var I=Wk(),P=l(I);{var D=k=>{var M=Vk();re(M,"draggable",!1),T(()=>{re(M,"src",r(x)),re(M,"alt",r(h).id)}),f(k,M)},N=k=>{var M=Yk(),q=l(M,!0);s(M),T(V=>y(q,V),[()=>(r(h).id||"").replace(/^minecraft:/,"").slice(0,3)]),f(k,M)};B(P,k=>{r(x)?k(D):k(N,-1)})}var F=c(P,2);{var L=k=>{var M=Gk(),q=l(M,!0);s(M),T(()=>y(q,r(A))),f(k,M)};B(F,k=>{r(A)&&k(L)})}var S=c(F,2),R=l(S,!0);s(S),s(I),T(k=>{re(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}})}};B(a,p=>{r(o)?p(i):p(d,-1)})}f(t,n),ce()}var Xk=_('
            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=b(()=>(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=Xk(),d=c(l(o),2),p=l(d);{let F=b(()=>(e.p.posX??0).toFixed(2));Gt(p,{get p(){return e.p},field:"posX",get value(){return r(F)}})}s(d);var u=c(d,4),$=l(u);{let F=b(()=>(e.p.posY??0).toFixed(2));Gt($,{get p(){return e.p},field:"posY",get value(){return r(F)}})}s(u);var g=c(u,4),v=l(g);{let F=b(()=>(e.p.posZ??0).toFixed(2));Gt(v,{get p(){return e.p},field:"posZ",get value(){return r(F)}})}s(g);var m=c(g,4),h=l(m);{let F=b(()=>(e.p.yaw??0).toFixed(1)+"\xB0");Gt(h,{get p(){return e.p},field:"yaw",get value(){return r(F)}})}s(m);var x=c(m,4),w=l(x);{let F=b(()=>(e.p.pitch??0).toFixed(1)+"\xB0");Gt(w,{get p(){return e.p},field:"pitch",get value(){return r(F)}})}s(x);var C=c(x,4),A=l(C);{let F=b(()=>String(!!e.p.onGround));Gt(A,{get p(){return e.p},field:"onGround",get value(){return r(F)}})}s(C);var I=c(C,4),P=l(I,!0);s(I);var D=c(I,4),N=l(D,!0);s(D),s(o),T((F,L)=>{y(P,F),y(N,L)},[()=>zt(e.p.traffic.bytesIn),()=>zt(e.p.traffic.bytesOut)]),f(a,o)},$$slots:{meta:!0,default:!0}}),ce()}var Zk=_(' '),Jk=_("
            ");function $p(t,e){le(e,!0);let n=ne(e,"className",3,""),a=ne(e,"withTimestamp",3,!0);var i=Jk(),o=l(i);{var d=u=>{var $=Zk(),g=l($,!0);s($),T(v=>y(g,v),[()=>Ln(e.ts).slice(0,8)]),f(u,$)};B(o,u=>{a()&&e.ts!=null&&u(d)})}var p=c(o,2);dn(p,{get value(){return e.value}}),s(i),T(u=>ue(i,1,u),[()=>Tt(("chat-line "+n()).trim())]),f(t,i),ce()}var Qk=_("HUD theater",1),e0=_('
            '),t0=_('
            '),r0=_(''),n0=_(''),a0=_(' '),i0=_('
            '),s0=_('
            '),o0=_('
            '),l0=_('
            ');function _p(t,e){le(e,!0),et(t,{meta:"live mirror",className:"hud-panel",flush:!0,title:a=>{me();var i=Qk();me(),f(a,i)},children:(a,i)=>{var o=l0(),d=l(o);{var p=w=>{var C=t0();de(C,23,()=>Object.entries(e.p.bossBars||{}).filter(([,A])=>A!=null),([A,I])=>A,(A,I)=>{var P=b(()=>ur(r(I),2));let D=()=>r(P)[0],N=()=>r(P)[1];var F=e0(),L=l(F),S=l(L);dn(S,{get value(){return N().title}}),s(L);var R=c(L,2);{let k=b(()=>N().progress??0);La(R,{variant:"boss",get value(){return r(k)},get color(){return N().color}})}s(F),f(A,F)}),s(C),f(w,C)},u=b(()=>Object.keys(e.p.bossBars||{}).length>0);B(d,w=>{r(u)&&w(p)})}var $=c(d,2);{var g=w=>{var C=s0(),A=l(C),I=l(A);{let D=b(()=>e.p.scoreboard.displayName||e.p.scoreboard.objectiveName||"\u2014");dn(I,{get value(){return r(D)}})}s(A);var P=c(A,2);de(P,17,()=>tv(e.p.scoreboard.rows),D=>D.key,(D,N)=>{var F=i0(),L=l(F),S=l(L);{let V=b(()=>r(N).display??r(N).key);dn(S,{get value(){return r(V)}})}s(L);var R=c(L,2);{var k=V=>{var H=r0(),G=l(H);dn(G,{get value(){return r(N).numberFormat.content}}),s(H),f(V,H)},M=V=>{var H=n0();f(V,H)},q=V=>{var H=a0(),G=l(H,!0);s(H),T(()=>y(G,r(N).score)),f(V,H)};B(R,V=>{r(N).numberFormat?.format==="FIXED"?V(k):r(N).numberFormat?.format==="BLANK"?V(M,1):V(q,-1)})}s(F),f(D,F)}),s(C),f(w,C)};B($,w=>{e.p.scoreboard&&w(g)})}var v=c($,2);{var m=w=>{var C=o0(),A=l(C);dn(A,{get value(){return e.p.lastActionBar}}),s(C),f(w,C)};B(v,w=>{e.p.lastActionBar!=null&&w(m)})}var h=c(v,2),x=l(h);de(x,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(x),s(h),s(o),f(a,o)},$$slots:{title:!0,default:!0}}),ce()}var hm="/assets/textures/entity/player/wide/steve.png",In={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 bm(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 c0(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,d,p,u,$)=>{n.save(),n.translate(u+d,$),n.scale(-1,1),n.drawImage(e,i,o,d,p,0,0,d,p),n.restore()};return a(44,20,4,12,36,52),a(4,20,4,12,20,52),e}function d0(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 p0(t,e){if(!t)return;let n=d0(e)||hm,a=await bm(n).catch(()=>bm(hm).catch(()=>null));if(!a)return;let i=a.naturalWidth===64&&a.naturalHeight===32?c0(a):a,o=document.createElement("canvas");o.width=16,o.height=32;let d=o.getContext("2d");d.imageSmoothingEnabled=!1;let p=([w,C,A,I],P,D)=>d.drawImage(i,w,C,A,I,P,D,A,I);p(In.headBase,4,0),p(In.headOverlay,4,0),p(In.rArm,0,8),p(In.rArmOverlay,0,8),p(In.body,4,8),p(In.bodyOverlay,4,8),p(In.lArm,12,8),p(In.lArmOverlay,12,8),p(In.rLeg,4,20),p(In.rLegOverlay,4,20),p(In.lLeg,8,20),p(In.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,x=o.height*m;v.drawImage(o,(t.width-h)/2,(t.height-x)/2,h,x)}var u0=_("");function gp(t,e){le(e,!0);let n=ne(e,"className",3,""),a=ne(e,"style",3,""),i;ge(()=>{p0(i,e.profileProperties).catch(()=>{})});var o=u0();Ct(o,d=>i=d,()=>i),T(()=>{ue(o,1,Tt(n())),ke(o,a())}),f(t,o),ce()}var xm=(t,e=At)=>{var n=Ce(),a=ie(n);{var i=o=>{var d=Ce(),p=ie(d);Tc(p,e,u=>{var $=v0();f(u,$)}),f(o,d)};B(a,o=>{e()&&o(i)})}f(t,n)},f0=_(''),v0=_(''),m0=_("
            "),$0=_(' '),_0=_(''),g0=_('
            '),h0=_('
            awaiting first Window-Items packet\u2026
            '),b0=_('
            '),x0=_('
            Open container
            ',1),y0=_('
            '),w0=_('
            '),k0=_('
            '),E0=_('
            ');function hp(t,e){le(e,!0);let n=(U,K=At)=>{let te=b(()=>g(K()));var se=Ce(),pe=ie(se);{var $e=ve=>{var he=f0();re(he,"draggable",!1),T(()=>re(he,"src",`/api/material-icon/${r(te)}`)),Mt("error",he,be=>{be.target.replaceWith(Object.assign(document.createElement("span"),{className:"mc-icon-fallback",textContent:r(te).slice(0,3)}))}),xc(he),f(ve,he)};B(pe,ve=>{r(te)&&ve($e)})}f(U,se)},a=(U,K=At,te=At,se=At,pe)=>{let $e=Ea(()=>ql(pe?.(),"")),ve=b(()=>P(te(),se()));var he=Ce(),be=ie(he);{var xe=Re=>{var Oe=m0(),De=l(Oe);xm(De,()=>r(ve)),s(Oe),T(()=>{ue(Oe,1,`mc-slot ${r($e)??""}`),re(Oe,"data-kind",te()),re(Oe,"data-idx",se())}),f(Re,Oe)},Be=Re=>{let Oe=b(()=>m(K()));var De=g0();let it;var Je=l(De);n(Je,()=>K().id);var we=c(Je,2);{var Qe=Ae=>{var Se=$0(),Fe=l(Se,!0);s(Se),T(()=>y(Fe,K().count)),f(Ae,Se)};B(we,Ae=>{K().count>1&&Ae(Qe)})}var Ye=c(we,2);{var Le=Ae=>{var Se=_0();let Fe;T(Ne=>Fe=ke(Se,"",Fe,Ne),[()=>({"--dur":`${(r(Oe)*100).toFixed(0)}%`,"--dur-color":h(r(Oe))})]),f(Ae,Se)};B(Ye,Ae=>{r(Oe)!=null&&Ae(Le)})}var ze=c(Ye,2);xm(ze,()=>r(ve)),s(De),T(Ae=>{it=ue(De,1,`mc-slot has-item ${r($e)??""}`,null,it,Ae),re(De,"data-kind",te()),re(De,"data-idx",se())},[()=>({enchanted:v(K())})]),Mt("mouseenter",De,Ae=>C(K(),Ae)),Y("mousemove",De,Ae=>C(K(),Ae)),Mt("mouseleave",De,A),Mt("click",De,Ae=>cl(Ae,K(),"Item JSON copied"),!0),f(Re,De)};B(be,Re=>{!K()||!K().id?Re(xe):Re(Be,-1)})}f(U,he)},i=ne(e,"armor",19,()=>[]),o=ne(e,"main",19,()=>[]),d=ne(e,"hotbar",19,()=>[]),p=ne(e,"selectedHotbar",3,0),u=ne(e,"openedWindow",3,null),$=ne(e,"recentClicks",19,()=>[]),g=U=>String(U||"").replace(/^minecraft:/,""),v=U=>{let K=U?.components;return!!(K&&(K.enchantments||K["minecraft:enchantments"]||K.stored_enchantments))};function m(U){let K=U?.components,te=K?.damage??K?.["minecraft:damage"],se=K?.max_damage??K?.["minecraft:max_damage"];return te==null||!se?null:Math.max(0,Math.min(1,1-te/se))}let h=U=>U>.66?"var(--acc)":U>.33?"var(--warn)":"var(--danger)";function x(U){let K=U.components||{},te=K.custom_name??K["minecraft:custom_name"]??K.item_name??K["minecraft:item_name"],se=K.lore||K["minecraft:lore"],pe=K.enchantments||K["minecraft:enchantments"]||K.stored_enchantments;return{id:U.id||"",title:te??Zi(U.id),count:U.count||1,lore:Array.isArray(se)?se:[],enchants:pe&&typeof pe=="object"?Object.entries(pe).map(([$e,ve])=>`${g($e)} ${ve}`):[]}}let w=X(null),C=(U,K)=>{if(K.altKey){E(w,null);return}E(w,{data:x(U),x:K.clientX+12,y:K.clientY+12},!0)},A=()=>{E(w,null)},I=X(null);ge(()=>{let U=$().at(-1);if(!U)return;let K=`${U.seq}:${U.ts}:${U.rawSlot}`;r(I)?.key!==K&&E(I,{kind:U.kind,idx:U.localSlot,key:K},!0)});let P=(U,K)=>r(I)&&r(I).kind===U&&r(I).idx===K?r(I).key:null;function D(U){return U<=0||U%9===0?9:U===5?5:U===3||U===10?3:Math.min(U,9)}var N=E0(),F=l(N),L=l(F);{var S=U=>{let K=b(()=>u().slots||[]),te=b(()=>D(r(K).length)),se=b(()=>Zi(u().type)||"window");var pe=x0(),$e=ie(pe),ve=l($e),he=c(l(ve),4),be=l(he);dn(be,{get value(){return u().title}}),s(he);var xe=c(he,2),Be=l(xe),Re=l(Be,!0);s(Be);var Oe=c(Be,2),De=l(Oe);s(Oe);var it=c(Oe,2),Je=l(it);s(it),s(xe),s(ve);var we=c(ve,2);{var Qe=Ae=>{var Se=h0();f(Ae,Se)},Ye=Ae=>{var Se=b0();let Fe;de(Se,21,()=>r(K),lt,(Ne,Ue,mt)=>{a(Ne,()=>r(Ue),()=>"container",()=>mt)}),s(Se),T(()=>Fe=ke(Se,"",Fe,{"--w":r(te)})),f(Ae,Se)};B(we,Ae=>{r(K).length===0?Ae(Qe):Ae(Ye,-1)})}s($e);var Le=c($e,2),ze=l(Le);s(Le),T(()=>{y(Re,r(se)),y(De,`${r(K).length??""} slot${r(K).length===1?"":"s"}`),y(Je,`id ${u().id??""}`),y(ze,`Player inventory \xB7 live mirror while ${r(se)??""} is open`)}),f(U,pe)};B(L,U=>{u()&&U(S)})}var R=c(L,2);let k;var M=l(R);de(M,20,()=>Array(4),lt,(U,K,te)=>{a(U,()=>i()[te],()=>"armor",()=>te)}),s(M);var q=c(M,2),V=l(q);gp(V,{get profileProperties(){return e.profileProperties}}),s(q);var H=c(q,2),G=l(H);a(G,()=>e.offHand,()=>"offhand",()=>0),s(H);var O=c(H,2);de(O,20,()=>Array(27),lt,(U,K,te)=>{a(U,()=>o()[te],()=>"main",()=>te)}),s(O);var j=c(O,2);de(j,20,()=>Array(9),lt,(U,K,te)=>{a(U,()=>d()[te],()=>"hotbar",()=>te,()=>te===p()?"selected":"")}),s(j),s(R),s(F);var z=c(F,2);let W;var Z=c(l(z),2),ee=l(Z);{var ae=U=>{a(U,()=>e.cursor,()=>"cursor",()=>0)};B(ee,U=>{e.cursor?.id&&U(ae)})}s(Z),s(z);var J=c(z,2);{var Q=U=>{var K=k0();let te;var se=l(K),pe=l(se);dn(pe,{get value(){return r(w).data.title}}),s(se);var $e=c(se,2);de($e,17,()=>r(w).data.lore,lt,(xe,Be)=>{var Re=y0(),Oe=l(Re);dn(Oe,{get value(){return r(Be)}}),s(Re),f(xe,Re)});var ve=c($e,2);de(ve,17,()=>r(w).data.enchants,lt,(xe,Be)=>{var Re=w0(),Oe=l(Re,!0);s(Re),T(()=>y(Oe,r(Be))),f(xe,Re)});var he=c(ve,2),be=l(he,!0);s(he),s(K),T(()=>{te=ke(K,"",te,{left:r(w).x+"px",top:r(w).y+"px"}),y(be,r(w).data.id)}),f(U,K)};B(J,U=>{r(w)?.data&&U(Q)})}s(N),T(()=>{k=ue(R,1,"mc-inv-stage",null,k,{"mc-inv-stage--ghosted":!!u()}),W=ue(z,1,"mc-cursor",null,W,{"mc-cursor--empty":!e.cursor?.id}),re(z,"aria-hidden",!e.cursor?.id)}),f(t,N),ce()}Pe(["mousemove"]);function Il(t,e){le(e,!0);let n=b(()=>e.p.openedWindow?(e.p.openedWindow.slots||[]).length:-1),a=b(()=>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 d=b(()=>e.p.armor||[]),p=b(()=>e.p.mainInventory||[]),u=b(()=>e.p.hotbar||[]),$=b(()=>e.p.recentClicks||[]);hp(i,{get armor(){return r(d)},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 S0=_('
            No attributes reported.
            '),T0=_(' no source'),C0=_(' '),A0=_('
            ');function bp(t,e){le(e,!0);var n=Ce(),a=ie(n);{var i=p=>{et(p,{title:"Attributes",meta:"none",children:(u,$)=>{var g=S0();f(u,g)},$$slots:{default:!0}})},o=b(()=>Object.entries(e.p.attributes||{}).length===0),d=p=>{{let u=b(()=>String(Object.entries(e.p.attributes).length));et(p,{title:"Attributes",get meta(){return r(u)},flush:!0,children:($,g)=>{var v=A0(),m=l(v);de(m,21,()=>Object.entries(e.p.attributes),([h,x])=>h,(h,x)=>{var w=b(()=>ur(r(x),2));let C=()=>r(w)[0],A=()=>r(w)[1],I=b(()=>"attributes."+C()),P=b(()=>Us(e.p,r(I)));var D=C0(),N=l(D),F=l(N,!0);s(N);var L=c(N),S=l(L);{var R=M=>{{let q=b(()=>Number(A()).toFixed(3));io(M,{get value(){return r(q)},get source(){return r(P)},get field(){return r(I)},variant:"tight"})}},k=M=>{var q=T0(),V=l(q),H=l(V),G=l(H,!0);s(H),s(V),me(2),s(q),T(O=>y(G,O),[()=>Number(A()).toFixed(3)]),f(M,q)};B(S,M=>{r(P)?M(R):M(k,-1)})}s(L),s(D),T(M=>y(F,M),[()=>C().replace(/^minecraft:/,"")]),f(h,D)}),s(m),s(v),f($,v)},$$slots:{default:!0}})}};B(a,p=>{r(o)?p(i):p(d,-1)})}f(t,n),ce()}function xp(t,e){le(e,!0),et(t,{title:"Latency",meta:a=>{{let i=b(()=>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=b(()=>({ping:e.p.traffic.pingHistory}));es(a,{get series(){return Qf},get data(){return r(o)},yLabel:"ms",yFormat:d=>Math.round(d)+"",gridX:5,gridY:3,showAxes:!0,showLegend:!1,className:"chart-sm"})}},$$slots:{meta:!0,default:!0}}),ce()}function ym(t){return Number.isInteger(t)?String(t):parseFloat(t.toFixed(6)).toString()}function wm(t){return Number.isInteger(t)?t>=-128&&t<=127?"Byte":t>=-32768&&t<=32767?"Short":t>=-2147483648&&t<=2147483647?"Int":"Long":"Float"}function M0(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 P0(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"?ym(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 R0(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=wm(n);else return null;if(e==null)e=a;else if(e!==a)return null}return e}var N0=_('
            '),L0=_(''),I0=_(' '),O0=_('
            '),D0=_('
            '),F0=_('[ ]'),B0=_(' '),z0=_('
            '),q0=_('
            '),H0=_('
            Unknown
            '),j0=_('
            ');function ei(t,e){le(e,!0);let n=A=>{var I=Ce(),P=ie(I);{var D=S=>{var R=N0(),k=l(R),M=l(k,!0);s(k);var q=c(k,2),V=l(q,!0);s(q);var H=c(q,2),G=l(H,!0);s(H),s(R),T(()=>{y(M,a()),ue(q,1,"nbt-row__value nbt-v--"+r(u).kind),y(V,r(u).text),y(G,r(u).type)}),f(S,R)},N=S=>{let R=b(()=>Object.entries(e.value));var k=D0(),M=l(k),q=l(M),V=l(q),H=l(V,!0);s(V);var G=c(V);s(q);var O=c(q,2),j=l(O);{var z=Q=>{var U=L0();U.textContent="{ }",f(Q,U)},W=Q=>{var U=I0(),K=l(U,!0);s(U),T(te=>y(K,te),[()=>M0(e.value)]),f(Q,U)};B(j,Q=>{r(R).length===0?Q(z):Q(W,-1)})}s(O);var Z=c(O,2),ee=l(Z);s(Z),s(M);var ae=c(M,2);{var J=Q=>{var U=O0();de(U,21,()=>r(R),([K,te])=>K,(K,te)=>{var se=b(()=>ur(r(te),2));let pe=()=>r(se)[0],$e=()=>r(se)[1];{let ve=b(()=>i()+1);ei(K,{get value(){return $e()},get name(){return pe()},get depth(){return r(ve)},root:!1,wrap:!1})}}),s(U),f(Q,U)};B(ae,Q=>{r(v)&&r(R).length>0&&Q(J)})}s(k),T(()=>{ue(M,1,"nbt-row nbt-row--toggle"+(r(v)?" is-open":"")),y(H,r(v)?"\u25BE":"\u25B8"),y(G,` ${a()??""}`),y(ee,`Object \xB7 ${r(R).length??""}`)}),Y("click",M,m),Y("keydown",M,Q=>{(Q.key==="Enter"||Q.key===" ")&&(Q.preventDefault(),m())}),f(S,k)},F=S=>{let R=b(()=>R0(e.value));var k=q0(),M=l(k),q=l(M),V=l(q),H=l(V,!0);s(V);var G=c(V);s(q);var O=c(q,2),j=l(O);{var z=Q=>{var U=F0();f(Q,U)},W=Q=>{var U=B0(),K=l(U,!0);s(U),T(te=>y(K,te),[()=>P0(e.value)]),f(Q,U)};B(j,Q=>{e.value.length===0?Q(z):Q(W,-1)})}s(O);var Z=c(O,2),ee=l(Z);s(Z),s(M);var ae=c(M,2);{var J=Q=>{var U=z0();de(U,21,()=>e.value,lt,(K,te,se)=>{{let pe=b(()=>i()+1);ei(K,{get value(){return r(te)},name:`[${se}]`,get depth(){return r(pe)},root:!1,wrap:!1})}}),s(U),f(Q,U)};B(ae,Q=>{r(v)&&e.value.length>0&&Q(J)})}s(k),T(()=>{ue(M,1,"nbt-row nbt-row--toggle"+(r(v)?" is-open":"")),y(H,r(v)?"\u25BE":"\u25B8"),y(G,` ${a()??""}`),y(ee,`List${r(R)?"\xB7"+r(R):""} \xB7 ${e.value.length??""}`)}),Y("click",M,m),Y("keydown",M,Q=>{(Q.key==="Enter"||Q.key===" ")&&(Q.preventDefault(),m())}),f(S,k)},L=S=>{var R=H0(),k=l(R),M=l(k,!0);s(k);var q=c(k,2),V=l(q,!0);s(q),me(2),s(R),T(H=>{y(M,a()),y(V,H)},[()=>String(e.value)]),f(S,R)};B(P,S=>{r(u)?S(D):r(g)?S(N,1):r($)?S(F,2):S(L,-1)})}f(A,I)},a=ne(e,"name",3,"root"),i=ne(e,"depth",3,0),o=ne(e,"root",3,!0),d=ne(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:ym(A),type:wm(A)}:null}let u=b(()=>p(e.value)),$=b(()=>!r(u)&&Array.isArray(e.value)),g=b(()=>!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=Ce(),x=ie(h);{var w=A=>{var I=j0(),P=l(I);n(P),s(I),f(A,I)},C=A=>{n(A)};B(x,A=>{d()?A(w):A(C,-1)})}f(t,h),ce()}Pe(["click","keydown"]);var U0=_('
            No server data pushed for this player.
            '),V0=_('

            Available in MQL as server.*.

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

            Player not found

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

            ',1);function Ep(t,e){le(e,!0);let n=H=>{var G=O1(),O=ie(G),j=l(O,!0);s(O),me(),T(()=>y(j,r(i).length)),f(H,G)},a=H=>{var G=D1();Y("click",G,g),f(H,G)},i=X(tt([])),o=X(null),d=X(""),p=X(null),u;async function $(){try{E(i,await je("/actions"),!0)}catch{E(i,[],!0)}}ge(()=>{$()}),ge(()=>{u&&(r(o)?u.showModal():u.close())});function g(){E(d,""),E(p,{type:"chat",component:""},!0),E(o,{id:null},!0)}function v(H){E(d,H.name||"",!0),E(p,H.action,!0),E(o,H,!0)}function m(){E(o,null)}async function h(){try{await je("/actions",{method:"POST",body:{id:r(o)?.id||void 0,name:r(d),action:r(p)||{type:"chat",component:""}}}),m(),await $(),vt("Action saved")}catch(H){vt(H.message,"error")}}async function x(H){if(confirm("Delete this action?"))try{await je("/actions/"+H,{method:"DELETE"}),await $(),vt("Deleted")}catch(G){vt(G.message,"error")}}var w=U1(),C=ie(w);{let H=b(()=>[I1]);Vr(C,{get crumbs(){return r(H)},get title(){return n},get actions(){return a}})}var A=c(C,2),I=l(A);{var P=H=>{so(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:O=>{var j=F1();Y("click",j,g),f(O,j)},$$slots:{cta:!0}})},D=H=>{var G=Ce(),O=ie(G);de(O,17,()=>r(i),j=>j.id,(j,z)=>{let W=b(()=>r(z).action?.type||"unknown"),Z=b(()=>r(z).usedBy?.length??0);oo(j,{get title(){return r(z).name},icon:U=>{var K=B1(),te=l(K,!0);s(K),T(se=>{re(K,"title",r(W)),y(te,se)},[()=>lm(r(z).action)]),f(U,K)},badges:U=>{var K=z1(),te=ie(K);dr(te,{kind:"on",children:($e,ve)=>{me();var he=bt();T(()=>y(he,r(W))),f($e,he)},$$slots:{default:!0}});var se=c(te,2),pe=l(se);s(se),T(()=>y(pe,`${r(Z)??""} routine${r(Z)===1?"":"s"}`)),f(U,K)},detail:U=>{var K=q1(),te=l(K,!0);s(K),T(se=>y(te,se),[()=>cs(r(z).action)]),f(U,K)},actions:U=>{var K=H1(),te=ie(K),se=c(te,2);Y("click",te,()=>v(r(z))),Y("click",se,()=>x(r(z).id)),f(U,K)},$$slots:{icon:!0,badges:!0,detail:!0,actions:!0}})}),f(H,G)};B(I,H=>{r(i).length===0?H(P):H(D,-1)})}s(A);var N=c(A,2),F=l(N),L=l(F),S=l(L,!0);s(L);var R=c(L,2),k=l(R),M=c(k,2);s(R),s(F);var q=c(F,2);{var V=H=>{var G=j1(),O=l(G),j=c(l(O),2);wt(j),s(O);var z=c(O,2),W=c(l(z),2);Ci(W,{get value(){return r(p)},onChange:Z=>E(p,Z,!0)}),s(z),s(G),Ca(j,()=>r(d),Z=>E(d,Z)),f(H,G)};B(q,H=>{r(o)&&H(V)})}s(N),Ct(N,H=>u=H,()=>u),T(()=>y(S,r(o)?.id?"Edit action":"New action")),Mt("close",N,m),Y("click",k,m),Y("click",M,h),f(t,w),ce()}Pe(["click"]);var V1=_('');function lo(t,e){le(e,!0);let n=X(null);ge(()=>{let o=!0;return Gs().then(d=>{o&&E(n,d,!0)}),()=>{o=!1}});let a=b(()=>Ws(Ks,e.src||"",null,r(n)));var i=V1();Bs(i,()=>r(a),!0),s(i),f(t,i),ce()}var Y1=_('
            Loading\u2026
            '),G1=_('
            '),W1=_('
            ');function ds(t,e){le(e,!0);{let n=b(()=>String(e.items.length||"\u2014"));et(t,{get title(){return e.title},get meta(){return r(n)},children:(a,i)=>{var o=W1(),d=l(o);{var p=$=>{var g=Y1();f($,g)},u=$=>{var g=Ce(),v=ie(g);de(v,17,()=>e.items,m=>m.name,(m,h)=>{var x=G1(),w=l(x),C=l(w),A=l(C,!0);s(C);var I=c(C,2),P=l(I,!0);s(I),s(w);var D=c(w,2),N=l(D,!0);s(D),s(x),T(()=>{y(A,r(h).name),y(P,r(h).kind),y(N,r(h).detail||"")}),f(m,x)}),f($,g)};B(d,$=>{e.items.length===0?$(p):$(u,-1)})}s(o),f(a,o)},$$slots:{default:!0}})}ce()}var K1=t=>{me();var e=bt("MQL guide");f(t,e)},X1=t=>{var e=Q1();me(),f(t,e)},Z1=[{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)'}],J1=[["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 Q1=_("MQL guide & sandbox",1),e2=_(' ',1),t2=_("press cmd\u21B5 to run",1),r2=_('
            '),n2=_(' '),a2=_('
            '),i2=_('
            Press \u21A9 to accept \xB7 esc to dismiss
            ',1),s2=_(''),o2=_('
            '),l2=_('
            '),c2=_(`
            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 G=e2(),O=ie(G),j=c(O,2);Y("click",O,()=>{E(a,""),u("")}),Y("click",j,()=>u(r(a))),f(H,G)},a=X(""),i=X(tt({kind:"dim",message:"Empty expression"})),o=X(tt([])),d=X(tt(new Map)),p=X(null);ge(()=>{Gs().then(H=>{E(p,H,!0)})});let u=Za(async H=>{if(!H.trim()){E(o,[],!0),E(i,{kind:"dim",message:"Empty expression"},!0);return}try{let O=(await je("/query",{method:"POST",body:{ql:H}})).matches||[];E(o,O,!0),E(i,{kind:O.length?"ok":"dim",message:`Compiled \xB7 ${O.length} match${O.length===1?"":"es"}`},!0);try{let j=await je("/players");E(d,new Map(j.map(z=>[z.uuid,z])),!0)}catch{}}catch(G){E(i,Na(G),!0),E(o,[],!0)}},220);ge(()=>{u(r(a))}),en(gr.players,()=>{r(a).trim()&&u(r(a))});let $=b(()=>r(p)?.fields||[]),g=b(()=>r(p)?.operators||[]),v=b(()=>r(g).filter(H=>["comparison","keyword","arithmetic","pipe"].includes(H.kind))),m=b(()=>r(g).filter(H=>H.kind==="logical")),h=b(()=>r(p)?.functions||[]),x=b(()=>r($).map(H=>({name:H.name,kind:"field",detail:H.detail||"(custom field)"}))),w=b(()=>r(v).map(H=>({name:H.name,kind:H.kind==="keyword"?"kw":"op",detail:H.detail||"(custom operator)"}))),C=b(()=>r(h).map(H=>({name:H.sig||H.name,kind:"fn",detail:H.detail||"(custom function)"}))),A=b(()=>r(m).map(H=>({name:H.name,kind:"kw",detail:H.detail||"(custom keyword)"})));var I=c2(),P=ie(I);{let H=b(()=>[K1]);Vr(P,{get crumbs(){return r(H)},get title(){return X1},get actions(){return n}})}var D=c(P,4),N=l(D),F=l(N);et(F,{title:"Sandbox",meta:G=>{me();var O=t2();me(3),f(G,O)},children:(G,O)=>{var j=i2(),z=ie(j);Yr(z,{get value(){return r(a)},onChange:K=>E(a,K,!0),rows:3,big:!0,placeholder:'health < 6 and gamemode = "SURVIVAL"',get status(){return r(i)},onSubmit:()=>u(r(a))});var W=c(z,2),Z=c(l(W),2),ee=l(Z,!0);s(Z),s(W);var ae=c(W,2),J=l(ae);{var Q=K=>{var te=r2(),se=l(te,!0);s(te),T(()=>y(se,r(i).message)),f(K,te)},U=K=>{var te=a2();de(te,20,()=>r(o),se=>se,(se,pe)=>{let $e=b(()=>r(d).get(pe));var ve=n2(),he=l(ve);dr(he,{kind:"on",children:(Oe,De)=>{me();var it=bt();T(Je=>y(it,Je),[()=>r($e)?.username||pe.slice(0,8)]),f(Oe,it)},$$slots:{default:!0}});var be=c(he,2),xe=l(be,!0);s(be);var Be=c(be,2),Re=l(Be,!0);s(Be),s(ve),T(Oe=>{re(ve,"href","/p/"+pe),y(xe,pe),y(Re,Oe)},[()=>(r($e)?.dimension||"\u2014").replace("minecraft:","")]),f(se,ve)}),s(te),f(K,te)};B(J,K=>{r(o).length===0&&r(i)?.kind==="error"?K(Q):r(o).length>0&&K(U,1)})}s(ae),T(()=>y(ee,r(o).length===0?"\u2014":`${r(o).length} match${r(o).length===1?"":"es"}`)),f(G,j)},$$slots:{meta:!0,default:!0}});var L=c(F,2);et(L,{title:"Examples",meta:"click to load",children:(H,G)=>{var O=o2();de(O,21,()=>Z1,lt,(j,z)=>{var W=s2(),Z=l(W),ee=l(Z,!0);s(Z);var ae=c(Z,2),J=l(ae,!0);s(ae);var Q=c(ae,2),U=l(Q);lo(U,{get src(){return r(z).ql}}),s(Q),me(2),s(W),T(()=>{y(ee,r(z).tag),y(J,r(z).desc)}),Y("click",W,()=>{E(a,r(z).ql,!0),u(r(z).ql)}),f(j,W)}),s(O),f(H,O)},$$slots:{default:!0}}),s(N);var S=c(N,2),R=l(S);et(R,{title:"Grammar",meta:"Pratt",children:(H,G)=>{var O=Ce(),j=ie(O);de(j,17,()=>J1,lt,(z,W)=>{var Z=b(()=>ur(r(W),2));let ee=()=>r(Z)[0],ae=()=>r(Z)[1];var J=l2(),Q=l(J),U=l(Q,!0);s(Q);var K=c(Q),te=l(K,!0);s(K),s(J),T(()=>{y(U,ee()),y(te,ae())}),f(z,J)}),f(H,O)},$$slots:{default:!0}});var k=c(R,2);ds(k,{title:"Fields",get items(){return r(x)}});var M=c(k,2);ds(M,{title:"Operators",get items(){return r(w)}});var q=c(M,2);ds(q,{title:"Functions",get items(){return r(C)}});var V=c(q,2);ds(V,{title:"Logic",get items(){return r(A)}}),s(S),s(D),f(t,I),ce()}Pe(["click"]);var Cp=[{id:or.onMatch,icon:"\u25B6",label:"On match",detail:"Edge \u2014 fires once when a player starts matching the filter."},{id:or.onUnmatch,icon:"\u25C0",label:"On unmatch",detail:"Edge \u2014 fires once when a player stops matching the filter."},{id:or.interval,icon:"\u27F3",label:"Interval",detail:"Periodic \u2014 fires every N ms for every matching player."},{id:or.onPacket,icon:"\u26A1",label:"On packet",detail:"Per-packet \u2014 fires for every decoded packet of the given class (after debounce)."}],km=[{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 d2(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 p2(t){return t===or.interval?{type:or.interval,millis:5e3}:t===or.onPacket?{type:or.onPacket,packet:""}:{type:t}}function Em(t){let e=Cp.find(n=>n.id===t?.type)?.id??or.onMatch;return e===or.interval?{type:or.interval,millis:Number(t?.millis)||5e3}:e===or.onPacket?{type:or.onPacket,packet:String(t?.packet??"")}:{type:e}}var u2=_(''),f2=_(''),v2=_('
            Fire every
            ms
            '),m2=_('
            Simple class name (e.g. ClientChatMessagePacket) \u2014 matched against every decoded packet.
            ',1),$2=_('
            ');function Ap(t,e){le(e,!0);let n="trig-"+Math.random().toString(36).slice(2,9),a=b(()=>Em(e.value)),i=b(()=>Cp.find(x=>x.id===r(a).type));function o(x){e.onChange?.(Em(x))}var d=$2(),p=l(d);de(p,21,()=>Cp,x=>x.id,(x,w)=>{var C=u2(),A=l(C);wt(A);var I=c(A,2),P=l(I,!0);s(I);var D=c(I,2),N=l(D,!0);s(D),s(C),T(()=>{re(A,"name",n),Dt(A,r(w).id),ua(A,r(a).type===r(w).id),y(P,r(w).icon),y(N,r(w).label)}),Y("change",A,()=>r(a).type===r(w).id?null:o(p2(r(w).id))),f(x,C)}),s(p);var u=c(p,2),$=l(u,!0);s(u);var g=c(u,2),v=l(g);{var m=x=>{let w=b(()=>r(a).millis),C=b(()=>km.some(S=>S.ms===r(w)));var A=v2(),I=c(l(A),2),P=l(I);de(P,17,()=>km,S=>S.ms,(S,R)=>{var k=f2(),M=l(k,!0);s(k),T(()=>{ue(k,1,"trig-interval__chip"+(r(R).ms===r(w)?" is-on":"")),y(M,r(R).label)}),Y("click",k,()=>o({...r(a),millis:r(R).ms})),f(S,k)});var D=c(P,2),N=l(D);wt(N),re(N,"min",100),re(N,"step",100),me(2),s(D),s(I);var F=c(I,2),L=l(F);s(F),s(A),T(S=>{ue(D,1,"trig-interval__custom"+(r(C)?"":" is-on")),Dt(N,r(w)),y(L,`\u2248 ${S??""}`)},[()=>d2(r(w))]),Y("change",N,S=>o({...r(a),millis:Math.max(0,Number(S.currentTarget.value)||0)})),f(x,A)},h=x=>{var w=m2(),C=ie(w),A=c(l(C),2);to(A,{get value(){return r(a).packet},onChange:I=>o({...r(a),packet:I})}),s(C),me(2),f(x,w)};B(v,x=>{r(a).type===or.interval?x(m):r(a).type===or.onPacket&&x(h,1)})}s(g),s(d),T(()=>y($,r(i)?.detail||"")),f(t,d),ce()}Pe(["change","click"]);var _2=t=>{me();var e=bt("Routines");f(t,e)},g2=_(" ",1),h2=_(''),b2=_(''),x2=_(" "),y2=_(' ',1),w2=_('(empty)'),k2=_(' \u2192 ',1),E2=_(' ',1),S2=_('
            Match (MQL)
            Trigger
            Action
            '),T2=_('

            ',1);function Mp(t,e){le(e,!0);let n=O=>{var j=g2(),z=ie(j),W=l(z,!0);s(z);var Z=c(z);T(()=>{y(W,r(C)),y(Z,` / ${r(i).length??""} active`)}),f(O,j)},a=O=>{var j=h2();Y("click",j,$),f(O,j)},i=X(tt([])),o=X(null),d=X(null),p;async function u(){try{E(i,await je("/routines"),!0)}catch{E(i,[],!0)}}ge(()=>{u()}),ge(()=>{p&&(r(o)?p.showModal():p.close())});function $(){E(d,{name:"",ql:"",trigger:{type:"onMatch"},action:null,enabled:!0},!0),E(o,{id:null},!0)}function g(O){E(d,{name:O.name||"",ql:O.ql||"",trigger:O.trigger||{type:"onMatch"},action:O.action||null,enabled:O.enabled??!0},!0),E(o,O,!0)}function v(){E(o,null)}async function m(){try{let O=await je("/routines",{method:"POST",body:{id:r(o)?.id||void 0,name:r(d).name,ql:(r(d).ql||"").trim(),trigger:r(d).trigger,action:r(d).action||{type:"chat",component:""}}});await w(O.id,r(d).enabled),v(),await u(),vt("Routine saved")}catch(O){vt(O.message,"error")}}async function h(O){if(confirm("Delete this routine?"))try{await je("/routines/"+O,{method:"DELETE"}),await u(),vt("Deleted")}catch(j){vt(j.message,"error")}}async function x(O){try{await w(O.id,!O.enabled),await u()}catch(j){vt(j.message,"error")}}function w(O,j){return je("/routines/"+O+"/enabled",{method:"PUT",body:{enabled:j}})}let C=b(()=>r(i).filter(O=>O.enabled).length);var A=T2(),I=ie(A);{let O=b(()=>[_2]);Vr(I,{get crumbs(){return r(O)},get title(){return n},get actions(){return a}})}var P=c(I,2),D=l(P);{var N=O=>{so(O,{title:"No routines defined yet.",hint:"Routines fire actions automatically when a query matches, on packet decode, or on a timer.",cta:z=>{var W=b2();Y("click",W,$),f(z,W)},$$slots:{cta:!0}})},F=O=>{var j=Ce(),z=ie(j);de(z,17,()=>r(i),W=>W.id,(W,Z)=>{let ee=b(()=>r(Z).action),ae=b(()=>Jn(r(ee))?"ref":r(ee)?.type||"inline"),J=b(()=>Jn(r(ee))?`(registered ${ls(r(ee))})`:cs(r(ee)));{let Q=pe=>{var $e=x2(),ve=l($e,!0);s($e),T((he,be)=>{re($e,"title",he),y(ve,be)},[()=>Xd(r(Z).trigger),()=>om(r(Z).trigger)]),f(pe,$e)},U=pe=>{var $e=y2(),ve=ie($e);dr(ve,{children:(xe,Be)=>{me();var Re=bt();T(Oe=>y(Re,Oe),[()=>Xd(r(Z).trigger)]),f(xe,Re)},$$slots:{default:!0}});var he=c(ve,2),be=l(he,!0);s(he),T(()=>y(be,r(Z).enabled?"enabled":"disabled")),f(pe,$e)},K=pe=>{var $e=k2(),ve=ie($e),he=l(ve);{var be=Je=>{lo(Je,{get src(){return r(Z).ql}})},xe=Je=>{var we=w2();f(Je,we)};B(he,Je=>{r(Z).ql?Je(be):Je(xe,-1)})}s(ve);var Be=c(ve,4),Re=l(Be),Oe=l(Re,!0);s(Re);var De=c(Re),it=l(De,!0);s(De),s(Be),T(()=>{y(Oe,r(ae)),y(it,r(J))}),f(pe,$e)},te=pe=>{var $e=E2(),ve=ie($e);wi(ve,{get on(){return r(Z).enabled},onchange:()=>x(r(Z))});var he=c(ve,2),be=c(he,2);Y("click",he,()=>g(r(Z))),Y("click",be,()=>h(r(Z).id)),f(pe,$e)},se=b(()=>!r(Z).enabled);oo(W,{get off(){return r(se)},get title(){return r(Z).name},icon:Q,badges:U,detail:K,actions:te,$$slots:{icon:!0,badges:!0,detail:!0,actions:!0}})}}),f(O,j)};B(D,O=>{r(i).length===0?O(N):O(F,-1)})}s(P);var L=c(P,2),S=l(L),R=l(S),k=l(R,!0);s(R);var M=c(R,2),q=l(M),V=c(q,2);s(M),s(S);var H=c(S,2);{var G=O=>{var j=S2(),z=l(j),W=c(l(z),2);wt(W),s(z);var Z=c(z,2),ee=c(l(Z),2);Yr(ee,{get value(){return r(d).ql},onChange:se=>r(d).ql=se,rows:2,placeholder:'health < 6 and gamemode = "SURVIVAL"',onSubmit:m}),s(Z);var ae=c(Z,2),J=c(l(ae),2);Ap(J,{get value(){return r(d).trigger},onChange:se=>r(d).trigger=se}),s(ae);var Q=c(ae,2),U=c(l(Q),2);Ai(U,{get value(){return r(d).action},onChange:se=>r(d).action=se}),s(Q);var K=c(Q,2),te=l(K);wt(te),me(2),s(K),s(j),Ca(W,()=>r(d).name,se=>r(d).name=se),zs(te,()=>r(d).enabled,se=>r(d).enabled=se),f(O,j)};B(H,O=>{r(d)&&O(G)})}s(L),Ct(L,O=>p=O,()=>p),T(()=>y(k,r(o)?.id?"Edit routine":"New routine")),Mt("close",L,v),Y("click",q,v),Y("click",V,m),f(t,A),ce()}Pe(["click"]);var C2=t=>{me();var e=bt("Terminal");f(t,e)},A2=t=>{me();var e=M2();me(),f(t,e)},Sm=2e3,M2=_("Server terminal",1),P2=_(' '),R2=_('
            CPU
            Heap
            TPS
            MSPT
            Threads
            Uptime
            Players
            '),N2=_(" lines",1),L2=_('
            Waiting for output\u2026
            '),I2=_('
            '),O2=_('
            '),D2=_('
            No data pushed yet.
            Queryable via global.<path>.
            '),F2=_('
            '),B2=_('
            ',1);function Pp(t,e){le(e,!0);let n=S=>{var R=P2(),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),d=X(""),p=X(!1),u=X(void 0),$=!0;ge(()=>{let S=!1;return(async()=>{try{let[R,k,M]=await Promise.all([je("/console/history"),je("/metrics/latest").catch(()=>null),je("/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}}),en(gr.console,S=>{let R=r(a).length>=Sm?r(a).slice(r(a).length-Sm+1):r(a);E(a,[...R,{ts:S.ts,level:S.level,message:S.message}],!0)}),en(gr.metrics,S=>{E(i,S,!0)}),en(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(d).trim();if(R){E(p,!0);try{await je("/console/command",{method:"POST",body:{command:R}}),E(d,"")}catch(k){vt("Command failed: "+k.message,"error")}finally{E(p,!1)}}}var m=B2(),h=ie(m);{let S=b(()=>[C2]);Vr(h,{get crumbs(){return r(S)},get title(){return A2},get actions(){return n}})}var x=c(h,2);{var w=S=>{let R=b(()=>r(i));var k=R2(),M=l(k),q=c(l(M)),V=l(q);s(q),s(M);var H=c(M,2),G=c(l(H)),O=l(G,!0);s(G),s(H);var j=c(H,2),z=c(l(j)),W=l(z,!0);s(z),s(j);var Z=c(j,2),ee=c(l(Z)),ae=l(ee);s(ee),s(Z);var J=c(Z,2),Q=c(l(J)),U=l(Q,!0);s(Q),s(J);var K=c(J,2),te=c(l(K)),se=l(te,!0);s(te),s(K);var pe=c(K,2),$e=c(l(pe)),ve=l($e,!0);s($e),s(pe),s(k),T((he,be,xe,Be,Re)=>{y(V,`${he??""}%`),y(O,be),y(W,xe),y(ae,`${Be??""} ms`),y(U,r(R).threadCount),y(se,Re),y(ve,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),()=>fa(r(R).uptimeMs)]),f(S,k)};B(x,S=>{r(i)&&S(w)})}var C=c(x,2),A=l(C),I=l(A);et(I,{title:"Output",flush:!0,meta:R=>{var k=N2(),M=ie(k),q=l(M,!0);s(M),me(),T(()=>y(q,r(a).length)),f(R,k)},children:(R,k)=>{var M=O2(),q=l(M);{var V=G=>{var O=L2();f(G,O)},H=G=>{var O=Ce(),j=ie(O);de(j,17,()=>r(a),lt,(z,W)=>{var Z=I2(),ee=l(Z),ae=l(ee,!0);s(ee);var J=c(ee,2),Q=l(J,!0);s(J);var U=c(J,2),K=l(U,!0);s(U),s(Z),T((te,se,pe)=>{ue(Z,1,te),y(ae,se),ue(J,1,pe),y(Q,r(W).level),y(K,r(W).message)},[()=>"console-line lvl-"+(r(W).level||"info").toLowerCase(),()=>Ln(r(W).ts).slice(0,8),()=>"console-level lvl-"+(r(W).level||"info").toLowerCase()]),f(z,Z)}),f(G,O)};B(q,G=>{r(a).length===0?G(V):G(H,-1)})}s(M),Ct(M,G=>E(u,G),()=>r(u)),Mt("scroll",M,g),f(R,M)},$$slots:{meta:!0,default:!0}});var P=c(I,2),D=l(P);D.textContent=">";var N=c(D,2);wt(N);var F=c(N,2);s(P),s(A);var L=c(A,2);{let S=b(()=>r(o)?`${Object.keys(r(o)).length} keys`:"empty");et(L,{title:"Global NBT",get meta(){return r(S)},flush:!0,children:(R,k)=>{var M=F2(),q=l(M);{var V=O=>{ei(O,{get value(){return r(o)},name:"global"})},H=b(()=>r(o)&&Object.keys(r(o)).length>0),G=O=>{var j=D2();f(O,j)};B(q,O=>{r(H)?O(V):O(G,-1)})}s(M),f(R,M)},$$slots:{default:!0}})}s(C),T(S=>{N.disabled=r(p),F.disabled=S},[()=>r(p)||!r(d).trim()]),Mt("submit",P,v),Ca(N,()=>r(d),S=>E(d,S)),f(t,m),ce()}var $n={latencyMs:0,jitterMs:0,bandwidthBytesPerSec:0,direction:null},Mi=16*1024*1024,Rp=t=>t<=0?0:Math.min(1,Math.log10(1+t)/Math.log10(1+Mi)),Tm=t=>t<=0?0:t>=1?Mi:Math.max(1,Math.round(Math.pow(10,t*Math.log10(1+Mi))-1)),Cm=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 z2(t){let e=Math.sin(t*12.9898)*43758.5453;return e-Math.floor(e)}function Am(t){let i=Qn(t),o=i?t:$n,d=Math.min(12,i?o.latencyMs/200+o.jitterMs/50:.5),p=.05+(i?o.bandwidthBytesPerSec/Mi: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?(z2(g*7+u)-.5)*(o.jitterMs/40):0,m=16+Math.sin(g*p)*d+v;$+=(g===0?"M":" L")+g.toFixed(0)+" "+m.toFixed(2)}return $}var q2=t=>{me();var e=bt("Throttle");f(t,e)},H2=t=>{me();var e=j2();me(),f(t,e)},Lp=(t,e=At)=>{var n=Y2();de(n,20,e,a=>a,(a,i)=>{var o=V2();let d;T(()=>d=ue(o,1,"",null,d,{maj:i%5===0})),f(a,o)}),s(n),f(t,n)},Ip=(t,e=At)=>{var n=W2();de(n,20,e,a=>a,(a,i)=>{var o=G2(),d=l(o,!0);s(o),T(()=>y(d,i)),f(a,o)}),s(n),f(t,n)},j2=_("Traffic shaper",1),U2=_(' ',1),V2=_(""),Y2=_('
            '),G2=_(" "),W2=_('
            '),K2=_(""),X2=_('target \u2192 '),Z2=_(' '),J2=_(''),Q2=_('
            No matching connections.
            '),eE=_('
            '),tE=_('
            Live (target)
            '),rE=_("\xB7 unlimited"),nE=_(" engaged",1),aE=_('
            '),iE=_('
            PlayerUUIDThrottle
            '),sE=_('
            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),oE={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,oE);let n=ye=>{var _t=U2(),Kt=ie(_t),lr=c(Kt,2),cr=c(lr,2);T(()=>{Kt.disabled=!r(q),lr.disabled=!r(M),cr.disabled=!r(x)}),Y("click",Kt,F),Y("click",lr,P),Y("click",cr,I),f(ye,_t)},a=X("global"),i=X(null),o=X(tt({})),d=X(tt({...$n})),p=X(tt({...$n})),u=X(null),$=X(""),g=X(!1);ln.boot();let v=b(()=>ln.list),m=b(()=>r(a)==="global"?r(d):r(p)),h=b(()=>r(i)?r(o)[r(i)]??null:null),x=b(()=>r(a)==="global"?!Np(r(d),r(u)??$n):r(i)?!Np(r(p),r(h)??$n):!1);async function w(){try{let ye=await je("/throttle");E(u,ye.global?C(ye.global):null,!0),E(o,Object.fromEntries(Object.entries(ye.players||{}).map(([_t,Kt])=>[_t,C(Kt)])),!0),r(g)||(E(d,{...r(u)??$n},!0),E(g,!0))}catch(ye){vt("Failed to load throttles: "+ye.message,"error")}}ge(()=>{w()});function C(ye){return{latencyMs:Number(ye?.latencyMs??0),jitterMs:Number(ye?.jitterMs??0),bandwidthBytesPerSec:Number(ye?.bandwidthBytesPerSec??0),direction:ye?.direction??null}}function A(ye){let _t=ye&&ye===r(i)?null:ye;E(i,_t,!0),E(p,_t?{...r(o)[_t]??$n}:{...$n},!0)}async function I(){try{if(r(a)==="global"){let ye=C(await je("/throttle/global",{method:"PUT",body:r(d)}));E(u,Qn(ye)?ye:null,!0),E(d,{...ye},!0),vt(Qn(ye)?"Global throttle engaged":"Global throttle stored (no-op)")}else{if(!r(i)){vt("Select a player first","error");return}let ye=C(await je("/throttle/players/"+r(i),{method:"PUT",body:r(p)}));if(Qn(ye))E(o,{...r(o),[r(i)]:ye},!0);else{let _t={...r(o)};delete _t[r(i)],E(o,_t,!0)}E(p,{...ye},!0),vt(Qn(ye)?`Throttle engaged for ${L(r(i))}`:`Throttle stored for ${L(r(i))} (no-op)`)}}catch(ye){vt("Failed to apply: "+ye.message,"error")}}async function P(){try{if(r(a)==="global")await je("/throttle/global",{method:"DELETE"}),E(u,null),E(d,{...$n},!0),vt("Global throttle disengaged");else{if(!r(i))return;await je("/throttle/players/"+r(i),{method:"DELETE"});let ye={...r(o)};delete ye[r(i)],E(o,ye,!0),E(p,{...$n},!0),vt("Throttle cleared for "+L(r(i)))}}catch(ye){vt(ye.message,"error")}}function D(ye){r(a)==="global"?E(d,{...r(d),direction:ye},!0):E(p,{...r(p),direction:ye},!0)}function N(ye){r(a)==="global"?E(d,{...r(d),...ye},!0):E(p,{...r(p),...ye},!0)}function F(){r(a)==="global"?E(d,{...$n},!0):E(p,{...$n},!0)}function L(ye){return r(v).find(Kt=>Kt.uuid===ye)?.username||cn(ye)}let S=b(()=>{let ye=r($).trim().toLowerCase();return ye?r(v).filter(_t=>(_t.username||"").toLowerCase().includes(ye)||(_t.uuid||"").toLowerCase().includes(ye)):r(v)}),R=b(()=>Object.values(r(o)).filter(Qn).length),k=b(()=>Qn(r(u))),M=b(()=>r(a)==="global"?r(k):!!(r(i)&&r(h))),q=b(()=>Qn(r(m))),V=b(()=>r(k)&&r(R)?`GLOBAL + ${r(R)} TARGETED`:r(k)?"GLOBAL ENGAGED":r(R)?`${r(R)} TARGETED`:"IDLE"),H=b(()=>r(k)||r(R)>0);function G(ye){if(!ye)return"pass-through";let _t=[];return ye.latencyMs&&_t.push(ye.latencyMs+"ms"),ye.jitterMs&&_t.push("\xB1"+ye.jitterMs+"ms"),ye.bandwidthBytesPerSec&&_t.push(Cm(ye.bandwidthBytesPerSec)),ye.direction&&_t.push(ye.direction==="CLIENTBOUND"?"S\u2192C":"C\u2192S"),_t.length?_t.join(" \xB7 "):"no-op"}let O=Array.from({length:11},(ye,_t)=>_t),j=Array.from({length:21},(ye,_t)=>_t);function z(ye){return ye>=1024*1024?"MB/s":ye>=1024?"KB/s":"B/s"}let W=ye=>ye==="MB/s"?1024*1024:ye==="KB/s"?1024:1,Z=ye=>ye==="MB/s"?"0.01":ye==="KB/s"?"0.1":"1",ee=(ye,_t)=>_t==="B/s"?ye:+(ye/W(_t)).toFixed(_t==="MB/s"?2:1),ae=X("B/s"),J=X(!1);ge(()=>{r(J)||E(ae,z(r(m).bandwidthBytesPerSec),!0)});function Q(ye,_t,Kt){let lr=ye.currentTarget.value;if(lr==="")return 0;let cr=Number(lr);return Number.isFinite(cr)?Math.max(_t,Math.min(Kt,Math.round(cr))):0}var U=sE(),K=ie(U);{let ye=b(()=>[q2]);Vr(K,{get crumbs(){return r(ye)},get title(){return H2},get actions(){return n}})}var te=c(K,2),se=l(te);let pe;var $e=l(se);de($e,20,()=>Array(6),lt,(ye,_t,Kt)=>{var lr=K2();ke(lr,`--d:${Kt*80}ms`);let cr;T(()=>cr=ue(lr,1,"",null,cr,{on:r(H)})),f(ye,lr)}),s($e);var ve=c($e,2),he=c(l(ve),4),be=l(he,!0);s(he),s(ve);var xe=c(ve,2),Be=l(xe),Re=c(l(Be)),Oe=l(Re,!0);s(Re),s(Be);var De=c(Be,2),it=c(l(De)),Je=l(it);s(it),s(De),s(xe);var we=c(xe,2),Qe=l(we);s(we),s(se);var Ye=c(se,2),Le=l(Ye),ze=l(Le);let Ae;var Se=c(ze,2);let Fe;s(Le);var Ne=c(Le,2);{var Ue=ye=>{var _t=eE(),Kt=l(_t),lr=l(Kt);wt(lr);var cr=c(lr,2);{var Dn=qr=>{var oe=X2(),fe=c(l(oe)),nt=l(fe,!0);s(fe),s(oe),T(ut=>y(nt,ut),[()=>L(r(i))]),f(qr,oe)};B(cr,qr=>{r(i)&&qr(Dn)})}s(Kt);var ra=c(Kt,2),na=l(ra);de(na,17,()=>r(S),qr=>qr.uuid,(qr,oe)=>{let fe=b(()=>r(i)===r(oe).uuid),nt=b(()=>r(o)[r(oe).uuid]);var ut=J2();let Nt;var nr=c(l(ut),2),Fn=l(nr,!0);s(nr);var Bn=c(nr,2);{var ti=ri=>{var aa=Z2(),Ol=l(aa,!0);s(aa),T(Dl=>y(Ol,Dl),[()=>G(r(nt))]),f(ri,aa)},po=b(()=>Qn(r(nt)));B(Bn,ri=>{r(po)&&ri(ti)})}s(ut),T((ri,aa)=>{Nt=ue(ut,1,"thr-chip",null,Nt,ri),re(ut,"title",r(oe).uuid),y(Fn,aa)},[()=>({on:r(fe),lit:Qn(r(nt))}),()=>r(oe).username||cn(r(oe).uuid)]),Y("click",ut,()=>A(r(oe).uuid)),f(qr,ut)});var qa=c(na,2);{var _n=qr=>{var oe=Q2();f(qr,oe)};B(qa,qr=>{r(S).length===0&&qr(_n)})}s(ra),s(_t),Ca(lr,()=>r($),qr=>E($,qr)),f(ye,_t)};B(Ne,ye=>{r(a)==="player"&&ye(Ue)})}s(Ye);var mt=c(Ye,2);let Ve;var Ie=l(mt),We=c(l(Ie),2),$t=l(We);let Ee;var Ge=c($t,2);let Ke;var st=c(Ge,2);let St;s(We),s(Ie);var qe=c(Ie,2),pt=c(l(qe),2);let ft;var ht=l(pt,!0);s(pt),s(qe);var at=c(qe,2);{var dt=ye=>{var _t=tE(),Kt=c(l(_t),2),lr=l(Kt,!0);s(Kt),s(_t),T(cr=>y(lr,cr),[()=>G(r(h))]),f(ye,_t)};B(at,ye=>{r(a)==="player"&&r(i)&&r(h)&&ye(dt)})}s(mt);var Me=c(mt,2),He=l(Me);let Xe;var ct=c(l(He),2),Et=l(ct);wt(Et),me(2),s(ct);var It=c(ct,2),qt=l(It);Lp(qt,()=>j);var ot=c(qt,2);wt(ot);let Pt;var Ot=c(ot,2);Ip(Ot,()=>["0","500","1k","1.5k","2k"]),s(It),me(2),s(He);var Ht=c(He,2);let tr;var Vt=c(l(Ht),2),Ft=c(l(Vt),2);wt(Ft),me(2),s(Vt);var Wt=c(Vt,2),fr=l(Wt);Lp(fr,()=>O);var Lt=c(fr,2);wt(Lt);let hr;var wr=c(Lt,2);Ip(wr,()=>["0","125","250","375","500"]),s(Wt),me(2),s(Ht);var Br=c(Ht,2);let zr;var Gr=c(l(Br),2),rr=l(Gr);wt(rr);var ea=c(rr,2),On=l(ea,!0);s(ea);var Tn=c(ea,2);{var Ba=ye=>{var _t=rE();f(ye,_t)};B(Tn,ye=>{r(m).bandwidthBytesPerSec||ye(Ba)})}s(Gr);var ta=c(Gr,2),za=l(ta);Lp(za,()=>j);var Cn=c(za,2);wt(Cn);let Pi;var ps=c(Cn,2);Ip(ps,()=>["0","1K","32K","1M","16M"]),s(ta),me(2),s(Br),s(Me);var us=c(Me,2);{var fs=ye=>{et(ye,{title:"Active overrides",flush:!0,meta:Kt=>{var lr=nE(),cr=ie(lr),Dn=l(cr,!0);s(cr),me(),T(()=>y(Dn,r(R))),f(Kt,lr)},children:(Kt,lr)=>{var cr=iE(),Dn=c(l(cr));de(Dn,21,()=>Object.entries(r(o)),([ra,na])=>ra,(ra,na)=>{var qa=b(()=>ur(r(na),2));let _n=()=>r(qa)[0],qr=()=>r(qa)[1];var oe=aE(),fe=l(oe),nt=l(fe,!0);s(fe);var ut=c(fe),Nt=l(ut,!0);s(ut);var nr=c(ut),Fn=l(nr,!0);s(nr);var Bn=c(nr),ti=l(Bn),po=l(ti),ri=c(po,2);s(ti),s(Bn),s(oe),T((aa,Ol,Dl)=>{y(nt,aa),y(Nt,Ol),y(Fn,Dl)},[()=>L(_n()),()=>cn(_n()),()=>G(qr())]),Y("click",po,()=>{E(a,"player"),A(_n())}),Y("click",ri,async()=>{await je("/throttle/players/"+_n(),{method:"DELETE"});let aa={...r(o)};delete aa[_n()],E(o,aa,!0),r(i)===_n()&&E(p,{...$n},!0),vt("Cleared "+L(_n()))}),f(ra,oe)}),s(Dn),s(cr),f(Kt,cr)},$$slots:{meta:!0,default:!0}})},co=b(()=>Object.keys(r(o)).length>0);B(us,ye=>{r(co)&&ye(fs)})}s(te),T((ye,_t,Kt,lr,cr,Dn,ra,na,qa,_n)=>{pe=ue(se,1,"thr-status",null,pe,{engaged:r(H),idle:!r(H)}),y(be,r(V)),y(Oe,ye),y(Je,`${r(R)??""} ${r(R)===1?"player":"players"}`),re(Qe,"d",_t),re(ze,"aria-selected",r(a)==="global"),Ae=ue(ze,1,"thr-mode seg-control__item",null,Ae,{on:r(a)==="global"}),re(Se,"aria-selected",r(a)==="player"),Fe=ue(Se,1,"thr-mode seg-control__item",null,Fe,{on:r(a)==="player"}),Ve=ue(mt,1,"thr-strip",null,Ve,{off:!r(q)}),Ee=ue($t,1,"thr-dir__opt",null,Ee,{on:r(m).direction===null}),Ke=ue(Ge,1,"thr-dir__opt",null,Ke,{on:r(m).direction==="CLIENTBOUND"}),St=ue(st,1,"thr-dir__opt",null,St,{on:r(m).direction==="SERVERBOUND"}),ft=ue(pt,1,"thr-strip__live",null,ft,{dim:!r(q)}),y(ht,Kt),Xe=ue(He,1,"thr-mod",null,Xe,{lit:r(m).latencyMs>0}),Dt(Et,r(m).latencyMs),Dt(ot,r(m).latencyMs),Pt=ke(ot,"",Pt,lr),tr=ue(Ht,1,"thr-mod",null,tr,{lit:r(m).jitterMs>0}),Dt(Ft,r(m).jitterMs),Dt(Lt,r(m).jitterMs),hr=ke(Lt,"",hr,cr),zr=ue(Br,1,"thr-mod",null,zr,{lit:r(m).bandwidthBytesPerSec>0}),re(rr,"max",Dn),re(rr,"step",ra),Dt(rr,na),y(On,r(ae)),Dt(Cn,qa),Pi=ke(Cn,"",Pi,_n)},[()=>G(r(u)),()=>Am(r(q)?r(m):null),()=>G(r(m)),()=>({"--pct":Math.min(r(m).latencyMs,2e3)/2e3*100+"%"}),()=>({"--pct":Math.min(r(m).jitterMs,500)/500*100+"%"}),()=>Mi/W(r(ae)),()=>Z(r(ae)),()=>ee(r(m).bandwidthBytesPerSec,r(ae)),()=>Math.round(Rp(r(m).bandwidthBytesPerSec)*1e3),()=>({"--pct":Rp(r(m).bandwidthBytesPerSec)*100+"%"})]),Y("click",ze,()=>E(a,"global")),Y("click",Se,()=>E(a,"player")),Y("click",$t,()=>D(null)),Y("click",Ge,()=>D("CLIENTBOUND")),Y("click",st,()=>D("SERVERBOUND")),Y("input",Et,ye=>N({latencyMs:Q(ye,0,6e4)})),Y("input",ot,ye=>N({latencyMs:+ye.currentTarget.value})),Y("input",Ft,ye=>N({jitterMs:Q(ye,0,1e4)})),Y("input",Lt,ye=>N({jitterMs:+ye.currentTarget.value})),Mt("focus",rr,()=>E(J,!0)),Mt("blur",rr,()=>E(J,!1)),Y("input",rr,ye=>{let _t=parseFloat(ye.currentTarget.value);!Number.isFinite(_t)||_t<0||N({bandwidthBytesPerSec:Math.min(Mi,Math.round(_t*W(r(ae))))})}),Y("input",Cn,ye=>N({bandwidthBytesPerSec:Tm(+ye.currentTarget.value/1e3)})),f(t,U),ce()}Pe(["click","input"]);function lE(t){let{root:e,segs:n}=t;return e?e==="p"&&n[1]?"p":e:"dashboard"}var cE=_('
            '),dE=_('
            ',1),pE=_(" ",1);function Dp(t,e){le(e,!0);let n=b(()=>lE(bi.current)),a=b(()=>r(n)==="p"?"players":r(n)),i=b(()=>r(n)==="p"?bi.current.segs[1]:null),o=b(()=>r(n)==="p"?bi.current.segs[2]||"overview":null),d=b(()=>pr.mode==="replay"&&!pr.scope),p=b(()=>pr.mode==="replay");ge(()=>{pr.scope&&Fp()}),ge(()=>(document.addEventListener("click",Kc),()=>document.removeEventListener("click",Kc)));let u=X(!1);var $=pE(),g=ie($);{var v=w=>{var C=cE(),A=l(C);fd(A,{}),s(C),f(w,C)},m=w=>{var C=dE(),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=c(A,2),P=l(I);{var D=W=>{ul(W,{})},N=W=>{gd(W,{})},F=W=>{Sd(W,{})},L=W=>{Pp(W,{})},S=W=>{kp(W,{})},R=W=>{Ep(W,{})},k=W=>{Sp(W,{})},M=W=>{Mp(W,{})},q=W=>{Op(W,{})},V=W=>{wp(W,{get uuid(){return r(i)},get tab(){return r(o)}})},H=W=>{ul(W,{})};B(P,W=>{r(n)==="dashboard"?W(D):r(n)==="players"?W(N,1):r(n)==="packets"?W(F,2):r(n)==="terminal"&&!r(p)?W(L,3):r(n)==="trigger"&&!r(p)?W(S,4):r(n)==="actions"&&!r(p)?W(R,5):r(n)==="query"?W(k,6):r(n)==="routines"&&!r(p)?W(M,7):r(n)==="throttle"&&!r(p)?W(q,8):r(n)==="p"?W(V,9):W(H,-1)})}s(I);var G=c(I,2);{var O=W=>{id(W,{onClose:()=>E(u,!1)})};B(G,W=>{r(u)&&W(O)})}var j=c(G,2);ld(j,{});var z=c(j,2);ud(z,{}),f(w,C)};B(g,w=>{r(d)?w(v):w(m,-1)})}var h=c(g,2);ad(h,{});var x=c(h,2);dd(x,{}),f(t,$),ce()}async function uE(){await pr.boot(),(pr.mode==="live"||pr.scope)&&Fp()}var Mm=!1;function Fp(){Mm||(Mm=!0,sr.connect(),ln.boot(),Ma.boot())}var Pm=document.getElementById("app");if(!Pm)throw new Error("Missing #app mount target");Yi(Dp,{target:Pm});uE();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..4604fdc38bd --- /dev/null +++ b/web/src/main/resources/web/index.html @@ -0,0 +1,19 @@ + + + + + + + + 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..e4938cbb351 --- /dev/null +++ b/web/src/main/resources/web/style.css @@ -0,0 +1,4538 @@ +@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: '