diff --git a/README.md b/README.md new file mode 100644 index 0000000..794360b --- /dev/null +++ b/README.md @@ -0,0 +1,85 @@ +# plugin-proxy + +The Velocity plugin that lets other plugins act on a player who is connected to a **different proxy**. + +A Velocity proxy only knows its own players. Ask it for `dahendriik` while they are on the other proxy and you get nothing — which is why `/msg`, party invites and party warps used to answer "not online" the moment a second proxy existed. plugin-proxy is the piece that answers for the whole network. + +## What it provides + +`ProxyService`, published into the [`ProxyServiceRegistry`](api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt): + +| method | local player | player on another proxy | +|---|---|---| +| `resolvePlayerId(name)` | Velocity's player list | `PlayerSessionQuery` (service-player) | +| `resolvePlayerName(id)` | Velocity's player list | `PlayerSessionQuery` | +| `isOnline(id)` | Velocity's player list | `PlayerSessionQuery` | +| `getPresence(id)` | current server | session's proxy + server | +| `sendToPlayer(id, msg)` | `player.sendMessage` | publish `proxy.system.` | +| `transferPlayer(id, server)` | connection request | publish `proxy.transfer.` | +| `suggestPlayerNames(prefix)` | filtered in memory | prefix search, capped, 2s cache | + +Local first, always — a player on this proxy is already in memory and costs nothing to find. + +## How the pieces fit + +``` +plugin-chat / plugin-social consumers: only ever call ProxyService + │ + ▼ + ProxyService ← registered by plugin-proxy (this repo) + │ + ├── lookups ──► PlayerSessionQuery ← registered by plugin-player, + │ answered by service-player (Postgres, TTL) + │ + └── delivery ──► NATS: proxy.system., proxy.transfer. + ↑ every proxy subscribes for its own players +``` + +Nobody registers `PlayerSessionQuery` → lookups return null and everything degrades to local-only, silently. That is exactly the state this repo was in before plugin-player shipped: the fallback existed and nothing filled it. + +**Why service-player and not a registry in the proxies?** Presence already lives there — `TryPlayerLogin` is on the login path, with heartbeats and a TTL. A second store in the proxies would be a second source of truth with its own expiry, and two of those drift. An earlier attempt (plugin-chat's own `chat.players.join/leave` map) also had no memory: core NATS does not replay, so a proxy only ever learned about players who joined *while it was running*. + +## Consuming it + +```kotlin +// build.gradle.kts — compileOnly, NEVER shaded (see ProxyServiceRegistry's KDoc) +compileOnly("gg.grounds:plugin-proxy-api:0.1.0") +``` + +```kotlin +@Plugin( + id = "plugin-yours", + dependencies = [Dependency(id = "plugin-proxy")], // orders init; optional = true to degrade +) +class YourPlugin { + @Subscribe + fun onInit(event: ProxyInitializeEvent) { + val proxyService = ProxyServiceRegistry.get(ProxyService::class.java) + val id = proxyService?.resolvePlayerId("dahendriik") // null → plugin-proxy absent + } +} +``` + +Shading `plugin-proxy-api` gives your plugin its own copy of the registry class — a different map, which nobody writes into. Every lookup then returns null and cross-proxy features degrade to local-only without a single error in the log. `compileOnly` is not a style preference here. + +## Tab-complete does not list the network + +`suggestPlayerNames(prefix, limit)` is a prefix search with a cap, and there is deliberately **no** "give me every online player". Velocity fires tab-complete on *every keystroke*: at 10k players online, a roster dump is a ~200 KB response issued thousands of times a second, with a table scan behind each one. So: local matches from memory, the network only once the prefix is ≥ 2 characters, answers cached 2s per prefix, result capped (default 20). + +## Configuration + +| env | meaning | +|---|---| +| `NATS_URL` | broker for `proxy.system.*` / `proxy.transfer.*` (default `nats://nats.infra:4222`) | +| `PROXY_ID` | this proxy's identity, recorded in a player's session — must differ per proxy (`velocity`, `velocity-2`) | +| `GROUNDS_TOKEN_FILE` | projected SA-token presented as the NATS bearer (default `/var/run/secrets/grounds/token`) | + +The NATS auth-callout scopes each pod to the subjects declared in its bundle `events:` block, so `proxy.system.*` and `proxy.transfer.*` must be listed there — an undeclared subject is denied and the message vanishes. + +## Build + +```bash +./gradlew build -Pgithub.user="$GITHUB_ACTOR" -Pgithub.token="$GITHUB_TOKEN" +``` + +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. diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerPresence.kt b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerPresence.kt index 95f1390..60d0657 100644 --- a/api/src/main/kotlin/gg/grounds/proxy/api/PlayerPresence.kt +++ b/api/src/main/kotlin/gg/grounds/proxy/api/PlayerPresence.kt @@ -1,3 +1,13 @@ package gg.grounds.proxy.api +/** + * Where a player currently is: which proxy holds their connection ([proxyId], e.g. `velocity-2`) + * and which backend server they are playing on ([server], e.g. `minestom-lobby-2-kvlkz-5h977`). + * + * Either can be empty. A player who has connected to a proxy but not yet reached a backend is on no + * server, and a session written by an older plugin-player carries no proxy id. + * + * [joinedAt] is epoch millis, and is 0 for a player on *this* proxy — Velocity does not record it, + * and asking the presence service for something already in memory is not worth a round trip. + */ data class PlayerPresence(val proxyId: String, val server: String, val joinedAt: Long) diff --git a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt index 8c48f54..f8e25f2 100644 --- a/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt +++ b/api/src/main/kotlin/gg/grounds/proxy/api/ProxyServiceRegistry.kt @@ -2,17 +2,49 @@ package gg.grounds.proxy.api import java.util.concurrent.ConcurrentHashMap +/** + * How Velocity plugins hand each other capabilities at runtime. + * + * Two providers meet here: + * - **plugin-proxy** registers [ProxyService] — find, message and transfer a player anywhere on the + * network. + * - **plugin-player** registers [PlayerSessionQuery] — the network-wide lookup [ProxyService] falls + * back to for anyone who is not on this proxy. + * + * Consumers (plugin-chat, plugin-social) only read: + * ``` + * val proxyService = ProxyServiceRegistry.get(ProxyService::class.java) + * val targetId = proxyService?.resolvePlayerId(name) // null → plugin-proxy is not installed + * ``` + * + * ## Two rules, and both of them bite silently + * + * **Never shade `plugin-proxy-api`.** Depend on it `compileOnly` and let plugin-proxy provide the + * classes at runtime. Shade it and your plugin loads its *own* `ProxyServiceRegistry` class — a + * different map, which nobody writes into — so every lookup returns null and every cross-proxy + * feature quietly degrades to local-only. + * + * **Declare the plugin dependency.** `@Plugin(dependencies = [Dependency(id = "plugin-proxy")])` is + * what makes Velocity initialise plugin-proxy before you; without it you may read the registry + * before anything has registered into it. Use `optional = true` if your plugin should still load + * (local-only) when plugin-proxy is absent. + * + * Providers register on `ProxyInitializeEvent` and unregister on `ProxyShutdownEvent`. + */ object ProxyServiceRegistry { private val services = ConcurrentHashMap, Any>() + /** Publishes [instance] under [serviceClass]. Providers call this on `ProxyInitializeEvent`. */ fun register(serviceClass: Class, instance: T) { services[serviceClass] = instance } + /** The registered implementation, or null when no plugin provides it. */ fun get(serviceClass: Class): T? { return serviceClass.cast(services[serviceClass]) } + /** Withdraws the implementation. Providers call this on `ProxyShutdownEvent`. */ fun unregister(serviceClass: Class) { services.remove(serviceClass) }