Adding client-side and server-side interactions. Also adds purpur/paper support

This commit is contained in:
Dmitri Shimanski
2026-07-20 05:41:57 +03:00
parent fbe4d786ca
commit ce93e99962
71 changed files with 5068 additions and 279 deletions
+142
View File
@@ -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
}
@@ -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();
}
}
@@ -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;
}
}
@@ -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;
}
}
}
@@ -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();
}
}
@@ -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();
}
}
@@ -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);
}
}
@@ -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
@@ -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);
}
}
@@ -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);
}
}
}
@@ -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));
}
}