1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
|
---
layout: doc
---
# platform-velocity - Velocity Plugin (Proxy Relay)
`platform-velocity` is a thin layer whose only job is to **relay** cross-server global chat.
Its substance is just `LunaticChat` / `BuildInfo` / two files under `messaging/`; it has no command classes. Note that while `/lcv` is a Velocity-related feature, the command implementation lives on the [platform-paper](/docs/developers/platform-paper) side.
::: tip Why the Velocity side is thin
The protocol definition is held by `engine`, and all chat state (channels, DMs, settings) lives on the Paper side. The only responsibility left to Velocity is "distribute a received global chat message to the other servers", so this layer is intentionally kept thin. Neither platform owns the protocol; both depend on `engine` as equal peers (see the [Design Overview](/docs/developers/architecture#why-extract-the-engine-module)).
:::
## Lifecycle
- `LunaticChat` (`@Plugin`) — receives `ProxyServer` / `Logger` / `PluginContainer` via a Guice `@Inject` constructor
- The `version` in the `@Plugin` annotation is fixed at `"0.0.0"` and **is not used at runtime**. The real version is obtained from `velocity-plugin.json` via `PluginContainer.description.version` (startup fails if it is missing)
- `@Subscribe onProxyInitialization` creates `CrossServerChatRelay`, then creates and `initialize()`s `PluginMessageHandler` with it injected
- `@Subscribe onProxyShutdown` calls `messageHandler.shutdown()`
## Message reception and dispatch
`PluginMessageHandler` handles reception on the `lunaticchat:main` channel. In `initialize()` it calls `channelRegistrar.register(CHANNEL)` and subscribes to events.
`@Subscribe onPluginMessage` processing:
1. Ignore if `event.identifier != CHANNEL`
2. Warn and discard if the source is not a `ServerConnection`
3. Branch on the result of `PluginMessageCodec.decode()` with `when`
4. `Handshake` → check compatibility and reply with `HandshakeResponse` / `StatusRequest` → reply with `StatusResponse` / `GlobalChatMessage` → delegate to the relay / otherwise (a Velocity-originated response type) → warn only
### Trust boundary: rejecting client-originated messages
The check for whether the source is a `ServerConnection` is not just a type guard — it is a **trust boundary**.
Velocity plugin messages can arrive not only from backend servers but also from clients. By rejecting anything other than a backend connection here, **it prevents clients from directly injecting global chat or forged handshakes**. Only messages from trusted server connections are relayed.
## Handshake handling
On receiving a `Handshake`, it judges compatibility via the engine's `ProtocolVersion.isCompatible(major, minor)`.
- **Compatible** — reply with `HandshakeResponse` where `compatible=true`
- **Incompatible** — reply with `compatible=false` and an error string carrying both the Paper-side and Velocity-side versions
`HandshakeResponse` / `StatusResponse` always carry Velocity's own `ProtocolVersion` (`MAJOR` / `MINOR` / `PATCH`), so the Paper side can learn the peer's protocol from the response.
## Cross-server relay
`CrossServerChatRelay.relayGlobalMessage(message, sourceServer)` is the heart of the relay.
```
server.allServers
.filter { it != sourceServer } // exclude the source
.forEach { it.sendPluginMessage(CHANNEL, encoded) }
```
It **excludes the source server** and broadcasts to all remaining backends (stage one of echo prevention). The relay count is logged.
### What gets relayed / what stays local
Keeping the relay scope minimal is a key design point.
- The only thing Velocity relays to other servers is the **`GlobalChatMessage`**
- `Handshake` / `HandshakeResponse` / `StatusRequest` / `StatusResponse` complete between Velocity and a single Paper, and are not forwarded
- **DM and channel chat are never sent to Velocity at all** (they complete locally within Paper)
### Two-stage echo/loop prevention
To keep global chat from being displayed multiple times through relay loops, it is prevented in two places.
1. **Velocity side** — broadcast excluding the source server
2. **Paper side** — a dedup LRU cache keyed by `messageId` (TTL 60s). The sender also registers its own `messageId` right after generation to prevent an echo on its own server
## Message flow
1. Paper sends a `Handshake` (its own protocol version), triggered by a player connecting
2. Velocity judges with `ProtocolVersion.isCompatible` and replies with `HandshakeResponse` → if compatible, the Paper side becomes `CONNECTED`
3. A player sends global chat (no active channel, or a `!` prefix) → Paper sends a `GlobalChatMessage` (a new `messageId`) to Velocity and displays normal chat on the source
4. Velocity relays to all backends except the source
5. Each Paper receives it → dedups by `messageId` → formats with `crossServerGlobalChatFormat` and delivers to all players
## Implementation notes
- The `plugin` parameter of `PluginMessageHandler` is typed `Any` because Velocity's `EventManager.register()` takes an `Object` (the API itself isn't type-safe, so making it generic offers little benefit).
- To run cross-server chat, Velocity's `velocity.toml` needs `bungee-plugin-message-channel=true` (plugin messaging enabled).
## Related
- [Design Overview](/docs/developers/architecture)
- [engine - Shared Kernel](/docs/developers/engine) — protocol details
- [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper) — the Paper-side counterpart
|