Skip to content

Commit 90bc9ea

Browse files
committed
Bridge: the Java plugin, unblocked
Closes #19, closes #20, closes #21. These were blocked on 'no Maven, no Paper API jar here'. Both were soluble: repo.papermc.io serves paper-api over plain HTTPS, and the wall behind it was that Paper's API is compiled against Adventure - javac needs adventure-api, adventure-key and examination-api on the classpath even though the plugin never names them. With those, JDK 21's javac + jar are enough. bridge/ holds the plugin (hello/tick/players/event/bye over stdout), a dependency-free Json writer split out so it is testable, a SelfTest harness that is excluded from the jar, and build.mjs. No Maven or Gradle: three source files with no runtime dependencies, and a second build system to produce a 6 KB jar would put it out of reach of most people who might want to change it. Verified by parsing the plugin's REAL output with the app's own parseBridgeLine: all six message shapes parse, a name carrying a quote, backslash, newline and a control character round-trips intact, NaN encodes as null, and a log4j2-prefixed line is still found by the marker. NOT run inside a live server - there is no Paper instance here. Said plainly in the plugin README, the protocol doc and the main README.
1 parent 9530f62 commit 90bc9ea

10 files changed

Lines changed: 436 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -449,9 +449,11 @@ That transport choice is the point: no extra port, no socket, no firewall rule,
449449

450450
The app side is implemented: protocol v1 parsing, and a freshness rule that falls back to RCON the moment the bridge goes quiet — so a crashed plugin cannot leave a frozen TPS on screen.
451451

452-
⚠️ **The Java plugin itself does not exist yet**, so nothing currently emits these lines on a real server. Tracked in #19–#21.
452+
The plugin itself lives in [`bridge/`](bridge/README.md) and builds with a JDK and Node alone — `node bridge/build.mjs` — no Maven or Gradle. Its output is verified against the app's own parser without needing a Minecraft server.
453453

454-
📖 **[Protocol documentation → `docs/bridge-protocol.md`](docs/bridge-protocol.md)**
454+
⚠️ It has **not yet been run inside a live server**, so treat the first run as a test.
455+
456+
📖 **[Protocol documentation → `docs/bridge-protocol.md`](docs/bridge-protocol.md)** · **[Plugin README → `bridge/README.md`](bridge/README.md)**
455457

456458
---
457459

‎bridge/.gitignore‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
build/
2+
.deps/

‎bridge/README.md‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# MSMS Bridge (Java plugin)
2+
3+
Reports what the console cannot: true TPS and **MSPT** read off the tick loop, player positions, and structured world events.
4+
5+
It prints one marked line per message to the server's **standard output** — a stream MSMS already reads. No port, no socket, no firewall rule, no credentials. See [`docs/bridge-protocol.md`](../docs/bridge-protocol.md) for the wire format.
6+
7+
## Build
8+
9+
```bash
10+
node bridge/build.mjs
11+
```
12+
13+
Produces `bridge/build/MSMS-Bridge-1.0.0.jar`. Requires **JDK 21+** and Node; compile dependencies are downloaded once into `bridge/.deps/` and cached.
14+
15+
There is no Maven or Gradle here on purpose. The plugin is three small source files with **no runtime dependencies**, and requiring a second build system to produce a 6 KB jar would put it out of reach of most people who want to change it.
16+
17+
## Install
18+
19+
Drop the jar into your server's `plugins/` folder and restart. Works on Paper and its forks (Purpur, Folia) and on any Bukkit-API server that provides `getTPS()`.
20+
21+
`config.yml`:
22+
23+
```yaml
24+
# How often the bridge reports TPS, MSPT and player positions.
25+
interval-seconds: 5
26+
```
27+
28+
MSMS picks the bridge up automatically — the Dashboard starts showing **ms/tick** and "Bridge active" once the first `hello` arrives, and falls back to RCON within ~2.5 intervals if the plugin stops reporting.
29+
30+
## Verify the wire format without a server
31+
32+
```bash
33+
node bridge/build.mjs --selftest
34+
```
35+
36+
Prints one of every message shape — built with the **same** `Json` helpers the plugin uses — including a player name carrying a quote, a backslash, a newline and a control character, and a line wrapped in a log4j2 prefix. Pipe it through the app's `parseBridgeLine` to confirm end-to-end conformance.
37+
38+
## Layout
39+
40+
| | |
41+
|---|---|
42+
| `MsmsBridge.java` | the plugin: hello / tick / players / event / bye |
43+
| `Json.java` | dependency-free JSON writing, split out so it is testable |
44+
| `SelfTest.java` | prints every message shape; **not** shipped in the jar |
45+
| `build.mjs` | fetch deps, compile, package |
46+
47+
## Status and limits
48+
49+
- **Not run against a live Minecraft server in development.** The jar compiles against the Paper 1.21.4 API and its output is verified byte-for-byte against the app's parser, but no one has watched it start inside a real server here. Treat the first run as a test.
50+
- `getAverageTickTime()` and `getTPS()` are Paper/Spigot extensions. A server that lacks them will not load the plugin.
51+
- The heartbeat runs on the **main thread** — reading the player list and their locations is main-thread state. It is a handful of field reads every few seconds, but it is not free.
52+
- Events are currently only `player.death`. Joins and leaves are deliberately left to the console parser, which already handles them, so the two do not double-count.

