From 4049bc6a0d69ce274fa70c1075c3e0679cb80504 Mon Sep 17 00:00:00 2001 From: YuKun Liu Date: Thu, 18 Dec 2025 04:41:06 -0800 Subject: [PATCH] feat: use `clang-format` --- .clang-format | 9 + .../online/mineroo/common/BindRequest.java | 174 ++++++++++++++- .../mineroo/common/ProtocolConstants.java | 5 +- .../online/mineroo/common/Serializable.java | 0 .../java/online/mineroo/paper/Config.java | 8 +- .../online/mineroo/paper/MinerooCore.java | 49 ++--- .../mineroo/paper/MinerooCoreBootstrap.java | 48 +++++ .../mineroo/paper/ProxyNetworkService.java | 148 +++++++++---- .../mineroo/paper/commands/BindCommand.java | 25 ++- .../mineroo/paper/listeners/BindListener.java | 34 +++ .../mineroo/paper/listeners/Config.java | 0 .../paper/listeners/PlayerBindListener.java | 175 +++++++++++++++ .../paper/listeners/PlayerJoinListener.java | 9 - ...{MinerooCore.java => MinerooVelocity.java} | 16 +- .../velocity/commands/MainCommand.java | 200 ------------------ .../velocity/listeners/BindListener.java | 2 +- .../velocity/listeners/ChannelListener.java | 194 ++++++++++++++++- .../velocity/listeners/JsonPrimitive.java | 0 18 files changed, 784 insertions(+), 312 deletions(-) create mode 100644 .clang-format rename velocity/src/main/java/online/mineroo/velocity/commands/NetworkService.java => common/src/main/java/online/mineroo/common/Serializable.java (100%) create mode 100644 paper/src/main/java/online/mineroo/paper/MinerooCoreBootstrap.java create mode 100644 paper/src/main/java/online/mineroo/paper/listeners/BindListener.java create mode 100644 paper/src/main/java/online/mineroo/paper/listeners/Config.java create mode 100644 paper/src/main/java/online/mineroo/paper/listeners/PlayerBindListener.java delete mode 100644 paper/src/main/java/online/mineroo/paper/listeners/PlayerJoinListener.java rename velocity/src/main/java/online/mineroo/velocity/{MinerooCore.java => MinerooVelocity.java} (91%) delete mode 100644 velocity/src/main/java/online/mineroo/velocity/commands/MainCommand.java create mode 100644 velocity/src/main/java/online/mineroo/velocity/listeners/JsonPrimitive.java diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..6a2b0f1 --- /dev/null +++ b/.clang-format @@ -0,0 +1,9 @@ +--- +Language: Java +BasedOnStyle: Google + +AlignAfterOpenBracket: BlockIndent + +ColumnLimit: 100 +BinPackArguments: false +AllowAllArgumentsOnNextLine: true diff --git a/common/src/main/java/online/mineroo/common/BindRequest.java b/common/src/main/java/online/mineroo/common/BindRequest.java index cd3c5cc..b491fe1 100644 --- a/common/src/main/java/online/mineroo/common/BindRequest.java +++ b/common/src/main/java/online/mineroo/common/BindRequest.java @@ -1,12 +1,17 @@ package online.mineroo.common; +import com.google.gson.Gson; import com.google.gson.JsonObject; import com.google.gson.JsonParser; +import com.google.gson.annotations.SerializedName; + import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; +import java.io.Serializable; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; /** @@ -43,14 +48,14 @@ public class BindRequest { * @return A future that completes with true if status is 'bound', false * otherwise */ - public CompletableFuture checkBindStatus(String hostname, int port) { + public CompletableFuture checkBindStatus(String hostname, String port) { // NetworkService is already configured with BaseURL, only need the path here String path = "/server/server-bind-status"; Map params = new HashMap<>(); params.put("address", hostname); - params.put("port", String.valueOf(port)); + params.put("port", port); return networkService.getData(path, params) .thenApply(response -> { @@ -86,12 +91,26 @@ public class BindRequest { * @param port The server port * @return A future that completes with true if tokens are received */ - public CompletableFuture initialMotdVerifyRequest(String hostname, int port) { + public CompletableFuture initialMotdVerifyRequest(String hostname, String port) { String path = "/server/motd-verify"; + Integer num_port = -1; + if (!"$port".equals(port)) { + try { + num_port = Integer.valueOf(port); + } catch (NumberFormatException ignored) { + num_port = 25565; + } + } + JsonObject json = new JsonObject(); json.addProperty("server_address", hostname); - json.addProperty("server_port", port); + + if (num_port == -1) { + json.addProperty("server_port", port); + } else { + json.addProperty("server_port", num_port); + } return networkService.postData(path, json) .thenApply(response -> { @@ -149,7 +168,6 @@ public class BindRequest { return networkService.postData(path, json) .thenApply(response -> { - String responseBody = response.getBody(); // Assume as long as the request returns successfully and no exception is // thrown, it is considered successful (HTTP 200) // If the API returns a specific {"success": false}, you need to parse the JSON @@ -162,6 +180,152 @@ public class BindRequest { }); } + public class PlayerBindResponse implements Serializable { + + @SerializedName("status") + private PlayerBindStatus status; + + @SerializedName("message") + private String message; + + @SerializedName("cached") + private boolean cached; + + @SerializedName("timestamp") + private long timestamp; + + @SerializedName("info") + private PlayerBindInfo info; + + // --- Getters --- + + public PlayerBindStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } + + public boolean isCached() { + return cached; + } + + public long getTimestamp() { + return timestamp; + } + + public PlayerBindInfo getInfo() { + return info; + } + + public void setStatus(PlayerBindStatus status) { + this.status = status; + } + + public void setMessage(String message) { + this.message = message; + } + } + + public static enum PlayerBindStatus { + @SerializedName("notBound") + NOT_BOUND, + + @SerializedName("bound") + BOUND, + + @SerializedName("conflictUser") + CONFLICT_USER, + + @SerializedName("conflictPlayer") + CONFLICT_PLAYER, + + @SerializedName("error") + ERROR + } + + public static class PlayerBindInfo implements Serializable { + @SerializedName("bound_uuid") + private String boundUuid; + + @SerializedName("minecraft_uuid") + private String minecraftUuid; + + @SerializedName("bound_user_id") + private Integer boundUserId; + + @SerializedName("user_id") + private Integer userId; + + // --- Getters --- + + public String getBoundUuid() { + return boundUuid; + } + + public String getMinecraftUuid() { + return minecraftUuid; + } + + public Integer getBoundUserId() { + return boundUserId; + } + + public Integer getUserId() { + return userId; + } + + public String getEffectiveUuid() { + return minecraftUuid != null ? minecraftUuid : boundUuid; + } + + public Integer getEffectiveUserId() { + return userId != null ? userId : boundUserId; + } + } + + private PlayerBindResponse createErrorResponse(String errorMessage) { + PlayerBindResponse errorRes = new PlayerBindResponse(); + errorRes.setStatus(PlayerBindStatus.ERROR); + errorRes.setMessage(errorMessage); + return errorRes; + } + + public CompletableFuture checkPlayerBindStatus(UUID player_uuid, String user_email) { + String path = "/server/user-bind-status"; + + Map params = new HashMap<>(); + + params.put("user_email", user_email); + params.put("player_uuid", player_uuid.toString()); + + return networkService.getData(path, params) + .thenApply(response -> { + Gson gson = new Gson(); + String responseBody = response.getBody(); + + if (response.getStatusCode() != 200) { + try { + return gson.fromJson(responseBody, PlayerBindResponse.class); + } catch (Exception ignored) { + return createErrorResponse("HTTP Error: " + response.getStatusCode()); + } + } + + try { + return gson.fromJson(responseBody, PlayerBindResponse.class); + } catch (Exception e) { + logger.error("Mineroo Bind: Failed to parse JSON", e); + return createErrorResponse("JSON Parse Error"); + } + }) + .exceptionally(e -> { + logger.error("Mineroo Bind: Network exception", e); + return createErrorResponse("Network Error: " + e.getMessage()); + }); + } + /** * Gets the MOTD token received from the API. * diff --git a/common/src/main/java/online/mineroo/common/ProtocolConstants.java b/common/src/main/java/online/mineroo/common/ProtocolConstants.java index 246f163..18fc4f5 100644 --- a/common/src/main/java/online/mineroo/common/ProtocolConstants.java +++ b/common/src/main/java/online/mineroo/common/ProtocolConstants.java @@ -2,9 +2,10 @@ package online.mineroo.common; public class ProtocolConstants { // Protocol channel name - public static String PROTOCOL_CHANNEL = "mineroo:plugin:api"; + public static String PROTOCOL_CHANNEL = "mineroo:net"; // Sub-channel/event names - public static String API_REQUEST = "API_REQUEST"; + public static String API_REQUEST = "OAPI_REQUEST"; + public static String API_RESPONSE = "OAPI_RESPONSE"; public static String BIND_MOTD_TOKEN = "SET_MOTD_TOKEN"; } diff --git a/velocity/src/main/java/online/mineroo/velocity/commands/NetworkService.java b/common/src/main/java/online/mineroo/common/Serializable.java similarity index 100% rename from velocity/src/main/java/online/mineroo/velocity/commands/NetworkService.java rename to common/src/main/java/online/mineroo/common/Serializable.java diff --git a/paper/src/main/java/online/mineroo/paper/Config.java b/paper/src/main/java/online/mineroo/paper/Config.java index e468165..e4affff 100644 --- a/paper/src/main/java/online/mineroo/paper/Config.java +++ b/paper/src/main/java/online/mineroo/paper/Config.java @@ -66,7 +66,7 @@ public class Config { this.description = config.getString("server.description", "A minecraft server"); this.bind = new ServerBindSection( config.getString("server.bind.address", ""), - config.getInt("server.bind.port", 0), + config.getObject("server.bind.port", Integer.class, null), config.getString("server.bind.token", "get token from `mineroo.online/resources/servers` page!")); } @@ -85,10 +85,10 @@ public class Config { public static class ServerBindSection { private final String address; - private final int port; + private final Integer port; private final String bindToken; - public ServerBindSection(String address, int port, String bindToken) { + public ServerBindSection(String address, Integer port, String bindToken) { this.address = address; this.port = port; this.bindToken = bindToken; @@ -98,7 +98,7 @@ public class Config { return address; } - public int getPort() { + public Integer getPort() { return port; } diff --git a/paper/src/main/java/online/mineroo/paper/MinerooCore.java b/paper/src/main/java/online/mineroo/paper/MinerooCore.java index c7d7057..f2cb4dd 100644 --- a/paper/src/main/java/online/mineroo/paper/MinerooCore.java +++ b/paper/src/main/java/online/mineroo/paper/MinerooCore.java @@ -1,38 +1,38 @@ package online.mineroo.paper; -import org.bukkit.plugin.java.JavaPlugin; - import io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents; +import online.mineroo.common.BindRequest; import online.mineroo.common.HttpNetworkService; import online.mineroo.common.MessageManager; import online.mineroo.common.NetworkServiceInterface; -import online.mineroo.common.ProtocolConstants; import online.mineroo.paper.commands.MainCommand; -import online.mineroo.paper.utils.PlayerBindDialog; - -import org.bukkit.entity.Player; -import org.bukkit.event.EventHandler; +import online.mineroo.paper.listeners.BindListener; +import online.mineroo.paper.listeners.PlayerBindListener; import org.bukkit.event.Listener; -import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.plugin.java.JavaPlugin; public class MinerooCore extends JavaPlugin implements Listener { - private MessageManager messageManager; private NetworkServiceInterface networkService; + private BindRequest bindRequest; private Config config; + private BindListener bindListener; + @Override public void onEnable() { - saveDefaultConfig(); + this.bindListener = new BindListener(); + getServer().getPluginManager().registerEvents(bindListener, this); + reloadAll(); this.getLifecycleManager().registerEventHandler(LifecycleEvents.COMMANDS, commands -> { commands.registrar().register(new MainCommand(this).build()); }); - this.getServer().getMessenger().registerOutgoingPluginChannel(this, ProtocolConstants.PROTOCOL_CHANNEL); + getServer().getPluginManager().registerEvents(new PlayerBindListener(this), this); messageManager = new MessageManager(); @@ -40,19 +40,7 @@ public class MinerooCore extends JavaPlugin implements Listener { } @Override - public void onDisable() { - } - - @EventHandler - public void onPlayerJoin(PlayerJoinEvent event) { - Player player = event.getPlayer(); - - PlayerBindDialog dialog = new PlayerBindDialog(); - - player.showDialog(dialog.getDialog(messageManager)); - - player.sendMessage(messageManager.get("message.test", "player", player.getName())); - } + public void onDisable() {} public void reloadAll() { reloadConfig(); @@ -66,10 +54,11 @@ public class MinerooCore extends JavaPlugin implements Listener { String token = config.getServer().getServerBind().getBindToken(); String hostname = config.getTest().getApiHostname(); this.networkService = new HttpNetworkService(hostname, "mserver", token); - getLogger().info("Using direct HTTP network service."); - getLogger().info("API: " + hostname); + getLogger().info("Using direct HTTP network [ " + hostname + " ]"); } + bindRequest = new BindRequest(this.getSLF4JLogger(), this.getNetworkService()); + if (this.messageManager == null) { this.messageManager = new MessageManager(); } else { @@ -88,4 +77,12 @@ public class MinerooCore extends JavaPlugin implements Listener { public MessageManager getMessageManager() { return messageManager; } + + public BindListener getBindListener() { + return this.bindListener; + } + + public BindRequest getBindRequest() { + return bindRequest; + } } diff --git a/paper/src/main/java/online/mineroo/paper/MinerooCoreBootstrap.java b/paper/src/main/java/online/mineroo/paper/MinerooCoreBootstrap.java new file mode 100644 index 0000000..366c8c8 --- /dev/null +++ b/paper/src/main/java/online/mineroo/paper/MinerooCoreBootstrap.java @@ -0,0 +1,48 @@ +package online.mineroo.paper; + +import io.papermc.paper.plugin.bootstrap.BootstrapContext; +import io.papermc.paper.plugin.bootstrap.PluginBootstrap; +import io.papermc.paper.registry.data.dialog.ActionButton; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.input.DialogInput; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import io.papermc.paper.registry.event.RegistryEvents; +import io.papermc.paper.registry.keys.DialogKeys; +import java.util.List; +import net.kyori.adventure.key.Key; +import online.mineroo.common.MessageManager; + +public class MinerooCoreBootstrap implements PluginBootstrap { + @Override + public void bootstrap(BootstrapContext context) { + MessageManager messageManager = new MessageManager(); + + ActionButton confirmButton = + ActionButton.create(messageManager.get("dialog.bind.player.confirm"), null, 100, null); + + ActionButton cancelButton = + ActionButton.create(messageManager.get("dialog.bind.player.cancel"), null, 100, null); + + DialogInput usernameInput = + DialogInput.text("username", messageManager.get("dialog.bind.player.email")).build(); + + DialogBase dialogBase = + DialogBase.builder(messageManager.get("dialog.bind.player.title")) + .body( + List.of(DialogBody.plainMessage(messageManager.get("dialog.bind.player.content"))) + ) + .inputs(List.of(usernameInput)) + .build(); + + DialogType confirmationType = DialogType.confirmation(confirmButton, cancelButton); + + context.getLifecycleManager().registerEventHandler(RegistryEvents.DIALOG.compose().newHandler( + event + -> event.registry().register( + DialogKeys.create(Key.key("papermc:bind_mineroo_user")), + builder -> builder.base(dialogBase).type(confirmationType) + ) + )); + } +} diff --git a/paper/src/main/java/online/mineroo/paper/ProxyNetworkService.java b/paper/src/main/java/online/mineroo/paper/ProxyNetworkService.java index 0975e0b..0d07429 100644 --- a/paper/src/main/java/online/mineroo/paper/ProxyNetworkService.java +++ b/paper/src/main/java/online/mineroo/paper/ProxyNetworkService.java @@ -5,33 +5,44 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; import com.google.gson.Gson; import com.google.gson.JsonObject; -import online.mineroo.common.ProxyNetworkRequest; +import online.mineroo.common.NetworkResponse; import online.mineroo.common.NetworkServiceInterface; import online.mineroo.common.ProtocolConstants; -import online.mineroo.common.NetworkResponse; +import online.mineroo.common.ProxyNetworkRequest; import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.messaging.PluginMessageListener; +import java.nio.charset.StandardCharsets; import java.util.Map; +import java.util.Queue; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; -public class ProxyNetworkService implements NetworkServiceInterface, PluginMessageListener { +public class ProxyNetworkService implements NetworkServiceInterface, PluginMessageListener, Listener { private final JavaPlugin plugin; private final Gson gson = new Gson(); - private final String CHANNEL = "mineroo:net"; + private final String CHANNEL = ProtocolConstants.PROTOCOL_CHANNEL; - // Store pending requests + // Stores sent requests private final Map> pendingRequests = new ConcurrentHashMap<>(); + // Buffer queue: stores raw packets that cannot be sent when no player is online + private final Queue messageQueue = new ConcurrentLinkedQueue<>(); + public ProxyNetworkService(JavaPlugin plugin) { this.plugin = plugin; // Register channels plugin.getServer().getMessenger().registerOutgoingPluginChannel(plugin, CHANNEL); plugin.getServer().getMessenger().registerIncomingPluginChannel(plugin, CHANNEL, this); + // Register event (used to listen for player join to flush queue) + plugin.getServer().getPluginManager().registerEvents(this, plugin); } @Override @@ -48,57 +59,110 @@ public class ProxyNetworkService implements NetworkServiceInterface, PluginMessa JsonObject body) { CompletableFuture future = new CompletableFuture<>(); - // 1. Check if there is any player online (Plugin Message must be sent via a - // player) - Player player = com.google.common.collect.Iterables.getFirst(Bukkit.getOnlinePlayers(), null); - if (player == null) { - future.completeExceptionally(new IllegalStateException("No player online to proxy request to Velocity")); - return future; + try { + // 1. Prepare request data + ProxyNetworkRequest req = new ProxyNetworkRequest(method, endpoint, params, body); + String jsonPayload = gson.toJson(req); + + // Safety check: Plugin Message single packet cannot be too large (conservative + // limit 30KB) + byte[] payloadBytes = jsonPayload.getBytes(StandardCharsets.UTF_8); + if (payloadBytes.length > 30000) { + future.completeExceptionally( + new IllegalArgumentException("Request body too large (>30KB). Protocol limit exceeded.")); + return future; + } + + // 2. Register Future + pendingRequests.put(req.getRequestId(), future); + + // 3. Build packet + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF(ProtocolConstants.API_REQUEST); + out.writeUTF(jsonPayload); // Length checked above, writeUTF is safe here + + // 4. Try to send or queue + trySendOrQueue(out.toByteArray()); + + // 5. Set timeout (prevent Future from hanging forever) + plugin.getServer().getScheduler().runTaskLaterAsynchronously(plugin, () -> { + CompletableFuture f = pendingRequests.remove(req.getRequestId()); + if (f != null) { + f.complete(new NetworkResponse(504, "Proxy request timed out (Proxy didn't respond or no player online)")); + } + }, 100L); // 5 seconds timeout + + } catch (Exception e) { + future.completeExceptionally(e); } - // 2. Prepare the packet - ProxyNetworkRequest req = new ProxyNetworkRequest(method, endpoint, params, body); - pendingRequests.put(req.getRequestId(), future); - - ByteArrayDataOutput out = ByteStreams.newDataOutput(); - out.writeUTF(ProtocolConstants.API_REQUEST); // Sub-channel - out.writeUTF(gson.toJson(req)); // Payload - - // 3. Send - player.sendPluginMessage(plugin, CHANNEL, out.toByteArray()); - - // 4. Set timeout (prevent memory leak if Velocity does not respond) - plugin.getServer().getScheduler().runTaskLaterAsynchronously(plugin, () -> { - CompletableFuture f = pendingRequests.remove(req.getRequestId()); - if (f != null) { - f.complete(new NetworkResponse(504, "Proxy request timed out")); - } - }, 100L); // 5 seconds timeout - return future; } + /** + * Core logic: send if there is a player online, otherwise queue + */ + private void trySendOrQueue(byte[] message) { + Player player = com.google.common.collect.Iterables.getFirst(Bukkit.getOnlinePlayers(), null); + + if (player != null) { + player.sendPluginMessage(plugin, CHANNEL, message); + } else { + // No player online, add to queue + // plugin.getLogger().info("[Network] Request queued (No player online)"); + messageQueue.add(message); + } + } + + /** + * Only flush queued requests when a player joins + */ + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + if (messageQueue.isEmpty()) + return; + + plugin.getLogger().info("[Network] Player joined. Flushing " + messageQueue.size() + " queued requests..."); + + // Delay 1 second to ensure player connection is stable + plugin.getServer().getScheduler().runTaskLater(plugin, () -> { + Player player = event.getPlayer(); + // Double check: prevent player from leaving immediately after joining + if (player == null || !player.isOnline()) + return; + + while (!messageQueue.isEmpty()) { + byte[] msg = messageQueue.poll(); + if (msg != null) { + player.sendPluginMessage(plugin, CHANNEL, msg); + } + } + }, 20L); + } + @Override public void onPluginMessageReceived(String channel, Player player, byte[] message) { if (!channel.equals(CHANNEL)) return; - ByteArrayDataInput in = ByteStreams.newDataInput(message); - String subChannel = in.readUTF(); + try { + ByteArrayDataInput in = ByteStreams.newDataInput(message); + String subChannel = in.readUTF(); - if (subChannel.equals("API_RESP")) { - String reqId = in.readUTF(); - boolean success = in.readBoolean(); - String data = in.readUTF(); // If success: body, if failure: error message + if (subChannel.equals(ProtocolConstants.API_RESPONSE)) { + String reqId = in.readUTF(); + boolean success = in.readBoolean(); + String data = in.readUTF(); - CompletableFuture future = pendingRequests.remove(reqId); - if (future != null) { - if (success) { - future.complete(new NetworkResponse(200, data)); - } else { - future.complete(new NetworkResponse(500, data)); + CompletableFuture future = pendingRequests.remove(reqId); + if (future != null) { + // If Proxy did not provide a specific status code, map simply here + int status = success ? 200 : 500; + future.complete(new NetworkResponse(status, data)); } } + } catch (Exception e) { + plugin.getLogger().warning("[Network] Failed to parse plugin message: " + e.getMessage()); } } } diff --git a/paper/src/main/java/online/mineroo/paper/commands/BindCommand.java b/paper/src/main/java/online/mineroo/paper/commands/BindCommand.java index bbb09f0..a98e655 100644 --- a/paper/src/main/java/online/mineroo/paper/commands/BindCommand.java +++ b/paper/src/main/java/online/mineroo/paper/commands/BindCommand.java @@ -10,6 +10,8 @@ import io.papermc.paper.command.brigadier.CommandSourceStack; import io.papermc.paper.command.brigadier.Commands; import online.mineroo.common.BindRequest; import online.mineroo.paper.MinerooCore; +import online.mineroo.paper.ProxyNetworkService; + import org.slf4j.Logger; public class BindCommand { @@ -46,6 +48,14 @@ public class BindCommand { return Command.SINGLE_SUCCESS; } + private void applyMotdToken(String motdToken) { + if (plugin.getNetworkService() instanceof ProxyNetworkService) { + sendMotdTokenToVelocity(motdToken); + } else { + plugin.getBindListener().setVerificationToken(motdToken); + } + } + private void sendMotdTokenToVelocity(String motdToken) { // Send via any online player (Plugin Messaging must be attached to a player) org.bukkit.entity.Player player = org.bukkit.Bukkit.getOnlinePlayers().stream().findFirst().orElse(null); @@ -61,12 +71,15 @@ public class BindCommand { // Run the binding process asynchronously to avoid blocking the main thread org.bukkit.Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { Logger logger = plugin.getSLF4JLogger(); - BindRequest bindRequest = new BindRequest( - logger, - plugin.getNetworkService()); + BindRequest bindRequest = plugin.getBindRequest(); try { + String address = plugin.getConfigObject().getServer().getServerBind().getAddress(); - int port = plugin.getConfigObject().getServer().getServerBind().getPort(); + String port = String.valueOf(plugin.getConfigObject().getServer().getServerBind().getPort()); + if (plugin.getNetworkService() instanceof ProxyNetworkService) { + address = "$hostname"; + port = "$port"; + } // 1. Check binding status if (bindRequest.checkBindStatus(address, port).join()) { @@ -80,7 +93,7 @@ public class BindCommand { if (motdOk) { String motdToken = bindRequest.getMotdToken(); // Send MOTD token to Velocity - sendMotdTokenToVelocity(motdToken); + applyMotdToken(motdToken); context.getSource().getSender().sendMessage(plugin.getMessageManager().get("command.bind.server.wait")); try { Thread.sleep(2 * 60 * 1000 + 5000); // 2m 5s @@ -124,7 +137,7 @@ public class BindCommand { plugin.getMessageManager().get("command.bind.server.failed")); } finally { // After binding is complete, you can send a message to clear the MOTD token - sendMotdTokenToVelocity(""); // Clear MOTD + applyMotdToken(""); // Clear MOTD } }); return Command.SINGLE_SUCCESS; diff --git a/paper/src/main/java/online/mineroo/paper/listeners/BindListener.java b/paper/src/main/java/online/mineroo/paper/listeners/BindListener.java new file mode 100644 index 0000000..2fe5168 --- /dev/null +++ b/paper/src/main/java/online/mineroo/paper/listeners/BindListener.java @@ -0,0 +1,34 @@ +package online.mineroo.paper.listeners; + +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.server.ServerListPingEvent; + +import net.kyori.adventure.text.Component; + +import java.util.concurrent.atomic.AtomicReference; + +public class BindListener implements Listener { + + private final AtomicReference verificationToken = new AtomicReference<>(null); + + public void setVerificationToken(String token) { + if (token == null || token.isEmpty()) { + this.verificationToken.set(null); + } else { + this.verificationToken.set(token); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onServerListPing(ServerListPingEvent event) { + String token = verificationToken.get(); + + if (token == null || token.isEmpty()) { + return; + } + + event.motd(Component.text(token)); + } +} diff --git a/paper/src/main/java/online/mineroo/paper/listeners/Config.java b/paper/src/main/java/online/mineroo/paper/listeners/Config.java new file mode 100644 index 0000000..e69de29 diff --git a/paper/src/main/java/online/mineroo/paper/listeners/PlayerBindListener.java b/paper/src/main/java/online/mineroo/paper/listeners/PlayerBindListener.java new file mode 100644 index 0000000..f6737b0 --- /dev/null +++ b/paper/src/main/java/online/mineroo/paper/listeners/PlayerBindListener.java @@ -0,0 +1,175 @@ +package online.mineroo.paper.listeners; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerMoveEvent; +import org.bukkit.potion.PotionEffect; +import org.bukkit.potion.PotionEffectType; +import org.jspecify.annotations.NullMarked; + +import online.mineroo.common.BindRequest.PlayerBindStatus; +import online.mineroo.common.MessageManager; +import online.mineroo.paper.Config; +import online.mineroo.paper.MinerooCore; +import online.mineroo.paper.utils.PlayerBindDialog; + +@NullMarked +public class PlayerBindListener implements Listener { + + private final MinerooCore plugin; + + private Set pendingBindPlayers = new HashSet<>(); + + public PlayerBindListener(MinerooCore plugin) { + this.plugin = plugin; + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + + MessageManager messageManager = this.plugin.getMessageManager(); + Config config = this.plugin.getConfigObject(); + + Player player = event.getPlayer(); + + boolean isForceBind = config.getPlayer().getPlayerBind().isRequired(); + + if (isForceBind) { + lockPlayer(player); + } + + this.plugin.getBindRequest().checkPlayerBindStatus(player.getUniqueId(), null) + .thenAccept(response -> { + + // 4. 切回主线程 (Bukkit API 必须在主线程调用) + // 这一步非常重要,否则报错 "Asynchronous player tracker update" + this.plugin.getServer().getScheduler().runTask(this.plugin, () -> { + + // 检查玩家是否还在同一个服务器 (防止请求期间玩家退出了) + if (!player.isOnline()) + return; + + PlayerBindStatus status = response.getStatus(); + + if (status == PlayerBindStatus.BOUND) { + if (isForceBind) { + unlockPlayer(player); + } + + } else { + if (status == PlayerBindStatus.ERROR) { + this.plugin.getLogger() + .warning("Bind check failed for " + player.getName() + ": " + response.getMessage()); + } + + PlayerBindDialog dialog = new PlayerBindDialog(); + player.showDialog(dialog.getDialog(messageManager)); + } + }); + }); + + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onPlayerMove(PlayerMoveEvent event) { + Player player = event.getPlayer(); + + if (!pendingBindPlayers.contains(player.getUniqueId())) { + return; + } + + Location from = event.getFrom(); + Location to = event.getTo(); + + if (from.getX() == to.getX() && from.getY() == to.getY() && from.getZ() == to.getZ()) { + return; + } + + Location newLocation = from.clone(); + newLocation.setYaw(to.getYaw()); + newLocation.setPitch(to.getPitch()); + event.setTo(newLocation); + } + + @EventHandler + public void onInteract(PlayerInteractEvent event) { + if (isLocked(event.getPlayer())) + event.setCancelled(true); + } + + @EventHandler + public void onDrop(PlayerDropItemEvent event) { + if (isLocked(event.getPlayer())) + event.setCancelled(true); + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (event.getWhoClicked() instanceof Player player && isLocked(player)) { + event.setCancelled(true); + } + } + + @EventHandler + public void onDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player && isLocked(player)) { + event.setCancelled(true); + } + } + + @EventHandler + public void onBlockBreak(BlockBreakEvent event) { + if (isLocked(event.getPlayer())) + event.setCancelled(true); + } + + @EventHandler + public void onBlockPlace(BlockPlaceEvent event) { + if (isLocked(event.getPlayer())) + event.setCancelled(true); + } + + @EventHandler + public void onChat(io.papermc.paper.event.player.AsyncChatEvent event) { + if (isLocked(event.getPlayer())) + event.setCancelled(true); + } + + private boolean isLocked(Player player) { + return pendingBindPlayers.contains(player.getUniqueId()); + } + + private void lockPlayer(Player player) { + pendingBindPlayers.add(player.getUniqueId()); + + player.setWalkSpeed(0f); + player.setFlySpeed(0f); + + // player.addPotionEffect(new PotionEffect(PotionEffectType.JUMP, + // Integer.MAX_VALUE, 200, false, false)); + player.addPotionEffect(new PotionEffect(PotionEffectType.BLINDNESS, Integer.MAX_VALUE, 1, false, false)); + } + + public void unlockPlayer(Player player) { + if (pendingBindPlayers.remove(player.getUniqueId())) { + player.setWalkSpeed(0.2f); + player.setFlySpeed(0.1f); + // player.removePotionEffect(PotionEffectType.JUMP); + player.removePotionEffect(PotionEffectType.BLINDNESS); + } + } +} diff --git a/paper/src/main/java/online/mineroo/paper/listeners/PlayerJoinListener.java b/paper/src/main/java/online/mineroo/paper/listeners/PlayerJoinListener.java deleted file mode 100644 index 15c94c4..0000000 --- a/paper/src/main/java/online/mineroo/paper/listeners/PlayerJoinListener.java +++ /dev/null @@ -1,9 +0,0 @@ -package online.mineroo.paper.listeners; - -import org.bukkit.event.EventHandler; -import org.bukkit.event.Listener; -import org.jspecify.annotations.NullMarked; - -@NullMarked -public class PlayerJoinListener implements Listener { -} diff --git a/velocity/src/main/java/online/mineroo/velocity/MinerooCore.java b/velocity/src/main/java/online/mineroo/velocity/MinerooVelocity.java similarity index 91% rename from velocity/src/main/java/online/mineroo/velocity/MinerooCore.java rename to velocity/src/main/java/online/mineroo/velocity/MinerooVelocity.java index 9d5a419..b9a4fb7 100644 --- a/velocity/src/main/java/online/mineroo/velocity/MinerooCore.java +++ b/velocity/src/main/java/online/mineroo/velocity/MinerooVelocity.java @@ -5,14 +5,15 @@ package online.mineroo.velocity; * Handles plugin initialization, configuration loading, and provides access to core components. */ import com.google.inject.Inject; -import com.velocitypowered.api.command.CommandManager; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; import com.velocitypowered.api.plugin.Plugin; import com.velocitypowered.api.plugin.annotation.DataDirectory; import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; import online.mineroo.common.MessageManager; +import online.mineroo.common.ProtocolConstants; import online.mineroo.velocity.listeners.BindListener; import java.io.IOException; @@ -27,7 +28,7 @@ import org.spongepowered.configurate.yaml.YamlConfigurationLoader; @Plugin(id = "mineroo-velocity", name = "Mineroo Velocity", version = "0.1.0-SNAPSHOT", url = "https://mineroo.online", description = "Mineroo main plugin", authors = { "YuKun Liu" }) -public class MinerooCore { +public class MinerooVelocity { // Reference to the Velocity ProxyServer instance private final ProxyServer server; @@ -53,7 +54,7 @@ public class MinerooCore { * @param dataDirectory Directory for plugin data */ @Inject - public MinerooCore(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory) { + public MinerooVelocity(ProxyServer server, Logger logger, @DataDirectory Path dataDirectory) { this.server = server; this.logger = logger; this.dataDirectory = dataDirectory; @@ -61,16 +62,19 @@ public class MinerooCore { @Subscribe public void onProxyInitialization(ProxyInitializeEvent event) { + // Register event listeners this.bindListener = new BindListener(); server.getEventManager().register(this, bindListener); - // Register ChannelListener to handle cross-platform MOTD token messages - server.getEventManager().register(this, new online.mineroo.velocity.listeners.ChannelListener(this)); - // Load configuration reloadConfig(); + server.getChannelRegistrar().register(MinecraftChannelIdentifier.from(ProtocolConstants.PROTOCOL_CHANNEL)); + + // Register ChannelListener to handle cross-platform MOTD token messages + server.getEventManager().register(this, new online.mineroo.velocity.listeners.ChannelListener(this)); + // Initialize message manager this.messageManager = new MessageManager(); } diff --git a/velocity/src/main/java/online/mineroo/velocity/commands/MainCommand.java b/velocity/src/main/java/online/mineroo/velocity/commands/MainCommand.java deleted file mode 100644 index e69fb44..0000000 --- a/velocity/src/main/java/online/mineroo/velocity/commands/MainCommand.java +++ /dev/null @@ -1,200 +0,0 @@ -// package online.mineroo.velocity.commands; -// -// import java.util.List; -// import java.util.Optional; -// import java.util.concurrent.CompletionException; -// import java.util.stream.Collectors; -// import java.util.stream.Stream; -// -// import org.slf4j.Logger; -// -// import com.velocitypowered.api.command.CommandSource; -// import com.velocitypowered.api.command.SimpleCommand; -// -// import net.kyori.adventure.text.Component; -// import net.kyori.adventure.text.format.NamedTextColor; -// import online.mineroo.common.MessageManager; -// import online.mineroo.common.NetworkServiceInterface; -// import online.mineroo.velocity.Config; -// import online.mineroo.velocity.MinerooCore; -// import online.mineroo.common.BindRequest; -// import online.mineroo.common.HttpNetworkService; -// -// public class MainCommand implements SimpleCommand { -// -// private final MinerooCore plugin; -// -// public MainCommand(MinerooCore plugin) { -// this.plugin = plugin; -// } -// -// @Override -// public void execute(Invocation invocation) { -// String[] args = invocation.arguments(); -// if (args.length < 1) { -// sendHelp(invocation.source()); -// return; -// } -// -// String subCommand = args[0]; -// -// if (subCommand.equals("bind")) { -// bindServer(invocation.source()); -// } -// -// if (subCommand.equalsIgnoreCase("reload")) { -// reload(invocation.source()); -// } -// -// } -// -// @Override -// public List suggest(Invocation invocation) { -// String[] args = invocation.arguments(); -// -// if (args.length == 0) { -// return List.of("bind", "reload"); -// } -// -// if (args.length == 1) { -// String input = args[0].toLowerCase(); -// return Stream.of("bind", "reload") -// .filter(cmd -> cmd.startsWith(input)) -// .collect(Collectors.toList()); -// } -// -// return List.of(); -// } -// -// public void reload(CommandSource source) { -// MessageManager msg = plugin.getMessageManager(); -// -// long start = System.currentTimeMillis(); -// boolean success = plugin.reloadConfig(); -// -// if (success) { -// long time = System.currentTimeMillis() - start; -// source.sendMessage(msg.get("command.reload.success", "time", -// String.valueOf(time))); -// } else { -// -// source.sendMessage(msg.get("command.reload.failed")); -// } -// return; -// } -// -// public void bindServer(CommandSource source) { -// -// Config config = plugin.getConfig(); -// MessageManager msg = plugin.getMessageManager(); -// -// String bind_name = config.getServerName(); -// String bind_description = config.getDescription(); -// -// String bind_token = config.getBind().getBindToken(); -// String bind_address = config.getBind().getAddress(); -// -// int bind_port = -// Optional.ofNullable(config.getBind().getPort()).orElse(25565); -// -// source.sendMessage(msg.get("command.bind.server.start")); -// -// Logger logger = plugin.getLogger(); -// -// // Run in a separate worker thread to allow blocking (Thread.sleep) -// plugin.getServer().getScheduler().buildTask(plugin, () -> { -// -// // 1. Initialize the Network Service locally (Velocity connects directly) -// NetworkServiceInterface networkService = new HttpNetworkService( -// "https://oapi.mineroo.online", -// "mserver", -// bind_token); -// -// BindRequest request = new BindRequest(logger, networkService); -// -// try { -// -// // 2. Check Status -// // We use .join() to block the thread until the Future completes. -// if (request.checkBindStatus(bind_address, bind_port).join()) { -// source.sendMessage(msg.get("command.bind.server.already-bound")); -// return; -// } -// -// // 3. Initial Verification -// boolean inital_bind = request.initialMotdVerifyRequest(bind_address, -// bind_port).join(); -// -// if (inital_bind) { -// String motdToken = request.getMotdToken(); -// plugin.getBindListener().setVerificationToken(motdToken); -// -// source.sendMessage(msg.get("command.bind.server.wait")); -// -// // 4. Wait for external refresh (Blocking this worker thread is intended) -// try { -// Thread.sleep(2 * 60 * 1000 + 5000); // 2m 5s -// } catch (InterruptedException e) { -// logger.error("Bind thread interrupted", e); -// return; -// } -// -// // 5. Final Bind & Retry Loop -// boolean success = false; -// int maxRetries = 3; -// -// for (int i = 1; i <= maxRetries; i++) { -// // Check final status synchronously using join() -// if (request.finalizeServerBinding(bind_name, bind_description).join()) { -// success = true; -// break; -// } -// -// if (i < maxRetries) { -// source.sendMessage(msg.get("command.bind.server.retry", -// "current", String.valueOf(i), -// "max", String.valueOf(maxRetries))); -// -// try { -// Thread.sleep(10000); // Wait 10s before retry -// } catch (InterruptedException e) { -// break; -// } -// } -// } -// -// if (success) { -// source.sendMessage(msg.get("command.bind.server.success")); -// } else { -// source.sendMessage(msg.get("command.bind.server.failed")); -// } -// -// } else { -// source.sendMessage( -// Component.text("Initial handshake failed. Check your token or network.", -// NamedTextColor.RED)); -// } -// -// } catch (CompletionException e) { -// // Unpack the wrapper exception from join() to see the real network error -// logger.error("Mineroo Bind Network Error: " + e.getCause().getMessage()); -// } catch (Exception e) { -// logger.error("Mineroo Bind Error: " + e.toString()); -// } finally { -// plugin.getBindListener().clearVerificationToken(); -// } -// -// }).schedule(); -// } -// -// public void sendHelp(CommandSource source) { -// source.sendMessage(Component.text("=== # @Mineroo # ===", -// NamedTextColor.AQUA)); -// source.sendMessage(Component.text(" /mineroo bind - Start to bind server", -// NamedTextColor.YELLOW)); -// source.sendMessage(Component.text(" /mineroo reload - Reload config", -// NamedTextColor.YELLOW)); -// source.sendMessage(Component.text("=== mineroo.online ===", -// NamedTextColor.AQUA)); -// } -// } diff --git a/velocity/src/main/java/online/mineroo/velocity/listeners/BindListener.java b/velocity/src/main/java/online/mineroo/velocity/listeners/BindListener.java index 31b03eb..06c5df7 100644 --- a/velocity/src/main/java/online/mineroo/velocity/listeners/BindListener.java +++ b/velocity/src/main/java/online/mineroo/velocity/listeners/BindListener.java @@ -23,7 +23,7 @@ public class BindListener { public void onPing(ProxyPingEvent event) { String token = verificationToken.get(); - if (token == null) { + if (token == null || token.isEmpty()) { return; } diff --git a/velocity/src/main/java/online/mineroo/velocity/listeners/ChannelListener.java b/velocity/src/main/java/online/mineroo/velocity/listeners/ChannelListener.java index 06fa748..87b5f3c 100644 --- a/velocity/src/main/java/online/mineroo/velocity/listeners/ChannelListener.java +++ b/velocity/src/main/java/online/mineroo/velocity/listeners/ChannelListener.java @@ -1,36 +1,208 @@ package online.mineroo.velocity.listeners; import com.google.common.io.ByteArrayDataInput; +import com.google.common.io.ByteArrayDataOutput; +import com.google.common.io.ByteStreams; +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.connection.PluginMessageEvent; import com.velocitypowered.api.proxy.ServerConnection; +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier; +import online.mineroo.common.*; +import online.mineroo.velocity.MinerooVelocity; -import online.mineroo.common.ProtocolConstants; - -import online.mineroo.velocity.MinerooCore; +import java.util.concurrent.CompletableFuture; public class ChannelListener { - private final MinerooCore plugin; - public ChannelListener(MinerooCore plugin) { + private final MinerooVelocity plugin; + private final Gson gson = new Gson(); + + private final HttpNetworkService httpService; + + public ChannelListener(MinerooVelocity plugin) { this.plugin = plugin; + + String apiUrl = "https://oapi.mineroo.online"; + + this.httpService = new HttpNetworkService(apiUrl, "mserver", plugin.getConfig().getBind().getBindToken()); } @Subscribe public void onPluginMessage(PluginMessageEvent event) { + // Only handle messages from the specified protocol channel if (!event.getIdentifier().getId().equals(ProtocolConstants.PROTOCOL_CHANNEL)) return; - + // Only handle messages from a backend server if (!(event.getSource() instanceof ServerConnection)) return; + ServerConnection backendServer = (ServerConnection) event.getSource(); ByteArrayDataInput in = event.dataAsDataStream(); - String subChannel = in.readUTF(); - if (subChannel.equals(ProtocolConstants.BIND_MOTD_TOKEN)) { - String token = in.readUTF(); - plugin.getBindListener().setVerificationToken(token); + try { + String subChannel = in.readUTF(); + + if (subChannel.equals(ProtocolConstants.API_REQUEST)) { + handleApiRequest(in, backendServer); + } + + if (subChannel.equals(ProtocolConstants.BIND_MOTD_TOKEN)) { + String token = in.readUTF(); + plugin.getBindListener().setVerificationToken(token); + } + + } catch (Exception e) { + plugin.getLogger().error("Failed to handle plugin message", e); } } -} + private void handleApiRequest(ByteArrayDataInput in, ServerConnection backendServer) { + String reqId = "unknown"; + try { + // 1. Parse the request + String jsonPayload = in.readUTF(); + ProxyNetworkRequest req = gson.fromJson(jsonPayload, ProxyNetworkRequest.class); + reqId = req.getRequestId(); + + CompletableFuture future; + + // 2. Delegate to HttpNetworkService for processing + if ("POST".equalsIgnoreCase(req.getMethod())) { + JsonObject bodyJson = null; + try { + JsonObject originalJson = gson.fromJson(req.getJsonBody(), JsonObject.class); + if (originalJson == null) + originalJson = new JsonObject(); + + bodyJson = resolvePlacehodersInJson(originalJson).getAsJsonObject(); + + plugin.getLogger().info("Proxy POST [ " + req.getEndpoint() + " ]\n\t" + bodyJson.toString()); + } catch (Exception e) { + plugin.getLogger().error("JSON parsing error", e); + bodyJson = new JsonObject(); + } + + future = httpService.postData(req.getEndpoint(), bodyJson); + } else { + + // replace placeholders + java.util.Map finalParams = new java.util.HashMap<>(); + + if (req.getParams() != null) { + for (java.util.Map.Entry entry : req.getParams().entrySet()) { + String originalKey = entry.getKey(); + String originalValue = entry.getValue(); + + String replacedValue = resolvePlaceholders(originalValue); + + finalParams.put(originalKey, replacedValue); + } + } + + plugin.getLogger().info("Proxy GET [ " + req.getEndpoint() + " ]\n\t" + finalParams.toString()); + + future = httpService.getData(req.getEndpoint(), finalParams); + } + + // 3. Handle the result and send back the response + String finalReqId = reqId; + future.thenAccept(response -> { + // Check if HTTP status code is 2xx + boolean success = response.getStatusCode() >= 200 && response.getStatusCode() < 300; + sendResponse(backendServer, finalReqId, success, response.getBody()); + }).exceptionally(e -> { + plugin.getLogger().error("API Proxy Request Failed", e); + sendResponse(backendServer, finalReqId, false, "Proxy Error: " + e.getMessage()); + return null; + }); + + } catch (Exception e) { + plugin.getLogger().error("Error parsing API request", e); + sendResponse(backendServer, reqId, false, "Protocol Error"); + } + } + + private void sendResponse(ServerConnection server, String reqId, boolean success, String data) { + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF(ProtocolConstants.API_RESPONSE); + out.writeUTF(reqId); + out.writeBoolean(success); + out.writeUTF(data != null ? data : ""); + server.sendPluginMessage(MinecraftChannelIdentifier.from(ProtocolConstants.PROTOCOL_CHANNEL), out.toByteArray()); + } + + private JsonElement resolvePlacehodersInJson(JsonElement element) { + if (element.isJsonPrimitive()) { + JsonPrimitive primitive = element.getAsJsonPrimitive(); + if (primitive.isString()) { + String original = primitive.getAsString(); + + if ("$port".equals(original)) { + Integer port = null; + if (plugin.getConfig().getBind() != null) { + port = plugin.getConfig().getBind().getPort(); + } + + if (port == null || port <= 0) { + return null; + } + return new JsonPrimitive(port); + } + + if ("$timestamp".equals(original)) { + return new JsonPrimitive(System.currentTimeMillis()); + } + + String replaced = resolvePlaceholders(original); + return new JsonPrimitive(replaced); + } + return element; + } + + if (element.isJsonObject()) { + JsonObject obj = element.getAsJsonObject(); + JsonObject newObj = new JsonObject(); + for (java.util.Map.Entry entry : obj.entrySet()) { + JsonElement processedValue = resolvePlacehodersInJson(entry.getValue()); + + if (processedValue != null) { + newObj.add(entry.getKey(), processedValue); + } + } + return newObj; + } + + if (element.isJsonArray()) { + JsonArray array = element.getAsJsonArray(); + JsonArray newArray = new JsonArray(); + for (JsonElement item : array) { + JsonElement processedItem = resolvePlacehodersInJson(item); + + if (processedItem != null) { + newArray.add(processedItem); + } + } + return newArray; + } + + return element; + } + + private String resolvePlaceholders(String input) { + if (input == null) + return null; + + String hostname = plugin.getConfig().getBind().getAddress(); + String port = String.valueOf(plugin.getConfig().getBind().getPort()); + + return input + .replace("$hostname", hostname != null ? hostname : "unknown") + .replace("$port", port) + .replace("$timestamp", String.valueOf(System.currentTimeMillis())); + } +} diff --git a/velocity/src/main/java/online/mineroo/velocity/listeners/JsonPrimitive.java b/velocity/src/main/java/online/mineroo/velocity/listeners/JsonPrimitive.java new file mode 100644 index 0000000..e69de29