From 2aeb8d03406ccb194d9b8dd5004d785ec9a0719c Mon Sep 17 00:00:00 2001 From: Coldsmile Date: Tue, 21 Jul 2026 23:45:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=20SkinBridge=20?= =?UTF-8?q?=E7=9A=AE=E8=82=A4=E5=85=BC=E5=AE=B9=E5=B9=B6=E5=8F=91=E5=B8=83?= =?UTF-8?q?=201.7.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 + build.gradle | 3 +- .../cn/infstar/essentialsC/EssentialsC.java | 31 ++ .../cn/infstar/essentialsC/LangManager.java | 19 +- .../cn/infstar/essentialsC/ModuleManager.java | 2 + .../essentialsC/commands/CommandRegistry.java | 1 + .../essentialsC/commands/HelpCommand.java | 25 + .../commands/SkinBridgeCommand.java | 127 +++++ .../skinbridge/MineSkinGateway.java | 179 +++++++ .../skinbridge/SkinBridgeGateway.java | 18 + .../skinbridge/SkinBridgeManager.java | 491 ++++++++++++++++++ src/main/resources/config.yml | 37 ++ src/main/resources/lang/en_US.yml | 20 + src/main/resources/lang/zh_CN.yml | 20 + src/main/resources/modules.yml | 3 + src/main/resources/paper-plugin.yml | 4 + 16 files changed, 979 insertions(+), 6 deletions(-) create mode 100644 src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java create mode 100644 src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java create mode 100644 src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeGateway.java create mode 100644 src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java diff --git a/README.md b/README.md index 7ed1f33..50aa56f 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ | `jei-sync` | 开启 | Fabric / NeoForge JEI 配方同步修复 | | `mob-drops` | 关闭 | 末影人掉落控制,默认关闭以保留过去标准版行为 | | `maintenance` | 开启 | 维护模式命令、MOTD 替换、登录拦截、白名单和拦截通知 | +| `skin-bridge` | 关闭 | 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤 | 修改模块开关后可先使用 `/essc reload` 刷新运行期服务与监听器状态。由于 Bukkit 命令表不适合在运行期完整热增删,若模块是在启动时关闭的,对应直连命令可能仍需重启后才会注册;通过 `/essc <子命令>` 入口通常可立即按新的模块状态执行。 @@ -82,12 +83,15 @@ 当前配置结构以“行为配置”和“文本配置”分离为原则: +SkinBridge 默认关闭。使用前需同时将 `modules.yml` 中的 `modules.skin-bridge.enabled` 与 `config.yml` 中的 `skin-bridge.enabled` 设为 `true`,在 `skin-bridge.mineskin.api-key` 中配置 MineSkin API Key,并至少启用一个 Provider。真实密钥只应填写在服务器运行目录的 `config.yml` 中,不要写入源码或提交到公开仓库。该模块不再要求安装 SkinsRestorer。`skin-bridge.send-player-message` 控制是否向玩家发送检测、匹配和同步结果提示。`skin-bridge.providers` 下的键名可自由命名,会显示在日志和 `/essc skin status <玩家>` 中;建议使用小写英文、数字和连字符,避免使用点号。控制台可使用 `/essc skin status <玩家>` 与 `/essc skin refresh <玩家>` 进行验证。 + - `config.yml` - 语言选择 - 管理模式行为 - JEI 同步开关 - 掉落控制 - TPSBar 模式 + - SkinBridge Provider、缓存和超时配置 - 便捷菜单布局 - `modules.yml` - 功能模块开关 @@ -127,6 +131,7 @@ essentialsc.command.seen essentialsc.command.admin essentialsc.command.tpsbar essentialsc.command.maintenance +essentialsc.command.skin essentialsc.maintenance.bypass essentialsc.maintenance.notify essentialsc.shulkerbox.open diff --git a/build.gradle b/build.gradle index 06acde8..9e99b6a 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'cn.infstar' -version = '1.6.0' +version = '1.7.0' repositories { mavenCentral() @@ -21,6 +21,7 @@ repositories { dependencies { paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT') implementation project(':compat-api') + implementation 'com.google.code.gson:gson:2.11.0' } java { diff --git a/src/main/java/cn/infstar/essentialsC/EssentialsC.java b/src/main/java/cn/infstar/essentialsC/EssentialsC.java index 034b12b..4228857 100644 --- a/src/main/java/cn/infstar/essentialsC/EssentialsC.java +++ b/src/main/java/cn/infstar/essentialsC/EssentialsC.java @@ -13,6 +13,7 @@ import cn.infstar.essentialsC.listeners.ShulkerBoxListener; import cn.infstar.essentialsC.listeners.VanishListener; import cn.infstar.essentialsC.maintenance.MaintenanceListener; import cn.infstar.essentialsC.maintenance.MaintenanceManager; +import cn.infstar.essentialsC.skinbridge.SkinBridgeManager; import cn.infstar.essentialsC.teleport.TeleportRequestManager; import cn.infstar.essentialsC.tpsbar.TpsBarManager; import cn.infstar.essentialsC.tpsbar.TpsBarService; @@ -45,6 +46,7 @@ public final class EssentialsC extends JavaPlugin { private MobDropListener mobDropListener; private MobDropMenuListener mobDropMenuListener; private VanishListener vanishListener; + private SkinBridgeManager skinBridgeManager; private boolean commandsRegistered; private final Map moduleStatus = new LinkedHashMap<>(); @@ -73,6 +75,9 @@ public final class EssentialsC extends JavaPlugin { if (teleportRequestManager != null) { teleportRequestManager.shutdown(); } + if (skinBridgeManager != null) { + skinBridgeManager.shutdown(); + } VanishCommand.clearAll(this); unregisterRuntimeListeners(); unregisterPluginChannels(); @@ -103,6 +108,10 @@ public final class EssentialsC extends JavaPlugin { return tpsBarManager; } + public SkinBridgeManager getSkinBridgeManager() { + return skinBridgeManager; + } + public void reloadRuntimeModules() { moduleStatus.clear(); refreshPlayer(); @@ -112,6 +121,7 @@ public final class EssentialsC extends JavaPlugin { refreshBlocks(); refreshJeiSync(); refreshMobDrops(); + refreshSkinBridge(); } private void refreshPlayer() { @@ -278,6 +288,26 @@ public final class EssentialsC extends JavaPlugin { setModuleStatus("生物掉落", true, "末影人掉落控制已启用"); } + private void refreshSkinBridge() { + if (!moduleManager.isEnabled(ModuleManager.SKIN_BRIDGE)) { + if (skinBridgeManager != null) { + skinBridgeManager.shutdown(); + HandlerList.unregisterAll(skinBridgeManager); + skinBridgeManager = null; + } + setModuleStatus("皮肤桥接", false, "已禁用"); + return; + } + + if (skinBridgeManager == null) { + skinBridgeManager = new SkinBridgeManager(this); + getServer().getPluginManager().registerEvents(skinBridgeManager, this); + } else { + skinBridgeManager.reload(); + } + setModuleStatus("皮肤桥接", true, skinBridgeManager.getModuleDetail()); + } + private void registerPluginChannels() { Messenger messenger = getServer().getMessenger(); messenger.registerOutgoingPluginChannel(this, "fabric:recipe_sync"); @@ -299,6 +329,7 @@ public final class EssentialsC extends JavaPlugin { unregisterListener(mobDropMenuListener); unregisterListener(vanishListener); unregisterListener(teleportRequestManager); + unregisterListener(skinBridgeManager); if (tpsBarManager instanceof Listener listener) { unregisterListener(listener); } diff --git a/src/main/java/cn/infstar/essentialsC/LangManager.java b/src/main/java/cn/infstar/essentialsC/LangManager.java index 94a8f3d..923ca9b 100644 --- a/src/main/java/cn/infstar/essentialsC/LangManager.java +++ b/src/main/java/cn/infstar/essentialsC/LangManager.java @@ -152,15 +152,24 @@ public class LangManager { } private void loadDefaultLanguageFallback() { - InputStream defaultLangStream = plugin.getResource("lang/en_US.yml"); - if (defaultLangStream == null) { + InputStream selectedLangStream = plugin.getResource("lang/" + currentLanguage + ".yml"); + if (selectedLangStream == null) { return; } - YamlConfiguration defaultLang = YamlConfiguration.loadConfiguration( - new InputStreamReader(defaultLangStream, StandardCharsets.UTF_8) + YamlConfiguration selectedDefaults = YamlConfiguration.loadConfiguration( + new InputStreamReader(selectedLangStream, StandardCharsets.UTF_8) ); - langFile.setDefaults(defaultLang); + if (!"en_US".equalsIgnoreCase(currentLanguage)) { + InputStream englishLangStream = plugin.getResource("lang/en_US.yml"); + if (englishLangStream != null) { + YamlConfiguration englishDefaults = YamlConfiguration.loadConfiguration( + new InputStreamReader(englishLangStream, StandardCharsets.UTF_8) + ); + selectedDefaults.setDefaults(englishDefaults); + } + } + langFile.setDefaults(selectedDefaults); } private String applyPlaceholders(String value, Map placeholders) { diff --git a/src/main/java/cn/infstar/essentialsC/ModuleManager.java b/src/main/java/cn/infstar/essentialsC/ModuleManager.java index 2a75911..7a23ab2 100644 --- a/src/main/java/cn/infstar/essentialsC/ModuleManager.java +++ b/src/main/java/cn/infstar/essentialsC/ModuleManager.java @@ -20,6 +20,7 @@ public final class ModuleManager { public static final String JEI_SYNC = "jei-sync"; public static final String MOB_DROPS = "mob-drops"; public static final String MAINTENANCE = "maintenance"; + public static final String SKIN_BRIDGE = "skin-bridge"; private static final Map DEFAULT_MODULES = new LinkedHashMap<>(); @@ -31,6 +32,7 @@ public final class ModuleManager { DEFAULT_MODULES.put(JEI_SYNC, true); DEFAULT_MODULES.put(MOB_DROPS, false); DEFAULT_MODULES.put(MAINTENANCE, true); + DEFAULT_MODULES.put(SKIN_BRIDGE, false); } private final JavaPlugin plugin; diff --git a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java index 148d9c4..ba9cfeb 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java +++ b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java @@ -50,6 +50,7 @@ public final class CommandRegistry { register("mobdrops", "essentialsc.mobdrops.enderman", ModuleManager.MOB_DROPS, "cn.infstar.essentialsC.commands.MobDropCommand"); register("maintenance", "essentialsc.command.maintenance", ModuleManager.MAINTENANCE, "cn.infstar.essentialsC.commands.MaintenanceCommand", "maint"); registerSubCommand("admin", "essentialsc.command.admin", ModuleManager.ADMIN_MODE, "cn.infstar.essentialsC.commands.AdminCommand"); + registerSubCommand("skin", "essentialsc.command.skin", ModuleManager.SKIN_BRIDGE, "cn.infstar.essentialsC.commands.SkinBridgeCommand"); } private CommandRegistry() { diff --git a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java index a53be82..a9bb704 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java @@ -202,6 +202,10 @@ public class HelpCommand extends BaseCommand implements TabCompleter { otherCommands.append(lang.getString("help.commands.tpignore")).append("\n"); hasOtherCommands = true; } + if (CommandRegistry.isAvailable("skin") && player.hasPermission("essentialsc.command.skin")) { + otherCommands.append(lang.getString("help.commands.skin")).append("\n"); + hasOtherCommands = true; + } if (CommandRegistry.isAvailable("admin") && player.hasPermission("essentialsc.command.admin")) { otherCommands.append(lang.getString("help.commands.admin")).append("\n"); hasOtherCommands = true; @@ -293,6 +297,7 @@ public class HelpCommand extends BaseCommand implements TabCompleter { {"tpdecline", "essentialsc.command.tpdeny"}, {"tpno", "essentialsc.command.tpdeny"}, {"tpignore", "essentialsc.command.tpignore"}, + {"skin", "essentialsc.command.skin"}, {"tpsbar", "essentialsc.command.tpsbar"}, {"maintenance", "essentialsc.command.maintenance"}, {"maint", "essentialsc.command.maintenance"}, @@ -345,6 +350,13 @@ public class HelpCommand extends BaseCommand implements TabCompleter { return manager == null ? List.of() : manager.getIncomingRequesterNames(completionPlayer, args[1]); } + if (subCmd.equals("skin") && sender.hasPermission("essentialsc.command.skin")) { + String partial = args[1].toLowerCase(); + return List.of("status", "refresh").stream() + .filter(option -> option.startsWith(partial)) + .toList(); + } + if ((subCmd.equals("nightvision") || subCmd.equals("nv")) && sender.hasPermission("essentialsc.command.nightvision")) { return completeToggleArgs(args[1]); } @@ -370,6 +382,19 @@ public class HelpCommand extends BaseCommand implements TabCompleter { } } + if (args.length == 3 && args[0].equalsIgnoreCase("skin") + && (args[1].equalsIgnoreCase("status") || args[1].equalsIgnoreCase("refresh")) + && sender.hasPermission("essentialsc.command.skin")) { + List players = new ArrayList<>(); + String partial = args[2].toLowerCase(); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase().startsWith(partial)) { + players.add(player.getName()); + } + } + return players; + } + return new ArrayList<>(); } diff --git a/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java b/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java new file mode 100644 index 0000000..be1893c --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java @@ -0,0 +1,127 @@ +package cn.infstar.essentialsC.commands; + +import cn.infstar.essentialsC.skinbridge.SkinBridgeManager; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +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; +import java.util.Map; + +public final class SkinBridgeCommand extends BaseCommand implements TabCompleter { + + public SkinBridgeCommand() { + super("essentialsc.command.skin"); + } + + @Override + protected boolean execute(Player player, String[] args) { + return executeCommand(player, args, false); + } + + @Override + protected boolean executeConsole(CommandSender sender, String[] args) { + return executeCommand(sender, args, true); + } + + private boolean executeCommand(CommandSender sender, String[] args, boolean console) { + if (args.length < 1 || args.length > 2) { + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage")); + return true; + } + + SkinBridgeManager manager = plugin.getSkinBridgeManager(); + if (manager == null) { + sender.sendMessage(getLang().getPrefixedString("messages.module-disabled")); + return true; + } + + Player target = console ? null : (Player) sender; + if (args.length == 2) { + target = Bukkit.getPlayerExact(args[1]); + if (target == null || !target.isOnline()) { + sender.sendMessage(getLang().getPrefixedString("messages.player-not-found", Map.of("player", args[1]))); + return true; + } + } + if (target == null) { + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage")); + return true; + } + + String action = args[0].toLowerCase(Locale.ROOT); + if (action.equals("status")) { + sendStatus(sender, target, manager); + return true; + } + if (action.equals("refresh")) { + sendRefreshResult(sender, target, manager.queueSync(target, true)); + return true; + } + + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.usage")); + return true; + } + + private void sendStatus(CommandSender sender, Player target, SkinBridgeManager manager) { + if (!manager.isEnabled()) { + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.feature-disabled")); + return; + } + if (!manager.isSkinGatewayAvailable()) { + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.dependency-missing")); + return; + } + if (manager.getProviderCount() == 0) { + sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.no-providers")); + return; + } + + SkinBridgeManager.Status status = manager.getStatus(target); + Map placeholders = Map.of("player", target.getName(), "provider", String.valueOf(status.providerId())); + switch (status.state()) { + case EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-external", placeholders)); + case NOT_EXTERNAL -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-not-external", placeholders)); + case PENDING -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-pending", placeholders)); + case UNKNOWN -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-unknown", placeholders)); + } + } + + private void sendRefreshResult(CommandSender sender, Player target, SkinBridgeManager.SyncResult result) { + Map placeholders = Map.of("player", target.getName()); + String messagePath = switch (result) { + case QUEUED -> "skin-bridge.messages.refresh-queued"; + case CACHED -> "skin-bridge.messages.refresh-cached"; + case ALREADY_RUNNING -> "skin-bridge.messages.refresh-running"; + case FEATURE_DISABLED -> "skin-bridge.messages.feature-disabled"; + case DEPENDENCY_MISSING -> "skin-bridge.messages.dependency-missing"; + case NO_PROVIDERS -> "skin-bridge.messages.no-providers"; + }; + sender.sendMessage(getLang().getPrefixedString(messagePath, placeholders)); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String label, String[] args) { + if (args.length == 1) { + String partial = args[0].toLowerCase(Locale.ROOT); + return List.of("status", "refresh").stream() + .filter(option -> option.startsWith(partial)) + .toList(); + } + if (args.length == 2 && (args[0].equalsIgnoreCase("status") || args[0].equalsIgnoreCase("refresh"))) { + String partial = args[1].toLowerCase(Locale.ROOT); + List players = new ArrayList<>(); + for (Player onlinePlayer : Bukkit.getOnlinePlayers()) { + if (onlinePlayer.getName().toLowerCase(Locale.ROOT).startsWith(partial)) { + players.add(onlinePlayer.getName()); + } + } + return players; + } + return List.of(); + } +} diff --git a/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java b/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java new file mode 100644 index 0000000..5595547 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java @@ -0,0 +1,179 @@ +package cn.infstar.essentialsC.skinbridge; + +import com.destroystokyo.paper.profile.PlayerProfile; +import com.destroystokyo.paper.profile.ProfileProperty; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.bukkit.entity.Player; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; + +public final class MineSkinGateway implements SkinBridgeGateway { + + private static final String TEXTURES_PROPERTY = "textures"; + private static final String QUEUE_PATH = "/v2/queue"; + private static final long POLL_INTERVAL_MILLIS = 1000L; + + private final HttpClient httpClient; + private final URI queueUri; + private final String apiKey; + private final String visibility; + private final int timeoutSeconds; + private final String userAgent; + + public MineSkinGateway(HttpClient httpClient, String endpoint, String apiKey, String visibility, int timeoutSeconds, String userAgent) { + this.httpClient = httpClient; + this.queueUri = URI.create(normalizeEndpoint(endpoint) + QUEUE_PATH); + this.apiKey = apiKey; + this.visibility = normalizeVisibility(visibility); + this.timeoutSeconds = timeoutSeconds; + this.userAgent = userAgent; + } + + @Override + public GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception { + JsonObject requestBody = new JsonObject(); + requestBody.addProperty("url", skinUrl); + requestBody.addProperty("variant", model == SkinModel.SLIM ? "slim" : "classic"); + requestBody.addProperty("visibility", visibility); + + HttpResponse response = send(HttpRequest.newBuilder(queueUri) + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8)) + .header("Content-Type", "application/json")); + JsonObject body = parseResponse(response); + if (response.statusCode() == 200) { + return parseGeneratedSkin(body); + } + if (response.statusCode() != 202) { + throw apiError("提交 MineSkin 生成请求", response, body); + } + + JsonObject job = object(body, "job"); + String jobId = string(job, "id"); + return pollJob(jobId); + } + + @Override + public void applySkin(Player player, GeneratedSkin skin) { + PlayerProfile profile = player.getPlayerProfile(); + profile.removeProperty(TEXTURES_PROPERTY); + profile.setProperty(new ProfileProperty(TEXTURES_PROPERTY, skin.value(), skin.signature())); + player.setPlayerProfile(profile); + } + + private GeneratedSkin pollJob(String jobId) throws Exception { + long deadline = System.nanoTime() + Duration.ofSeconds(timeoutSeconds).toNanos(); + URI jobUri = URI.create(queueUri + "/" + jobId); + while (System.nanoTime() < deadline) { + HttpResponse response = send(HttpRequest.newBuilder(jobUri).GET()); + JsonObject body = parseResponse(response); + if (response.statusCode() != 200) { + throw apiError("查询 MineSkin 生成任务", response, body); + } + + JsonObject job = object(body, "job"); + String status = string(job, "status"); + if ("completed".equalsIgnoreCase(status)) { + return parseGeneratedSkin(body); + } + if ("failed".equalsIgnoreCase(status)) { + throw new IllegalStateException("MineSkin 生成任务失败: " + errorMessage(body)); + } + if (!"waiting".equalsIgnoreCase(status) && !"active".equalsIgnoreCase(status)) { + throw new IllegalStateException("MineSkin 返回未知任务状态: " + status); + } + Thread.sleep(POLL_INTERVAL_MILLIS); + } + throw new IllegalStateException("MineSkin 生成任务超时,请增大 skin-bridge.mineskin.request-timeout-seconds。"); + } + + private HttpResponse send(HttpRequest.Builder builder) throws Exception { + HttpRequest request = builder + .timeout(Duration.ofSeconds(timeoutSeconds)) + .header("Accept", "application/json") + .header("Authorization", "Bearer " + apiKey) + .header("User-Agent", userAgent) + .build(); + return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + } + + private JsonObject parseResponse(HttpResponse response) { + if (response.body() == null || response.body().isBlank()) { + throw new IllegalStateException("MineSkin 返回空响应,HTTP " + response.statusCode()); + } + if (response.body().length() > 1_048_576) { + throw new IllegalStateException("MineSkin 响应超过 1 MiB 限制。"); + } + try { + return JsonParser.parseString(response.body()).getAsJsonObject(); + } catch (RuntimeException exception) { + throw new IllegalStateException("MineSkin 返回了无效 JSON,HTTP " + response.statusCode(), exception); + } + } + + private GeneratedSkin parseGeneratedSkin(JsonObject body) { + JsonObject skin = object(body, "skin"); + JsonObject texture = object(skin, "texture"); + JsonObject data = object(texture, "data"); + return new GeneratedSkin(string(data, "value"), string(data, "signature")); + } + + private IllegalStateException apiError(String action, HttpResponse response, JsonObject body) { + return new IllegalStateException(action + "失败,HTTP " + response.statusCode() + ": " + errorMessage(body)); + } + + private String errorMessage(JsonObject body) { + for (String key : new String[]{"message", "error", "code"}) { + if (body.has(key) && body.get(key).isJsonPrimitive()) { + return body.get(key).getAsString(); + } + } + return "未提供错误信息"; + } + + private static JsonObject object(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) { + throw new IllegalStateException("MineSkin 响应缺少对象字段: " + key); + } + return parent.getAsJsonObject(key); + } + + private static String string(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonPrimitive()) { + throw new IllegalStateException("MineSkin 响应缺少字符串字段: " + key); + } + String value = parent.get(key).getAsString(); + if (value.isBlank()) { + throw new IllegalStateException("MineSkin 响应字符串字段为空: " + key); + } + return value; + } + + private static String normalizeEndpoint(String endpoint) { + String normalized = endpoint == null ? "" : endpoint.trim(); + while (normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + if (normalized.isBlank()) { + throw new IllegalArgumentException("MineSkin API 地址不能为空。"); + } + URI uri = URI.create(normalized); + if (!"https".equalsIgnoreCase(uri.getScheme())) { + throw new IllegalArgumentException("MineSkin API 必须使用 HTTPS。"); + } + return normalized; + } + + private static String normalizeVisibility(String visibility) { + String normalized = visibility == null ? "" : visibility.trim().toLowerCase(java.util.Locale.ROOT); + if (!normalized.equals("public") && !normalized.equals("unlisted") && !normalized.equals("private")) { + throw new IllegalArgumentException("MineSkin visibility 必须是 public、unlisted 或 private。"); + } + return normalized; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeGateway.java b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeGateway.java new file mode 100644 index 0000000..7e97bba --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeGateway.java @@ -0,0 +1,18 @@ +package cn.infstar.essentialsC.skinbridge; + +import org.bukkit.entity.Player; + +interface SkinBridgeGateway { + + GeneratedSkin generateSkin(String skinUrl, SkinModel model) throws Exception; + + void applySkin(Player player, GeneratedSkin skin) throws Exception; +} + +enum SkinModel { + CLASSIC, + SLIM +} + +record GeneratedSkin(String value, String signature) { +} diff --git a/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java new file mode 100644 index 0000000..35fea51 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java @@ -0,0 +1,491 @@ +package cn.infstar.essentialsC.skinbridge; + +import cn.infstar.essentialsC.EssentialsC; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicLong; + +public final class SkinBridgeManager implements Listener { + + private final EssentialsC plugin; + private final HttpClient httpClient; + private final ExecutorService executor; + private final ConcurrentMap cache = new ConcurrentHashMap<>(); + private final ConcurrentMap pendingLookups = new ConcurrentHashMap<>(); + private final AtomicLong configurationGeneration = new AtomicLong(); + + private volatile List providers = List.of(); + private volatile SkinBridgeGateway gateway; + private volatile boolean enabled; + private volatile boolean debug; + private volatile boolean sendPlayerMessage; + private volatile int requestTimeoutSeconds; + private volatile int cacheMinutes; + private volatile long joinDelayTicks; + + public SkinBridgeManager(EssentialsC plugin) { + this.plugin = plugin; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + this.executor = Executors.newFixedThreadPool(2, new SkinBridgeThreadFactory()); + reload(); + } + + public void reload() { + configurationGeneration.incrementAndGet(); + FileConfiguration config = plugin.getConfig(); + addDefaults(config); + config.options().copyDefaults(true); + plugin.saveConfig(); + + enabled = config.getBoolean("skin-bridge.enabled", false); + debug = config.getBoolean("skin-bridge.debug", false); + sendPlayerMessage = config.getBoolean("skin-bridge.send-player-message", true); + requestTimeoutSeconds = clamp(config.getInt("skin-bridge.profile-request-timeout-seconds", 5), 1, 30); + String mineSkinEndpoint = config.getString("skin-bridge.mineskin.endpoint", "https://api.mineskin.org"); + String mineSkinApiKey = config.getString("skin-bridge.mineskin.api-key", "").trim(); + String mineSkinVisibility = config.getString("skin-bridge.mineskin.visibility", "unlisted"); + int mineSkinTimeoutSeconds = clamp(config.getInt("skin-bridge.mineskin.request-timeout-seconds", 30), 10, 180); + cacheMinutes = clamp(config.getInt("skin-bridge.cache-minutes", 120), 5, 10080); + joinDelayTicks = clamp(config.getLong("skin-bridge.join-delay-ticks", 20L), 0, 200); + providers = loadProviders(config); + cache.clear(); + gateway = loadGateway(mineSkinEndpoint, mineSkinApiKey, mineSkinVisibility, mineSkinTimeoutSeconds); + + if (enabled && gateway == null) { + plugin.getLogger().warning("SkinBridge 已启用,但未配置有效的 MineSkin API Key,皮肤同步不会执行。"); + } + } + + public void shutdown() { + configurationGeneration.incrementAndGet(); + cache.clear(); + pendingLookups.clear(); + executor.shutdownNow(); + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + queueSync(event.getPlayer(), false); + } + + public SyncResult queueSync(Player player, boolean force) { + if (!enabled) { + return SyncResult.FEATURE_DISABLED; + } + if (gateway == null) { + return SyncResult.DEPENDENCY_MISSING; + } + if (providers.isEmpty()) { + return SyncResult.NO_PROVIDERS; + } + + UUID playerId = player.getUniqueId(); + if (force) { + cache.remove(playerId); + } + + CachedLookup cached = cache.get(playerId); + if (!force && cached != null && !cached.hasExpired()) { + if (cached.skin() != null) { + applySkin(playerId, cached, configurationGeneration.get()); + } + return SyncResult.CACHED; + } + + long lookupGeneration = configurationGeneration.get(); + if (!registerPendingLookup(playerId, lookupGeneration)) { + return SyncResult.ALREADY_RUNNING; + } + + String playerName = player.getName(); + Bukkit.getScheduler().runTaskLater(plugin, () -> startLookup(playerId, playerName, lookupGeneration), joinDelayTicks); + sendPlayerNotification(playerId, "skin-bridge.notifications.detecting", Map.of()); + return SyncResult.QUEUED; + } + + public Status getStatus(Player player) { + CachedLookup cached = cache.get(player.getUniqueId()); + if (cached != null && !cached.hasExpired()) { + return new Status(cached.state(), cached.providerId()); + } + if (pendingLookups.containsKey(player.getUniqueId())) { + return new Status(State.PENDING, null); + } + return new Status(State.UNKNOWN, null); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isSkinGatewayAvailable() { + return gateway != null; + } + + public int getProviderCount() { + return providers.size(); + } + + public String getModuleDetail() { + if (!enabled) { + return "配置未启用"; + } + if (gateway == null) { + return "缺少 MineSkin API Key 或配置无效"; + } + if (providers.isEmpty()) { + return "未配置有效 Provider"; + } + return providers.size() + " 个 Provider 已就绪"; + } + + private void startLookup(UUID playerId, String playerName, long lookupGeneration) { + if (executor.isShutdown() || lookupGeneration != configurationGeneration.get()) { + pendingLookups.remove(playerId, lookupGeneration); + return; + } + executor.execute(() -> { + try { + CachedLookup resolved = resolve(playerId, playerName); + if (lookupGeneration != configurationGeneration.get()) { + return; + } + cache.put(playerId, resolved); + if (resolved.skin() != null) { + applySkin(playerId, resolved, lookupGeneration); + } else { + sendPlayerNotification(playerId, "skin-bridge.notifications.not-external", Map.of()); + if (debug) { + plugin.getLogger().info("SkinBridge 未识别到外置登录玩家: " + playerName); + } + } + } catch (Exception exception) { + plugin.getLogger().warning("SkinBridge 查询 " + playerName + " 的皮肤资料失败: " + exception.getMessage()); + sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of()); + if (debug) { + plugin.getLogger().warning("SkinBridge 异常类型: " + exception.getClass().getName()); + } + } finally { + pendingLookups.remove(playerId, lookupGeneration); + } + }); + } + + private boolean registerPendingLookup(UUID playerId, long lookupGeneration) { + while (true) { + Long runningGeneration = pendingLookups.putIfAbsent(playerId, lookupGeneration); + if (runningGeneration == null) { + return true; + } + if (runningGeneration == lookupGeneration) { + return false; + } + if (pendingLookups.replace(playerId, runningGeneration, lookupGeneration)) { + return true; + } + } + } + + private CachedLookup resolve(UUID playerId, String playerName) throws Exception { + Exception lastFailure = null; + for (SkinProvider provider : providers) { + Optional profile; + try { + profile = queryProfile(provider, playerId, playerName); + } catch (Exception exception) { + lastFailure = exception; + plugin.getLogger().warning("SkinBridge Provider " + provider.id() + " 查询失败: " + exception.getMessage()); + continue; + } + if (profile.isEmpty()) { + continue; + } + + SkinBridgeGateway currentGateway = gateway; + if (currentGateway == null) { + throw new IllegalStateException("MineSkin 网关在查询期间不可用。"); + } + + ProviderProfile matchedProfile = profile.get(); + GeneratedSkin generatedSkin = currentGateway.generateSkin(matchedProfile.skinUrl(), matchedProfile.model()); + return cached(provider.id(), generatedSkin, State.EXTERNAL); + } + + if (lastFailure != null) { + throw new IllegalStateException("所有可用 Provider 均未能完成确认。", lastFailure); + } + return cached(null, null, State.NOT_EXTERNAL); + } + + private Optional queryProfile(SkinProvider provider, UUID playerId, String playerName) throws Exception { + URI requestUri = URI.create(provider.resolveProfileUrl(playerId)); + HttpRequest request = HttpRequest.newBuilder(requestUri) + .timeout(Duration.ofSeconds(requestTimeoutSeconds)) + .header("Accept", "application/json") + .header("User-Agent", "EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge") + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + if (response.statusCode() == 204 || response.statusCode() == 404) { + return Optional.empty(); + } + if (response.statusCode() != 200) { + throw new IllegalStateException(provider.id() + " 返回 HTTP " + response.statusCode()); + } + if (response.body().length() > 1_048_576) { + throw new IllegalStateException(provider.id() + " 返回的 profile 超过 1 MiB 限制。"); + } + + JsonObject profile = JsonParser.parseString(response.body()).getAsJsonObject(); + String profileId = requireString(profile, "id"); + String profileName = requireString(profile, "name"); + if (!normalizeUuid(profileId).equals(normalizeUuid(playerId.toString())) || !profileName.equalsIgnoreCase(playerName)) { + if (debug) { + plugin.getLogger().warning("SkinBridge 忽略 " + provider.id() + " 的不匹配 profile: " + profileName + " / " + profileId); + } + return Optional.empty(); + } + + JsonObject textureData = findTextureData(profile); + JsonObject skin = textureData.getAsJsonObject("textures").getAsJsonObject("SKIN"); + String skinUrl = requireString(skin, "url"); + URI skinUri = URI.create(skinUrl); + if (!"https".equalsIgnoreCase(skinUri.getScheme()) && !"http".equalsIgnoreCase(skinUri.getScheme())) { + throw new IllegalStateException(provider.id() + " 返回了不支持的皮肤 URL 协议。"); + } + + SkinModel model = SkinModel.CLASSIC; + JsonObject metadata = skin.has("metadata") && skin.get("metadata").isJsonObject() + ? skin.getAsJsonObject("metadata") + : null; + if (metadata != null && "slim".equalsIgnoreCase(metadata.has("model") ? metadata.get("model").getAsString() : "")) { + model = SkinModel.SLIM; + } + return Optional.of(new ProviderProfile(skinUrl, model)); + } + + private JsonObject findTextureData(JsonObject profile) { + JsonArray properties = profile.has("properties") && profile.get("properties").isJsonArray() + ? profile.getAsJsonArray("properties") + : new JsonArray(); + for (JsonElement element : properties) { + if (!element.isJsonObject()) { + continue; + } + JsonObject property = element.getAsJsonObject(); + if (!"textures".equals(property.has("name") ? property.get("name").getAsString() : "")) { + continue; + } + String encodedValue = requireString(property, "value"); + String decodedValue = new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8); + JsonObject textureData = JsonParser.parseString(decodedValue).getAsJsonObject(); + if (textureData.has("textures") + && textureData.get("textures").isJsonObject() + && textureData.getAsJsonObject("textures").has("SKIN") + && textureData.getAsJsonObject("textures").get("SKIN").isJsonObject()) { + return textureData; + } + } + throw new IllegalStateException("profile 不包含有效的皮肤 textures 属性。"); + } + + private List loadProviders(FileConfiguration config) { + ConfigurationSection providersSection = config.getConfigurationSection("skin-bridge.providers"); + if (providersSection == null) { + return List.of(); + } + + List loadedProviders = new ArrayList<>(); + for (String key : providersSection.getKeys(false)) { + ConfigurationSection providerSection = providersSection.getConfigurationSection(key); + if (providerSection == null || !providerSection.getBoolean("enabled", false)) { + continue; + } + String profileUrl = providerSection.getString("profile-url", "").trim(); + if (!profileUrl.contains("{uuid}") && !profileUrl.contains("{uuid-dashed}")) { + plugin.getLogger().warning("SkinBridge Provider " + key + " 缺少 {uuid} 或 {uuid-dashed} 占位符,已跳过。"); + continue; + } + try { + URI.create(profileUrl.replace("{uuid}", "00000000000000000000000000000000") + .replace("{uuid-dashed}", "00000000-0000-0000-0000-000000000000")); + loadedProviders.add(new SkinProvider(key, profileUrl, providerSection.getInt("priority", 100))); + } catch (IllegalArgumentException exception) { + plugin.getLogger().warning("SkinBridge Provider " + key + " 的 profile-url 无效,已跳过。"); + } + } + loadedProviders.sort(Comparator.comparingInt(SkinProvider::priority).thenComparing(SkinProvider::id)); + return List.copyOf(loadedProviders); + } + + private SkinBridgeGateway loadGateway(String endpoint, String apiKey, String visibility, int timeoutSeconds) { + if (apiKey.isBlank()) { + return null; + } + try { + return new MineSkinGateway(httpClient, endpoint, apiKey, visibility, timeoutSeconds, + "EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge"); + } catch (Exception | LinkageError exception) { + plugin.getLogger().warning("加载 MineSkin SkinBridge 适配器失败: " + exception.getMessage()); + return null; + } + } + + private void addDefaults(FileConfiguration config) { + config.addDefault("skin-bridge.enabled", false); + config.addDefault("skin-bridge.debug", false); + config.addDefault("skin-bridge.send-player-message", true); + config.addDefault("skin-bridge.profile-request-timeout-seconds", 5); + config.addDefault("skin-bridge.mineskin.endpoint", "https://api.mineskin.org"); + config.addDefault("skin-bridge.mineskin.api-key", ""); + config.addDefault("skin-bridge.mineskin.visibility", "unlisted"); + config.addDefault("skin-bridge.mineskin.request-timeout-seconds", 30); + config.addDefault("skin-bridge.cache-minutes", 120); + config.addDefault("skin-bridge.join-delay-ticks", 20); + } + + private void applySkin(UUID playerId, CachedLookup resolved, long lookupGeneration) { + Bukkit.getScheduler().runTask(plugin, () -> { + if (lookupGeneration != configurationGeneration.get()) { + return; + } + Player player = Bukkit.getPlayer(playerId); + SkinBridgeGateway currentGateway = gateway; + if (player == null || !player.isOnline() || currentGateway == null || resolved.skin() == null) { + return; + } + try { + currentGateway.applySkin(player, resolved.skin()); + sendPlayerNotification(playerId, "skin-bridge.notifications.synced", + Map.of("provider", resolved.providerId())); + if (debug) { + plugin.getLogger().info("SkinBridge 已应用 " + player.getName() + " 的 " + resolved.providerId() + " 皮肤。"); + } + } catch (Exception exception) { + plugin.getLogger().warning("SkinBridge 应用 " + player.getName() + " 的皮肤失败: " + exception.getMessage()); + sendPlayerNotification(playerId, "skin-bridge.notifications.failed", Map.of()); + } + }); + } + + private void sendPlayerNotification(UUID playerId, String messagePath, Map placeholders) { + if (!sendPlayerMessage) { + return; + } + + Runnable notification = () -> { + Player player = Bukkit.getPlayer(playerId); + if (player != null && player.isOnline()) { + player.sendMessage(EssentialsC.getLangManager().getPrefixedString(messagePath, placeholders)); + } + }; + if (Bukkit.isPrimaryThread()) { + notification.run(); + } else { + Bukkit.getScheduler().runTask(plugin, notification); + } + } + + private CachedLookup cached(String providerId, GeneratedSkin skin, State state) { + return new CachedLookup(providerId, skin, state, System.currentTimeMillis() + Duration.ofMinutes(cacheMinutes).toMillis()); + } + + private static String requireString(JsonObject object, String key) { + if (!object.has(key) || !object.get(key).isJsonPrimitive()) { + throw new IllegalStateException("缺少字符串字段: " + key); + } + String value = object.get(key).getAsString(); + if (value.isBlank()) { + throw new IllegalStateException("字符串字段为空: " + key); + } + return value; + } + + private static String normalizeUuid(String value) { + return value.replace("-", "").toLowerCase(java.util.Locale.ROOT); + } + + private static int clamp(int value, int min, int max) { + return Math.max(min, Math.min(max, value)); + } + + private static long clamp(long value, long min, long max) { + return Math.max(min, Math.min(max, value)); + } + + private record SkinProvider(String id, String profileUrl, int priority) { + private String resolveProfileUrl(UUID playerId) { + return profileUrl.replace("{uuid}", playerId.toString().replace("-", "")) + .replace("{uuid-dashed}", playerId.toString()); + } + } + + private record ProviderProfile(String skinUrl, SkinModel model) { + } + + private record CachedLookup(String providerId, GeneratedSkin skin, State state, long expiresAtMillis) { + private boolean hasExpired() { + return System.currentTimeMillis() >= expiresAtMillis; + } + } + + private static final class SkinBridgeThreadFactory implements ThreadFactory { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "EssentialsC-SkinBridge"); + thread.setDaemon(true); + return thread; + } + } + + public enum SyncResult { + QUEUED, + CACHED, + ALREADY_RUNNING, + FEATURE_DISABLED, + DEPENDENCY_MISSING, + NO_PROVIDERS + } + + public record Status(State state, String providerId) { + } + + public enum State { + EXTERNAL, + NOT_EXTERNAL, + PENDING, + UNKNOWN + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a7bc42f..6c32e97 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -75,6 +75,43 @@ tpa: # 传送完成时播放的音效。 complete: "entity.enderman.teleport" +skin-bridge: + # 是否启用外置皮肤站桥接。还需要在 modules.yml 启用 skin-bridge 模块。 + enabled: false + # 是否输出 Provider 查询和皮肤应用的调试日志。 + debug: false + # 是否像 JEI 修复一样向玩家发送检测与同步提示。 + send-player-message: true + # 查询 Yggdrasil profile 的超时时间,单位为秒。 + profile-request-timeout-seconds: 5 + mineskin: + # MineSkin v2 API 地址,一般不需要修改。 + endpoint: "https://api.mineskin.org" + # MineSkin API Key。请仅在服务器运行目录中填写,不要提交到公开仓库。 + api-key: "" + # 生成结果的可见性:public、unlisted 或 private。默认使用 unlisted。 + visibility: "unlisted" + # MineSkin 生成队列的总等待时间,单位为秒。 + request-timeout-seconds: 30 + # 玩家登录来源与生成皮肤的内存缓存时间,单位为分钟。 + cache-minutes: 120 + # 玩家加入后延迟多少 tick 再查询,避免登录资料尚未完全稳定。 + join-delay-ticks: 20 + providers: + # 每个子节点都是一个 Provider,键名可自由命名,并会显示在日志和 /essc skin status 中。 + # 建议使用小写英文、数字和连字符;不要使用点号,因为点号会被 Bukkit 视为配置路径分隔符。 + littleskin: + # LittleSkin 示例 Provider。键名 littleskin 可按需改名。 + enabled: false + priority: 10 + # {uuid} 为无连字符 UUID;{uuid-dashed} 为标准 UUID。 + profile-url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" + my-blessing-skin: + # 自建 Blessing Skin 示例 Provider。my-blessing-skin 可替换为皮肤站名称。 + enabled: false + priority: 20 + profile-url: "https://skin.example.com/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" + blocks-menu: # 菜单布局版本,用于后续自动迁移槽位布局 layout-version: 2 diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 3b964ad..3327e7d 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -88,6 +88,7 @@ help: tpaccept: " &f/tpaccept [player] &7- Accept a teleport request" tpdeny: " &f/tpdeny [player] &7- Deny a teleport request" tpignore: " &f/tpignore &7- Toggle ignoring teleport requests" + skin: " &f/essc skin [player] &7- View or refresh SkinBridge status" admin: " &f/essc admin &7- Toggle admin mode" tpsbar: " &f/tpsbar [player] &7- Toggle TPS boss bar" maintenance: " &f/maintenance &7- Manage maintenance mode" @@ -132,6 +133,25 @@ tpa: tpaall-sent: "&aSent teleport requests to all players." tpaall-no-targets: "&cThere are no online players who can receive the request." +skin-bridge: + notifications: + detecting: "&6SKIN-FIX&8: &eDetecting your skin source..." + synced: "&6SKIN-FIX&8(&b{provider}&8): &aSkin synchronization completed." + not-external: "&6SKIN-FIX&8: &7No external skin provider matched; your current skin was kept." + failed: "&6SKIN-FIX&8: &cSkin detection or synchronization failed. Please try again later." + messages: + usage: "&cUsage: /essc skin [player]" + feature-disabled: "&cSkinBridge is disabled in config.yml." + dependency-missing: "&cNo valid MineSkin API key is configured in config.yml." + no-providers: "&cNo valid SkinBridge provider is enabled." + status-external: "&a{player} is identified as an external skin provider player. Provider: &f{provider}" + status-not-external: "&7{player} does not match any configured external skin provider." + status-pending: "&eSkinBridge is still detecting {player}'s login source." + status-unknown: "&7{player} has not been checked by SkinBridge yet." + refresh-queued: "&aSkinBridge is detecting and refreshing {player}'s skin." + refresh-cached: "&7{player} is using a valid skin cache entry." + refresh-running: "&eSkinBridge is already processing {player}." + blocks-menu: title: "&6&lEssentialsC &8- &e&lShortcut Menu" items: diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 2a8b1cb..d334596 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -88,6 +88,7 @@ help: tpaccept: " &f/tpaccept [玩家] &7- 接受传送请求" tpdeny: " &f/tpdeny [玩家] &7- 拒绝传送请求" tpignore: " &f/tpignore &7- 切换是否忽略传送请求" + skin: " &f/essc skin [玩家] &7- 查看或刷新皮肤桥接状态" admin: " &f/essc admin &7- 切换管理模式" tpsbar: " &f/tpsbar [玩家] &7- 切换 TPS 状态栏" maintenance: " &f/maintenance &7- 管理维护模式" @@ -132,6 +133,25 @@ tpa: tpaall-sent: "&a已向所有玩家发送传送请求。" tpaall-no-targets: "&c没有可接收请求的在线玩家。" +skin-bridge: + notifications: + detecting: "&6SKIN-FIX&8: &e正在检测皮肤来源..." + synced: "&6SKIN-FIX&8(&b{provider}&8): &a皮肤同步完成。" + not-external: "&6SKIN-FIX&8: &7未匹配外置皮肤站,已保留当前皮肤。" + failed: "&6SKIN-FIX&8: &c皮肤检测或同步失败,请稍后重试。" + messages: + usage: "&c用法:/essc skin [玩家]" + feature-disabled: "&cSkinBridge 在 config.yml 中未启用。" + dependency-missing: "&c未在 config.yml 中配置有效的 MineSkin API Key。" + no-providers: "&c没有启用有效的 SkinBridge Provider。" + status-external: "&a{player} 已识别为外置皮肤站玩家,Provider:&f{provider}" + status-not-external: "&7{player} 未匹配任何已配置的外置皮肤站。" + status-pending: "&e{player} 的皮肤站来源正在检测中。" + status-unknown: "&7{player} 尚未进行 SkinBridge 检测。" + refresh-queued: "&a已开始重新检测并刷新 {player} 的皮肤。" + refresh-cached: "&7{player} 正在使用有效的皮肤缓存。" + refresh-running: "&e{player} 的皮肤检测任务仍在运行。" + blocks-menu: title: "&6&lEssentialsC &8- &e&l便捷菜单" items: diff --git a/src/main/resources/modules.yml b/src/main/resources/modules.yml index 056c82b..b7f7b66 100644 --- a/src/main/resources/modules.yml +++ b/src/main/resources/modules.yml @@ -30,3 +30,6 @@ modules: maintenance: # 维护模式。开启维护后可替换 MOTD,并阻止无绕过权限的玩家进入。 enabled: true + skin-bridge: + # 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤。 + enabled: false diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 281a840..8345aab 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -119,6 +119,9 @@ permissions: essentialsc.command.maintenance: description: 允许管理维护模式 default: op + essentialsc.command.skin: + description: 允许管理 SkinBridge 状态与刷新 + default: op essentialsc.maintenance.bypass: description: 允许在维护模式下进入服务器 default: op @@ -171,6 +174,7 @@ permissions: essentialsc.command.tpsbar: true essentialsc.command.tpsbar.others: true essentialsc.command.maintenance: true + essentialsc.command.skin: true essentialsc.maintenance.bypass: true essentialsc.maintenance.notify: true essentialsc.shulkerbox.open: true