Adding client-side and server-side interactions. Also adds purpur/paper support
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'com.gradleup.shadow'
|
||||
}
|
||||
|
||||
version = rootProject.mod_version
|
||||
group = rootProject.maven_group
|
||||
|
||||
base {
|
||||
archivesName = "${rootProject.archives_base_name}-paper"
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven {
|
||||
name = 'papermc'
|
||||
url = 'https://repo.papermc.io/repository/maven-public/'
|
||||
}
|
||||
}
|
||||
|
||||
def sharedRoot = rootProject.projectDir
|
||||
def prepareMainSources = tasks.register('prepareMainSources', Sync) {
|
||||
from(sharedRoot.toPath().resolve('src/main/java')) {
|
||||
include 'dev/yawaflua/gominecraftbridge/backend/**'
|
||||
include 'dev/yawaflua/gominecraftbridge/management/**'
|
||||
include 'dev/yawaflua/gominecraftbridge/protocol/**'
|
||||
include 'dev/yawaflua/gominecraftbridge/host/LoadedPlugin.java'
|
||||
include 'dev/yawaflua/gominecraftbridge/host/PluginState.java'
|
||||
}
|
||||
from(sharedRoot.toPath().resolve('src/main/generated'))
|
||||
from(file('src/main/java'))
|
||||
into(layout.buildDirectory.dir('shared-sources/main'))
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
java.setSrcDirs([layout.buildDirectory.dir('shared-sources/main')])
|
||||
resources.setSrcDirs([file('src/main/resources')])
|
||||
}
|
||||
test {
|
||||
java.setSrcDirs([sharedRoot.toPath().resolve('src/test/java'), file('src/test/java')])
|
||||
java.include 'dev/yawaflua/gominecraftbridge/backend/NativePluginBackendTest.java'
|
||||
java.include 'dev/yawaflua/gominecraftbridge/host/LoadedPluginMetadataTest.java'
|
||||
java.include 'dev/yawaflua/gominecraftbridge/paper/**'
|
||||
}
|
||||
}
|
||||
|
||||
configurations {
|
||||
paperApi12111 {
|
||||
canBeConsumed = false
|
||||
canBeResolved = true
|
||||
}
|
||||
paperApi261 {
|
||||
canBeConsumed = false
|
||||
canBeResolved = true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly 'io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT'
|
||||
paperApi12111 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT'
|
||||
paperApi261 'io.papermc.paper:paper-api:26.1.2.build.74-stable'
|
||||
|
||||
implementation 'com.google.code.gson:gson:2.13.2'
|
||||
implementation 'com.google.flatbuffers:flatbuffers-java:25.2.10'
|
||||
implementation 'net.java.dev.jna:jna:5.18.1'
|
||||
|
||||
testImplementation 'io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT'
|
||||
testImplementation platform('org.junit:junit-bom:5.13.4')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.withType(JavaCompile).configureEach {
|
||||
dependsOn prepareMainSources
|
||||
options.release = 21
|
||||
}
|
||||
|
||||
def registerPaperCompatibilityCompile = { String taskName, Configuration paperApi ->
|
||||
tasks.register(taskName, JavaCompile) {
|
||||
group = 'verification'
|
||||
description = "Compiles the Paper/Purpur adapter against ${paperApi.name}."
|
||||
dependsOn prepareMainSources
|
||||
source = sourceSets.main.java
|
||||
classpath = paperApi + configurations.runtimeClasspath
|
||||
destinationDirectory = layout.buildDirectory.dir("compatibility/${paperApi.name}")
|
||||
options.release = 21
|
||||
}
|
||||
}
|
||||
|
||||
def compilePaper12111Compatibility = registerPaperCompatibilityCompile(
|
||||
'compilePaper12111Compatibility', configurations.paperApi12111
|
||||
)
|
||||
def compilePaper261Compatibility = registerPaperCompatibilityCompile(
|
||||
'compilePaper261Compatibility', configurations.paperApi261
|
||||
)
|
||||
|
||||
tasks.named('check') {
|
||||
dependsOn compilePaper12111Compatibility, compilePaper261Compatibility
|
||||
}
|
||||
|
||||
processResources {
|
||||
inputs.property 'version', project.version
|
||||
filesMatching('plugin.yml') {
|
||||
expand 'version': project.version
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType(Test).configureEach {
|
||||
useJUnitPlatform()
|
||||
jvmArgs '--enable-native-access=ALL-UNNAMED'
|
||||
}
|
||||
|
||||
tasks.named('jar') {
|
||||
enabled = false
|
||||
}
|
||||
|
||||
tasks.named('shadowJar') {
|
||||
archiveClassifier = ''
|
||||
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
|
||||
relocate 'com.google.gson', 'dev.yawaflua.gominecraftbridge.paper.libs.gson'
|
||||
relocate 'com.google.flatbuffers', 'dev.yawaflua.gominecraftbridge.paper.libs.flatbuffers'
|
||||
}
|
||||
|
||||
tasks.named('assemble') {
|
||||
dependsOn tasks.named('shadowJar')
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
dependsOn tasks.named('shadowJar')
|
||||
systemProperty 'gmb.paper.shadowJar', tasks.named('shadowJar').flatMap { it.archiveFile }.get().asFile
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
tasks.named('sourcesJar') {
|
||||
dependsOn prepareMainSources
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ActionRequest;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
final class PaperActionExecutor {
|
||||
void execute(ActionRequest action) {
|
||||
JsonObject payload = action.payload();
|
||||
switch (action.type()) {
|
||||
case "minecraft:chat.broadcast" ->
|
||||
Bukkit.broadcastMessage(requiredString(payload, "message"));
|
||||
case "minecraft:chat.player" -> {
|
||||
UUID playerId = UUID.fromString(requiredString(payload, "playerUuid"));
|
||||
Player player = Bukkit.getPlayer(playerId);
|
||||
if (player == null) {
|
||||
throw new IllegalArgumentException("Player is not online: " + playerId);
|
||||
}
|
||||
player.sendMessage(requiredString(payload, "message"));
|
||||
}
|
||||
default -> throw new IllegalArgumentException("Unknown plugin action " + action.type());
|
||||
}
|
||||
}
|
||||
|
||||
private static String requiredString(JsonObject payload, String field) {
|
||||
if (payload == null || !payload.has(field) || !payload.get(field).isJsonPrimitive()) {
|
||||
throw new IllegalArgumentException("Action field is required: " + field);
|
||||
}
|
||||
return payload.get(field).getAsString();
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.PackageInspection;
|
||||
import dev.yawaflua.gominecraftbridge.management.ReloadResult;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginLog;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson;
|
||||
import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.command.TabCompleter;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
final class PaperAdminCommand implements CommandExecutor, TabCompleter {
|
||||
private static final String PREFIX = ChatColor.DARK_AQUA + "[GoBridge] " + ChatColor.RESET;
|
||||
private final PaperGoPluginManager plugins;
|
||||
|
||||
PaperAdminCommand(PaperGoPluginManager plugins) {
|
||||
this.plugins = plugins;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onCommand(
|
||||
CommandSender sender,
|
||||
Command command,
|
||||
String label,
|
||||
String[] args
|
||||
) {
|
||||
if (sender instanceof Player player && !player.isOp()) {
|
||||
sender.sendMessage(PREFIX + ChatColor.RED + "Server operator permission is required.");
|
||||
return true;
|
||||
}
|
||||
|
||||
String action = args.length == 0 ? "status" : args[0].toLowerCase(Locale.ROOT);
|
||||
BridgeManagementSnapshot snapshot = this.plugins.managementSnapshot(null);
|
||||
switch (action) {
|
||||
case "status" -> status(sender, snapshot);
|
||||
case "packages" -> packages(sender, snapshot);
|
||||
case "metadata" -> metadata(sender, snapshot, args);
|
||||
case "logs" -> logs(sender, snapshot, args);
|
||||
case "reload" -> reload(sender, args);
|
||||
case "rescan" -> result(sender, this.plugins.rescan());
|
||||
default -> usage(sender, label);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void status(CommandSender sender, BridgeManagementSnapshot snapshot) {
|
||||
sender.sendMessage(PREFIX + "runtime=" + (snapshot.serverRunning() ? "running" : "stopped")
|
||||
+ ", packages=" + snapshot.packages().size() + ", plugins=" + snapshot.plugins().size());
|
||||
for (ManagedPluginSnapshot plugin : snapshot.plugins()) {
|
||||
sender.sendMessage(" - " + plugin.metadata().id() + " " + plugin.metadata().version()
|
||||
+ " [" + plugin.state() + ", "
|
||||
+ plugin.metadata().environment().name().toLowerCase(Locale.ROOT) + "]");
|
||||
}
|
||||
}
|
||||
|
||||
private static void packages(CommandSender sender, BridgeManagementSnapshot snapshot) {
|
||||
if (snapshot.packages().isEmpty()) {
|
||||
sender.sendMessage(PREFIX + "No native packages found.");
|
||||
return;
|
||||
}
|
||||
for (PackageInspection inspected : snapshot.packages()) {
|
||||
sender.sendMessage((inspected.valid() ? ChatColor.GREEN + "✓ " : ChatColor.RED + "✗ ")
|
||||
+ ChatColor.RESET + inspected.path());
|
||||
sender.sendMessage(" " + (inspected.valid()
|
||||
? "plugin=" + inspected.pluginId()
|
||||
: "error=" + inspected.error()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void metadata(CommandSender sender, BridgeManagementSnapshot snapshot, String[] args) {
|
||||
ManagedPluginSnapshot plugin = requiredPlugin(sender, snapshot, args, "metadata");
|
||||
if (plugin == null) {
|
||||
return;
|
||||
}
|
||||
var metadata = plugin.metadata();
|
||||
sender.sendMessage(PREFIX + metadata.name() + " [" + metadata.id() + "]");
|
||||
sender.sendMessage(" version=" + metadata.version() + ", ABI=" + metadata.apiVersion()
|
||||
+ ", environment=" + metadata.environment().name().toLowerCase(Locale.ROOT));
|
||||
sender.sendMessage(" state=" + plugin.state() + ", backend=" + plugin.backend());
|
||||
sender.sendMessage(" authors=" + String.join(", ", metadata.authors()));
|
||||
sender.sendMessage(" origin=" + plugin.origin());
|
||||
if (metadata.description() != null && !metadata.description().isBlank()) {
|
||||
sender.sendMessage(" " + metadata.description());
|
||||
}
|
||||
if (metadata.configSchema() != null) {
|
||||
sender.sendMessage(" schema=" + ProtocolJson.GSON.toJson(metadata.configSchema()));
|
||||
}
|
||||
}
|
||||
|
||||
private static void logs(CommandSender sender, BridgeManagementSnapshot snapshot, String[] args) {
|
||||
ManagedPluginSnapshot plugin = requiredPlugin(sender, snapshot, args, "logs");
|
||||
if (plugin == null) {
|
||||
return;
|
||||
}
|
||||
int count = 20;
|
||||
if (args.length >= 3) {
|
||||
try {
|
||||
count = Math.clamp(Integer.parseInt(args[2]), 1, 100);
|
||||
} catch (NumberFormatException exception) {
|
||||
sender.sendMessage(PREFIX + ChatColor.RED + "Log count must be an integer from 1 to 100.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
List<PluginLog> logs = plugin.logs();
|
||||
int from = Math.max(0, logs.size() - count);
|
||||
sender.sendMessage(PREFIX + "Last " + (logs.size() - from) + " log entries for " + plugin.metadata().id());
|
||||
for (PluginLog log : logs.subList(from, logs.size())) {
|
||||
sender.sendMessage(" [" + value(log.level()) + "/" + value(log.stream()) + "] " + value(log.message()));
|
||||
}
|
||||
}
|
||||
|
||||
private void reload(CommandSender sender, String[] args) {
|
||||
if (args.length < 2) {
|
||||
sender.sendMessage(PREFIX + ChatColor.RED + "Usage: /gmb reload <plugin-id>");
|
||||
return;
|
||||
}
|
||||
result(sender, this.plugins.reload(args[1]));
|
||||
}
|
||||
|
||||
private static ManagedPluginSnapshot requiredPlugin(
|
||||
CommandSender sender, BridgeManagementSnapshot snapshot, String[] args, String action
|
||||
) {
|
||||
if (args.length < 2) {
|
||||
sender.sendMessage(PREFIX + ChatColor.RED + "Usage: /gmb " + action + " <plugin-id>");
|
||||
return null;
|
||||
}
|
||||
return snapshot.plugins().stream()
|
||||
.filter(plugin -> plugin.metadata().id().equals(args[1]))
|
||||
.findFirst()
|
||||
.orElseGet(() -> {
|
||||
sender.sendMessage(PREFIX + ChatColor.RED + "Unknown Go plugin: " + args[1]);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
private static void result(CommandSender sender, ReloadResult result) {
|
||||
sender.sendMessage(PREFIX + (result.success() ? ChatColor.GREEN : ChatColor.RED) + result.message());
|
||||
}
|
||||
|
||||
private static void usage(CommandSender sender, String label) {
|
||||
sender.sendMessage(PREFIX + "Usage: /" + label
|
||||
+ " <status|packages|metadata <id>|logs <id> [count]|reload <id>|rescan>");
|
||||
}
|
||||
|
||||
private static String value(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> onTabComplete(
|
||||
CommandSender sender,
|
||||
Command command,
|
||||
String alias,
|
||||
String[] args
|
||||
) {
|
||||
if (sender instanceof Player player && !player.isOp()) {
|
||||
return List.of();
|
||||
}
|
||||
if (args.length == 1) {
|
||||
return matching(List.of("status", "packages", "metadata", "logs", "reload", "rescan"), args[0]);
|
||||
}
|
||||
if (args.length == 2 && List.of("metadata", "logs", "reload").contains(args[0].toLowerCase(Locale.ROOT))) {
|
||||
List<String> ids = this.plugins.managementSnapshot(null).plugins().stream()
|
||||
.map(plugin -> plugin.metadata().id())
|
||||
.toList();
|
||||
return matching(ids, args[1]);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
private static List<String> matching(List<String> values, String prefix) {
|
||||
String lower = prefix.toLowerCase(Locale.ROOT);
|
||||
List<String> result = new ArrayList<>();
|
||||
for (String value : values) {
|
||||
if (value.toLowerCase(Locale.ROOT).startsWith(lower)) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.ReloadResult;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.plugin.messaging.PluginMessageListener;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/** Fabric custom-payload compatible management bridge exposed by Paper/Purpur. */
|
||||
final class PaperAdminMessaging implements PluginMessageListener {
|
||||
static final String REQUEST_CHANNEL = "go_minecraft_bridge:admin_request";
|
||||
static final String RESPONSE_CHANNEL = "go_minecraft_bridge:admin_response";
|
||||
private static final int MAX_PLUGIN_MESSAGE_BYTES = 30_000;
|
||||
|
||||
private final Plugin owner;
|
||||
private final PaperGoPluginManager plugins;
|
||||
private final Logger logger;
|
||||
|
||||
PaperAdminMessaging(Plugin owner, PaperGoPluginManager plugins) {
|
||||
this.owner = owner;
|
||||
this.plugins = plugins;
|
||||
this.logger = owner.getLogger();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPluginMessageReceived(String channel, Player player, byte[] message) {
|
||||
if (!REQUEST_CHANNEL.equals(channel)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
AdminRequest request = decodeRequest(message);
|
||||
if (!player.isOp()) {
|
||||
send(player, withoutDetails(this.plugins.managementSnapshot(
|
||||
"Administrator permission is required"
|
||||
)));
|
||||
return;
|
||||
}
|
||||
|
||||
String responseMessage = null;
|
||||
if ("reload".equals(request.action())) {
|
||||
ReloadResult result = this.plugins.reload(request.pluginId());
|
||||
responseMessage = result.message();
|
||||
} else if ("rescan".equals(request.action())) {
|
||||
ReloadResult result = this.plugins.rescan();
|
||||
responseMessage = result.message();
|
||||
} else if (!"refresh".equals(request.action())) {
|
||||
responseMessage = "Unknown admin action: " + request.action();
|
||||
}
|
||||
send(player, this.plugins.managementSnapshot(responseMessage));
|
||||
} catch (RuntimeException exception) {
|
||||
this.logger.log(Level.WARNING,
|
||||
"Rejected malformed Go Minecraft Bridge management payload from " + player.getName(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void send(Player player, BridgeManagementSnapshot snapshot) {
|
||||
player.sendPluginMessage(this.owner, RESPONSE_CHANNEL, encodeString(boundedJson(snapshot)));
|
||||
}
|
||||
|
||||
static AdminRequest decodeRequest(byte[] payload) {
|
||||
Cursor cursor = new Cursor(payload);
|
||||
String action = cursor.readString(32);
|
||||
String pluginId = cursor.readString(64);
|
||||
if (!cursor.atEnd()) {
|
||||
throw new IllegalArgumentException("Trailing bytes in admin request");
|
||||
}
|
||||
return new AdminRequest(action, pluginId);
|
||||
}
|
||||
|
||||
static byte[] encodeRequest(String action, String pluginId) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
writeString(output, action, 32);
|
||||
writeString(output, pluginId, 64);
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
static byte[] encodeString(String value) {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
writeString(output, value, MAX_PLUGIN_MESSAGE_BYTES);
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
static String decodeString(byte[] payload) {
|
||||
Cursor cursor = new Cursor(payload);
|
||||
String value = cursor.readString(MAX_PLUGIN_MESSAGE_BYTES);
|
||||
if (!cursor.atEnd()) {
|
||||
throw new IllegalArgumentException("Trailing bytes after string payload");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String boundedJson(BridgeManagementSnapshot snapshot) {
|
||||
String json = ProtocolJson.GSON.toJson(snapshot);
|
||||
if (fits(json)) {
|
||||
return json;
|
||||
}
|
||||
for (int retainedLogs : new int[]{20, 5, 0}) {
|
||||
List<ManagedPluginSnapshot> trimmedPlugins = new ArrayList<>();
|
||||
for (ManagedPluginSnapshot plugin : snapshot.plugins()) {
|
||||
int from = Math.max(0, plugin.logs().size() - retainedLogs);
|
||||
trimmedPlugins.add(new ManagedPluginSnapshot(
|
||||
plugin.metadata(), plugin.state(), plugin.backend(), plugin.origin(),
|
||||
plugin.logs().subList(from, plugin.logs().size())
|
||||
));
|
||||
}
|
||||
json = ProtocolJson.GSON.toJson(new BridgeManagementSnapshot(
|
||||
snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(),
|
||||
"Response was shortened to fit the Paper plugin-message limit",
|
||||
snapshot.packages(), trimmedPlugins
|
||||
));
|
||||
if (fits(json)) {
|
||||
return json;
|
||||
}
|
||||
}
|
||||
return ProtocolJson.GSON.toJson(new BridgeManagementSnapshot(
|
||||
snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), snapshot.canReload(),
|
||||
"Management data exceeded the Paper plugin-message limit; use /gmb for full details",
|
||||
List.of(), List.of()
|
||||
));
|
||||
}
|
||||
|
||||
private static BridgeManagementSnapshot withoutDetails(BridgeManagementSnapshot snapshot) {
|
||||
return new BridgeManagementSnapshot(
|
||||
snapshot.generatedAtUnixMilli(), snapshot.serverRunning(), false,
|
||||
snapshot.message(), List.of(), List.of()
|
||||
);
|
||||
}
|
||||
|
||||
private static boolean fits(String json) {
|
||||
return json.getBytes(StandardCharsets.UTF_8).length + 3 <= MAX_PLUGIN_MESSAGE_BYTES;
|
||||
}
|
||||
|
||||
private static void writeString(ByteArrayOutputStream output, String value, int maxChars) {
|
||||
String safe = value == null ? "" : value;
|
||||
if (safe.length() > maxChars) {
|
||||
throw new IllegalArgumentException("String exceeds " + maxChars + " characters");
|
||||
}
|
||||
byte[] encoded = safe.getBytes(StandardCharsets.UTF_8);
|
||||
writeVarInt(output, encoded.length);
|
||||
output.writeBytes(encoded);
|
||||
}
|
||||
|
||||
private static void writeVarInt(ByteArrayOutputStream output, int value) {
|
||||
while ((value & -128) != 0) {
|
||||
output.write(value & 127 | 128);
|
||||
value >>>= 7;
|
||||
}
|
||||
output.write(value);
|
||||
}
|
||||
|
||||
record AdminRequest(String action, String pluginId) {
|
||||
}
|
||||
|
||||
private static final class Cursor {
|
||||
private final byte[] bytes;
|
||||
private int offset;
|
||||
|
||||
private Cursor(byte[] bytes) {
|
||||
this.bytes = bytes;
|
||||
}
|
||||
|
||||
private String readString(int maxChars) {
|
||||
int byteLength = readVarInt();
|
||||
if (byteLength < 0 || byteLength > maxChars * 4 || this.offset + byteLength > this.bytes.length) {
|
||||
throw new IllegalArgumentException("Invalid UTF-8 byte length " + byteLength);
|
||||
}
|
||||
String value = new String(this.bytes, this.offset, byteLength, StandardCharsets.UTF_8);
|
||||
this.offset += byteLength;
|
||||
if (value.length() > maxChars) {
|
||||
throw new IllegalArgumentException("Decoded string exceeds " + maxChars + " characters");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private int readVarInt() {
|
||||
int result = 0;
|
||||
for (int position = 0; position < 35; position += 7) {
|
||||
if (this.offset >= this.bytes.length) {
|
||||
throw new IllegalArgumentException("Truncated VarInt");
|
||||
}
|
||||
int current = this.bytes[this.offset++] & 0xFF;
|
||||
result |= (current & 0x7F) << position;
|
||||
if ((current & 0x80) == 0) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("VarInt is too large");
|
||||
}
|
||||
|
||||
private boolean atEnd() {
|
||||
return this.offset == this.bytes.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ChatEvent;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.DeathEvent;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.command.PluginCommand;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Projectile;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.entity.EntityDamageByEntityEvent;
|
||||
import org.bukkit.event.entity.EntityDamageEvent;
|
||||
import org.bukkit.event.entity.EntityDeathEvent;
|
||||
import org.bukkit.event.player.AsyncPlayerChatEvent;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.bukkit.projectiles.ProjectileSource;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public final class PaperBridgePlugin extends JavaPlugin implements Listener {
|
||||
private final PaperSnapshotFactory snapshots = new PaperSnapshotFactory();
|
||||
private PaperGoPluginManager plugins;
|
||||
private PaperAdminMessaging adminMessaging;
|
||||
private BukkitTask tickTask;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
this.plugins = new PaperGoPluginManager(getLogger(), getDataFolder().toPath());
|
||||
this.plugins.discover();
|
||||
this.plugins.start();
|
||||
|
||||
Bukkit.getPluginManager().registerEvents(this, this);
|
||||
PluginCommand command = getCommand("go-minecraft-bridge");
|
||||
if (command == null) {
|
||||
throw new IllegalStateException("go-minecraft-bridge command is missing from plugin.yml");
|
||||
}
|
||||
PaperAdminCommand administrator = new PaperAdminCommand(this.plugins);
|
||||
command.setExecutor(administrator);
|
||||
command.setTabCompleter(administrator);
|
||||
this.adminMessaging = new PaperAdminMessaging(this, this.plugins);
|
||||
getServer().getMessenger().registerIncomingPluginChannel(
|
||||
this, PaperAdminMessaging.REQUEST_CHANNEL, this.adminMessaging
|
||||
);
|
||||
getServer().getMessenger().registerOutgoingPluginChannel(
|
||||
this, PaperAdminMessaging.RESPONSE_CHANNEL
|
||||
);
|
||||
this.tickTask = Bukkit.getScheduler().runTaskTimer(this, this.plugins::tick, 1L, 1L);
|
||||
|
||||
getLogger().info("Go Minecraft Bridge Paper/Purpur runtime enabled");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (this.tickTask != null) {
|
||||
this.tickTask.cancel();
|
||||
}
|
||||
if (this.plugins != null) {
|
||||
this.plugins.stop();
|
||||
}
|
||||
getServer().getMessenger().unregisterIncomingPluginChannel(this);
|
||||
getServer().getMessenger().unregisterOutgoingPluginChannel(this);
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onChat(AsyncPlayerChatEvent event) {
|
||||
ChatEvent chat = new ChatEvent(
|
||||
event.getPlayer().getUniqueId().toString(),
|
||||
event.getPlayer().getName(),
|
||||
event.getMessage(),
|
||||
Instant.now().toEpochMilli()
|
||||
);
|
||||
if (event.isAsynchronous()) {
|
||||
Bukkit.getScheduler().runTask(this, () -> this.plugins.chat(chat));
|
||||
} else {
|
||||
this.plugins.chat(chat);
|
||||
}
|
||||
}
|
||||
|
||||
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
|
||||
public void onDeath(EntityDeathEvent event) {
|
||||
EntityDamageEvent damage = event.getEntity().getLastDamageCause();
|
||||
String damageType = damage == null ? "unknown" : damage.getCause().name().toLowerCase();
|
||||
String attackerUuid = attacker(damage);
|
||||
this.plugins.death(new DeathEvent(
|
||||
this.snapshots.entity(event.getEntity()), damageType, attackerUuid,
|
||||
Instant.now().toEpochMilli()
|
||||
));
|
||||
}
|
||||
|
||||
private static String attacker(EntityDamageEvent damage) {
|
||||
if (!(damage instanceof EntityDamageByEntityEvent entityDamage)) {
|
||||
return null;
|
||||
}
|
||||
Entity damager = entityDamage.getDamager();
|
||||
if (damager instanceof Projectile projectile) {
|
||||
ProjectileSource shooter = projectile.getShooter();
|
||||
if (shooter instanceof Entity entity) {
|
||||
damager = entity;
|
||||
}
|
||||
}
|
||||
return damager.getUniqueId().toString();
|
||||
}
|
||||
}
|
||||
+357
@@ -0,0 +1,357 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend;
|
||||
import dev.yawaflua.gominecraftbridge.host.LoadedPlugin;
|
||||
import dev.yawaflua.gominecraftbridge.host.PluginState;
|
||||
import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.PackageInspection;
|
||||
import dev.yawaflua.gominecraftbridge.management.ReloadResult;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ChatEvent;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.DeathEvent;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.DeinitEvent;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.InitEvent;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginLog;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginResponse;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.Protocol;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.SystemCallRequest;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.SystemCallResult;
|
||||
import org.bukkit.Bukkit;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
final class PaperGoPluginManager {
|
||||
private static final int MAX_SYSTEM_CALL_CHAIN = 32;
|
||||
|
||||
private final Logger logger;
|
||||
private final Path pluginDirectory;
|
||||
private final Path dataDirectory;
|
||||
private final Map<String, LoadedPlugin> plugins = new LinkedHashMap<>();
|
||||
private final List<PackageInspection> packageInspections = new ArrayList<>();
|
||||
private final PaperSnapshotFactory snapshots = new PaperSnapshotFactory();
|
||||
private final PaperActionExecutor actions = new PaperActionExecutor();
|
||||
private final PaperSystemCalls systemCalls = new PaperSystemCalls(this.snapshots);
|
||||
private boolean running;
|
||||
private long tick;
|
||||
|
||||
PaperGoPluginManager(Logger logger, Path pluginDataDirectory) {
|
||||
this.logger = logger;
|
||||
this.pluginDirectory = pluginDataDirectory.resolve("go-plugins");
|
||||
this.dataDirectory = pluginDataDirectory.resolve("data");
|
||||
}
|
||||
|
||||
synchronized void discover() {
|
||||
scanCandidates();
|
||||
}
|
||||
|
||||
synchronized ReloadResult rescan() {
|
||||
int before = this.plugins.size();
|
||||
scanCandidates();
|
||||
return new ReloadResult(true, "Package check completed; new plugins: " + (this.plugins.size() - before));
|
||||
}
|
||||
|
||||
private void scanCandidates() {
|
||||
this.packageInspections.clear();
|
||||
try {
|
||||
Files.createDirectories(this.pluginDirectory);
|
||||
Files.createDirectories(this.dataDirectory);
|
||||
} catch (IOException exception) {
|
||||
throw new IllegalStateException("Cannot create Paper Go plugin directories", exception);
|
||||
}
|
||||
|
||||
for (Path candidate : nativeCandidates()) {
|
||||
Path normalized = candidate.toAbsolutePath().normalize();
|
||||
try {
|
||||
LoadedPlugin existing = this.plugins.values().stream()
|
||||
.filter(plugin -> plugin.backend().origin().equals(normalized))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (existing != null) {
|
||||
this.packageInspections.add(new PackageInspection(
|
||||
normalized.toString(), true, existing.metadata().id(), null
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
LoadedPlugin plugin = new LoadedPlugin(new NativePluginBackend(candidate));
|
||||
if (!plugin.metadata().environment().supportsServer()) {
|
||||
this.packageInspections.add(new PackageInspection(
|
||||
normalized.toString(), true, plugin.metadata().id(), null
|
||||
));
|
||||
this.logger.info("Skipping client-only Go plugin " + plugin.metadata().id());
|
||||
continue;
|
||||
}
|
||||
if (this.plugins.putIfAbsent(plugin.metadata().id(), plugin) != null) {
|
||||
throw new IllegalArgumentException("Duplicate plugin id " + plugin.metadata().id());
|
||||
}
|
||||
this.packageInspections.add(new PackageInspection(
|
||||
normalized.toString(), true, plugin.metadata().id(), null
|
||||
));
|
||||
bridgeLog(plugin, "info", "Package check passed: " + normalized);
|
||||
this.logger.info("Discovered Go plugin " + plugin.metadata().name()
|
||||
+ " " + plugin.metadata().version() + " from " + candidate.getFileName());
|
||||
if (this.running) {
|
||||
startPlugin(plugin);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
this.packageInspections.add(new PackageInspection(
|
||||
normalized.toString(), false, null, rootMessage(exception)
|
||||
));
|
||||
this.logger.log(Level.SEVERE, "Cannot load Go plugin " + candidate, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void start() {
|
||||
this.running = true;
|
||||
for (LoadedPlugin plugin : this.plugins.values()) {
|
||||
startPlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void tick() {
|
||||
this.tick++;
|
||||
for (LoadedPlugin plugin : runningPlugins()) {
|
||||
invoke(plugin, Protocol.Operation.TICK,
|
||||
this.snapshots.create(this.tick, plugin.snapshotSubscription()));
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void chat(ChatEvent event) {
|
||||
for (LoadedPlugin plugin : runningPlugins()) {
|
||||
invoke(plugin, Protocol.Operation.CHAT, event);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void death(DeathEvent event) {
|
||||
for (LoadedPlugin plugin : runningPlugins()) {
|
||||
invoke(plugin, Protocol.Operation.DEATH, event);
|
||||
}
|
||||
}
|
||||
|
||||
synchronized void stop() {
|
||||
for (LoadedPlugin plugin : runningPlugins()) {
|
||||
try {
|
||||
processResponse(plugin, plugin.invoke(
|
||||
Protocol.Operation.DEINIT, new DeinitEvent("paper_plugin_disabling")
|
||||
), 0);
|
||||
} catch (RuntimeException exception) {
|
||||
bridgeLog(plugin, "error", "Deinit failed: " + rootMessage(exception));
|
||||
this.logger.log(Level.SEVERE, "Go plugin " + plugin.metadata().id() + " failed during deinit", exception);
|
||||
} finally {
|
||||
plugin.markStopped();
|
||||
}
|
||||
}
|
||||
this.running = false;
|
||||
}
|
||||
|
||||
synchronized ReloadResult reload(String pluginId) {
|
||||
if (!this.running) {
|
||||
return new ReloadResult(false, "Paper plugin runtime is stopped");
|
||||
}
|
||||
LoadedPlugin plugin = this.plugins.get(pluginId);
|
||||
if (plugin == null) {
|
||||
return new ReloadResult(false, "Unknown Go plugin: " + pluginId);
|
||||
}
|
||||
if (plugin.state() == PluginState.RUNNING) {
|
||||
try {
|
||||
processResponse(plugin, plugin.invoke(
|
||||
Protocol.Operation.DEINIT, new DeinitEvent("paper_admin_reload")
|
||||
), 0);
|
||||
} catch (RuntimeException exception) {
|
||||
bridgeLog(plugin, "error", "Reload deinit failed: " + rootMessage(exception));
|
||||
}
|
||||
}
|
||||
plugin.prepareReload();
|
||||
boolean started = startPlugin(plugin);
|
||||
return new ReloadResult(started, started
|
||||
? "Go plugin " + pluginId + " restarted (the native binary remains loaded)"
|
||||
: "Go plugin " + pluginId + " failed to restart");
|
||||
}
|
||||
|
||||
synchronized BridgeManagementSnapshot managementSnapshot(String message) {
|
||||
List<ManagedPluginSnapshot> pluginSnapshots = this.plugins.values().stream()
|
||||
.map(plugin -> new ManagedPluginSnapshot(
|
||||
plugin.metadata(), plugin.state().name().toLowerCase(Locale.ROOT),
|
||||
plugin.backend().getClass().getSimpleName(),
|
||||
plugin.backend().origin().toString(), plugin.logs()
|
||||
))
|
||||
.toList();
|
||||
return new BridgeManagementSnapshot(
|
||||
Instant.now().toEpochMilli(), this.running, this.running, message,
|
||||
List.copyOf(this.packageInspections), pluginSnapshots
|
||||
);
|
||||
}
|
||||
|
||||
private boolean startPlugin(LoadedPlugin plugin) {
|
||||
try {
|
||||
Path pluginData = this.dataDirectory.resolve(plugin.metadata().id());
|
||||
Files.createDirectories(pluginData);
|
||||
PluginResponse response = plugin.invoke(
|
||||
Protocol.Operation.INIT,
|
||||
new InitEvent(
|
||||
Bukkit.getMinecraftVersion(), true,
|
||||
pluginData.toAbsolutePath().toString(), PluginEnvironment.SERVER
|
||||
)
|
||||
);
|
||||
processResponse(plugin, response, 0);
|
||||
if (response.isError()) {
|
||||
plugin.disable();
|
||||
bridgeLog(plugin, "error", "Initialization failed: " + response.error());
|
||||
return false;
|
||||
}
|
||||
plugin.markRunning();
|
||||
bridgeLog(plugin, "info", "Plugin started on Paper/Purpur");
|
||||
return true;
|
||||
} catch (Exception exception) {
|
||||
disable(plugin, "initialization failed", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void invoke(LoadedPlugin plugin, Protocol.Operation operation, Object event) {
|
||||
try {
|
||||
processResponse(plugin, plugin.invoke(operation, event), 0);
|
||||
} catch (RuntimeException exception) {
|
||||
disable(plugin, operation + " failed", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void processResponse(LoadedPlugin plugin, PluginResponse response, int systemCallDepth) {
|
||||
for (PluginLog log : response.logs()) {
|
||||
writeLog(plugin, log);
|
||||
}
|
||||
if (response.snapshot() != null) {
|
||||
plugin.snapshotSubscription(response.snapshot());
|
||||
}
|
||||
if (response.isPanic()) {
|
||||
disable(plugin, "callback panicked: " + response.error(), null);
|
||||
return;
|
||||
}
|
||||
if (response.isError()) {
|
||||
bridgeLog(plugin, "error", "Callback returned an error: " + response.error());
|
||||
}
|
||||
|
||||
response.actions().forEach(action -> {
|
||||
try {
|
||||
this.actions.execute(action);
|
||||
} catch (RuntimeException exception) {
|
||||
this.logger.log(Level.SEVERE,
|
||||
"Action " + action.type() + " from " + plugin.metadata().id() + " failed", exception);
|
||||
}
|
||||
});
|
||||
|
||||
if (response.systemCalls().isEmpty()) {
|
||||
return;
|
||||
}
|
||||
if (systemCallDepth >= MAX_SYSTEM_CALL_CHAIN) {
|
||||
disable(plugin, "system call chain exceeded " + MAX_SYSTEM_CALL_CHAIN, null);
|
||||
return;
|
||||
}
|
||||
for (SystemCallRequest request : response.systemCalls()) {
|
||||
SystemCallResult result = executeSystemCall(request);
|
||||
try {
|
||||
processResponse(plugin, plugin.invoke(Protocol.Operation.SYSTEM_CALL_RESULT, result), systemCallDepth + 1);
|
||||
} catch (RuntimeException exception) {
|
||||
disable(plugin, "system call result callback failed", exception);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private SystemCallResult executeSystemCall(SystemCallRequest request) {
|
||||
try {
|
||||
JsonElement result = this.systemCalls.execute(
|
||||
request.name(), request.payload() == null ? JsonNull.INSTANCE : request.payload(), this.tick
|
||||
);
|
||||
return new SystemCallResult(
|
||||
request.id(), request.name(), true, result == null ? JsonNull.INSTANCE : result, null
|
||||
);
|
||||
} catch (Exception exception) {
|
||||
return new SystemCallResult(
|
||||
request.id(), request.name(), false, JsonNull.INSTANCE, rootMessage(exception)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private List<LoadedPlugin> runningPlugins() {
|
||||
return this.plugins.values().stream()
|
||||
.filter(plugin -> plugin.state() == PluginState.RUNNING)
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Path> nativeCandidates() {
|
||||
if (!Files.isDirectory(this.pluginDirectory)) {
|
||||
return List.of();
|
||||
}
|
||||
try (var files = Files.list(this.pluginDirectory)) {
|
||||
return files.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(nativeExtension()))
|
||||
.sorted(Comparator.comparing(Path::toString))
|
||||
.toList();
|
||||
} catch (IOException exception) {
|
||||
this.logger.log(Level.SEVERE, "Cannot scan native plugin directory " + this.pluginDirectory, exception);
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static String nativeExtension() {
|
||||
String os = System.getProperty("os.name", "").toLowerCase(Locale.ROOT);
|
||||
if (os.contains("win")) {
|
||||
return ".dll";
|
||||
}
|
||||
if (os.contains("mac")) {
|
||||
return ".dylib";
|
||||
}
|
||||
return ".so";
|
||||
}
|
||||
|
||||
private void disable(LoadedPlugin plugin, String reason, Throwable throwable) {
|
||||
plugin.disable();
|
||||
bridgeLog(plugin, "error", "Plugin disabled: " + reason
|
||||
+ (throwable == null ? "" : " — " + rootMessage(throwable)));
|
||||
if (throwable == null) {
|
||||
this.logger.severe("Disabled Go plugin " + plugin.metadata().id() + ": " + reason);
|
||||
} else {
|
||||
this.logger.log(Level.SEVERE, "Disabled Go plugin " + plugin.metadata().id() + ": " + reason, throwable);
|
||||
}
|
||||
}
|
||||
|
||||
private void writeLog(LoadedPlugin plugin, PluginLog log) {
|
||||
plugin.appendLog(log);
|
||||
String message = "[Go/" + plugin.metadata().id() + "/" + log.stream() + "] " + log.message();
|
||||
Level level = switch (log.level() == null ? "info" : log.level()) {
|
||||
case "trace", "debug" -> Level.FINE;
|
||||
case "warn" -> Level.WARNING;
|
||||
case "error" -> Level.SEVERE;
|
||||
default -> Level.INFO;
|
||||
};
|
||||
this.logger.log(level, message);
|
||||
}
|
||||
|
||||
private void bridgeLog(LoadedPlugin plugin, String level, String message) {
|
||||
plugin.appendLog(new PluginLog("paper-bridge", level, message, Instant.now().toEpochMilli()));
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable throwable) {
|
||||
Throwable current = throwable;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
return current.getMessage() == null ? current.getClass().getSimpleName() : current.getMessage();
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import dev.yawaflua.gominecraftbridge.protocol.BlockReference;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.BlockSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.EntitySnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.LevelSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ServerSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.SnapshotSubscription;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
final class PaperSnapshotFactory {
|
||||
ServerSnapshot create(long tick, SnapshotSubscription subscription) {
|
||||
List<LevelSnapshot> levels = Bukkit.getWorlds().stream()
|
||||
.map(this::level)
|
||||
.toList();
|
||||
List<EntitySnapshot> entities = subscription.includesEntities()
|
||||
? Bukkit.getWorlds().stream()
|
||||
.flatMap(world -> world.getEntities().stream())
|
||||
.map(this::entity)
|
||||
.toList()
|
||||
: List.of();
|
||||
List<BlockSnapshot> blocks = new ArrayList<>();
|
||||
for (BlockReference reference : subscription.blocks()) {
|
||||
BlockSnapshot snapshot = block(reference);
|
||||
if (snapshot != null) {
|
||||
blocks.add(snapshot);
|
||||
}
|
||||
}
|
||||
return new ServerSnapshot(tick, Instant.now().toEpochMilli(), levels, entities, blocks);
|
||||
}
|
||||
|
||||
EntitySnapshot entity(Entity entity) {
|
||||
var location = entity.getLocation();
|
||||
Vector velocity = entity.getVelocity();
|
||||
Float health = null;
|
||||
Float maxHealth = null;
|
||||
if (entity instanceof LivingEntity living) {
|
||||
health = (float) living.getHealth();
|
||||
// Kept deliberately: Attribute.GENERIC_MAX_HEALTH was renamed to
|
||||
// Attribute.MAX_HEALTH after 1.21.1, while this Bukkit method remains
|
||||
// binary-compatible across the three supported Paper lines.
|
||||
maxHealth = (float) living.getMaxHealth();
|
||||
}
|
||||
return new EntitySnapshot(
|
||||
entity.getEntityId(),
|
||||
entity.getUniqueId().toString(),
|
||||
entity.getType().getKey().toString(),
|
||||
entity.getName(),
|
||||
entity.getWorld().getKey().toString(),
|
||||
location.getX(), location.getY(), location.getZ(),
|
||||
location.getYaw(), location.getPitch(),
|
||||
velocity.getX(), velocity.getY(), velocity.getZ(),
|
||||
!entity.isDead(), entity instanceof Player, health, maxHealth
|
||||
);
|
||||
}
|
||||
|
||||
private LevelSnapshot level(World world) {
|
||||
return new LevelSnapshot(
|
||||
world.getKey().toString(),
|
||||
world.getFullTime(),
|
||||
world.getTime(),
|
||||
world.hasStorm(),
|
||||
world.isThundering()
|
||||
);
|
||||
}
|
||||
|
||||
private BlockSnapshot block(BlockReference reference) {
|
||||
NamespacedKey key = NamespacedKey.fromString(reference.dimension());
|
||||
World world = key == null ? null : Bukkit.getWorld(key);
|
||||
if (world == null || !world.isChunkLoaded(reference.x() >> 4, reference.z() >> 4)) {
|
||||
return null;
|
||||
}
|
||||
Block block = world.getBlockAt(reference.x(), reference.y(), reference.z());
|
||||
return new BlockSnapshot(
|
||||
reference.dimension(), reference.x(), reference.y(), reference.z(),
|
||||
block.getType().getKey().toString(), blockProperties(block.getBlockData().getAsString())
|
||||
);
|
||||
}
|
||||
|
||||
private static Map<String, String> blockProperties(String blockData) {
|
||||
int open = blockData.indexOf('[');
|
||||
int close = blockData.lastIndexOf(']');
|
||||
if (open < 0 || close <= open + 1) {
|
||||
return Map.of();
|
||||
}
|
||||
Map<String, String> properties = new LinkedHashMap<>();
|
||||
for (String assignment : blockData.substring(open + 1, close).split(",")) {
|
||||
int equals = assignment.indexOf('=');
|
||||
if (equals > 0) {
|
||||
properties.put(assignment.substring(0, equals), assignment.substring(equals + 1));
|
||||
}
|
||||
}
|
||||
return Map.copyOf(properties);
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.ProtocolJson;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.NamespacedKey;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.UUID;
|
||||
|
||||
final class PaperSystemCalls {
|
||||
private final PaperSnapshotFactory snapshots;
|
||||
|
||||
PaperSystemCalls(PaperSnapshotFactory snapshots) {
|
||||
this.snapshots = snapshots;
|
||||
}
|
||||
|
||||
JsonElement execute(String name, JsonElement payload, long tick) {
|
||||
return switch (name) {
|
||||
case "minecraft:server.info" -> serverInfo(tick);
|
||||
case "minecraft:player.get" -> playerGet(payload);
|
||||
case "minecraft:block.get" -> blockGet(payload);
|
||||
case "minecraft:get_entity" -> entityGet(payload);
|
||||
default -> throw new IllegalArgumentException("Unknown system call " + name);
|
||||
};
|
||||
}
|
||||
|
||||
private JsonElement serverInfo(long tick) {
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("tick", tick);
|
||||
result.addProperty("dedicated", true);
|
||||
result.addProperty("onlinePlayers", Bukkit.getOnlinePlayers().size());
|
||||
result.addProperty("maxPlayers", Bukkit.getMaxPlayers());
|
||||
result.addProperty("minecraftVersion", Bukkit.getMinecraftVersion());
|
||||
result.addProperty("server", Bukkit.getVersion());
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonElement playerGet(JsonElement payload) {
|
||||
JsonObject request = object(payload, "minecraft:player.get");
|
||||
UUID playerId = UUID.fromString(requiredString(request, "playerUuid"));
|
||||
Player player = Bukkit.getPlayer(playerId);
|
||||
if (player == null) {
|
||||
return JsonNull.INSTANCE;
|
||||
}
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("uuid", player.getUniqueId().toString());
|
||||
result.addProperty("name", player.getName());
|
||||
result.addProperty("dimension", player.getWorld().getKey().toString());
|
||||
result.addProperty("x", player.getLocation().getX());
|
||||
result.addProperty("y", player.getLocation().getY());
|
||||
result.addProperty("z", player.getLocation().getZ());
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonElement blockGet(JsonElement payload) {
|
||||
JsonObject request = object(payload, "minecraft:block.get");
|
||||
String dimension = requiredString(request, "dimension");
|
||||
NamespacedKey key = NamespacedKey.fromString(dimension);
|
||||
World world = key == null ? null : Bukkit.getWorld(key);
|
||||
if (world == null) {
|
||||
throw new IllegalArgumentException("Unknown dimension " + dimension);
|
||||
}
|
||||
int x = requiredInt(request, "x");
|
||||
int y = requiredInt(request, "y");
|
||||
int z = requiredInt(request, "z");
|
||||
boolean loaded = world.isChunkLoaded(x >> 4, z >> 4);
|
||||
JsonObject result = new JsonObject();
|
||||
result.addProperty("loaded", loaded);
|
||||
if (loaded) {
|
||||
Block block = world.getBlockAt(x, y, z);
|
||||
result.addProperty("block", block.getType().getKey().toString());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private JsonElement entityGet(JsonElement payload) {
|
||||
JsonObject request = object(payload, "minecraft:get_entity");
|
||||
boolean hasUuid = request.has("uuid");
|
||||
boolean hasRuntimeId = request.has("runtimeId");
|
||||
if (hasUuid == hasRuntimeId) {
|
||||
throw new IllegalArgumentException(
|
||||
"minecraft:get_entity payload must contain exactly one of uuid or runtimeId"
|
||||
);
|
||||
}
|
||||
|
||||
Entity selected = null;
|
||||
if (hasUuid) {
|
||||
JsonElement value = request.get("uuid");
|
||||
if (!(value instanceof JsonPrimitive primitive) || !primitive.isString()) {
|
||||
throw new IllegalArgumentException("minecraft:get_entity uuid must be a string");
|
||||
}
|
||||
UUID uuid;
|
||||
try {
|
||||
String raw = primitive.getAsString();
|
||||
uuid = UUID.fromString(raw);
|
||||
if (!uuid.toString().equalsIgnoreCase(raw)) {
|
||||
throw new IllegalArgumentException("non-canonical UUID");
|
||||
}
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new IllegalArgumentException("minecraft:get_entity uuid is not a valid UUID", exception);
|
||||
}
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
selected = world.getEntity(uuid);
|
||||
if (selected != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
int runtimeId = exactInt(request.get("runtimeId"), "minecraft:get_entity runtimeId");
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
for (Entity entity : world.getEntities()) {
|
||||
if (entity.getEntityId() == runtimeId) {
|
||||
selected = entity;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selected != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return selected == null ? JsonNull.INSTANCE : ProtocolJson.tree(this.snapshots.entity(selected));
|
||||
}
|
||||
|
||||
private static JsonObject object(JsonElement payload, String call) {
|
||||
if (payload == null || !payload.isJsonObject()) {
|
||||
throw new IllegalArgumentException(call + " payload must be a JSON object");
|
||||
}
|
||||
return payload.getAsJsonObject();
|
||||
}
|
||||
|
||||
private static String requiredString(JsonObject object, String field) {
|
||||
JsonElement value = object.get(field);
|
||||
if (value == null || !value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) {
|
||||
throw new IllegalArgumentException(field + " must be a string");
|
||||
}
|
||||
return value.getAsString();
|
||||
}
|
||||
|
||||
private static int requiredInt(JsonObject object, String field) {
|
||||
return exactInt(object.get(field), field);
|
||||
}
|
||||
|
||||
private static int exactInt(JsonElement value, String field) {
|
||||
if (!(value instanceof JsonPrimitive primitive) || !primitive.isNumber()) {
|
||||
throw new IllegalArgumentException(field + " must be an integer");
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(primitive.getAsString()).intValueExact();
|
||||
} catch (ArithmeticException | NumberFormatException exception) {
|
||||
throw new IllegalArgumentException(field + " must be a 32-bit integer", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
name: GoMinecraftBridge
|
||||
version: '${version}'
|
||||
main: dev.yawaflua.gominecraftbridge.paper.PaperBridgePlugin
|
||||
api-version: '1.21'
|
||||
description: Native Go plugin host for Paper and Purpur servers
|
||||
author: yawaflua
|
||||
commands:
|
||||
go-minecraft-bridge:
|
||||
description: Inspect and manage native Go plugins
|
||||
usage: /gmb <status|packages|metadata|logs|reload|rescan>
|
||||
aliases: [gmb, gobridge]
|
||||
permissions:
|
||||
go_minecraft_bridge.admin:
|
||||
description: Allows management of native Go plugins
|
||||
default: op
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import dev.yawaflua.gominecraftbridge.management.BridgeManagementSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.management.ManagedPluginSnapshot;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginEnvironment;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginLog;
|
||||
import dev.yawaflua.gominecraftbridge.protocol.PluginMetadata;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class PaperAdminMessagingTest {
|
||||
@Test
|
||||
void requestCodecMatchesMinecraftVarIntUtfFormat() {
|
||||
byte[] encoded = PaperAdminMessaging.encodeRequest("reload", "пример");
|
||||
|
||||
assertEquals(
|
||||
new PaperAdminMessaging.AdminRequest("reload", "пример"),
|
||||
PaperAdminMessaging.decodeRequest(encoded)
|
||||
);
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
PaperAdminMessaging.decodeRequest(new byte[]{1, 'x', 0, 1}));
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseCodecRoundTripsUnicode() {
|
||||
String input = "Привет from Paper";
|
||||
assertEquals(input, PaperAdminMessaging.decodeString(PaperAdminMessaging.encodeString(input)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void managementResponseIsTrimmedBelowBukkitMessageLimit() {
|
||||
PluginMetadata metadata = new PluginMetadata(
|
||||
"large", "Large", "1", "", List.of(), null, 2, null, PluginEnvironment.SERVER
|
||||
);
|
||||
List<PluginLog> logs = java.util.stream.IntStream.range(0, 500)
|
||||
.mapToObj(index -> new PluginLog("sdk", "info", "Ж".repeat(8192), index))
|
||||
.toList();
|
||||
BridgeManagementSnapshot snapshot = new BridgeManagementSnapshot(
|
||||
1, true, true, null, List.of(),
|
||||
List.of(new ManagedPluginSnapshot(metadata, "running", "native", "/plugin.so", logs))
|
||||
);
|
||||
|
||||
String json = PaperAdminMessaging.boundedJson(snapshot);
|
||||
|
||||
assertTrue(json.getBytes(StandardCharsets.UTF_8).length + 3 <= 30_000);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable;
|
||||
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
@EnabledIfEnvironmentVariable(named = "GMB_TEST_LIBRARY", matches = ".+")
|
||||
class PaperShadedNativeBackendTest {
|
||||
@Test
|
||||
void shadedPaperJarLoadsTheRealGoLibrary() throws Exception {
|
||||
URL jar = Path.of(System.getProperty("gmb.paper.shadowJar")).toUri().toURL();
|
||||
try (URLClassLoader loader = new URLClassLoader(
|
||||
new URL[]{jar}, ClassLoader.getPlatformClassLoader()
|
||||
)) {
|
||||
Class<?> backendType = loader.loadClass(
|
||||
"dev.yawaflua.gominecraftbridge.backend.nativeffi.NativePluginBackend"
|
||||
);
|
||||
Object backend = backendType.getConstructor(Path.class)
|
||||
.newInstance(Path.of(System.getenv("GMB_TEST_LIBRARY")));
|
||||
Class<?> pluginBackendType = loader.loadClass(
|
||||
"dev.yawaflua.gominecraftbridge.backend.PluginBackend"
|
||||
);
|
||||
Class<?> loadedPluginType = loader.loadClass(
|
||||
"dev.yawaflua.gominecraftbridge.host.LoadedPlugin"
|
||||
);
|
||||
Object plugin = loadedPluginType.getConstructor(pluginBackendType).newInstance(backend);
|
||||
Object metadata = loadedPluginType.getMethod("metadata").invoke(plugin);
|
||||
String id = (String) metadata.getClass().getMethod("id").invoke(metadata);
|
||||
|
||||
assertEquals("hello_native", id);
|
||||
}
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package dev.yawaflua.gominecraftbridge.paper;
|
||||
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class PaperSystemCallsTest {
|
||||
private final PaperSystemCalls calls = new PaperSystemCalls(new PaperSnapshotFactory());
|
||||
|
||||
@Test
|
||||
void getEntityRequiresExactlyOneSelector() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
this.calls.execute("minecraft:get_entity", new JsonObject(), 0));
|
||||
|
||||
JsonObject both = new JsonObject();
|
||||
both.addProperty("uuid", "00000000-0000-0000-0000-000000000001");
|
||||
both.addProperty("runtimeId", 1);
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
this.calls.execute("minecraft:get_entity", both, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownCallsAndMalformedPayloads() {
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
this.calls.execute("example:missing", JsonNull.INSTANCE, 0));
|
||||
assertThrows(IllegalArgumentException.class, () ->
|
||||
this.calls.execute("minecraft:block.get", JsonNull.INSTANCE, 0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user