‎bridge/build.mjs‎

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Build the MSMS Bridge plugin jar with nothing but a JDK and Node.
4+
*
5+
* Deliberately not Maven or Gradle. The whole plugin is three small source
6+
* files with no runtime dependencies, and requiring a second build system to
7+
* produce a 6 KB jar would make it something most contributors cannot build.
8+
* Compile dependencies are fetched once into `bridge/.deps/` and cached.
9+
*
10+
* node bridge/build.mjs build the jar
11+
* node bridge/build.mjs --selftest build, then verify the wire format
12+
*/
13+
import { execFileSync } from 'node:child_process'
14+
import { mkdirSync, existsSync, writeFileSync, copyFileSync, rmSync, readdirSync } from 'node:fs'
15+
import { join, dirname } from 'node:path'
16+
import { fileURLToPath } from 'node:url'
17+
18+
const HERE = dirname(fileURLToPath(import.meta.url))
19+
const DEPS = join(HERE, '.deps')
20+
const OUT = join(HERE, 'build')
21+
const CLASSES = join(OUT, 'classes')
22+
23+
/**
24+
* Paper's API is compiled against Adventure, and javac needs those class files
25+
* on the classpath even though the plugin never names them itself.
26+
*/
27+
const DEPENDENCIES = [
28+
{
29+
name: 'paper-api.jar',
30+
url:
31+
'https://repo.papermc.io/repository/maven-public/io/papermc/paper/paper-api/' +
32+
'1.21.4-R0.1-SNAPSHOT/paper-api-1.21.4-R0.1-20250925.065901-231.jar'
33+
},
34+
{ name: 'adventure-api.jar', url: mvn('net/kyori', 'adventure-api', '4.17.0') },
35+
{ name: 'adventure-key.jar', url: mvn('net/kyori', 'adventure-key', '4.17.0') },
36+
{ name: 'examination-api.jar', url: mvn('net/kyori', 'examination-api', '1.3.0') }
37+
]
38+
39+
function mvn(group, artifact, version) {
40+
return `https://repo1.maven.org/maven2/${group}/${artifact}/${version}/${artifact}-${version}.jar`
41+
}
42+
43+
async function fetchDeps() {
44+
mkdirSync(DEPS, { recursive: true })
45+
for (const d of DEPENDENCIES) {
46+
const dest = join(DEPS, d.name)
47+
if (existsSync(dest)) continue
48+
process.stdout.write(`fetching ${d.name}… `)
49+
const r = await fetch(d.url)
50+
if (!r.ok) throw new Error(`${d.name}: HTTP ${r.status}`)
51+
const buf = Buffer.from(await r.arrayBuffer())
52+
if (buf.length === 0) throw new Error(`${d.name}: empty download`)
53+
writeFileSync(dest, buf)
54+
console.log(`${buf.length} bytes`)
55+
}
56+
}
57+
58+
const sep = process.platform === 'win32' ? ';' : ':'
59+
const cp = () => DEPENDENCIES.map((d) => join(DEPS, d.name)).join(sep)
60+
const run = (cmd, args) => execFileSync(cmd, args, { stdio: 'inherit' })
61+
62+
const SRC = join(HERE, 'src', 'main', 'java', 'dev', 'cayadev', 'msms', 'bridge')
63+
const RES = join(HERE, 'src', 'main', 'resources')
64+
65+
async function build({ selftest }) {
66+
await fetchDeps()
67+
rmSync(CLASSES, { recursive: true, force: true })
68+
mkdirSync(CLASSES, { recursive: true })
69+
70+
const sources = readdirSync(SRC)
71+
.filter((f) => f.endsWith('.java'))
72+
// SelfTest is a harness, not part of the plugin.
73+
.filter((f) => selftest || f !== 'SelfTest.java')
74+
.map((f) => join(SRC, f))
75+
76+
console.log('compiling…')
77+
run('javac', ['-encoding', 'UTF-8', '--release', '21', '-cp', cp(), '-d', CLASSES, ...sources])
78+
79+
if (selftest) {
80+
console.log('--- self test output ---')
81+
run('java', ['-cp', CLASSES, 'dev.cayadev.msms.bridge.SelfTest'])
82+
return
83+
}
84+
85+
for (const f of readdirSync(RES)) copyFileSync(join(RES, f), join(CLASSES, f))
86+
const jar = join(OUT, 'MSMS-Bridge-1.0.0.jar')
87+
run('jar', ['--create', '--file', jar, '-C', CLASSES, '.'])
88+
console.log(`\nbuilt ${jar}`)
89+
console.log('Drop it in your server\'s plugins/ folder and restart.')
90+
}
91+
92+
build({ selftest: process.argv.includes('--selftest') }).catch((e) => {
93+
console.error('build failed:', e.message)
94+
process.exit(1)
95+
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package dev.cayadev.msms.bridge;
2+
3+
/**
4+
* Minimal JSON writing, deliberately dependency-free.
5+
*
6+
* Split out of {@link MsmsBridge} so it can be exercised without a running
7+
* Minecraft server: {@link SelfTest} prints one of every message shape through
8+
* these helpers, and the repository's protocol parser reads them back. That is
9+
* the only part of the bridge whose correctness the app depends on.
10+
*/
11+
final class Json {
12+
13+
private Json() {
14+
}
15+
16+
/** JSON number, or `null` for anything not finite - NaN is not valid JSON. */
17+
static String num(double d) {
18+
if (Double.isNaN(d) || Double.isInfinite(d)) return "null";
19+
return String.valueOf(Math.round(d * 100.0) / 100.0);
20+
}
21+
22+
/**
23+
* A quoted, escaped string. Control characters are escaped rather than
24+
* passed through: a raw newline in a player name would split the line and
25+
* the message would arrive as two unparsable halves.
26+
*/
27+
static String str(String s) {
28+
StringBuilder b = new StringBuilder("\"");
29+
for (int i = 0; i < s.length(); i++) {
30+
char c = s.charAt(i);
31+
switch (c) {
32+
case '"':
33+
b.append("\\\"");
34+
break;
35+
case '\\':
36+
b.append("\\\\");
37+
break;
38+
case '\n':
39+
b.append("\\n");
40+
break;
41+
case '\r':
42+
b.append("\\r");
43+
break;
44+
case '\t':
45+
b.append("\\t");
46+
break;
47+
default:
48+
if (c < 0x20) b.append(String.format("\\u%04x", (int) c));
49+
else b.append(c);
50+
}
51+
}
52+
return b.append('"').toString();
53+
}
54+
55+
/** Append `,"key":"value"`, skipping a null value entirely. */
56+
static void field(StringBuilder b, String key, String value) {
57+
if (value == null) return;
58+
b.append(",\"").append(key).append("\":").append(str(value));
59+
}
60+
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package dev.cayadev.msms.bridge;
2+
3+
import org.bukkit.Bukkit;
4+
import org.bukkit.Location;
5+
import org.bukkit.World;
6+
import org.bukkit.entity.Player;
7+
import org.bukkit.event.EventHandler;
8+
import org.bukkit.event.Listener;
9+
import org.bukkit.event.entity.PlayerDeathEvent;
10+
import org.bukkit.plugin.java.JavaPlugin;
11+
12+
import java.util.List;
13+
14+
/**
15+
* MSMS Bridge - reports telemetry the console cannot otherwise expose.
16+
*
17+
* Transport is the server's own standard output: one marked line per message.
18+
* That needs no port, no socket, no firewall rule and no credentials, and it
19+
* works identically for a LAN server, a box behind NAT, and a host with every
20+
* port closed. See docs/bridge-protocol.md for the wire format.
21+
*
22+
* Everything here runs on the main thread and does almost nothing: reading the
23+
* tick rate and the player list must not itself become a source of lag.
24+
*/
25+
public final class MsmsBridge extends JavaPlugin implements Listener {
26+
27+
private static final String MARKER = "[MSMS-BRIDGE]";
28+
private static final int PROTOCOL = 1;
29+
30+
private long intervalMs;
31+
32+
@Override
33+
public void onEnable() {
34+
saveDefaultConfig();
35+
int seconds = Math.max(1, getConfig().getInt("interval-seconds", 5));
36+
this.intervalMs = seconds * 1000L;
37+
38+
emitHello();
39+
getServer().getPluginManager().registerEvents(this, this);
40+
41+
// Sync, because the player list and their locations are main-thread
42+
// state. The work is a handful of field reads every few seconds.
43+
long ticks = seconds * 20L;
44+
getServer().getScheduler().runTaskTimer(this, this::heartbeat, ticks, ticks);
45+
}
46+
47+
@Override
48+
public void onDisable() {
49+
emit("{\"v\":" + PROTOCOL + ",\"t\":\"bye\"}");
50+
}
51+
52+
// ---- messages ----
53+
54+
private void emitHello() {
55+
StringBuilder b = new StringBuilder();
56+
b.append("{\"v\":").append(PROTOCOL).append(",\"t\":\"hello\"");
57+
Json.field(b, "plugin", getName());
58+
Json.field(b, "pluginVersion", getPluginMeta().getVersion());
59+
Json.field(b, "server", getServer().getName());
60+
Json.field(b, "mc", getServer().getMinecraftVersion());
61+
b.append(",\"interval\":").append(intervalMs);
62+
b.append("}");
63+
emit(b.toString());
64+
}
65+
66+
private void heartbeat() {
67+
emitTick();
68+
emitPlayers();
69+
}
70+
71+
private void emitTick() {
72+
double[] tps = getServer().getTPS();
73+
StringBuilder b = new StringBuilder();
74+
b.append("{\"v\":").append(PROTOCOL).append(",\"t\":\"tick\"");
75+
// The app treats these as reported and does no clamping, so a warming-up
76+
// server showing 20.4 stays visible rather than being tidied away.
77+
b.append(",\"tps\":").append(Json.num(tps.length > 0 ? tps[0] : Double.NaN));
78+
b.append(",\"tps5\":").append(Json.num(tps.length > 1 ? tps[1] : Double.NaN));
79+
b.append(",\"tps15\":").append(Json.num(tps.length > 2 ? tps[2] : Double.NaN));
80+
b.append(",\"mspt\":").append(Json.num(getServer().getAverageTickTime()));
81+
b.append("}");
82+
emit(b.toString());
83+
}
84+
85+
private void emitPlayers() {
86+
List<? extends Player> online = List.copyOf(getServer().getOnlinePlayers());
87+
StringBuilder b = new StringBuilder();
88+
b.append("{\"v\":").append(PROTOCOL).append(",\"t\":\"players\",\"online\":").append(online.size());
89+
b.append(",\"list\":[");
90+
boolean first = true;
91+
for (Player p : online) {
92+
if (!first) b.append(',');
93+
first = false;
94+
Location loc = p.getLocation();
95+
World w = loc.getWorld();
96+
b.append('{');
97+
b.append("\"name\":").append(Json.str(p.getName()));
98+
b.append(",\"uuid\":").append(Json.str(p.getUniqueId().toString()));
99+
if (w != null) {
100+
b.append(",\"world\":").append(Json.str(w.getName()));
101+
b.append(",\"dim\":").append(Json.str(dimension(w)));
102+
}
103+
b.append(",\"x\":").append(Json.num(loc.getX()));
104+
b.append(",\"y\":").append(Json.num(loc.getY()));
105+
b.append(",\"z\":").append(Json.num(loc.getZ()));
106+
b.append('}');
107+
}
108+
b.append("]}");
109+
emit(b.toString());
110+
}
111+
112+
@EventHandler
113+
public void onDeath(PlayerDeathEvent e) {
114+
// A death is exactly the kind of thing the console cannot report in a
115+
// structured way - the death message is free text and locale-dependent.
116+
Player p = e.getEntity();
117+
Location loc = p.getLocation();
118+
World w = loc.getWorld();
119+
StringBuilder b = new StringBuilder();
120+
b.append("{\"v\":").append(PROTOCOL).append(",\"t\":\"event\",\"kind\":\"player.death\"");
121+
Json.field(b, "text", p.getName());
122+
b.append(",\"data\":{");
123+
b.append("\"player\":").append(Json.str(p.getName()));
124+
if (w != null) b.append(",\"world\":").append(Json.str(w.getName()));
125+
b.append(",\"x\":").append(Json.num(loc.getX()));
126+
b.append(",\"y\":").append(Json.num(loc.getY()));
127+
b.append(",\"z\":").append(Json.num(loc.getZ()));
128+
b.append("}}");
129+
emit(b.toString());
130+
}
131+
132+
// ---- plumbing ----
133+
134+
private static String dimension(World w) {
135+
switch (w.getEnvironment()) {
136+
case NETHER:
137+
return "nether";
138+
case THE_END:
139+
return "the_end";
140+
default:
141+
return "overworld";
142+
}
143+
}
144+
145+
/**
146+
* One message per line. A newline inside the payload would split the
147+
* message, so every string is escaped and nothing is pretty-printed.
148+
*/
149+
private void emit(String json) {
150+
System.out.println(MARKER + " " + json);
151+
}
152+
153+
}

0 commit comments

Comments
 (0)