diff --git a/README.md b/README.md index f90c6a3..f9baa77 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,53 @@ A Velocity proxy only knows its own players. Ask it for `dahendriik` while they Local first, always — a player on this proxy is already in memory and costs nothing to find. +## Join welcome + +A fresh network join receives a single welcome block with the large 26px Grounds +wordmark used by the network HUD, the player's name, and independent Discord, +TikTok and Shop links in one horizontal row. The resource pack supplies sharp +pixel-art brand icons (Discord, TikTok and a shopping bag); both icon and label +open the link. Modern clients receive it after the first backend connection and +one acknowledgement round trip, with no fixed welcome delay. Required packs have +already loaded during configuration. A `grounds:welcome_ready` cookie request is +queued after JoinGame so client join-time mod announcements precede its response. +Clients without a verified HUD font pack see the +Grounds name instead of an unsupported glyph. + +Discord defaults to `https://grnds.link/discord`, matching the lobby bossbar. +TikTok (`https://example.com/grounds/tiktok`) and Shop +(`https://example.com/grounds/shop`) are intentional placeholders. Replace these +with `WELCOME_TIKTOK_URL` and `WELCOME_SHOP_URL`; `WELCOME_DISCORD_URL` can override +Discord as well. Keep these values consistent across proxy deployments. + +Clients that have not announced Simple Voice Chat receive a short description +and a link to the official installation guide, as a complete localized sentence +followed by the installation action. Client channel registration or a +voice handshake suppresses that section even if voice is disabled or UDP cannot +connect. Handshake messages continue to the backend unchanged. Server switches +do not repeat the welcome. A connection-scoped `grounds:welcome` client cookie +carries whether the block was delivered across proxy/region transfers: an early +redirect still gets its first welcome at the destination. Fresh logins reset +the marker before preferred-region routing. Disconnect and shutdown cancel +pending delivery. + +Before rollout, check a fresh join on each proxy, a backend switch, and a proxy +transfer. Check clients with the mod absent, present but disabled, and present +with UDP blocked; only the first should get installation help. Also check a +declined resource pack and a disconnect/reconnect before the ready-cookie reply. +Only legacy protocols before 1.20.5 retain the old five-second negotiation grace. + +The installation observers capture only an in-memory bit at maximum synchronous +priority, then explicitly hand off to an asynchronous EventTask. This preserves +packet order before the ready reply without moving other plugins' handlers onto +the packet thread. Before changing the Velocity runtime, verify these dispatcher +semantics against its actual JAR: + +```sh +velocity/src/test/scripts/verify-welcome-dispatch.sh \ + velocity/build/libs/plugin-proxy-velocity.jar /path/to/deployed-velocity.jar +``` + ## How the pieces fit ``` @@ -121,6 +168,20 @@ allowed can still _show_ the MOTD; `/motd set` then reports the refusal instead ## Tab footer and diagnostics +`/whereami` is available to every player and shows their current region, full proxy +ID and current backend ID in one indented Grounds chat block. A short explanation +asks players to use these details when contacting support. Hover and click the +proxy or server ID to copy it. Missing values appear as `—`. The command reads the +current connection on whichever proxy handles it, so backend switches appear +immediately without a presence-service lookup. Region uses `REGION`, falling back +to the existing `CONTINENT`/region code when the datacentre is not configured. +All text is localized in English, German, French and Spanish. +The heading and body follow the Minecraft Mini-CD (warm white, sand and cyan +copy actions). Three first-party pixel icons in `grounds:connection` appear only +with verified HUD readiness and a compatible `WHEREAMI_FONT_PACK_SHA1S` allowlist; +otherwise the indented text labels remain. The icons use an additive platform +pack font and do not replace vanilla fonts or the existing welcome graphics. + The footer is one localized status line, for example `Lobby 2 • 42 players online • 17 ms`. The server number is its deterministic position among the currently registered servers of the same kind, not a pod identifier. All proxies with the same discovery inventory show the same @@ -215,3 +276,7 @@ maintenance is active. New maintenance drafts default to an empty override. ``` Published on tag as `gg.grounds:plugin-proxy-api` (consume this) and `plugin-proxy-velocity`, and as `ghcr.io/groundsgg/plugin-proxy` — the image carries the shaded JAR at `/jar/plugin.jar` for the `plugin-velocity-jar` Helm chart. + +Welcome bitmap icons use `grounds:welcome` U+E400/U+E401/U+E402. Set `WELCOME_FONT_PACK_SHA1S` to the verified platform-pack hashes containing these glyphs and restrict `HUD_FONT_PACK_SHA1S` to that same set. Icons remain disabled unless every HUD-ready hash is covered; unknown packs show plain labels. Existing chat, sidebar and HUD pack allowlists must include a newly activated platform pack as appropriate. + +The welcome can use the original full Grounds logo with floating stones. Only enable WELCOME_LOGO_PACK_SHA1S for HUD-approved hashes containing U+E403; otherwise the existing compact wordmark remains. Its40px artwork reserves more rows; with Voice help the block ends in normal-height text and still fits ten closed-chat rows. Voice copy and Install use the same sand-coloured bullet as the bossbar. diff --git a/docs/superpowers/plans/2026-11-04-join-welcome.md b/docs/superpowers/plans/2026-11-04-join-welcome.md new file mode 100644 index 0000000..f1a1596 --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-join-welcome.md @@ -0,0 +1,41 @@ +# Join Welcome Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox syntax for tracking. + +**Goal:** Deliver the agreed welcome block once per fresh network join with conditional voice installation help. +**Architecture:** A renderer builds translated Adventure components; a Velocity listener handles connection lifecycle and observes mod negotiation. Wire both into plugin-proxy. +**Tech Stack:** Kotlin, Velocity, Adventure, library-i18n, JUnit 6. +**Spec:** `docs/superpowers/specs/2026-11-04-join-welcome-design.md` + +## Global Constraints + +- Discord is `https://grnds.link/discord`; TikTok and Shop are replaceable placeholders. +- Skip server switches and delivered cross-proxy transfers; preserve a pending first welcome through early region redirects. Clean up on disconnect/shutdown. +- Voice off or disconnected does not mean the mod is absent. +- Draw logo only with verified chat-font readiness; support all four plugin locales. + +## Review Focus + +- Mod handshake before the first backend connection must suppress the hint. +- Forward client voice handshakes intact and ignore backend-originated messages. +- Late callbacks from a disconnected session must not message a reconnect. +- A transfer onto another proxy must preserve the first welcome if still pending and suppress an already delivered welcome. +- Missing packs and markup-like player names must produce readable, safe text. + +### Task 1: Welcome rendering and lifecycle + +**Files:** New `welcome/WelcomeMessage.kt`, `welcome/WelcomeListener.kt`, tests under the same package; modify plugin initialization, four resource bundles, README. +**Interfaces:** Renderer `render(playerName, locale, logoReady, voiceInstalled): Component`; listener accepts a delayed scheduler and readiness lookup to test actual event handlers without a running proxy. + +- [x] Write rendering tests for clickable URLs, localized voice visibility, safe names and logo fallback; lifecycle tests for switches, transfers, early/disabled voice, backend spoofing, disconnect and reconnect. +- [x] Run `./gradlew :velocity:test --offline --console=plain` and confirm new tests fail because the feature is absent. +- [x] Implement renderer, observer and lifecycle gates; wire a five-second delayed task and register observed voice channels. Add translations and document configurable placeholder links. +- [x] Run `./gradlew test --offline --console=plain` and repository formatting checks. Review the diff for forwarding, cleanup and cross-proxy behavior. + +Execution: primary agent, current suitable checkout on a task branch; no separate implementation agents needed. + +Review ruling: use a client cookie rather than unconditional transfer suppression, because preferred-region redirects happen at PostLogin before a first backend connection. Cookie state is cosmetic, reset for each LOGIN, queried only for TRANSFER, and tracked by the exact connection. + +Validation: `./gradlew spotlessApply test spotlessCheck --offline --console=plain` passed after the transfer fix; 229 tests, zero failures/errors/skips. `git diff --check` passed. Live Minecraft clients and multi-proxy deployment smoke checks were not available in this session. + +Visual follow-up: replace the small chat G plus text heading with the verified large HUD wordmark, switch readiness to PlayerHudAppearanceQuery, reserve space for the tall bitmap, and put native icon+label links on separate rows. Updated existing link/layout tests first (three expected failures), then implemented; full Gradle test and formatting checks passed. Stage follow-up is within the existing deployment authorization. diff --git a/docs/superpowers/plans/2026-11-04-whereami.md b/docs/superpowers/plans/2026-11-04-whereami.md new file mode 100644 index 0000000..e43d1dc --- /dev/null +++ b/docs/superpowers/plans/2026-11-04-whereami.md @@ -0,0 +1,43 @@ +# Whereami Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans for this small change. + +**Goal:** Add the approved support-oriented `/whereami` chat block. + +**Architecture:** A Velocity SimpleCommand renders a single localized component +through the existing system renderer. The command uses the current invoker's +backend and proxy-specific environment data; no external lookup is needed. + +**Tech Stack:** Kotlin, Velocity, Adventure, library-i18n, JUnit, Gradle. + +**Spec:** `docs/superpowers/specs/2026-11-04-whereami-design.md` + +## Constraints and review focus + +No permission grant, no local-proxy assumption about other players, no cached +backend, no duplicate system prefix. Console has no location. Missing data must +not become a copy action. Preserve full IDs and treat all values as literal text. +Use existing colors and pack readiness behavior in all four supported locales. + +## Task 1: Command and presentation + +- [x] Update `velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt` + to assert the single indented block, independent proxy identities, backend + switches, unavailable fields, localized console handling, literal IDs and copy + payloads. Run the focused tests and observe failure for the missing command. +- [x] Add `WhereAmICommand.kt` beside the existing proxy commands; add message keys + in `ProxySystemMessage.kt` and all four `messages*.properties` bundles. +- [x] Register `whereami` in `GroundsProxyPlugin.kt` using this proxy's environment + and document player-facing behavior in `README.md`. +- [x] Run focused tests, then `./gradlew test check shadowJar --offline --console=plain`. + Expected: successful checks and packaged Velocity plugin. +- [x] Review the final diff and record actual verification results. + +## Verification + +The six new command tests failed against the empty command, then passed after +implementation. The final `test check shadowJar --offline --console=plain` run +succeeded, including format checks and packaging. Final review checked one-prefix +rendering, literal values, exact copy payloads, per-proxy identity, current backend +reads and missing data. The command was not exercised on live Minecraft clients. +Unrelated concurrent welcome changes in the checkout were preserved. diff --git a/docs/superpowers/plans/2026-11-05-welcome-mosaic.md b/docs/superpowers/plans/2026-11-05-welcome-mosaic.md new file mode 100644 index 0000000..a0f6584 --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-welcome-mosaic.md @@ -0,0 +1,35 @@ +# Welcome Mosaic Implementation Plan + +> **For agentic workers:** Use superpowers:executing-plans to implement this plan inline. + +**Goal:** A larger, sharper, seamless welcome logo that fits closed chat. +**Architecture:** Two native bitmap cells from existing branded artwork; compact decorated rendering; exact pack hash gates on Stage. +**Tech Stack:** Kotlin, Adventure, Java ImageIO, Minecraft bitmap fonts, Gradle, Argo. +**Spec:** ../specs/2026-11-05-welcome-mosaic-design.md + +## Global Constraints + +Ten rows, original colors, 14px social icons, separate click targets, conditional voice hint, fallback unchanged, preserve current Stage features. + +## Review Focus + +Native cell limits; seams from glyph advance; closed-chat cropping; four locales; old-pack fallback. + +## Task 1: Native mosaic + +- [ ] Add failing font tests for 512x241 source, two 256x241 cells, height56/ascent50 and fractional join spacing. +- [ ] Run focused test, then update WelcomeFont and pinned asset provenance. +- [ ] Verify each cell with native client atlas code and pixel reconstruction. + +## Task 2: Compact welcome + +- [ ] Add failing tests for both slices, ten rows, icon/logo bounds, all locales and unchanged fallback. +- [ ] Update WelcomeGlyphs, WelcomeLayout, WelcomeMessage and four compact greeting keys. +- [ ] Run focused tests then full Gradle test, spotlessApply and build for both projects. + +## Task 3: Stage + +- [ ] Review changes, sign isolated source commits and build immutable artifacts. +- [ ] Preserve active content ZIP and current Stage features; publish catalog, pack and proxy image. +- [ ] Update hash gates and pack selection with concurrency checks; activate Stage. +- [ ] Verify both Java proxies, Bedrock fallback and client atlas behavior. diff --git a/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md b/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md new file mode 100644 index 0000000..811318c --- /dev/null +++ b/docs/superpowers/plans/2026-11-05-welcome-separated-lines.md @@ -0,0 +1,9 @@ +# Separate welcome rows + +The user approved a slightly smaller logo to keep everything visible in the default ten-row chat. + +- Test the 42px logo at ascent41, preserving two 256x241 source cells and exact fractional join spacing. +- Test separate centered greeting/name and social rows, full icon bounds, voice copy and blank footer in ten rows for all four locales. +- Implement using existing welcome fonts and copy; preserve link targets, conditional voice hint and fallback. +- Run test, spotlessApply and build; sign source commits and publish immutable PackSet v1.2.2-stage-welcome.6 plus matching proxy image. +- Preserve current Stage features and content; verify all three proxies after staged rollout. diff --git a/docs/superpowers/specs/2026-11-04-join-welcome-design.md b/docs/superpowers/specs/2026-11-04-join-welcome-design.md new file mode 100644 index 0000000..1520eb5 --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-join-welcome-design.md @@ -0,0 +1,25 @@ +# Join welcome + +Show a compact Grounds welcome once on a fresh network connection. Use the existing large 26px Grounds HUD wordmark when the client's HUD resource pack is verified; otherwise show the Grounds name. Put Discord, TikTok and Shop on independent clickable rows below it, with native Minecraft controller, music-note and cart icons. Discord uses the lobby bossbar's `https://grnds.link/discord`; the other two links use explicitly documented, replaceable placeholders. + +Keep the original voice request: explain party, game and nearby voice and link to Simple Voice Chat installation only when the client has not announced the mod. Disabled voice and failed UDP connections must not be treated as missing installation. Observe client channel registration and the voice handshake without consuming its messages. + +Approach: the existing proxy plugin owns network joins and translations, so render and deliver there. Backend delivery would repeat on server changes; voice presence snapshots conflate missing installation with disconnected voice. Wait five seconds after the first backend connection for mod negotiation and pack readiness. Carry a delivered marker in the namespaced `grounds:welcome` client cookie across transfers. Reset it on a fresh login before preferred-region routing; incoming transfers with an absent/pending marker receive their first welcome, while delivered markers suppress repeats. Cancel delayed delivery on disconnect, and identify pending work by connection object to avoid stale reconnect callbacks. Provide English, German, French and Spanish copy. + +User supplied the layout and selected the three links and placeholder policy. Implementation choices: five-second grace period, standard text fallback, configurable link environment variables. No publishing is part of this change. + +Review fix: an unconditional transfer skip loses the first welcome when PreferredRegionListener redirects before backend selection. Regression tests cover both this route and transfers during the delivery delay, delivered markers, and stale cookie replies. + +Visual correction: the user requested the large wordmark used by the scoreboard rather than the compact G chat icon. Use the existing approved `grounds:tab` U+E000 wordmark at 26px height, with enough normal chat lines reserved around its ascent. Show native 🎮 / 🎵 / 🛒 icons on separate linked rows. The supplied Cytooxien screenshot informs hierarchy only; no competitor assets or exact layout are copied. Native Minecraft 26.3 glyphs and the exact active Stage font pack were inspected before use. + +Keep the full pack-ready welcome, including voice help, within the default ten closed-chat rows: two empty rows, logo anchor, one empty row, greeting, three links, one short voice-description row, installation link. Do not append a trailing newline. This reserves the tall bitmap without pushing its anchor out of view. + +Pixel-logo follow-up: replace the native Unicode icons with actual Discord/TikTok silhouettes and a shopping-bag bitmap. Use a single horizontal row; each icon and name share a click target. The 14px glyphs have 11px ascent, with an empty normal row above and below. A full pack-ready welcome still uses at most ten rows. Only verified new HUD pack hashes covered by WELCOME_FONT_PACK_SHA1S may enable icons. Stage uses an immutable resource-pack prerelease pin; global Edge and Stable channels remain untouched. + +Centered Mini-CD follow-up: center every visible line within the default 320 GUI-pixel chat column. The resulting space at either edge insets the block; the client does not expose custom chat-width settings to the server. Use the existing verified tab-font pixel spacing, and ordinary spaces for clients without the pack. Measure native Latin advances and the actual wordmark/icon advances, including punctuation and localized accents. Preserve the ten-row height, individual link targets, mod detection and transfer behavior. Use gold.primary #E7B84B for link labels and the Grounds name, text.primary #F3F0EA for the greeting, text.secondary #C1B9AA for voice help and energy.accent #67D9EC for the installation action. Keep the original wordmark and social bitmap colours neutral-white modulated as required by the Mini-CD. No new pack is needed. + +Chat-background spacing correction: the user's actual client screenshot shows social bitmaps extending below the final chat background when the mod is installed. Always append a blank footer row. With verified wordmark and no voice help, reserve three leading rows for extra top margin. With voice help, retain two leading rows and combine the short localized voice description with a separate cyan installation link on one centered row so the full welcome still fits ten closed-chat rows including footer. Social icons retain an empty row above and below. Shorten the greeting to “Willkommen, !” and equivalents because the wordmark already names Grounds; highlight the name gold. A generic welcome is appropriate for both first-time and returning players and does not imply new player-history detection. + +Immediate-delivery follow-up: remove the fixed five-second modern-client delay. Required packs already complete in configuration. After the first backend joins and transfer dedupe is resolved false, request a separate `grounds:welcome_ready` cookie and schedule welcome without a delay on its reply. This costs a single network round trip and does not conflate an earlier Login-cookie response with Play readiness. Preserve exact connection identity, disconnect cancellation and duplicate guards. Unsolicited/stale readiness replies do not schedule anything. Protocols before1.20.5 retain the old five-second grace. Voice installation observers run at maximum priority with a synchronous memory capture, then always return EventTask.async so later handlers preserve their normal async execution. The actual deployed Velocity dispatcher is covered by a retained standalone probe that blocks a following async handler while delivering the cookie response. Native26.3 cookie handling runs on the client main thread; SVC's Fabric early-join callback sends its secret request from JoinWorld. Full localized Voice sentences now end in a period followed by a normal space and an independent installation link; native measured widths EN282/DE308/FR301/ES314px, all below320. + +Floating-stones follow-up: use the original full logo in a separate grounds:welcome U+E403 glyph at40px height/ascent33 with86px advance. Reserve four leading rows without Voice help and three with it; the complete block remains ten rows. The normal text ending in the Voice variant needs no extra footer; the bitmap-ending variant retains its footer. The original tab/sidebar assets remain unchanged. Gate new art with WELCOME_LOGO_PACK_SHA1S covering all allowed HUD hashes. Replace the punctuation/space before Install with the same sand-coloured “ • ” separator as the bossbar; localized copy omits the terminal period and Spanish is shortened to remain under320px. diff --git a/docs/superpowers/specs/2026-11-04-whereami-design.md b/docs/superpowers/specs/2026-11-04-whereami-design.md new file mode 100644 index 0000000..9f97419 --- /dev/null +++ b/docs/superpowers/specs/2026-11-04-whereami-design.md @@ -0,0 +1,45 @@ +# Whereami + +Players can use `/whereami` without a permission grant to identify their current +connection for support. The agreed presentation is one Grounds system message: + +```text +[Grounds] Allgemein | Dein Standort im Netzwerk + Diese Angaben helfen dem Support, Probleme einzugrenzen. + + [Pin] Region: nl-ams1 + [Knoten] Proxy: velocity-eu-1 + [Server] Server: lobby-2 +``` + +Use the existing recipient-aware system renderer and palette. The hint and labels +are muted. Proxy and server values are interactive accent text: hover explains +that clicking copies the full ID. Retain complete IDs. Region is ordinary body +text. Missing values render as a noninteractive em dash. Localize all prose in +English, German, French and Spanish, including the player-only console response. + +The user chose the indented block over a compact single-line summary. Send the +block once so the system prefix is not repeated on every row. No separate UI, +network request or location cache is needed for a command about its invoker. +Each proxy supplies its own `REGION` (falling back to the existing region code) +and `PROXY_ID`; read the invoker's current backend at execution time. This works +independently on every proxy and reflects backend switches immediately. + +Implementation choices: colon-separated labels avoid fragile table alignment in +Minecraft's proportional font; no ID abbreviation is required. Keep existing +`/proxydebug` behavior unchanged. + + +Refinement: use the actual Minecraft Mini-CD values, not the web palette: +primary #F3F0EA, secondary #C1B9AA, interactive #67D9EC. The title is bold. +Three first-party, hard-alpha 10px pixel icons (pin, connected nodes, server rack) +use gold #E7B84B, copper #DDA16F, warm white and cyan. Their own atlas is +`grounds:connection`, U+E410–U+E412, rendered white to preserve texture colors. +One transparent top/bottom margin keeps the visible ink inside the native chat +row. Existing support copy, full IDs and independent copy actions remain. + +Icons require both recipient-specific verified HUD readiness and an environment +allowlist proving every approved HUD pack contains the new glyphs. Otherwise +omit icons and preserve the text labels, including on Bedrock. No unverified PUA +characters or assumed client glyphs are sent. A separate immutable Stage pack pin +adds only the new font and atlas; existing pack content and global channels remain. diff --git a/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md new file mode 100644 index 0000000..ade2dae --- /dev/null +++ b/docs/superpowers/specs/2026-11-05-welcome-mosaic-design.md @@ -0,0 +1,9 @@ +# Welcome layout with separate community links + +The user approved a smaller logo to keep the greeting, community links, voice hint and blank footer fully visible in the default ten-row chat. Keep the original 512x241 artwork and two 256x241 native cells; render the logo at height42/ascent41 with exact fractional join spacing. + +Center each row separately. The greeting retains the player name, and Discord/TikTok/Shop follow on their own row with three-space gaps. Preserve Mini-CD colors, independent click targets, conditional voice installation copy, the sand bullet and existing fallback. + +Without the voice mod, the logo is on row4 (ink bounds y2–44), greeting row5 (text y45), social icons row7 (bitmap y58–72), voice row8 (text y72), and blank footer row9. With the mod installed, shift the logo, greeting and socials down one row and omit voice copy, retaining footer row9. Social icon height14/ascent12 remains unchanged. + +A taller twelve-row block with the large56px logo was considered and rejected because it clips in closed chat. The chosen42px logo retains higher-resolution source artwork. diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt index f62b880..926d831 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/GroundsProxyPlugin.kt @@ -38,6 +38,8 @@ import gg.grounds.proxy.velocity.command.OnlineCommand import gg.grounds.proxy.velocity.command.ProxyCommand import gg.grounds.proxy.velocity.command.ProxyDebugCommand import gg.grounds.proxy.velocity.command.RegionCommand +import gg.grounds.proxy.velocity.command.WhereAmICommand +import gg.grounds.proxy.velocity.command.WhereAmIGlyphs import gg.grounds.proxy.velocity.handler.NatsHandler import gg.grounds.proxy.velocity.listener.LobbyCountsListener import gg.grounds.proxy.velocity.listener.ProxyHopListener @@ -56,6 +58,10 @@ import gg.grounds.proxy.velocity.tab.PlayerDisplayQueryImpl import gg.grounds.proxy.velocity.tab.TabList import gg.grounds.proxy.velocity.tab.TabParties import gg.grounds.proxy.velocity.tab.VoicePresences +import gg.grounds.proxy.velocity.welcome.WelcomeGlyphs +import gg.grounds.proxy.velocity.welcome.WelcomeLinks +import gg.grounds.proxy.velocity.welcome.WelcomeListener +import gg.grounds.proxy.velocity.welcome.WelcomeMessage import io.micrometer.core.instrument.MeterRegistry import io.nats.client.Subscription import java.net.URI @@ -234,6 +240,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { private var countSubscription: Subscription? = null private var metrics: ProxyMetrics? = null private var tabList: TabList? = null + private var welcome: WelcomeListener? = null private var bedrockRoster: BedrockRoster? = null private val tabParties = TabParties() private val voicePresences = VoicePresences() @@ -308,6 +315,40 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.channelRegistrar.register(lobbyIdentifier) proxy.eventManager.register(this, LobbyCountsListener(lobbyIdentifier, service, logger)) + val pixelIconsEnabled = WelcomeGlyphs.enabled(System.getenv()) + val welcomeListener = + WelcomeListener( + messages, + WelcomeMessage( + messages, + WelcomeLinks.from(), + decoratedLogoEnabled = WelcomeGlyphs.decoratedLogoEnabled(System.getenv()), + ), + schedule = { task -> proxy.scheduler.buildTask(this, task).schedule() }, + logoReady = { player -> + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + }, + iconsReady = { player -> + pixelIconsEnabled && + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + }, + legacySchedule = { task -> + proxy.scheduler + .buildTask(this, task) + .delay(WelcomeListener.LEGACY_GRACE_SECONDS, TimeUnit.SECONDS) + .schedule() + }, + ) + welcome = welcomeListener + proxy.channelRegistrar.register( + MinecraftChannelIdentifier.from(WelcomeListener.VOICE_CHANNEL) + ) + proxy.eventManager.register(this, welcomeListener) + // The network-wide player count, pushed every few seconds. Subscribed once for the whole // proxy rather than per player: it is a property of the network, not of anybody's session. countSubscription = @@ -375,6 +416,23 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { proxy.commandManager.register("proxydebug", ProxyDebugCommand(messages)) val proxyId = System.getenv("PROXY_ID").orEmpty() + val whereamiIconsEnabled = WhereAmIGlyphs.enabled(System.getenv()) + proxy.commandManager.register( + "whereami", + WhereAmICommand( + currentRegion = { + System.getenv("REGION")?.trim()?.takeIf(String::isNotEmpty) ?: region() + }, + currentProxy = { proxyId }, + messages = messages, + iconsReady = { player -> + whereamiIconsEnabled && + ProxyServiceRegistry.get(PlayerHudAppearanceQuery::class.java) + ?.appearanceOf(player.uniqueId) + ?.ready == true + }, + ), + ) val hops = ProxyHopListener( this, @@ -637,6 +695,7 @@ constructor(private val proxy: ProxyServer, private val logger: Logger) { @Subscribe fun onShutdown(event: ProxyShutdownEvent) { + welcome?.close() announcementSync?.close() announcementPoll?.cancel() announcementTick?.cancel() diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt index 508fd25..f3f68f4 100644 --- a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/ProxySystemMessage.kt @@ -14,6 +14,13 @@ enum class ProxySystemMessage( DEBUG_DENIED("debug.denied", level = SystemLevel.ERROR), DEBUG_SERVER("debug.server"), DEBUG_PROXY("debug.proxy"), + WHEREAMI_PLAYER_ONLY("whereami.player-only", level = SystemLevel.ERROR), + WHEREAMI_HEADER("whereami.header"), + WHEREAMI_SUPPORT("whereami.support"), + WHEREAMI_REGION("whereami.region"), + WHEREAMI_PROXY("whereami.proxy"), + WHEREAMI_SERVER("whereami.server"), + WHEREAMI_COPY_HINT("whereami.copy-hint"), ONLINE_UNAVAILABLE("online.unavailable", level = SystemLevel.ERROR), ONLINE_HEADER("online.header"), ONLINE_NOBODY("online.nobody"), diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt new file mode 100644 index 0000000..1491b5a --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommand.kt @@ -0,0 +1,106 @@ +package gg.grounds.proxy.velocity.command + +import com.velocitypowered.api.command.SimpleCommand +import com.velocitypowered.api.proxy.Player +import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.ProxySystemMessage +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.event.HoverEvent +import net.kyori.adventure.text.format.TextColor + +/** The invoker's live connection, rendered as one support-friendly system message. */ +class WhereAmICommand +@JvmOverloads +constructor( + private val currentRegion: () -> String?, + private val currentProxy: () -> String?, + private val messages: Translations, + private val iconsReady: (Player) -> Boolean = { false }, +) : SimpleCommand { + override fun execute(invocation: SimpleCommand.Invocation) { + val source = invocation.source() + val player = source as? Player + if (player == null) { + messages.send(source, ProxySystemMessage.WHEREAMI_PLAYER_ONLY) + return + } + + // The invoker is connected here. Read their backend directly so a server switch is + // reflected immediately, without waiting for network presence heartbeats. + val showIcons = iconsReady(player) + val region = currentRegion() + val proxyId = currentProxy() + val server = player.currentServer.orElse(null)?.serverInfo?.name + val block = + Component.empty() + .append(messages.render(ProxySystemMessage.WHEREAMI_HEADER, player)) + .append(Component.newline()) + .append( + messages.render(ProxySystemMessage.WHEREAMI_SUPPORT, player).color(SECONDARY) + ) + .append(Component.newline()) + .append(Component.newline()) + .append( + messages + .render( + ProxySystemMessage.WHEREAMI_REGION, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.REGION, showIcons), + "region" to value(player, region), + ) + .color(SECONDARY) + ) + .append(Component.newline()) + .append( + messages + .render( + ProxySystemMessage.WHEREAMI_PROXY, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.PROXY, showIcons), + "proxy" to value(player, proxyId, copyable = true), + ) + .color(SECONDARY) + ) + .append(Component.newline()) + .append( + messages + .render( + ProxySystemMessage.WHEREAMI_SERVER, + player, + "icon" to WhereAmIGlyphs.render(WhereAmIGlyphs.SERVER, showIcons), + "server" to value(player, server, copyable = true), + ) + .color(SECONDARY) + ) + + messages.sendRendered(player, ProxySystemMessage.WHEREAMI_HEADER, block) + } + + private fun value(player: Player, raw: String?, copyable: Boolean = false): Component { + val value = raw?.trim()?.takeIf(String::isNotEmpty) ?: return Component.text("—", SECONDARY) + val text = Component.text(value, if (copyable) INTERACTIVE else PRIMARY) + if (!copyable) return text + return text + .clickEvent(ClickEvent.copyToClipboard(value)) + .hoverEvent( + HoverEvent.showText( + messages + .render( + ProxySystemMessage.WHEREAMI_COPY_HINT, + player, + "value" to Component.text(value, SECONDARY), + ) + .color(PRIMARY) + ) + ) + } + + private companion object { + // Minecraft Mini-CD tokens (docs/design/minecraft-mini-cd-and-chat.md). + // The general web palette's muted/mint tokens have different values. + val PRIMARY = TextColor.color(0xF3F0EA) + val SECONDARY = TextColor.color(0xC1B9AA) + val INTERACTIVE = TextColor.color(0x67D9EC) + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt new file mode 100644 index 0000000..5a1e2ea --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/command/WhereAmIGlyphs.kt @@ -0,0 +1,33 @@ +package gg.grounds.proxy.velocity.command + +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor + +internal object WhereAmIGlyphs { + val FONT = Key.key("grounds:connection") + const val REGION = '\uE410' + const val PROXY = '\uE411' + const val SERVER = '\uE412' + + fun render(glyph: Char, ready: Boolean): Component = + if (ready) + Component.empty() + .append(Component.text(glyph.toString(), NamedTextColor.WHITE).font(FONT)) + .append(Component.space()) + else Component.empty() + + /** Readiness for a HUD pack is proof only when every allowed pack has these new glyphs. */ + fun enabled(environment: Map): Boolean { + fun hashes(name: String) = + environment[name] + .orEmpty() + .split(',') + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + val hud = hashes("HUD_FONT_PACK_SHA1S") + val connections = hashes("WHEREAMI_FONT_PACK_SHA1S") + return hud.isNotEmpty() && hud.all { it in connections } + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt new file mode 100644 index 0000000..97c0bcd --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeGlyphs.kt @@ -0,0 +1,37 @@ +package gg.grounds.proxy.velocity.welcome + +import net.kyori.adventure.key.Key + +internal object WelcomeGlyphs { + val FONT = Key.key("grounds:welcome") + const val DISCORD = '\uE400' + const val TIKTOK = '\uE401' + const val SHOP = '\uE402' + const val LOGO = '\uE403' + const val LOGO_RIGHT = '\uE404' + const val LOGO_JOIN = '\uE405' + const val LOGO_TEXT = "\uE403\uE405\uE404" + const val LOGO_SLICE_ADVANCE = 46.0 + // Each 256x241 cell renders 42*256/241 pixels wide; remove native rounded glyph spacing. + const val LOGO_JOIN_ADVANCE = -1.38589211618257 + const val LOGO_ADVANCE = 90.61410788381743 + + /** HUD readiness is sufficient only if every approved HUD pack contains these glyphs. */ + fun supportedBy(hudPacks: Set, welcomePacks: Set): Boolean = + hudPacks.isNotEmpty() && hudPacks.all { it in welcomePacks } + + private fun hashes(environment: Map, name: String): Set = + environment[name].orEmpty().split(',').map(String::trim).filter(String::isNotEmpty).toSet() + + fun enabled(environment: Map): Boolean = + supportedBy( + hashes(environment, "HUD_FONT_PACK_SHA1S"), + hashes(environment, "WELCOME_FONT_PACK_SHA1S"), + ) + + fun decoratedLogoEnabled(environment: Map): Boolean = + supportedBy( + hashes(environment, "HUD_FONT_PACK_SHA1S"), + hashes(environment, "WELCOME_LOGO_PACK_SHA1S"), + ) +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt new file mode 100644 index 0000000..1a47180 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeLayout.kt @@ -0,0 +1,92 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.proxy.velocity.tab.TabGlyphs +import gg.grounds.proxy.velocity.tab.TabSpaces +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.format.TextDecoration + +/** + * Align within the default 320px chat column; the server cannot read client chat-width settings. + */ +internal object WelcomeLayout { + private val defaultFont = Key.key("minecraft:default") + + fun center(line: Component, packReady: Boolean): Component { + val inset = ((320 - width(line)) / 2).coerceAtLeast(0.0) + val padding = + if (packReady) Component.text(TabSpaces.of(inset.toInt())).font(TabGlyphs.FONT) + else Component.text(" ".repeat(inset.toInt() / 4)).font(defaultFont) + // Padding is a sibling so it cannot inherit a link target or tint the bitmap. + return Component.empty().append(padding).append(line) + } + + private fun width( + component: Component, + inheritedFont: Key = defaultFont, + inheritedBold: Boolean = false, + ): Double { + val font = component.font() ?: inheritedFont + val bold = + when (component.decoration(TextDecoration.BOLD)) { + TextDecoration.State.TRUE -> true + TextDecoration.State.FALSE -> false + TextDecoration.State.NOT_SET -> inheritedBold + } + val own = + (component as? TextComponent)?.content()?.sumOf { ch -> + when { + font == TabGlyphs.FONT && ch == TabGlyphs.LOGO -> + TabGlyphs.LOGO_ADVANCE.toDouble() + font == WelcomeGlyphs.FONT && + (ch == WelcomeGlyphs.LOGO || ch == WelcomeGlyphs.LOGO_RIGHT) -> + WelcomeGlyphs.LOGO_SLICE_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.LOGO_JOIN -> + WelcomeGlyphs.LOGO_JOIN_ADVANCE + font == WelcomeGlyphs.FONT && ch == WelcomeGlyphs.SHOP -> 13.0 + font == WelcomeGlyphs.FONT && ch in '\uE400'..'\uE401' -> 14.0 + else -> (advance(ch) + if (bold && ch != ' ') 1 else 0).toDouble() + } + } ?: 0.0 + return own + component.children().sumOf { width(it, font, bold) } + } + + /** Native Java bitmap advances, including the Latin accents used by the welcome locales. */ + private fun advance(ch: Char): Int = + when (ch) { + ' ' -> 4 + '!', + '\'', + ',', + '.', + ':', + ';', + 'i', + '|', + '¡', + '·' -> 2 + '`', + 'l', + 'í', + '•' -> 3 + '"', + '(', + ')', + '*', + 'I', + '[', + ']', + 't', + '{', + '}' -> 4 + '<', + '>', + 'f', + 'k' -> 5 + '@', + '~' -> 7 + '→' -> 8 + else -> 6 + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt new file mode 100644 index 0000000..3c25579 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListener.kt @@ -0,0 +1,192 @@ +package gg.grounds.proxy.velocity.welcome + +import com.velocitypowered.api.event.EventTask +import com.velocitypowered.api.event.PostOrder +import com.velocitypowered.api.event.Subscribe +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.event.player.CookieReceiveEvent +import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.network.HandshakeIntent +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.i18n.Translations +import java.util.concurrent.ConcurrentHashMap +import net.kyori.adventure.key.Key + +/** + * Observe installation, not UDP presence: a muted or disconnected mod still counts as installed. + */ +internal class WelcomeListener( + private val messages: Translations, + private val renderer: WelcomeMessage, + private val schedule: (Runnable) -> ScheduledTask, + private val logoReady: (Player) -> Boolean, + private val iconsReady: (Player) -> Boolean = { false }, + private val legacySchedule: (Runnable) -> ScheduledTask = schedule, +) : AutoCloseable { + // Connection objects prevent a delayed task or old disconnect from affecting a reconnect. + private val sessions = ConcurrentHashMap() + @Volatile private var closed = false + + private class Join(transfer: Boolean) { + var voiceInstalled = false + var welcomed: Boolean? = if (transfer) null else false + var cookieRequested = false + var readyRequested = false + var clientReady = false + var connected = false + var scheduled = false + var sent = false + var task: ScheduledTask? = null + } + + private fun session(player: Player): Join = + sessions.computeIfAbsent(player) { + Join(player.handshakeIntent == HandshakeIntent.TRANSFER) + } + + @Subscribe(order = PostOrder.FIRST) + fun onLogin(event: PostLoginEvent) { + val player = event.player + if (closed || player.protocolVersion < ProtocolVersion.MINECRAFT_1_20_5) return + val join = session(player) + synchronized(join) { + if (closed || sessions[player] !== join) return + if (player.handshakeIntent == HandshakeIntent.TRANSFER) { + join.cookieRequested = true + player.requestCookie(COOKIE_KEY) + } else { + // Reset before PreferredRegionListener can transfer this fresh network join. + player.storeCookie(COOKIE_KEY, byteArrayOf(0)) + } + } + } + + @Subscribe + fun onCookie(event: CookieReceiveEvent) { + if (event.originalKey != COOKIE_KEY && event.originalKey != READY_KEY) return + val player = event.player + val join = sessions[player] ?: return + synchronized(join) { + if (closed || sessions[player] !== join) return + if (event.originalKey == READY_KEY) { + if (!join.readyRequested) return + join.readyRequested = false + join.clientReady = true + } else { + if (!join.cookieRequested) return + join.cookieRequested = false + join.welcomed = event.originalData?.contentEquals(byteArrayOf(1)) == true + } + event.result = CookieReceiveEvent.ForwardResult.handled() + scheduleIfNeeded(player, join) + } + } + + @Subscribe(priority = Short.MAX_VALUE, async = false) + fun onChannels(event: PlayerChannelRegisterEvent): EventTask { + if (event.channels.any { it.id.startsWith("voicechat:") }) markInstalled(event.player) + return continueAsync() + } + + // Observe on the packet thread before async handlers can let a later cookie reply overtake us. + @Subscribe(priority = Short.MAX_VALUE, async = false) + fun onPluginMessage(event: PluginMessageEvent): EventTask { + if (event.identifier.id == VOICE_CHANNEL && event.data.size == 4) { + (event.source as? Player)?.let(::markInstalled) + } + // Leave the result unchanged: the backend must receive the original handshake. + return continueAsync() + } + + // Velocity continues later void handlers on our current thread unless we explicitly hand off. + // Capture only the installation bit synchronously; preserve async execution for other plugins. + private fun continueAsync(): EventTask = EventTask.async {} + + private fun markInstalled(player: Player) { + if (closed) return + val join = session(player) + synchronized(join) { join.voiceInstalled = true } + } + + @Subscribe + fun onServerConnected(event: ServerPostConnectEvent) { + val player = event.player + if (closed || event.previousServer != null) return + val join = session(player) + synchronized(join) { + join.connected = true + scheduleIfNeeded(player, join) + } + } + + /** Called under the connection's monitor, after either backend connect or cookie reply. */ + private fun scheduleIfNeeded(player: Player, join: Join) { + if ( + closed || + sessions[player] !== join || + !join.connected || + join.welcomed != false || + join.scheduled + ) + return + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5 && !join.clientReady) { + if (!join.readyRequested) { + join.readyRequested = true + // Queued after JoinGame: the client processes join-time mod announcements before + // replying on its main thread. Unlike a timer this costs only a network round trip. + player.requestCookie(READY_KEY) + } + return + } + join.scheduled = true + val scheduler = + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5) schedule + else legacySchedule + join.task = scheduler(Runnable { deliver(player, join) }) + } + + private fun deliver(player: Player, join: Join) { + synchronized(join) { + if (closed || sessions[player] !== join || join.sent || !player.isActive) return + join.sent = true + join.task = null + player.sendMessage( + renderer.render( + player.username, + messages.localeOf(player), + logoReady(player), + join.voiceInstalled, + iconsReady(player), + ) + ) + join.welcomed = true + if (player.protocolVersion >= ProtocolVersion.MINECRAFT_1_20_5) { + player.storeCookie(COOKIE_KEY, byteArrayOf(1)) + } + } + } + + @Subscribe + fun onDisconnect(event: DisconnectEvent) { + val join = sessions.remove(event.player) ?: return + synchronized(join) { join.task?.cancel() } + } + + override fun close() { + closed = true + sessions.values.forEach { join -> synchronized(join) { join.task?.cancel() } } + sessions.clear() + } + + companion object { + private val COOKIE_KEY = Key.key("grounds:welcome") + private val READY_KEY = Key.key("grounds:welcome_ready") + const val VOICE_CHANNEL = "voicechat:request_secret" + const val LEGACY_GRACE_SECONDS = 5L + } +} diff --git a/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt new file mode 100644 index 0000000..f46ab51 --- /dev/null +++ b/velocity/src/main/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessage.kt @@ -0,0 +1,155 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.i18n.MessageKey +import gg.grounds.i18n.Translations +import gg.grounds.proxy.velocity.tab.TabGlyphs +import java.util.Locale +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.TextColor +import net.kyori.adventure.text.format.TextDecoration + +internal data class WelcomeLinks( + val discord: String = "https://grnds.link/discord", + val tiktok: String = "https://example.com/grounds/tiktok", + val shop: String = "https://example.com/grounds/shop", +) { + companion object { + fun from(environment: (String) -> String? = System::getenv): WelcomeLinks { + val defaults = WelcomeLinks() + return WelcomeLinks( + environment("WELCOME_DISCORD_URL")?.takeIf(String::isNotBlank) ?: defaults.discord, + environment("WELCOME_TIKTOK_URL")?.takeIf(String::isNotBlank) ?: defaults.tiktok, + environment("WELCOME_SHOP_URL")?.takeIf(String::isNotBlank) ?: defaults.shop, + ) + } + } +} + +/** One branded block: every link owns its click event, so it cannot leak onto the next line. */ +internal class WelcomeMessage +@JvmOverloads +constructor( + private val messages: Translations, + private val links: WelcomeLinks = WelcomeLinks(), + private val decoratedLogoEnabled: Boolean = false, +) { + private enum class Copy(override val id: String) : MessageKey { + GREETING("welcome.greeting"), + VOICE("welcome.voice"), + INSTALL("welcome.install"), + } + + @JvmOverloads + fun render( + playerName: String, + locale: Locale, + logoReady: Boolean, + voiceInstalled: Boolean, + iconsReady: Boolean = false, + ): Component { + val decoratedLogo = decoratedLogoEnabled && logoReady && iconsReady + val brand = + if (decoratedLogo) + Component.text(WelcomeGlyphs.LOGO_TEXT) + .font(WelcomeGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + else if (logoReady) + Component.text(TabGlyphs.LOGO.toString()) + .font(TabGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + else Component.text("GROUNDS", GOLD, TextDecoration.BOLD) + val result = Component.text().font(Key.key("minecraft:default")) + // At height42/ascent41, row4 starts the mosaic at y2 and ends at y44. + // The greeting starts at y45; the separate social row7 starts its icons at y58. + val leadingRows = + when { + decoratedLogo -> if (voiceInstalled) 5 else 4 + logoReady -> if (voiceInstalled) 3 else 2 + else -> 1 + } + repeat(leadingRows) { result.appendNewline() } + result.append(WelcomeLayout.center(brand, logoReady)).appendNewline() + if (logoReady && !decoratedLogo) result.appendNewline() + result + .append( + WelcomeLayout.center( + messages.render(Copy.GREETING, locale, "player" to playerName), + logoReady, + ) + ) + .appendNewline() + .appendNewline() + val communityGap = Component.text(" ") + val community = + Component.empty() + .append( + communityLink( + WelcomeGlyphs.DISCORD, + "Discord", + links.discord, + iconsReady && logoReady, + ) + ) + .append(communityGap) + .append( + communityLink( + WelcomeGlyphs.TIKTOK, + "TikTok", + links.tiktok, + iconsReady && logoReady, + ) + ) + .append(communityGap) + .append( + communityLink(WelcomeGlyphs.SHOP, "Shop", links.shop, iconsReady && logoReady) + ) + result.append(WelcomeLayout.center(community, logoReady)) + if (!voiceInstalled) { + result.appendNewline() + // The compact pack aligns the full icon bitmap above the following normal text row; + // reserve the spare row below the copy. + if (!decoratedLogo) result.appendNewline() + result.append( + WelcomeLayout.center( + Component.empty() + .append(messages.render(Copy.VOICE, locale)) + .append(Component.text(" • ", SAND)) + .append(link(messages.render(Copy.INSTALL, locale), INSTALL_URL)), + logoReady, + ) + ) + } + // Always retain background below the last sentence or bitmap icon row. + return result.appendNewline().build() + } + + private fun communityLink( + icon: Char, + label: String, + url: String, + iconsReady: Boolean, + ): Component { + val row = Component.text() + if (iconsReady) { + row.append( + Component.text(icon.toString()) + .font(WelcomeGlyphs.FONT) + .color(TextColor.color(0xFFFFFF)) + ) + .append(Component.space()) + } + return link(row.append(Component.text(label, GOLD)).build(), url) + } + + private fun link(label: Component, url: String): Component = + label.clickEvent(ClickEvent.openUrl(url)).hoverEvent(Component.text(url, SAND)) + + companion object { + // Minecraft Mini-CD: gold.primary and text.secondary, not the web UI palette. + private val GOLD = TextColor.color(0xE7B84B) + private val SAND = TextColor.color(0xC1B9AA) + const val INSTALL_URL = "https://modrepo.de/minecraft/voicechat/wiki/installation" + } +} diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages.properties b/velocity/src/main/resources/gg/grounds/proxy/messages.properties index 21623c5..dc9119c 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages.properties @@ -11,6 +11,13 @@ online.nobody=nobody is connected online.region= — online.region.unknown=unknown region — online.proxy=: +whereami.player-only=This command is only available in-game. +whereami.header=Your network location +whereami.support=\ Share these details with support if you need help. +whereami.region=\ Region: +whereami.proxy=\ Proxy: +whereami.server=\ Server: +whereami.copy-hint=Click to copy region.player-only=Only a player can change region. region.none=No regions are configured on this proxy. region.unknown=Unknown region ''. Available: @@ -78,3 +85,6 @@ proxy.current=You are on . proxy.entry=Switch to (): proxy.entry.current= (): (you are here) proxy.usage=Click a proxy to switch. +welcome.greeting=Welcome, <#E7B84B>! +welcome.voice=<#C1B9AA>We support party, game and proximity voice chat +welcome.install=<#67D9EC>Install diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties index 41a8fdc..52fac82 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_de.properties @@ -9,6 +9,13 @@ online.nobody=niemand ist verbunden online.region= — online.region.unknown=unbekannte Region — online.proxy=: +whereami.player-only=Diesen Befehl kannst du nur im Spiel nutzen. +whereami.header=Dein Standort im Netzwerk +whereami.support=\ Bei Problemen helfen diese Angaben dem Support. +whereami.region=\ Region: +whereami.proxy=\ Proxy: +whereami.server=\ Server: +whereami.copy-hint=Klicken zum Kopieren region.player-only=Nur Spieler können die Region wechseln. region.none=Auf diesem Proxy sind keine Regionen konfiguriert. region.unknown=Unbekannte Region ''. Verfügbar: @@ -76,3 +83,7 @@ proxy.current=Du bist auf . proxy.entry=Zu wechseln (): proxy.entry.current= (): (du bist hier) proxy.usage=Klicke auf einen Proxy, um zu wechseln. +# Welcome block +welcome.greeting=Willkommen, <#E7B84B>! +welcome.voice=<#C1B9AA>Wir bieten Voice Chat in Partys, Spielen und lokal +welcome.install=<#67D9EC>Installieren diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties index 5ca0fc6..2faf7a3 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_es.properties @@ -9,6 +9,13 @@ online.nobody=no hay nadie conectado online.region= — online.region.unknown=región desconocida — online.proxy=: +whereami.player-only=Este comando solo está disponible dentro del juego. +whereami.header=Tu ubicación en la red +whereami.support=\ Estos datos ayudan al soporte si tienes problemas. +whereami.region=\ Región: +whereami.proxy=\ Proxy: +whereami.server=\ Servidor: +whereami.copy-hint=Haz clic para copiar region.player-only=Solo un jugador puede cambiar de región. region.none=No hay regiones configuradas en este proxy. region.unknown=Región desconocida ''. Disponibles: @@ -76,3 +83,7 @@ proxy.current=Estás en . proxy.entry=Cambiar a (): proxy.entry.current= (): (estás aquí) proxy.usage=Haz clic en un proxy para cambiar. +# Welcome block +welcome.greeting=¡Bienvenido, <#E7B84B>! +welcome.voice=<#C1B9AA>Hay chat de voz grupal, en partida y por cercanía +welcome.install=<#67D9EC>Instalar diff --git a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties index e9d8b70..7c3d312 100644 --- a/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties +++ b/velocity/src/main/resources/gg/grounds/proxy/messages_fr.properties @@ -9,6 +9,13 @@ online.nobody=personne n'est connecté online.region= — online.region.unknown=région inconnue — online.proxy=: +whereami.player-only=Cette commande est disponible uniquement en jeu. +whereami.header=Votre position sur le réseau +whereami.support=\ Ces informations aident le support en cas de problème. +whereami.region=\ Région : +whereami.proxy=\ Proxy : +whereami.server=\ Serveur : +whereami.copy-hint=Cliquez pour copier region.player-only=Seul un joueur peut changer de région. region.none=Aucune région n'est configurée sur ce proxy. region.unknown=Région inconnue ''. Disponibles : @@ -76,3 +83,7 @@ proxy.current=Vous êtes sur . proxy.entry=Aller vers () : proxy.entry.current= () : (vous êtes ici) proxy.usage=Cliquez sur un proxy pour changer. +# Welcome block +welcome.greeting=Bienvenue, <#E7B84B> ! +welcome.voice=<#C1B9AA>Le vocal marche en groupe, en jeu et à proximité +welcome.install=<#67D9EC>Installer diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt new file mode 100644 index 0000000..46cbfdd --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/command/WhereAmICommandTest.kt @@ -0,0 +1,227 @@ +package gg.grounds.proxy.velocity.command + +import com.velocitypowered.api.command.CommandSource +import com.velocitypowered.api.command.SimpleCommand +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.server.ServerInfo +import gg.grounds.i18n.Translations +import gg.grounds.presentation.VelocitySystemMessages +import java.lang.reflect.Proxy +import java.net.InetSocketAddress +import java.util.Locale +import java.util.Optional +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.TextComponent +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.format.TextColor +import net.kyori.adventure.text.format.TextDecoration +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WhereAmICommandTest { + private val messages = + Translations.forBundleWithSystemRenderer( + "gg.grounds.proxy.messages", + javaClass.classLoader, + systemRenderer = VelocitySystemMessages.renderer, + ) + private val received = mutableListOf() + private var server: String? = "lobby-1" + + @Test + fun `each proxy reports its own region and proxy without a permission grant`() { + for ((region, proxyId) in + listOf("nl-ams1" to "velocity-eu-1", "us-nyc1" to "velocity-na-2")) { + received.clear() + val command = WhereAmICommand({ region }, { proxyId }, messages) + val invocation = invocation(player()) + + assertTrue(command.hasPermission(invocation)) + command.execute(invocation) + + assertEquals(1, received.size) + assertTrue(plain().contains("Region: $region"), plain()) + assertTrue(plain().contains("Proxy: $proxyId"), plain()) + assertTrue(plain().contains("Server: lobby-1"), plain()) + } + } + + @Test + fun `support block has one prefix and indented rows with copyable full IDs`() { + val proxyId = "velocity-eu-5fbc57c96d-r8pf9" + server = "minestom-lobby-7c77bc4b6f-9zpht" + WhereAmICommand({ "nl-ams1" }, { proxyId }, messages, iconsReady = { true }) + .execute(invocation(player(Locale.GERMAN))) + + val lines = plain().lines() + assertEquals(6, lines.size) + assertTrue(lines[0].contains("Dein Standort im Netzwerk"), plain()) + assertTrue(lines[1].startsWith(" "), plain()) + assertTrue(lines[1].contains("Support"), plain()) + assertEquals("", lines[2]) + assertEquals(" \uE410 Region: nl-ams1", lines[3]) + assertEquals(" \uE411 Proxy: $proxyId", lines[4]) + assertEquals(" \uE412 Server: $server", lines[5]) + val copies = nodes(received.single()).filter { it.clickEvent() != null } + assertEquals(2, copies.size) + assertEquals(ClickEvent.copyToClipboard(proxyId), copies[0].clickEvent()) + assertEquals(ClickEvent.copyToClipboard(server!!), copies[1].clickEvent()) + copies.forEach { node -> + val hint = node.hoverEvent()!!.value() as Component + assertTrue( + PlainTextComponentSerializer.plainText() + .serialize(hint) + .contains("kopieren", ignoreCase = true) + ) + } + } + + @Test + fun `location block follows Minecraft Mini CD instead of web palette colors`() { + WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages, iconsReady = { true }) + .execute(invocation(player(Locale.GERMAN))) + + val content = nodes(received.single()).filterIsInstance() + val title = content.single { it.content() == "Dein Standort im Netzwerk" } + assertEquals(TextDecoration.State.TRUE, title.decoration(TextDecoration.BOLD)) + val region = content.single { it.content() == "nl-ams1" } + assertEquals(TextColor.color(0xF3F0EA), region.color()) + val copies = content.filter { it.clickEvent() != null } + assertEquals(2, copies.size) + copies.forEach { assertEquals(TextColor.color(0x67D9EC), it.color()) } + val icons = content.filter { it.content() in listOf("\uE410", "\uE411", "\uE412") } + assertEquals(3, icons.size) + icons.forEach { + assertEquals(TextColor.color(0xFFFFFF), it.color()) + assertEquals("grounds:connection", it.font()?.asString()) + } + val hint = content.single { it.content().contains("Support") } + assertEquals(TextColor.color(0xC1B9AA), hint.color()) + } + + @Test + fun `a backend switch is reflected on the next invocation`() { + val command = WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages) + val invocation = invocation(player()) + command.execute(invocation) + received.clear() + + server = "bedwars-9" + command.execute(invocation) + + assertTrue(plain().contains("Server: bedwars-9"), plain()) + assertFalse(plain().contains("lobby-1"), plain()) + } + + @Test + fun `missing region proxy and backend render as unknown`() { + server = null + WhereAmICommand({ null }, { " " }, messages).execute(invocation(player())) + + assertTrue(plain().contains("Region: —"), plain()) + assertTrue(plain().contains("Proxy: —"), plain()) + assertTrue(plain().contains("Server: —"), plain()) + assertTrue(nodes(received.single()).all { it.clickEvent() == null }) + } + + @Test + fun `console gets a localized player-only message`() { + val console = source(CommandSource::class.java, Locale.GERMAN) + WhereAmICommand({ error("Console has no location") }, { "proxy" }, messages) + .execute(invocation(console)) + + assertEquals(1, received.size) + assertTrue(plain().contains("nur im Spiel"), plain()) + } + + @Test + fun `location values render safely in every supported locale`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + received.clear() + WhereAmICommand({ "nl-ams1" }, { "proxy" }, messages) + .execute(invocation(player(locale))) + + assertEquals(1, received.size) + assertTrue(plain().contains("nl-ams1"), plain()) + assertTrue(plain().contains("proxy"), plain()) + assertTrue(plain().contains("lobby-1"), plain()) + assertFalse(plain().contains("whereami."), plain()) + } + } + + @Test + fun `unverified clients retain text labels without private glyphs`() { + WhereAmICommand({ "nl-ams1" }, { "velocity-eu-1" }, messages).execute(invocation(player())) + assertFalse(plain().any { it in '\uE000'..'\uF8FF' }) + assertTrue(plain().lines().contains(" Region: nl-ams1"), plain()) + } + + @Test + fun `connection glyphs require every allowed HUD pack to contain them`() { + assertFalse(WhereAmIGlyphs.enabled(emptyMap())) + assertFalse(WhereAmIGlyphs.enabled(mapOf("HUD_FONT_PACK_SHA1S" to "old"))) + assertFalse( + WhereAmIGlyphs.enabled( + mapOf("HUD_FONT_PACK_SHA1S" to "old,new", "WHEREAMI_FONT_PACK_SHA1S" to "new") + ) + ) + assertTrue( + WhereAmIGlyphs.enabled( + mapOf("HUD_FONT_PACK_SHA1S" to " new ", "WHEREAMI_FONT_PACK_SHA1S" to "new") + ) + ) + } + + private fun nodes(component: Component): List = + listOf(component) + component.children().flatMap(::nodes) + + private fun plain() = + received.joinToString("\n") { PlainTextComponentSerializer.plainText().serialize(it) } + + private fun player(locale: Locale = Locale.ENGLISH): Player = source(Player::class.java, locale) + + private fun source(type: Class, locale: Locale): T = + fake(type) { name, args -> + when (name) { + "getCurrentServer" -> + Optional.ofNullable( + server?.let { name -> + fake(ServerConnection::class.java) { method, _ -> + if (method == "getServerInfo") { + ServerInfo(name, InetSocketAddress("127.0.0.1", 25565)) + } else error("Unexpected server method: $method") + } + } + ) + "get" -> Optional.of(locale) + "hasPermission" -> false + "sendMessage" -> { + received += args!!.filterIsInstance().single() + null + } + else -> error("Unexpected source method: $name") + } + } + + private fun invocation(source: CommandSource): SimpleCommand.Invocation = + fake(SimpleCommand.Invocation::class.java) { name, _ -> + when (name) { + "source" -> source + "arguments" -> emptyArray() + "alias" -> "whereami" + else -> error("Unexpected invocation method: $name") + } + } + + private fun fake(type: Class, invoke: (String, Array?) -> Any?): T = + type.cast( + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(type)) { _, method, args -> + invoke(method.name, args) + } + ) +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt new file mode 100644 index 0000000..ea2f0ee --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeListenerTest.kt @@ -0,0 +1,435 @@ +package gg.grounds.proxy.velocity.welcome + +import com.velocitypowered.api.event.connection.DisconnectEvent +import com.velocitypowered.api.event.connection.PluginMessageEvent +import com.velocitypowered.api.event.connection.PostLoginEvent +import com.velocitypowered.api.event.player.CookieReceiveEvent +import com.velocitypowered.api.event.player.PlayerChannelRegisterEvent +import com.velocitypowered.api.event.player.ServerPostConnectEvent +import com.velocitypowered.api.network.HandshakeIntent +import com.velocitypowered.api.network.ProtocolVersion +import com.velocitypowered.api.proxy.Player +import com.velocitypowered.api.proxy.ServerConnection +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import com.velocitypowered.api.scheduler.ScheduledTask +import gg.grounds.i18n.Translations +import java.lang.reflect.Proxy +import java.util.Locale +import java.util.Optional +import java.util.UUID +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WelcomeListenerTest { + private val pending = mutableListOf() + private var cancelled = 0 + private var ready = false + private var legacyScheduled = 0 + private val messages = + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader) + private val listener = newListener() + + private fun newListener() = + WelcomeListener( + messages, + WelcomeMessage(messages), + schedule = ::queue, + logoReady = { ready }, + legacySchedule = { work -> + legacyScheduled++ + queue(work) + }, + ) + + private fun queue(work: Runnable): ScheduledTask { + pending += work + return fake(ScheduledTask::class.java) { name, _ -> + if (name == "cancel") cancelled++ + null + } + } + + private class Client( + val player: Player, + val received: MutableList, + val cookies: MutableMap, + val requested: MutableList, + ) + + private fun client( + intent: HandshakeIntent = HandshakeIntent.LOGIN, + id: UUID = UUID.randomUUID(), + cookies: MutableMap = mutableMapOf(), + protocol: ProtocolVersion = ProtocolVersion.MINECRAFT_1_21, + ): Client { + val received = mutableListOf() + val requested = mutableListOf() + val player = + fake(Player::class.java) { name, args -> + when (name) { + "getUniqueId" -> id + "getUsername" -> "Alex" + "getHandshakeIntent" -> intent + "getProtocolVersion" -> protocol + "storeCookie" -> { + cookies[args!![0] as Key] = (args[1] as ByteArray).copyOf() + null + } + "requestCookie" -> { + requested += args!![0] as Key + null + } + "isActive" -> true + "get" -> Optional.of(Locale.ENGLISH) + "sendMessage" -> { + received += args!![0] as Component + null + } + else -> null + } + } + return Client(player, received, cookies, requested) + } + + private fun plain(client: Client) = + client.received.joinToString { PlainTextComponentSerializer.plainText().serialize(it) } + + private fun hasInstallLink(client: Client): Boolean { + fun linked(component: Component): Boolean = + component.clickEvent() == ClickEvent.openUrl(WelcomeMessage.INSTALL_URL) || + component.children().any(::linked) + return client.received.any(::linked) + } + + private val readyCookieKey = Key.key("grounds:welcome_ready") + + private fun acknowledgeReady(client: Client, proxy: WelcomeListener = listener) { + proxy.onCookie(CookieReceiveEvent(client.player, readyCookieKey, null)) + } + + private fun connect(client: Client) { + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + } + + @Test + fun `a fresh join needs only a play cookie reply before immediate scheduling`() { + val client = client() + listener.onLogin(PostLoginEvent(client.player)) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertEquals(listOf(readyCookieKey), client.requested) + assertEquals(0, legacyScheduled) + assertTrue(pending.isEmpty(), "Do not infer a missing mod before the client processes join") + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + acknowledgeReady(client) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, client.received.size) + assertFalse(hasInstallLink(client)) + } + + @Test + fun `a late login cookie reply cannot stand in for the play acknowledgement`() { + val client = client(HandshakeIntent.TRANSFER) + listener.onLogin(PostLoginEvent(client.player)) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + assertEquals(listOf(cookieKey), client.requested) + assertTrue(pending.isEmpty()) + listener.onCookie(CookieReceiveEvent(client.player, cookieKey, byteArrayOf(0))) + assertEquals(listOf(cookieKey, readyCookieKey), client.requested) + assertTrue(pending.isEmpty()) + acknowledgeReady(client) + assertEquals(1, pending.size) + } + + @Test + fun `legacy clients retain negotiation grace without unsupported cookies`() { + val client = client(protocol = ProtocolVersion.MINECRAFT_1_20_3) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertTrue(client.requested.isEmpty()) + assertEquals(1, legacyScheduled) + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + pending.single().run() + assertFalse(hasInstallLink(client)) + } + + @Test + fun `duplicate ready replies and reconnect events cannot schedule twice`() { + val client = client() + connect(client) + acknowledgeReady(client) + listener.onServerConnected(ServerPostConnectEvent(client.player, null)) + acknowledgeReady(client) + assertEquals(listOf(readyCookieKey), client.requested) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, client.received.size) + } + + @Test + fun `unsolicited or stale ready replies cannot release another connection`() { + val id = UUID.randomUUID() + val old = client(id = id) + acknowledgeReady(old) + assertTrue(pending.isEmpty()) + listener.onServerConnected(ServerPostConnectEvent(old.player, null)) + listener.onDisconnect( + DisconnectEvent(old.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val current = client(id = id) + listener.onServerConnected(ServerPostConnectEvent(current.player, null)) + acknowledgeReady(old) + assertTrue(pending.isEmpty()) + acknowledgeReady(current) + assertEquals(1, pending.size) + } + + @Test + fun `backend reconnect events cannot schedule a second welcome`() { + val client = client() + connect(client) + connect(client) + assertEquals(1, pending.size) + pending.single().run() + connect(client) + assertEquals(1, client.received.size) + assertEquals(1, pending.size) + assertTrue(hasInstallLink(client)) + } + + private val cookieKey = Key.key("grounds:welcome") + + @Test + fun `transfers with delivered cookie onto either proxy do not repeat the welcome`() { + for (proxy in listOf(listener, newListener())) { + val client = client(HandshakeIntent.TRANSFER) + proxy.onLogin(PostLoginEvent(client.player)) + proxy.onServerConnected(ServerPostConnectEvent(client.player, null)) + assertEquals(listOf(cookieKey), client.requested) + assertTrue(pending.isEmpty()) + proxy.onCookie(CookieReceiveEvent(client.player, cookieKey, byteArrayOf(1))) + assertTrue(pending.isEmpty()) + } + } + + @Test + fun `preferred region redirect before any backend receives its first welcome at destination`() { + val cookies = mutableMapOf(cookieKey to byteArrayOf(1)) + val source = client(cookies = cookies) + listener.onLogin(PostLoginEvent(source.player)) + listener.onDisconnect( + DisconnectEvent(source.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val destination = client(HandshakeIntent.TRANSFER, cookies = cookies) + val otherProxy = newListener() + otherProxy.onLogin(PostLoginEvent(destination.player)) + otherProxy.onCookie(CookieReceiveEvent(destination.player, cookieKey, cookies[cookieKey])) + otherProxy.onChannels( + PlayerChannelRegisterEvent( + destination.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) + acknowledgeReady(destination, otherProxy) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, destination.received.size) + assertFalse(hasInstallLink(destination)) + assertArrayEquals(byteArrayOf(1), cookies[cookieKey]) + } + + @Test + fun `transfer during the delivery delay moves the pending welcome to the destination`() { + val cookies = mutableMapOf() + val source = client(cookies = cookies) + listener.onLogin(PostLoginEvent(source.player)) + connect(source) + listener.onDisconnect( + DisconnectEvent(source.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val destination = client(HandshakeIntent.TRANSFER, cookies = cookies) + val otherProxy = newListener() + otherProxy.onLogin(PostLoginEvent(destination.player)) + otherProxy.onServerConnected(ServerPostConnectEvent(destination.player, null)) + otherProxy.onCookie(CookieReceiveEvent(destination.player, cookieKey, cookies[cookieKey])) + acknowledgeReady(destination, otherProxy) + assertEquals(2, pending.size) + pending.forEach(Runnable::run) + assertTrue(source.received.isEmpty()) + assertEquals(1, destination.received.size) + } + + @Test + fun `absent transfer cookie allows welcome but stale responses cannot reach a reconnect`() { + val id = UUID.randomUUID() + val old = client(HandshakeIntent.TRANSFER, id) + listener.onLogin(PostLoginEvent(old.player)) + listener.onDisconnect( + DisconnectEvent(old.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val current = client(HandshakeIntent.TRANSFER, id) + listener.onLogin(PostLoginEvent(current.player)) + connect(current) + listener.onCookie(CookieReceiveEvent(old.player, cookieKey, null)) + assertTrue(pending.isEmpty()) + val response = CookieReceiveEvent(current.player, cookieKey, null) + listener.onCookie(response) + assertFalse(response.result.isAllowed) + acknowledgeReady(current) + assertEquals(1, pending.size) + pending.single().run() + assertEquals(1, current.received.size) + } + + @Test + fun `a backend switch never initiates a welcome`() { + val client = client() + val previous = fake(RegisteredServer::class.java) { _, _ -> null } + listener.onServerConnected(ServerPostConnectEvent(client.player, previous)) + assertTrue(pending.isEmpty()) + } + + @Test + fun `client mod registration before backend connection hides installation help`() { + val client = client() + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("voicechat:secret")), + ) + ) + connect(client) + pending.single().run() + assertEquals(1, client.received.size) + assertFalse(hasInstallLink(client)) + } + + @Test + fun `client handshake hides help even without connected voice and is forwarded intact`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + val event = + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(0, 0, 0, 20), + ) + listener.onPluginMessage(event) + assertTrue(event.result.isAllowed) + pending.single().run() + assertFalse(hasInstallLink(client)) + } + + @Test + fun `backend messages do not claim the client installed a mod`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + listener.onPluginMessage( + PluginMessageEvent( + backend, + client.player, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(0, 0, 0, 20), + ) + ) + pending.single().run() + assertTrue(hasInstallLink(client)) + } + + @Test + fun `unrelated channels and malformed voice packets cannot suppress help`() { + val client = client() + connect(client) + val backend = fake(ServerConnection::class.java) { _, _ -> null } + listener.onChannels( + PlayerChannelRegisterEvent( + client.player, + listOf(MinecraftChannelIdentifier.from("other:secret")), + ) + ) + listener.onPluginMessage( + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("voicechat:request_secret"), + byteArrayOf(), + ) + ) + listener.onPluginMessage( + PluginMessageEvent( + client.player, + backend, + MinecraftChannelIdentifier.from("other:secret"), + byteArrayOf(0, 0, 0, 20), + ) + ) + pending.single().run() + pending.single().run() + assertEquals(1, client.received.size) + assertTrue(hasInstallLink(client)) + } + + @Test + fun `disconnect cancels delivery and a stale callback cannot reach a new connection`() { + val id = UUID.randomUUID() + val first = client(id = id) + connect(first) + listener.onDisconnect( + DisconnectEvent(first.player, DisconnectEvent.LoginStatus.SUCCESSFUL_LOGIN) + ) + val second = client(id = id) + connect(second) + pending[0].run() + pending[1].run() + assertEquals(1, cancelled) + assertTrue(first.received.isEmpty()) + assertEquals(1, second.received.size) + } + + @Test + fun `pack readiness is read when sending and shutdown cancels outstanding work`() { + val first = client() + connect(first) + ready = true + pending[0].run() + assertTrue(plain(first).contains('\uE000')) + val second = client() + connect(second) + listener.close() + pending[1].run() + assertTrue(second.received.isEmpty()) + assertTrue(cancelled >= 1) + } + + private fun fake(type: Class, invoke: (String, Array?) -> Any?): T = + type.cast( + Proxy.newProxyInstance(javaClass.classLoader, arrayOf(type)) { proxy, method, args -> + when (method.name) { + "hashCode" -> System.identityHashCode(proxy) + "equals" -> proxy === args?.get(0) + "toString" -> type.simpleName + else -> invoke(method.name, args) + } + } + ) +} diff --git a/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt new file mode 100644 index 0000000..588d67b --- /dev/null +++ b/velocity/src/test/kotlin/gg/grounds/proxy/velocity/welcome/WelcomeMessageTest.kt @@ -0,0 +1,243 @@ +package gg.grounds.proxy.velocity.welcome + +import gg.grounds.i18n.Translations +import java.util.Locale +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.event.ClickEvent +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class WelcomeMessageTest { + private val renderer = + WelcomeMessage(Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader)) + + private fun nodes(component: Component): List = + listOf(component) + component.children().flatMap(::nodes) + + private fun url(component: Component) = + (component.clickEvent()?.payload() as? ClickEvent.Payload.Text)?.value() + + private fun plain(component: Component) = + PlainTextComponentSerializer.plainText().serialize(component) + + @Test + fun `each welcome line is inset and shares the same visual center`() { + val lines = plain(renderer.render("Alex", Locale.GERMAN, true, false, true)).lines() + // Widths measured from the native Minecraft font and the active bitmap pack: + // wordmark 129, shorter greeting 78, social row 169, voice row with bossbar bullet 313px. + val expectedInsets = mapOf(2 to 95, 4 to 121, 6 to 75, 8 to 3) + for ((row, expected) in expectedInsets) { + val prefix = lines[row].takeWhile { it in '\uE010'..'\uE025' } + val inset = prefix.sumOf { 1 shl ((it.code - 0xE010) / 2) } + assertEquals(expected, inset, "Row $row should be centered without touching the edge") + } + val fallback = renderer.render("Alex", Locale.GERMAN, false, true, false) + assertTrue(plain(fallback).lines().filter(String::isNotBlank).all { it.startsWith(" ") }) + assertFalse(plain(fallback).any { it in '\uE000'..'\uF8FF' }) + } + + @Test + fun `bitmap welcome reserves background above and below the last icon row`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (voiceInstalled in listOf(false, true)) { + val text = plain(renderer.render("Alex", locale, true, voiceInstalled, true)) + assertTrue( + text.endsWith("\n"), + "The final row must extend the chat background: $locale", + ) + assertTrue( + text.lines().size <= 10, + "Padding must not push the logo out of closed chat", + ) + if (voiceInstalled) { + assertTrue(text.startsWith("\n\n\n"), "Give the tall logo extra room above") + assertTrue(text.lines().last().isEmpty()) + } + } + } + val greeting = plain(renderer.render("Alex", Locale.GERMAN, true, true, true)) + assertTrue(greeting.contains("Willkommen, Alex!")) + assertFalse(greeting.contains("Grounds"), "The wordmark already names Grounds") + } + + @Test + fun `decorated logo fits with floating stones inside the chat background`() { + val decorated = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + decoratedLogoEnabled = true, + ) + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (installed in listOf(false, true)) { + val message = decorated.render("Alex", locale, true, installed, true) + val lines = plain(message).lines() + assertEquals(10, lines.size) + val brandRow = if (installed) 5 else 4 + val linksRow = if (installed) 8 else 7 + assertTrue(lines[brandRow].contains("\uE403\uE405\uE404")) + assertTrue(lines[linksRow].contains("Discord")) + assertTrue(lines.last().isEmpty(), "Reserve a blank row below the final sentence") + val greetingRow = if (installed) 6 else 5 + assertTrue(lines[greetingRow].contains("Alex"), "Keep the personalized greeting") + assertFalse( + lines[greetingRow].contains("Discord"), + "Social links have their own row", + ) + assertFalse(lines[linksRow].contains("Alex"), "Keep the social row independent") + assertTrue(lines[greetingRow + 1].isEmpty(), "Leave room above tall icons") + if (!installed) { + assertTrue(lines[8].contains(" • "), "Voice copy follows the social row") + assertTrue( + iconsTopForRow(linksRow) + 14 <= 8 * 9, + "The full icon bitmap ends before adjacent voice text", + ) + } + val logoTop = brandRow * 9 + 7 - 41 + val logoBottom = logoTop + 42 + val iconsTop = linksRow * 9 + 7 - 12 + assertTrue(logoTop >= 0) + assertTrue(logoBottom < greetingRow * 9, "Logo must end before greeting ink") + assertTrue(logoBottom <= iconsTop, "Logo must not overlap social icons") + assertTrue(iconsTop + 14 <= 90, "Icons must remain inside chat background") + + assertTrue(lines[if (installed) 5 else 4].contains('\uE403')) + assertTrue( + nodes(message).any { + it.font() == Key.key("grounds:welcome") && plain(it).contains('\uE403') + } + ) + if (installed) assertTrue(lines.last().isEmpty()) + else assertTrue(plain(message).contains(" • ")) + } + } + assertFalse( + plain(decorated.render("Alex", Locale.GERMAN, true, true, false)).contains('\uE403') + ) + assertFalse(WelcomeGlyphs.decoratedLogoEnabled(mapOf("HUD_FONT_PACK_SHA1S" to "old"))) + assertTrue( + WelcomeGlyphs.decoratedLogoEnabled( + mapOf("HUD_FONT_PACK_SHA1S" to "new", "WELCOME_LOGO_PACK_SHA1S" to "new") + ) + ) + } + + private fun iconsTopForRow(row: Int) = row * 9 + 7 - 12 + + @Test + fun `personalized separate greeting fits maximum width names in every locale`() { + val decorated = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + decoratedLogoEnabled = true, + ) + val name = "W".repeat(16) + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + for (installed in listOf(false, true)) { + val message = decorated.render(name, locale, true, installed, true) + val lines = plain(message).lines() + val row = lines[if (installed) 6 else 5] + assertEquals(10, lines.size) + assertTrue(row.contains(name), "Do not remove or truncate long player names") + assertTrue( + row.first() in '\uE010'..'\uE025', + "Maximum-width name must leave positive centering space: $locale", + ) + assertEquals(if (installed) 3 else 4, nodes(message).mapNotNull { url(it) }.size) + } + } + } + + @Test + fun `links below the brand have independent click targets`() { + val message = renderer.render("Alex", Locale.ENGLISH, true, true, true) + val clickable = nodes(message).filter { url(it) != null } + assertEquals( + listOf("\uE400 Discord", "\uE401 TikTok", "\uE402 Shop"), + clickable.map(::plain), + ) + assertTrue(plain(message).contains("\uE400 Discord \uE401 TikTok \uE402 Shop")) + assertTrue(nodes(message).any { it.font() == Key.key("grounds:welcome") }) + assertTrue(plain(message).indexOf("Alex") < plain(message).indexOf("Discord")) + assertEquals( + listOf( + "https://grnds.link/discord", + "https://example.com/grounds/tiktok", + "https://example.com/grounds/shop", + ), + nodes(message).mapNotNull { url(it) }, + ) + } + + @Test + fun `installation link only appears for clients without the mod in every locale`() { + for (locale in + listOf(Locale.ENGLISH, Locale.GERMAN, Locale.FRENCH, Locale.forLanguageTag("es"))) { + val missing = renderer.render("Alex", locale, false, false) + val installed = renderer.render("Alex", locale, false, true) + val full = renderer.render("Alex", locale, true, false, true) + assertTrue( + plain(full).lines().size <= 10, + "The full welcome must fit the default closed chat: $locale", + ) + assertTrue( + nodes(missing).any { + url(it) == "https://modrepo.de/minecraft/voicechat/wiki/installation" + } + ) + assertFalse( + nodes(installed).any { + url(it) == "https://modrepo.de/minecraft/voicechat/wiki/installation" + } + ) + assertFalse(plain(missing).contains("welcome."), plain(missing)) + } + assertTrue( + plain(renderer.render("Alex", Locale.GERMAN, false, false)).contains("Willkommen") + ) + } + + @Test + fun `logo requires verified pack and player names cannot inject markup`() { + val fallback = renderer.render("Alex", Locale.ENGLISH, false, true) + val packed = renderer.render("Alex", Locale.ENGLISH, true, true) + assertTrue(plain(fallback).contains("Alex")) + assertFalse(plain(fallback).contains('\uE000')) + assertFalse(plain(packed).contains("GROUNDS"), "The wordmark replaces the text heading") + assertTrue( + nodes(packed).any { + it.font() == Key.key("grounds:tab") && plain(it).contains('\uE000') + } + ) + } + + @Test + fun `unverified icon packs render readable labels without private glyphs`() { + val fallback = plain(renderer.render("Alex", Locale.ENGLISH, true, true, false)) + assertTrue(fallback.contains("Discord TikTok Shop")) + assertFalse(fallback.any { it in '\uE400'..'\uE402' }) + assertFalse(WelcomeGlyphs.supportedBy(setOf("old", "new"), setOf("new"))) + assertTrue(WelcomeGlyphs.supportedBy(setOf("new"), setOf("new"))) + assertFalse(WelcomeGlyphs.supportedBy(emptySet(), setOf("new"))) + } + + @Test + fun `configured links replace the placeholders`() { + val configured = + WelcomeMessage( + Translations.forBundle("gg.grounds.proxy.messages", javaClass.classLoader), + WelcomeLinks( + tiktok = "https://www.tiktok.com/@test", + shop = "https://shop.example.org", + ), + ) + .render("Alex", Locale.ENGLISH, false, true) + val urls = nodes(configured).mapNotNull { url(it) } + assertTrue("https://www.tiktok.com/@test" in urls) + assertTrue("https://shop.example.org" in urls) + } +} diff --git a/velocity/src/test/scripts/WelcomeDispatchProbe.java b/velocity/src/test/scripts/WelcomeDispatchProbe.java new file mode 100644 index 0000000..129bec4 --- /dev/null +++ b/velocity/src/test/scripts/WelcomeDispatchProbe.java @@ -0,0 +1,64 @@ +import com.velocitypowered.api.event.*; +import com.velocitypowered.api.event.connection.*; +import com.velocitypowered.api.event.player.*; +import com.velocitypowered.api.network.*; +import com.velocitypowered.api.plugin.*; +import com.velocitypowered.api.proxy.*; +import com.velocitypowered.api.proxy.messages.*; +import com.velocitypowered.api.scheduler.*; +import com.velocitypowered.proxy.event.VelocityEventManager; +import gg.grounds.i18n.Translations; +import gg.grounds.proxy.velocity.welcome.*; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.*; +import net.kyori.adventure.text.event.ClickEvent; +import java.lang.reflect.Proxy; +import java.util.*; +import java.util.concurrent.*; +import java.util.function.BiFunction; + +public final class WelcomeDispatchProbe { + static T fake(Class type, BiFunction fn) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type},(p,m,a)->switch(m.getName()) { + case "hashCode" -> System.identityHashCode(p);case "equals" -> p==a[0];case "toString" -> type.getSimpleName();default -> fn.apply(m.getName(),a); + })); + } + static void check(boolean value,String reason){if(!value)throw new IllegalStateException(reason);} + static boolean install(Component c){return ClickEvent.openUrl(WelcomeMessage.INSTALL_URL).equals(c.clickEvent())||c.children().stream().anyMatch(WelcomeDispatchProbe::install);} + public static final class Blocker { + final CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1); + @Subscribe(order=PostOrder.FIRST) + public void block(PluginMessageEvent event) throws InterruptedException {entered.countDown();release.await(10,TimeUnit.SECONDS);} + } + public static void main(String[] args) throws Exception { + ExecutorService workers=Executors.newFixedThreadPool(2); + var description=fake(PluginDescription.class,(n,a)->switch(n){case "getId" -> "welcome-dispatch-probe";case "getVersion","getName" -> Optional.of("probe");default -> null;}); + var plugin=fake(PluginContainer.class,(n,a)->switch(n){case "getDescription" -> description;case "getExecutorService" -> workers;default -> Optional.empty();}); + var manager=new VelocityEventManager(fake(PluginManager.class,(n,a)->Optional.of(plugin))); + var messages=Translations.forBundle("gg.grounds.proxy.messages",WelcomeDispatchProbe.class.getClassLoader()); + var sent=new CopyOnWriteArrayList();var requested=new CopyOnWriteArrayList(); + var player=fake(Player.class,(n,a)->switch(n){case "getUniqueId" -> UUID.fromString("a87fa68d-7d18-43ad-82c2-219d447a3867");case "getUsername" -> "Alex";case "getHandshakeIntent" -> HandshakeIntent.LOGIN;case "getProtocolVersion" -> ProtocolVersion.MINECRAFT_1_21;case "isActive" -> true;case "get" -> Optional.of(Locale.ENGLISH);case "requestCookie" -> {requested.add((Key)a[0]);yield null;}case "sendMessage" -> {sent.add((Component)a[0]);yield null;}default -> null;}); + var task=fake(ScheduledTask.class,(n,a)->null); + var listener=new WelcomeListener(messages,new WelcomeMessage(messages,new WelcomeLinks("https://grnds.link/discord","https://example.com/grounds/tiktok","https://example.com/grounds/shop")),work->{work.run();return task;},p->true,p->true,work->{throw new IllegalStateException("Modern client entered legacy grace");}); + Blocker blocker=new Blocker(); + try { + // Register the blocking async FIRST handler first, so a mere default/FIRST observer cannot win. + manager.registerInternally(plugin,blocker);manager.registerInternally(plugin,listener); + manager.fire(new PostLoginEvent(player)).get(2,TimeUnit.SECONDS); + manager.fire(new ServerPostConnectEvent(player,null)).get(2,TimeUnit.SECONDS); + check(requested.equals(List.of(Key.key("grounds:welcome_ready"))),"Missing play acknowledgement"); + var event=new PluginMessageEvent(player,fake(ServerConnection.class,(n,a)->null),MinecraftChannelIdentifier.from("voicechat:request_secret"),new byte[]{0,0,0,20}); + var forwarding=manager.fire(event); + check(blocker.entered.await(2,TimeUnit.SECONDS),"Async handler never started"); + check(!forwarding.isDone(),"The simulated async forwarding was not blocked"); + manager.fire(new CookieReceiveEvent(player,Key.key("grounds:welcome_ready"),null)).get(2,TimeUnit.SECONDS); + check(sent.size()==1,"No prompt welcome while later async forwarding is blocked"); + check(!install(sent.get(0)),"Cookie reply overtook the installed-mod observer"); + check(event.getResult().isAllowed(),"Observer consumed the actual handshake"); + blocker.release.countDown();forwarding.get(2,TimeUnit.SECONDS); + manager.fire(new CookieReceiveEvent(player,Key.key("grounds:welcome_ready"),null)).get(2,TimeUnit.SECONDS); + check(sent.size()==1,"Duplicate ready reply repeated welcome"); + System.out.println("actualVelocityDispatch=ok syncObserverBeforeAsync=ok playCookie=ok immediateScheduler=ok voiceFalsePositive=none duplicateReply=ignored forwarding=unchanged"); + } finally {blocker.release.countDown();listener.close();workers.shutdownNow();} + } +} diff --git a/velocity/src/test/scripts/verify-welcome-dispatch.sh b/velocity/src/test/scripts/verify-welcome-dispatch.sh new file mode 100755 index 0000000..36a0f0f --- /dev/null +++ b/velocity/src/test/scripts/verify-welcome-dispatch.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail +if [[ $# != 2 ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +plugin_jar=$(realpath "$1") +velocity_jar=$(realpath "$2") +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +probe_dir=$(mktemp -d /tmp/grounds-welcome-dispatch.XXXXXX) +trap 'rm -rf -- "$probe_dir"' EXIT +"${JAVA_HOME:+$JAVA_HOME/bin/}javac" -cp "$plugin_jar:$velocity_jar" -d "$probe_dir" "$script_dir/WelcomeDispatchProbe.java" +(cd -- "$probe_dir" && "${JAVA_HOME:+$JAVA_HOME/bin/}java" --enable-native-access=ALL-UNNAMED -cp "$probe_dir:$plugin_jar:$velocity_jar" WelcomeDispatchProbe)