From 18cfbc2002f82cfd1e6248b1e1bfa8d86e3e16d9 Mon Sep 17 00:00:00 2001 From: Coldsmile Date: Sat, 1 Aug 2026 00:36:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E7=A8=B3=E5=AE=9A=E6=80=A7=E4=B8=8E=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/dependabot.yml | 13 + .github/workflows/build.yml | 34 ++ .github/workflows/release.yml | 2 +- README.md | 31 +- build.gradle | 11 +- .../cn/infstar/essentialsC/EssentialsC.java | 27 +- .../essentialsC/FeatureConfigManager.java | 45 +-- .../cn/infstar/essentialsC/LangManager.java | 12 +- .../cn/infstar/essentialsC/ModuleManager.java | 11 - .../essentialsC/admin/AdminModeManager.java | 35 +- .../essentialsC/api/event/TeleportEvent.java | 59 +++ .../event/TeleportRequestReceiveEvent.java | 48 +++ .../api/event/TeleportRequestReplyEvent.java | 52 +++ .../api/event/TeleportRequestSendEvent.java | 48 +++ .../event/TeleportWarmupCancelledEvent.java | 62 +++ .../api/event/TeleportWarmupEvent.java | 54 +++ .../essentialsC/commands/CommandRegistry.java | 110 +++--- .../essentialsC/commands/HelpCommand.java | 14 +- .../essentialsC/commands/MobDropCommand.java | 4 +- .../essentialsC/commands/SeenCommand.java | 24 +- .../commands/SkinBridgeCommand.java | 58 ++- .../essentialsC/commands/TpaCommand.java | 3 + .../essentialsC/commands/VanishCommand.java | 58 ++- .../listeners/JeiRecipeSyncListener.java | 14 +- .../listeners/MobDropListener.java | 16 +- .../listeners/MobDropMenuListener.java | 7 +- .../essentialsC/listeners/VanishListener.java | 28 +- .../maintenance/MaintenanceListener.java | 10 +- .../maintenance/MaintenanceManager.java | 45 ++- .../skinbridge/MineSkinGateway.java | 62 ++- .../skinbridge/SkinBridgeManager.java | 365 ++++++++++++++---- .../teleport/TeleportRequestManager.java | 232 ++++++++--- .../teleport/TeleportRequestQueuePolicy.java | 20 + .../essentialsC/tpsbar/TpsBarManager.java | 63 +-- .../essentialsC/util/AtomicYamlWriter.java | 37 ++ src/main/resources/config.yml | 66 +++- src/main/resources/lang/en_US.yml | 20 +- src/main/resources/lang/zh_CN.yml | 20 +- src/main/resources/maintenance.yml | 4 +- src/main/resources/modules.yml | 9 +- src/main/resources/paper-plugin.yml | 24 +- src/main/resources/skin-bridge.yml | 64 --- .../ConfigurationResourcesTest.java | 41 ++ .../TeleportRequestQueuePolicyTest.java | 32 ++ 44 files changed, 1523 insertions(+), 471 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/build.yml create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReceiveEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReplyEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestSendEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupCancelledEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupEvent.java create mode 100644 src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicy.java create mode 100644 src/main/java/cn/infstar/essentialsC/util/AtomicYamlWriter.java delete mode 100644 src/main/resources/skin-bridge.yml create mode 100644 src/test/java/cn/infstar/essentialsC/ConfigurationResourcesTest.java create mode 100644 src/test/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicyTest.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..46f1477 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: gradle + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a49b915 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build + +on: + push: + branches: + - main + - 'dev/**' + pull_request: + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 设置 Java 25 + uses: actions/setup-java@v4 + with: + java-version: '25' + distribution: temurin + cache: gradle + + - name: 验证 Gradle Wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: 构建插件 + uses: gradle/actions/setup-gradle@v4 + with: + arguments: clean build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c388d6..6180358 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,7 +30,7 @@ jobs: run: ./gradlew build - name: 创建发行版 - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: files: build/libs/EssentialsC*.jar generate_release_notes: true diff --git a/README.md b/README.md index 07ab8c7..997f303 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,8 @@ - 最低支持版本为 `Paper 1.21.11` - 已适配 `Paper 26.2` -- 构建环境固定为 `Java 21` -- 配置按职责拆分:主行为配置、模块开关、SkinBridge、菜单布局、维护模式和语言文本分别管理 +- 主插件使用 `Java 21`,Paper 26.x 版本适配模块使用对应服务端要求的 Java 工具链 +- 配置按职责拆分:主行为与 SkinBridge、模块开关、菜单布局、维护模式和语言文本分别管理 - 支持运行期模块开关,避免为不同功能组合构建多个插件版本 ## 主要功能 @@ -56,20 +56,19 @@ ## 模块配置 -项目现在默认构建一个完整插件,功能是否启用由 `plugins/EssentialsC/modules.yml` 控制。 +项目默认构建一个完整插件。玩家常用命令、Vanish 与 TPA 属于核心功能,始终加载并通过权限控制;其他可选功能由 `plugins/EssentialsC/modules.yml` 控制。 | 模块 | 默认状态 | 说明 | | --- | --- | --- | | `blocks` | 开启 | 便捷方块命令、`/essc blocks` 菜单、潜影盒快捷打开 | -| `player` | 开启 | 飞行、夜视、发光、治疗、喂食、修复、帽子、自杀、隐身、查询玩家 | | `admin-mode` | 开启 | `/essc admin` 管理模式与独立状态保存 | -| `tpsbar` | 开启 | 插件版 TPSBar,仍受 `config.yml` 中 `tpsbar.mode` 控制 | +| `tpsbar` | 开启 | 插件版 TPSBar,检测到服务端原生命令时自动避免冲突 | | `jei-sync` | 开启 | Fabric / NeoForge JEI 配方同步修复 | | `mob-drops` | 关闭 | 末影人掉落控制,默认关闭以保留过去标准版行为 | | `maintenance` | 开启 | 维护模式命令、MOTD 替换、登录拦截、白名单和拦截通知 | | `skin-bridge` | 关闭 | 查询外置 Yggdrasil profile,并通过 MineSkin 与 Paper Profile API 同步皮肤 | -修改模块开关后可先使用 `/essc reload` 刷新运行期服务与监听器状态。由于 Bukkit 命令表不适合在运行期完整热增删,若模块是在启动时关闭的,对应直连命令可能仍需重启后才会注册;通过 `/essc <子命令>` 入口通常可立即按新的模块状态执行。 +修改模块开关后使用 `/essc reload` 即可刷新运行期服务、监听器和命令门禁。可选模块命令始终注册,模块关闭时会返回统一的停用提示,因此无需为了启用命令重启服务器。 ## 安装说明 @@ -83,7 +82,7 @@ 当前配置按功能职责拆分,避免所有设置堆积在一个文件中: -SkinBridge 默认关闭。使用前需同时启用 `modules.yml` 中的 `modules.skin-bridge.enabled` 和 `skin-bridge.yml` 中的 `enabled`,填写 `mineskin.api-key`,并至少启用一个 Provider。真实密钥只应填写在服务器运行目录的 `skin-bridge.yml` 中,不要写入源码或提交到公开仓库。该模块不要求安装 SkinsRestorer。`send-player-message` 控制是否向玩家发送检测、匹配和同步结果提示;`providers` 下的键名可自由命名,并会显示在日志和 `/essc skin status <玩家>` 中。 +SkinBridge 默认关闭。使用前需启用 `modules.yml` 中的 `modules.skin-bridge.enabled`,填写 `config.yml` 中的 `skin-bridge.mineskin.api-key`,并至少启用一个 Provider。真实密钥只应填写在服务器运行目录的 `config.yml` 中,不要写入源码或提交到公开仓库。该模块不要求安装 SkinsRestorer。默认支持 InfstarMC 与 LittleSkin;Provider 的 `name` 可自由修改,并会显示在日志和 `/essc skin status <玩家>` 中。生成后的 MineSkin 纹理会按皮肤 URL 缓存到 `skin-cache.yml`,并遵守配置的 TTL。 - `config.yml` - 语言选择 @@ -92,11 +91,9 @@ SkinBridge 默认关闭。使用前需同时启用 `modules.yml` 中的 `modules - 掉落控制 - TPSBar 模式 - TPA 请求、预热、冷却和音效 + - SkinBridge、MineSkin、Provider 与排除名单 - `modules.yml` - 功能模块开关 -- `skin-bridge.yml` - - MineSkin API 与队列设置 - - Provider、缓存和检测设置 - `blocks-menu.yml` - 便捷菜单分区、槽位、材质和权限 - `maintenance.yml` @@ -114,7 +111,7 @@ SkinBridge 默认关闭。使用前需同时启用 `modules.yml` 中的 `modules - 管理模式文本 - TPSBar 文本 -配置文件包含 `config-version`。从旧结构升级时,插件会备份主配置,并自动把 SkinBridge 与便捷菜单节点迁移到独立文件。 +主配置当前使用 `config-version: 2`。SkinBridge 直接配置在 `config.yml` 中,便捷菜单配置仍保存在独立文件中。 ## 权限示例 @@ -131,6 +128,7 @@ essentialsc.command.heal essentialsc.command.feed essentialsc.command.repair essentialsc.command.vanish +essentialsc.vanish.see essentialsc.command.seen essentialsc.command.tpa essentialsc.command.tpahere @@ -146,6 +144,9 @@ essentialsc.command.admin essentialsc.command.tpsbar essentialsc.command.maintenance essentialsc.command.skin +essentialsc.command.skin.status +essentialsc.command.skin.refresh +essentialsc.command.skin.others essentialsc.maintenance.bypass essentialsc.maintenance.notify essentialsc.shulkerbox.open @@ -178,16 +179,18 @@ Windows 可使用: ./gradlew build ./gradlew deployToPaper12111 ./gradlew deployToPaper2612 +./gradlew deployToPaper262 ``` ## 本地测试服 -项目包含两个本地测试服目录: +项目包含三个本地测试服目录: | 测试服 | 端口 | 部署任务 | 启动脚本 | | --- | --- | --- | --- | | Paper 1.21.11 | `25566` | `deployToPaper12111` | `test-server/paper-1.21.11/start.bat` | | Paper 26.1.2 | `25565` | `deployToPaper2612` | `test-server/paper-26.1.2/start.bat` | +| Paper 26.2 | 以本地配置为准 | `deployToPaper262` | `test-server/paper-26.2/start.bat` | IDEA 运行配置会在启动测试服前自动执行对应部署任务。部署任务会替换 `EssentialsC*.jar`,并删除 `plugins/EssentialsC` 数据目录,以便测试新增默认配置和语言文本。 @@ -196,8 +199,8 @@ IDEA 运行配置会在启动测试服前自动执行对应部署任务。部署 - 使用 `paperweight-userdev` 进行 Paper 开发 - 使用 Paper Lifecycle Command API 注册命令,避免直接反射 Bukkit CommandMap - 涉及 NMS 的功能通过 `compat-api` + `versions/v<版本>` 适配模块隔离,主插件只依赖稳定接口;当前包含 `1.21.11`、`26.1.2`、`26.2` -- 运行时通过 `modules.yml` 控制模块加载,命令与监听器按模块状态注册 -- 发布流程基于 GitHub Actions 和 Gradle Wrapper +- 运行时通过 `modules.yml` 控制模块加载,命令始终注册并按模块状态执行门禁 +- 普通 push/PR 会执行构建与测试,版本标签继续生成 GitHub Release ## 许可证 diff --git a/build.gradle b/build.gradle index 36eeaa2..0c37481 100644 --- a/build.gradle +++ b/build.gradle @@ -1,10 +1,11 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import org.gradle.language.jvm.tasks.ProcessResources +import org.gradle.api.tasks.testing.Test plugins { id 'java' id 'io.papermc.paperweight.userdev' version '2.0.0-beta.21' - id 'com.gradleup.shadow' version '8.3.6' + id 'com.gradleup.shadow' version '8.3.11' } group = 'cn.infstar' @@ -22,6 +23,9 @@ dependencies { paperweight.paperDevBundle('1.21.11-R0.1-SNAPSHOT') implementation project(':compat-api') implementation 'com.google.code.gson:gson:2.11.0' + testImplementation platform('org.junit:junit-bom:5.13.4') + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } java { @@ -38,6 +42,10 @@ tasks.withType(ProcessResources).configureEach { filteringCharset = 'UTF-8' } +tasks.withType(Test).configureEach { + useJUnitPlatform() +} + tasks.named('processResources', ProcessResources).configure { inputs.property('version', project.version) filesMatching('paper-plugin.yml') { @@ -54,6 +62,7 @@ tasks.named('shadowJar', ShadowJar).configure { description = '构建包含全部运行期可开关模块的 EssentialsC 插件。' archiveFileName.set("EssentialsC-${project.version}.jar") configurations = [project.configurations.runtimeClasspath] + relocate 'com.google.gson', 'cn.infstar.essentialsC.libs.gson' [ project(':versions:v1_21_11'), diff --git a/src/main/java/cn/infstar/essentialsC/EssentialsC.java b/src/main/java/cn/infstar/essentialsC/EssentialsC.java index 08fdaa3..6b74955 100644 --- a/src/main/java/cn/infstar/essentialsC/EssentialsC.java +++ b/src/main/java/cn/infstar/essentialsC/EssentialsC.java @@ -80,6 +80,9 @@ public final class EssentialsC extends JavaPlugin { if (skinBridgeManager != null) { skinBridgeManager.shutdown(); } + if (vanishListener != null) { + vanishListener.shutdown(); + } VanishCommand.clearAll(this); unregisterRuntimeListeners(); unregisterPluginChannels(); @@ -120,7 +123,7 @@ public final class EssentialsC extends JavaPlugin { public void reloadRuntimeModules() { moduleStatus.clear(); - refreshPlayer(); + refreshCorePlayerFeatures(); refreshAdminMode(); refreshMaintenance(); refreshTpsBar(); @@ -130,22 +133,7 @@ public final class EssentialsC extends JavaPlugin { refreshSkinBridge(); } - private void refreshPlayer() { - if (!moduleManager.isEnabled(ModuleManager.PLAYER)) { - if (teleportRequestManager != null) { - teleportRequestManager.shutdown(); - HandlerList.unregisterAll(teleportRequestManager); - teleportRequestManager = null; - } - VanishCommand.clearAll(this); - if (vanishListener != null) { - HandlerList.unregisterAll(vanishListener); - vanishListener = null; - } - setModuleStatus("玩家功能", false, "已禁用"); - return; - } - + private void refreshCorePlayerFeatures() { if (vanishListener == null) { vanishListener = new VanishListener(this); getServer().getPluginManager().registerEvents(vanishListener, this); @@ -156,7 +144,6 @@ public final class EssentialsC extends JavaPlugin { } else { teleportRequestManager.reload(); } - setModuleStatus("玩家功能", true, "命令可用"); } private void refreshAdminMode() { @@ -173,6 +160,8 @@ public final class EssentialsC extends JavaPlugin { if (adminModeManager == null) { adminModeManager = new AdminModeManager(this); getServer().getPluginManager().registerEvents(adminModeManager, this); + } else { + adminModeManager.reload(); } setModuleStatus("管理模式", true, "监听器已注册"); } @@ -380,7 +369,7 @@ public final class EssentialsC extends JavaPlugin { if (!spec.standalone()) { continue; } - BaseCommand executor = CommandRegistry.getCommand(spec.name()); + BaseCommand executor = CommandRegistry.getRegisteredCommand(spec.name()); if (executor == null) { continue; } diff --git a/src/main/java/cn/infstar/essentialsC/FeatureConfigManager.java b/src/main/java/cn/infstar/essentialsC/FeatureConfigManager.java index 6b7a042..3436b6d 100644 --- a/src/main/java/cn/infstar/essentialsC/FeatureConfigManager.java +++ b/src/main/java/cn/infstar/essentialsC/FeatureConfigManager.java @@ -1,5 +1,6 @@ package cn.infstar.essentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; @@ -11,50 +12,35 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; /** - * 管理体积较大的独立功能配置,并负责从旧版 config.yml 迁移数据。 + * 管理独立功能配置,并负责迁移旧版配置结构。 */ public final class FeatureConfigManager { private static final int MAIN_CONFIG_VERSION = 2; private final EssentialsC plugin; - private final File skinBridgeFile; private final File blocksMenuFile; - private FileConfiguration skinBridgeConfig; private FileConfiguration blocksMenuConfig; public FeatureConfigManager(EssentialsC plugin) { this.plugin = plugin; - this.skinBridgeFile = new File(plugin.getDataFolder(), "skin-bridge.yml"); this.blocksMenuFile = new File(plugin.getDataFolder(), "blocks-menu.yml"); reload(); } public void reload() { - ensureResource(skinBridgeFile, "skin-bridge.yml"); ensureResource(blocksMenuFile, "blocks-menu.yml"); - skinBridgeConfig = loadWithDefaults(skinBridgeFile, "skin-bridge.yml"); blocksMenuConfig = loadWithDefaults(blocksMenuFile, "blocks-menu.yml"); migrateLegacyMainConfig(); migrateLegacyDebugSettings(); updateMainConfigVersion(); - saveSkinBridgeConfig(); - saveBlocksMenuConfig(); - } - - public FileConfiguration getSkinBridgeConfig() { - return skinBridgeConfig; } public FileConfiguration getBlocksMenuConfig() { return blocksMenuConfig; } - public void saveSkinBridgeConfig() { - save(skinBridgeConfig, skinBridgeFile); - } - public void saveBlocksMenuConfig() { save(blocksMenuConfig, blocksMenuFile); } @@ -63,11 +49,6 @@ public final class FeatureConfigManager { FileConfiguration mainConfig = plugin.getConfig(); boolean migrated = false; - if (mainConfig.contains("skin-bridge", true)) { - copySection(mainConfig.getConfigurationSection("skin-bridge"), skinBridgeConfig); - mainConfig.set("skin-bridge", null); - migrated = true; - } if (mainConfig.contains("blocks-menu", true)) { copySection(mainConfig.getConfigurationSection("blocks-menu"), blocksMenuConfig); mainConfig.set("blocks-menu", null); @@ -79,25 +60,25 @@ public final class FeatureConfigManager { } mainConfig.set("config-version", MAIN_CONFIG_VERSION); - plugin.saveConfig(); - plugin.getLogger().info("已将 SkinBridge 与便捷菜单配置迁移到独立配置文件。"); + saveMainConfig(); + plugin.getLogger().info("已将便捷菜单配置迁移到 blocks-menu.yml。"); } private void migrateLegacyDebugSettings() { FileConfiguration mainConfig = plugin.getConfig(); boolean hasJeiDebug = mainConfig.contains("jei-sync.debug", true); - boolean hasSkinBridgeDebug = skinBridgeConfig.contains("debug", true); + boolean hasSkinBridgeDebug = mainConfig.contains("skin-bridge.debug", true); if (!hasJeiDebug && !hasSkinBridgeDebug) { return; } boolean debugEnabled = mainConfig.getBoolean("debug", false) || mainConfig.getBoolean("jei-sync.debug", false) - || skinBridgeConfig.getBoolean("debug", false); + || mainConfig.getBoolean("skin-bridge.debug", false); mainConfig.set("debug", debugEnabled); mainConfig.set("jei-sync.debug", null); - skinBridgeConfig.set("debug", null); - plugin.saveConfig(); + mainConfig.set("skin-bridge.debug", null); + saveMainConfig(); plugin.getLogger().info("已将独立功能调试开关合并到 config.yml 的全局 debug。"); } @@ -106,7 +87,7 @@ public final class FeatureConfigManager { return; } plugin.getConfig().set("config-version", MAIN_CONFIG_VERSION); - plugin.saveConfig(); + saveMainConfig(); } private void copySection(ConfigurationSection source, FileConfiguration target) { @@ -141,9 +122,13 @@ public final class FeatureConfigManager { private void save(FileConfiguration config, File file) { try { - config.save(file); - } catch (IOException exception) { + AtomicYamlWriter.save(config, file); + } catch (Exception exception) { plugin.getLogger().warning("保存 " + file.getName() + " 失败: " + exception.getMessage()); } } + + private void saveMainConfig() { + save(plugin.getConfig(), new File(plugin.getDataFolder(), "config.yml")); + } } diff --git a/src/main/java/cn/infstar/essentialsC/LangManager.java b/src/main/java/cn/infstar/essentialsC/LangManager.java index 0618878..293cfa2 100644 --- a/src/main/java/cn/infstar/essentialsC/LangManager.java +++ b/src/main/java/cn/infstar/essentialsC/LangManager.java @@ -1,5 +1,6 @@ package cn.infstar.essentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; @@ -23,7 +24,7 @@ import java.util.regex.Pattern; public class LangManager { - private static final int CURRENT_CONFIG_VERSION = 3; + private static final int CURRENT_CONFIG_VERSION = 2; private static final Pattern HEX_COLOR_PATTERN = Pattern.compile("(?i)&#([0-9a-f]{6})"); private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); private static final Map THEME_COLORS = Map.of( @@ -136,11 +137,6 @@ public class LangManager { config.addDefault("debug", false); config.options().copyDefaults(true); - try { - config.save(configFile); - } catch (Exception e) { - plugin.getLogger().severe("保存 config.yml 失败: " + e.getMessage()); - } } private void migrateConfigIfNeeded(File configFile) { @@ -173,10 +169,10 @@ public class LangManager { migratedConfig.set("debug", debugEnabled); migratedConfig.set("jei-sync.debug", null); migratedConfig.set("config-version", CURRENT_CONFIG_VERSION); - migratedConfig.save(configFile); + AtomicYamlWriter.save(migratedConfig, configFile); } else { existingConfig.set("config-version", CURRENT_CONFIG_VERSION); - existingConfig.save(configFile); + AtomicYamlWriter.save(existingConfig, configFile); } plugin.getLogger().info("已将 config.yml 从版本 " + existingVersion diff --git a/src/main/java/cn/infstar/essentialsC/ModuleManager.java b/src/main/java/cn/infstar/essentialsC/ModuleManager.java index 7a23ab2..9297021 100644 --- a/src/main/java/cn/infstar/essentialsC/ModuleManager.java +++ b/src/main/java/cn/infstar/essentialsC/ModuleManager.java @@ -5,7 +5,6 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.java.JavaPlugin; import java.io.File; -import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; @@ -14,7 +13,6 @@ public final class ModuleManager { private static final int CURRENT_CONFIG_VERSION = 1; public static final String BLOCKS = "blocks"; - public static final String PLAYER = "player"; public static final String ADMIN_MODE = "admin-mode"; public static final String TPSBAR = "tpsbar"; public static final String JEI_SYNC = "jei-sync"; @@ -26,7 +24,6 @@ public final class ModuleManager { static { DEFAULT_MODULES.put(BLOCKS, true); - DEFAULT_MODULES.put(PLAYER, true); DEFAULT_MODULES.put(ADMIN_MODE, true); DEFAULT_MODULES.put(TPSBAR, true); DEFAULT_MODULES.put(JEI_SYNC, true); @@ -56,7 +53,6 @@ public final class ModuleManager { modulesConfig.addDefault(path(module.getKey()), module.getValue()); } modulesConfig.options().copyDefaults(true); - save(); } public boolean isEnabled(String moduleKey) { @@ -70,11 +66,4 @@ public final class ModuleManager { return "modules." + moduleKey + ".enabled"; } - private void save() { - try { - modulesConfig.save(modulesFile); - } catch (IOException e) { - plugin.getLogger().warning("保存 modules.yml 失败: " + e.getMessage()); - } - } } diff --git a/src/main/java/cn/infstar/essentialsC/admin/AdminModeManager.java b/src/main/java/cn/infstar/essentialsC/admin/AdminModeManager.java index ee9ec3c..1bddf4d 100644 --- a/src/main/java/cn/infstar/essentialsC/admin/AdminModeManager.java +++ b/src/main/java/cn/infstar/essentialsC/admin/AdminModeManager.java @@ -1,6 +1,7 @@ package cn.infstar.essentialsC.admin; import cn.infstar.essentialsC.EssentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; import org.bukkit.GameMode; import org.bukkit.Material; import org.bukkit.attribute.Attribute; @@ -15,7 +16,6 @@ import org.bukkit.inventory.PlayerInventory; import org.bukkit.scheduler.BukkitTask; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -41,6 +41,23 @@ public final class AdminModeManager implements Listener { this.data = YamlConfiguration.loadConfiguration(dataFile); } + public void reload() { + if (actionBarTask != null) { + actionBarTask.cancel(); + actionBarTask = null; + } + float flySpeed = getAdminFlySpeed(); + for (UUID uuid : new ArrayList<>(activePlayers)) { + Player player = plugin.getServer().getPlayer(uuid); + if (player != null && player.isOnline()) { + player.setFlySpeed(flySpeed); + } + } + if (!activePlayers.isEmpty()) { + startActionBarTask(); + } + } + public boolean isAdminMode(Player player) { return activePlayers.contains(player.getUniqueId()); } @@ -95,13 +112,18 @@ public final class AdminModeManager implements Listener { String playerPath = getPlayerPath(player); player.closeInventory(); saveProfile(player, playerPath + ".normal"); + data.set(playerPath + ".active", true); + if (!saveData()) { + sendLangMessage(player, "admin-mode.messages.save-failed"); + data.set(playerPath + ".active", false); + return; + } if (!loadProfile(player, playerPath + ".admin")) { clearInventory(player); } activePlayers.add(player.getUniqueId()); - data.set(playerPath + ".active", true); player.setGameMode(GameMode.CREATIVE); player.setAllowFlight(true); @@ -320,11 +342,13 @@ public final class AdminModeManager implements Listener { player.sendMessage(EssentialsC.getLangManager().getPrefixedString(path)); } - private void saveData() { + private boolean saveData() { try { - data.save(dataFile); - } catch (IOException e) { + AtomicYamlWriter.save(data, dataFile); + return true; + } catch (Exception e) { plugin.getLogger().warning("保存 admin-mode.yml 失败: " + e.getMessage()); + return false; } } @@ -332,6 +356,5 @@ public final class AdminModeManager implements Listener { plugin.getConfig().addDefault("admin-mode.fly-speed", 0.2D); plugin.getConfig().addDefault("admin-mode.actionbar.interval-ticks", 40); plugin.getConfig().options().copyDefaults(true); - plugin.saveConfig(); } } diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportEvent.java new file mode 100644 index 0000000..ac9c79c --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportEvent.java @@ -0,0 +1,59 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final TeleportRequest request; + private Location destination; + private boolean cancelled; + + public TeleportEvent(Player player, TeleportRequest request, Location destination) { + this.player = player; + this.request = request; + this.destination = destination.clone(); + } + + public Player getPlayer() { + return player; + } + + public TeleportRequest getRequest() { + return request; + } + + public Location getDestination() { + return destination.clone(); + } + + public void setDestination(Location destination) { + this.destination = destination.clone(); + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReceiveEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReceiveEvent.java new file mode 100644 index 0000000..c393092 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReceiveEvent.java @@ -0,0 +1,48 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportRequestReceiveEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player recipient; + private final TeleportRequest request; + private boolean cancelled; + + public TeleportRequestReceiveEvent(Player recipient, TeleportRequest request) { + this.recipient = recipient; + this.request = request; + } + + public Player getRecipient() { + return recipient; + } + + public TeleportRequest getRequest() { + return request; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReplyEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReplyEvent.java new file mode 100644 index 0000000..d88b389 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestReplyEvent.java @@ -0,0 +1,52 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportRequestReplyEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player recipient; + private final TeleportRequest request; + private boolean cancelled; + + public TeleportRequestReplyEvent(Player recipient, TeleportRequest request) { + this.recipient = recipient; + this.request = request; + } + + public Player getRecipient() { + return recipient; + } + + public TeleportRequest getRequest() { + return request; + } + + public boolean isAccepted() { + return request.status() == TeleportRequest.Status.ACCEPTED; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestSendEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestSendEvent.java new file mode 100644 index 0000000..60eac3e --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportRequestSendEvent.java @@ -0,0 +1,48 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportRequestSendEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player sender; + private final TeleportRequest request; + private boolean cancelled; + + public TeleportRequestSendEvent(Player sender, TeleportRequest request) { + this.sender = sender; + this.request = request; + } + + public Player getSender() { + return sender; + } + + public TeleportRequest getRequest() { + return request; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupCancelledEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupCancelledEvent.java new file mode 100644 index 0000000..a672d13 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupCancelledEvent.java @@ -0,0 +1,62 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportWarmupCancelledEvent extends Event { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final TeleportRequest request; + private final int durationSeconds; + private final int elapsedSeconds; + private final Reason reason; + + public TeleportWarmupCancelledEvent(Player player, TeleportRequest request, int durationSeconds, + int elapsedSeconds, Reason reason) { + this.player = player; + this.request = request; + this.durationSeconds = durationSeconds; + this.elapsedSeconds = elapsedSeconds; + this.reason = reason; + } + + public Player getPlayer() { + return player; + } + + public TeleportRequest getRequest() { + return request; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + public int getElapsedSeconds() { + return elapsedSeconds; + } + + public Reason getReason() { + return reason; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + + public enum Reason { + PLAYER_MOVE, + PLAYER_DAMAGE, + PLAYER_QUIT, + PLUGIN_SHUTDOWN + } +} diff --git a/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupEvent.java b/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupEvent.java new file mode 100644 index 0000000..47a54cf --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/api/event/TeleportWarmupEvent.java @@ -0,0 +1,54 @@ +package cn.infstar.essentialsC.api.event; + +import cn.infstar.essentialsC.teleport.TeleportRequestManager.TeleportRequest; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; + +public final class TeleportWarmupEvent extends Event implements Cancellable { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final Player player; + private final TeleportRequest request; + private final int durationSeconds; + private boolean cancelled; + + public TeleportWarmupEvent(Player player, TeleportRequest request, int durationSeconds) { + this.player = player; + this.request = request; + this.durationSeconds = durationSeconds; + } + + public Player getPlayer() { + return player; + } + + public TeleportRequest getRequest() { + return request; + } + + public int getDurationSeconds() { + return durationSeconds; + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancelled) { + this.cancelled = cancelled; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java index ba9cfeb..08a3436 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java +++ b/src/main/java/cn/infstar/essentialsC/commands/CommandRegistry.java @@ -4,7 +4,6 @@ import cn.infstar.essentialsC.EssentialsC; import cn.infstar.essentialsC.ModuleManager; import cn.infstar.essentialsC.tpsbar.TpsBarService; -import java.lang.reflect.Constructor; import java.util.Collection; import java.util.Collections; import java.util.HashMap; @@ -12,6 +11,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Supplier; public final class CommandRegistry { @@ -21,52 +21,57 @@ public final class CommandRegistry { private static final Set UNAVAILABLE_COMMANDS = new java.util.HashSet<>(); static { - register("workbench", "essentialsc.command.workbench", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.WorkbenchCommand", "wb"); - register("anvil", "essentialsc.command.anvil", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.AnvilCommand"); - register("cartographytable", "essentialsc.command.cartographytable", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.CartographyTableCommand", "ct", "cartography"); - register("grindstone", "essentialsc.command.grindstone", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.GrindstoneCommand", "gs"); - register("loom", "essentialsc.command.loom", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.LoomCommand"); - register("smithingtable", "essentialsc.command.smithingtable", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.SmithingTableCommand", "st", "smithing"); - register("stonecutter", "essentialsc.command.stonecutter", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.StonecutterCommand", "sc"); - register("enderchest", "essentialsc.command.enderchest", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.EnderChestCommand", "ec"); - register("blocks", "essentialsc.command.blocks", ModuleManager.BLOCKS, "cn.infstar.essentialsC.commands.BlocksMenuCommand"); - register("hat", "essentialsc.command.hat", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.HatCommand"); - register("suicide", "essentialsc.command.suicide", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.SuicideCommand", "die"); - register("fly", "essentialsc.command.fly", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.FlyCommand"); - register("nightvision", "essentialsc.command.nightvision", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.NightVisionCommand", "nv"); - register("glow", "essentialsc.command.glow", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.GlowCommand"); - register("heal", "essentialsc.command.heal", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.HealCommand"); - register("vanish", "essentialsc.command.vanish", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.VanishCommand", "v"); - register("seen", "essentialsc.command.seen", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.SeenCommand", "info"); - register("feed", "essentialsc.command.feed", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.FeedCommand"); - register("repair", "essentialsc.command.repair", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.RepairCommand", "rep"); - register("tpa", "essentialsc.command.tpa", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaCommand"); - register("tpahere", "essentialsc.command.tpahere", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaHereCommand"); - register("tpaall", "essentialsc.command.tpaall", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpaAllCommand"); - register("tpaccept", "essentialsc.command.tpaccept", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpAcceptCommand", "tpyes"); - register("tpdeny", "essentialsc.command.tpdeny", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpDenyCommand", "tpdecline", "tpno"); - register("tpignore", "essentialsc.command.tpignore", ModuleManager.PLAYER, "cn.infstar.essentialsC.commands.TpIgnoreCommand"); - register("tpsbar", "essentialsc.command.tpsbar", ModuleManager.TPSBAR, "cn.infstar.essentialsC.commands.TpsBarCommand"); - 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"); + register("workbench", "essentialsc.command.workbench", ModuleManager.BLOCKS, WorkbenchCommand::new, "wb"); + register("anvil", "essentialsc.command.anvil", ModuleManager.BLOCKS, AnvilCommand::new); + register("cartographytable", "essentialsc.command.cartographytable", ModuleManager.BLOCKS, CartographyTableCommand::new, "ct", "cartography"); + register("grindstone", "essentialsc.command.grindstone", ModuleManager.BLOCKS, GrindstoneCommand::new, "gs"); + register("loom", "essentialsc.command.loom", ModuleManager.BLOCKS, LoomCommand::new); + register("smithingtable", "essentialsc.command.smithingtable", ModuleManager.BLOCKS, SmithingTableCommand::new, "st", "smithing"); + register("stonecutter", "essentialsc.command.stonecutter", ModuleManager.BLOCKS, StonecutterCommand::new, "sc"); + register("enderchest", "essentialsc.command.enderchest", ModuleManager.BLOCKS, EnderChestCommand::new, "ec"); + register("blocks", "essentialsc.command.blocks", ModuleManager.BLOCKS, BlocksMenuCommand::new); + registerCore("hat", "essentialsc.command.hat", HatCommand::new); + registerCore("suicide", "essentialsc.command.suicide", SuicideCommand::new, "die"); + registerCore("fly", "essentialsc.command.fly", FlyCommand::new); + registerCore("nightvision", "essentialsc.command.nightvision", NightVisionCommand::new, "nv"); + registerCore("glow", "essentialsc.command.glow", GlowCommand::new); + registerCore("heal", "essentialsc.command.heal", HealCommand::new); + registerCore("vanish", "essentialsc.command.vanish", VanishCommand::new, "v"); + registerCore("seen", "essentialsc.command.seen", SeenCommand::new, "info"); + registerCore("feed", "essentialsc.command.feed", FeedCommand::new); + registerCore("repair", "essentialsc.command.repair", RepairCommand::new, "rep"); + registerCore("tpa", "essentialsc.command.tpa", TpaCommand::new); + registerCore("tpahere", "essentialsc.command.tpahere", TpaHereCommand::new); + registerCore("tpaall", "essentialsc.command.tpaall", TpaAllCommand::new); + registerCore("tpaccept", "essentialsc.command.tpaccept", TpAcceptCommand::new, "tpyes"); + registerCore("tpdeny", "essentialsc.command.tpdeny", TpDenyCommand::new, "tpdecline", "tpno"); + registerCore("tpignore", "essentialsc.command.tpignore", TpIgnoreCommand::new); + register("tpsbar", "essentialsc.command.tpsbar", ModuleManager.TPSBAR, TpsBarCommand::new); + register("mobdrops", "essentialsc.mobdrops.enderman", ModuleManager.MOB_DROPS, MobDropCommand::new); + register("maintenance", "essentialsc.command.maintenance", ModuleManager.MAINTENANCE, MaintenanceCommand::new, "maint"); + registerSubCommand("admin", "essentialsc.command.admin", ModuleManager.ADMIN_MODE, AdminCommand::new); + registerSubCommand("skin", "essentialsc.command.skin", ModuleManager.SKIN_BRIDGE, SkinBridgeCommand::new); } private CommandRegistry() { } - private static void register(String name, String permission, String moduleKey, String className, String... aliases) { - register(name, permission, moduleKey, className, true, aliases); + private static void registerCore(String name, String permission, Supplier factory, String... aliases) { + register(name, permission, null, factory, aliases); } - private static void registerSubCommand(String name, String permission, String moduleKey, String className, String... aliases) { - register(name, permission, moduleKey, className, false, aliases); + private static void register(String name, String permission, String moduleKey, Supplier factory, String... aliases) { + register(name, permission, moduleKey, factory, true, aliases); } - private static void register(String name, String permission, String moduleKey, String className, boolean standalone, String... aliases) { + private static void registerSubCommand(String name, String permission, String moduleKey, Supplier factory, String... aliases) { + register(name, permission, moduleKey, factory, false, aliases); + } + + private static void register(String name, String permission, String moduleKey, Supplier factory, + boolean standalone, String... aliases) { List aliasList = List.of(aliases); - CommandSpec spec = new CommandSpec(name, permission, moduleKey, className, aliasList, standalone); + CommandSpec spec = new CommandSpec(name, permission, moduleKey, factory, aliasList, standalone); COMMANDS.put(name, spec); ALIAS_TO_COMMAND.put(name, name); for (String alias : aliasList) { @@ -86,7 +91,8 @@ public final class CommandRegistry { } public static boolean isAvailable(String name) { - return getCommand(name) != null; + String resolvedName = resolveCommandName(name); + return resolvedName != null && !isRuntimeDisabled(resolvedName) && getRegisteredCommand(resolvedName) != null; } public static String getPermission(String name) { @@ -104,6 +110,15 @@ public final class CommandRegistry { return null; } + return getRegisteredCommand(resolvedName); + } + + public static BaseCommand getRegisteredCommand(String name) { + String resolvedName = resolveCommandName(name); + if (resolvedName == null) { + return null; + } + BaseCommand cached = COMMAND_CACHE.get(resolvedName); if (cached != null) { return cached; @@ -118,18 +133,16 @@ public final class CommandRegistry { } try { - Class rawClass = Class.forName(spec.className()); - if (!BaseCommand.class.isAssignableFrom(rawClass)) { - UNAVAILABLE_COMMANDS.add(resolvedName); - return null; - } - - Constructor constructor = rawClass.asSubclass(BaseCommand.class).getDeclaredConstructor(); - BaseCommand command = constructor.newInstance(); + BaseCommand command = spec.factory().get(); COMMAND_CACHE.put(resolvedName, command); return command; - } catch (ReflectiveOperationException | LinkageError ignored) { + } catch (RuntimeException | LinkageError exception) { UNAVAILABLE_COMMANDS.add(resolvedName); + try { + EssentialsC.getPlugin(EssentialsC.class).getLogger() + .warning("初始化命令 /" + resolvedName + " 失败: " + exception.getMessage()); + } catch (IllegalStateException ignored) { + } return null; } } @@ -168,6 +181,7 @@ public final class CommandRegistry { UNAVAILABLE_COMMANDS.clear(); } - public record CommandSpec(String name, String permission, String moduleKey, String className, List aliases, boolean standalone) { + public record CommandSpec(String name, String permission, String moduleKey, Supplier factory, + List aliases, boolean standalone) { } } diff --git a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java index 7057d54..27494fa 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/HelpCommand.java @@ -354,7 +354,14 @@ public class HelpCommand extends BaseCommand implements TabCompleter { if (subCmd.equals("skin") && sender.hasPermission("essentialsc.command.skin")) { String partial = args[1].toLowerCase(); - return List.of("status", "refresh").stream() + List actions = new ArrayList<>(); + if (sender.hasPermission("essentialsc.command.skin.status")) { + actions.add("status"); + } + if (sender.hasPermission("essentialsc.command.skin.refresh")) { + actions.add("refresh"); + } + return actions.stream() .filter(option -> option.startsWith(partial)) .toList(); } @@ -386,7 +393,10 @@ 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")) { + && sender.hasPermission("essentialsc.command.skin.others") + && sender.hasPermission(args[1].equalsIgnoreCase("status") + ? "essentialsc.command.skin.status" + : "essentialsc.command.skin.refresh")) { List players = new ArrayList<>(); String partial = args[2].toLowerCase(); for (Player player : Bukkit.getOnlinePlayers()) { diff --git a/src/main/java/cn/infstar/essentialsC/commands/MobDropCommand.java b/src/main/java/cn/infstar/essentialsC/commands/MobDropCommand.java index 4835b8c..dd4c6ab 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/MobDropCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/MobDropCommand.java @@ -42,8 +42,8 @@ public class MobDropCommand extends BaseCommand { public static void openMobDropMenu(EssentialsC plugin, Player player) { var lang = EssentialsC.getLangManager(); - boolean endermanEnabled = plugin.getConfig().getBoolean("mob-drops.enderman.enabled", true); - String status = lang.getString(endermanEnabled + boolean endermanDropsAllowed = plugin.getConfig().getBoolean("mob-drops.enderman.allow-drops", true); + String status = lang.getString(endermanDropsAllowed ? "mobdrops-menu.status.enabled" : "mobdrops-menu.status.disabled"); diff --git a/src/main/java/cn/infstar/essentialsC/commands/SeenCommand.java b/src/main/java/cn/infstar/essentialsC/commands/SeenCommand.java index 2f7497b..15ed1fa 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/SeenCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/SeenCommand.java @@ -1,7 +1,6 @@ package cn.infstar.essentialsC.commands; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; @@ -29,30 +28,27 @@ public class SeenCommand extends BaseCommand { } SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); - StringBuilder info = new StringBuilder(); - info.append(getLang().getPrefix()).append(ChatColor.GOLD).append("玩家信息: ") - .append(ChatColor.WHITE).append(target.getName()).append("\n"); + player.sendMessage(getLang().getPrefixedComponent("messages.seen-header", + Map.of("player", String.valueOf(target.getName())))); if (target.isOnline()) { - info.append(ChatColor.GRAY).append("状态: ").append(ChatColor.GREEN).append("在线").append("\n"); + player.sendMessage(getLang().getComponent("messages.seen-status-online")); Player onlinePlayer = target.getPlayer(); if (onlinePlayer != null) { - info.append(ChatColor.GRAY).append("所在世界: ").append(ChatColor.WHITE) - .append(onlinePlayer.getWorld().getName()).append("\n"); + player.sendMessage(getLang().getComponent("messages.seen-world", + Map.of("world", onlinePlayer.getWorld().getName()))); } } else { - info.append(ChatColor.GRAY).append("状态: ").append(ChatColor.RED).append("离线").append("\n"); + player.sendMessage(getLang().getComponent("messages.seen-status-offline")); long lastSeen = target.getLastSeen(); if (lastSeen > 0) { - info.append(ChatColor.GRAY).append("最后在线: ").append(ChatColor.WHITE) - .append(format.format(new Date(lastSeen))).append("\n"); + player.sendMessage(getLang().getComponent("messages.seen-last-online", + Map.of("time", format.format(new Date(lastSeen))))); } } - info.append(ChatColor.GRAY).append("首次加入: ").append(ChatColor.WHITE) - .append(format.format(new Date(target.getFirstPlayed()))); - - player.sendMessage(info.toString()); + player.sendMessage(getLang().getComponent("messages.seen-first-joined", + Map.of("time", format.format(new Date(target.getFirstPlayed()))))); return true; } } diff --git a/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java b/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java index be1893c..596ce05 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/SkinBridgeCommand.java @@ -14,6 +14,10 @@ import java.util.Map; public final class SkinBridgeCommand extends BaseCommand implements TabCompleter { + private static final String STATUS_PERMISSION = "essentialsc.command.skin.status"; + private static final String REFRESH_PERMISSION = "essentialsc.command.skin.refresh"; + private static final String OTHERS_PERMISSION = "essentialsc.command.skin.others"; + public SkinBridgeCommand() { super("essentialsc.command.skin"); } @@ -54,11 +58,29 @@ public final class SkinBridgeCommand extends BaseCommand implements TabCompleter } String action = args[0].toLowerCase(Locale.ROOT); + boolean targetsOther = sender instanceof Player playerSender + && !playerSender.getUniqueId().equals(target.getUniqueId()); if (action.equals("status")) { + if (!hasPermission(sender, STATUS_PERMISSION)) { + sendNoPermission(sender, STATUS_PERMISSION); + return true; + } + if (targetsOther && !hasPermission(sender, OTHERS_PERMISSION)) { + sendNoPermission(sender, OTHERS_PERMISSION); + return true; + } sendStatus(sender, target, manager); return true; } if (action.equals("refresh")) { + if (!hasPermission(sender, REFRESH_PERMISSION)) { + sendNoPermission(sender, REFRESH_PERMISSION); + return true; + } + if (targetsOther && !hasPermission(sender, OTHERS_PERMISSION)) { + sendNoPermission(sender, OTHERS_PERMISSION); + return true; + } sendRefreshResult(sender, target, manager.queueSync(target, true)); return true; } @@ -67,11 +89,15 @@ public final class SkinBridgeCommand extends BaseCommand implements TabCompleter return true; } + private boolean hasPermission(CommandSender sender, String permission) { + return !(sender instanceof Player) || sender.hasPermission(permission); + } + + private void sendNoPermission(CommandSender sender, String permission) { + sender.sendMessage(getLang().getPrefixedString("messages.no-permission", Map.of("permission", permission))); + } + 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; @@ -85,6 +111,7 @@ public final class SkinBridgeCommand extends BaseCommand implements TabCompleter 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 EXCLUDED -> sender.sendMessage(getLang().getPrefixedString("skin-bridge.messages.status-excluded", 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)); @@ -97,10 +124,18 @@ public final class SkinBridgeCommand extends BaseCommand implements TabCompleter 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 EXCLUDED -> "skin-bridge.messages.refresh-excluded"; + case QUEUE_FULL -> "skin-bridge.messages.queue-full"; case DEPENDENCY_MISSING -> "skin-bridge.messages.dependency-missing"; case NO_PROVIDERS -> "skin-bridge.messages.no-providers"; + case REFRESH_COOLDOWN -> "skin-bridge.messages.refresh-cooldown"; }; + if (result == SkinBridgeManager.SyncResult.REFRESH_COOLDOWN) { + placeholders = Map.of( + "player", target.getName(), + "seconds", String.valueOf(plugin.getSkinBridgeManager().getRemainingForceRefreshCooldownSeconds(target)) + ); + } sender.sendMessage(getLang().getPrefixedString(messagePath, placeholders)); } @@ -108,11 +143,20 @@ public final class SkinBridgeCommand extends BaseCommand implements TabCompleter 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() + List actions = new ArrayList<>(); + if (hasPermission(sender, STATUS_PERMISSION)) { + actions.add("status"); + } + if (hasPermission(sender, REFRESH_PERMISSION)) { + actions.add("refresh"); + } + return actions.stream() .filter(option -> option.startsWith(partial)) .toList(); } - if (args.length == 2 && (args[0].equalsIgnoreCase("status") || args[0].equalsIgnoreCase("refresh"))) { + if (args.length == 2 && hasPermission(sender, OTHERS_PERMISSION) + && ((args[0].equalsIgnoreCase("status") && hasPermission(sender, STATUS_PERMISSION)) + || (args[0].equalsIgnoreCase("refresh") && hasPermission(sender, REFRESH_PERMISSION)))) { String partial = args[1].toLowerCase(Locale.ROOT); List players = new ArrayList<>(); for (Player onlinePlayer : Bukkit.getOnlinePlayers()) { diff --git a/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java b/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java index 33c0ee3..738ab26 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/TpaCommand.java @@ -66,6 +66,9 @@ public class TpaCommand extends BaseCommand implements TabCompleter { Map.of("seconds", String.valueOf(createdRequest.cooldownSeconds())))); return true; } + if (createdRequest.status() == TeleportRequestManager.CreateRequestStatus.CANCELLED) { + return true; + } TeleportRequestManager.TeleportRequest request = createdRequest.request(); Map placeholders = manager.placeholders(request); diff --git a/src/main/java/cn/infstar/essentialsC/commands/VanishCommand.java b/src/main/java/cn/infstar/essentialsC/commands/VanishCommand.java index a97a062..f05c78b 100644 --- a/src/main/java/cn/infstar/essentialsC/commands/VanishCommand.java +++ b/src/main/java/cn/infstar/essentialsC/commands/VanishCommand.java @@ -1,8 +1,12 @@ package cn.infstar.essentialsC.commands; import cn.infstar.essentialsC.EssentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; +import java.io.File; import java.util.HashSet; import java.util.Set; import java.util.UUID; @@ -10,6 +14,8 @@ import java.util.UUID; public class VanishCommand extends BaseCommand { private static final Set vanishedPlayers = new HashSet<>(); + public static final String SEE_PERMISSION = "essentialsc.vanish.see"; + private static File stateFile; public VanishCommand() { super("essentialsc.command.vanish"); @@ -28,10 +34,30 @@ public class VanishCommand extends BaseCommand { hidePlayerFromAll(plugin, player); player.sendMessage(getLang().getPrefixedString("messages.vanish-enabled")); } + saveState(plugin); return true; } + public static void loadState(EssentialsC plugin) { + stateFile = new File(plugin.getDataFolder(), "vanished-players.yml"); + vanishedPlayers.clear(); + if (!stateFile.exists()) { + return; + } + FileConfiguration state = YamlConfiguration.loadConfiguration(stateFile); + for (String value : state.getStringList("players")) { + try { + vanishedPlayers.add(UUID.fromString(value)); + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("忽略无效的隐身玩家 UUID: " + value); + } + } + } + public static void hideVanishedPlayersFrom(EssentialsC plugin, Player observer) { + if (observer.hasPermission(SEE_PERMISSION)) { + return; + } for (Player vanished : plugin.getServer().getOnlinePlayers()) { if (!observer.equals(vanished) && isVanished(vanished)) { observer.hidePlayer(plugin, vanished); @@ -39,8 +65,21 @@ public class VanishCommand extends BaseCommand { } } - public static void removeVanished(Player player) { - vanishedPlayers.remove(player.getUniqueId()); + public static void restoreVisibility(EssentialsC plugin, Player player, boolean notify) { + if (!vanishedPlayers.remove(player.getUniqueId())) { + return; + } + showPlayerToAll(plugin, player); + saveState(plugin); + if (notify) { + player.sendMessage(EssentialsC.getLangManager().getPrefixedString("messages.vanish-permission-removed")); + } + } + + public static void applyHiddenState(EssentialsC plugin, Player player) { + if (isVanished(player)) { + hidePlayerFromAll(plugin, player); + } } public static void clearAll(EssentialsC plugin) { @@ -54,7 +93,7 @@ public class VanishCommand extends BaseCommand { private static void hidePlayerFromAll(EssentialsC plugin, Player player) { for (Player online : player.getServer().getOnlinePlayers()) { - if (online != player) { + if (online != player && !online.hasPermission(SEE_PERMISSION)) { online.hidePlayer(plugin, player); } } @@ -71,4 +110,17 @@ public class VanishCommand extends BaseCommand { public static boolean isVanished(Player player) { return vanishedPlayers.contains(player.getUniqueId()); } + + private static void saveState(EssentialsC plugin) { + if (stateFile == null) { + stateFile = new File(plugin.getDataFolder(), "vanished-players.yml"); + } + FileConfiguration state = new YamlConfiguration(); + state.set("players", vanishedPlayers.stream().map(UUID::toString).sorted().toList()); + try { + AtomicYamlWriter.save(state, stateFile); + } catch (Exception exception) { + plugin.getLogger().warning("保存 vanished-players.yml 失败: " + exception.getMessage()); + } + } } diff --git a/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java b/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java index ccf8425..f7fc11e 100644 --- a/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java +++ b/src/main/java/cn/infstar/essentialsC/listeners/JeiRecipeSyncListener.java @@ -9,6 +9,8 @@ import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import java.util.Locale; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; /** @@ -16,8 +18,9 @@ import java.util.logging.Level; */ public class JeiRecipeSyncListener implements Listener { + private static final Set WARNED_UNSUPPORTED_VERSIONS = ConcurrentHashMap.newKeySet(); + private final EssentialsC plugin; - private final boolean enabled; private final boolean debug; private final boolean sendPlayerMessage; private final int brandCheckDelayTicks; @@ -29,9 +32,7 @@ public class JeiRecipeSyncListener implements Listener { FileConfiguration config = plugin.getConfig(); config.addDefault("jei-sync.brand-check-delay-ticks", 20); config.options().copyDefaults(true); - plugin.saveConfig(); - this.enabled = config.getBoolean("jei-sync.enabled", true); this.debug = config.getBoolean("debug", false); this.sendPlayerMessage = config.getBoolean("jei-sync.send-player-message", true); this.brandCheckDelayTicks = Math.max(0, config.getInt("jei-sync.brand-check-delay-ticks", 20)); @@ -40,9 +41,6 @@ public class JeiRecipeSyncListener implements Listener { @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { - if (!enabled) { - return; - } if (adapter == null) { if (debug) { plugin.getLogger().warning("当前服务端版本没有可用的 JEI 配方同步适配器。"); @@ -152,6 +150,10 @@ public class JeiRecipeSyncListener implements Listener { } plugin.getLogger().warning("JEI 配方同步适配器类型无效: " + className); } catch (ReflectiveOperationException | LinkageError e) { + if (WARNED_UNSUPPORTED_VERSIONS.add(versionKey)) { + plugin.getLogger().warning("当前 Paper 版本 " + plugin.getServer().getBukkitVersion() + + " 没有可用的 JEI 配方同步适配器;仅 JEI 同步功能已停用。"); + } if (debug) { plugin.getLogger().warning("加载 JEI 配方同步适配器失败: " + className + " (" + e.getMessage() + ")"); } diff --git a/src/main/java/cn/infstar/essentialsC/listeners/MobDropListener.java b/src/main/java/cn/infstar/essentialsC/listeners/MobDropListener.java index 67d1828..df9d102 100644 --- a/src/main/java/cn/infstar/essentialsC/listeners/MobDropListener.java +++ b/src/main/java/cn/infstar/essentialsC/listeners/MobDropListener.java @@ -10,7 +10,7 @@ import org.bukkit.event.entity.EntityDeathEvent; public class MobDropListener implements Listener { private final EssentialsC plugin; - private boolean endermanDropEnabled; + private boolean endermanDropsAllowed; public MobDropListener(EssentialsC plugin) { this.plugin = plugin; @@ -19,16 +19,10 @@ public class MobDropListener implements Listener { private void loadConfig() { FileConfiguration config = plugin.getConfig(); - config.addDefault("mob-drops.enderman.enabled", true); + config.addDefault("mob-drops.enderman.allow-drops", true); config.options().copyDefaults(true); - try { - config.save(plugin.getDataFolder().toPath().resolve("config.yml").toFile()); - } catch (Exception e) { - plugin.getLogger().warning("无法保存配置文件: " + e.getMessage()); - } - - this.endermanDropEnabled = config.getBoolean("mob-drops.enderman.enabled", true); + this.endermanDropsAllowed = config.getBoolean("mob-drops.enderman.allow-drops", true); } @EventHandler @@ -37,13 +31,13 @@ public class MobDropListener implements Listener { return; } - if (!plugin.getConfig().getBoolean("mob-drops.enderman.enabled", true)) { + if (!plugin.getConfig().getBoolean("mob-drops.enderman.allow-drops", true)) { event.getDrops().clear(); } } public void reload() { loadConfig(); - plugin.getLogger().info("生物掉落配置已重载(末影人: " + (endermanDropEnabled ? "开启" : "关闭") + ")"); + plugin.getLogger().info("生物掉落配置已重载(末影人掉落: " + (endermanDropsAllowed ? "允许" : "禁止") + ")"); } } diff --git a/src/main/java/cn/infstar/essentialsC/listeners/MobDropMenuListener.java b/src/main/java/cn/infstar/essentialsC/listeners/MobDropMenuListener.java index 680b92c..4a3e412 100644 --- a/src/main/java/cn/infstar/essentialsC/listeners/MobDropMenuListener.java +++ b/src/main/java/cn/infstar/essentialsC/listeners/MobDropMenuListener.java @@ -2,6 +2,7 @@ package cn.infstar.essentialsC.listeners; import cn.infstar.essentialsC.EssentialsC; import cn.infstar.essentialsC.commands.MobDropCommand; +import cn.infstar.essentialsC.util.AtomicYamlWriter; import org.bukkit.Bukkit; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.entity.Player; @@ -46,11 +47,11 @@ public class MobDropMenuListener implements Listener { private void toggleEndermanDrops(Player player) { FileConfiguration config = plugin.getConfig(); - boolean newValue = !config.getBoolean("mob-drops.enderman.enabled", true); - config.set("mob-drops.enderman.enabled", newValue); + boolean newValue = !config.getBoolean("mob-drops.enderman.allow-drops", true); + config.set("mob-drops.enderman.allow-drops", newValue); try { - config.save(plugin.getDataFolder().toPath().resolve("config.yml").toFile()); + AtomicYamlWriter.save(config, plugin.getDataFolder().toPath().resolve("config.yml").toFile()); } catch (Exception e) { player.sendMessage(EssentialsC.getLangManager().getPrefixedString("messages.mobdrop-save-failed", Map.of("error", e.getMessage()))); diff --git a/src/main/java/cn/infstar/essentialsC/listeners/VanishListener.java b/src/main/java/cn/infstar/essentialsC/listeners/VanishListener.java index c9906fd..4c8eee9 100644 --- a/src/main/java/cn/infstar/essentialsC/listeners/VanishListener.java +++ b/src/main/java/cn/infstar/essentialsC/listeners/VanishListener.java @@ -7,23 +7,49 @@ import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.scheduler.BukkitTask; public final class VanishListener implements Listener { private final EssentialsC plugin; + private final BukkitTask permissionTask; public VanishListener(EssentialsC plugin) { this.plugin = plugin; + VanishCommand.loadState(plugin); + this.permissionTask = plugin.getServer().getScheduler().runTaskTimer(plugin, this::checkPermissions, 40L, 40L); } @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + if (VanishCommand.isVanished(player) && !player.hasPermission("essentialsc.command.vanish")) { + VanishCommand.restoreVisibility(plugin, player, false); + } + if (VanishCommand.isVanished(player)) { + event.joinMessage(null); + plugin.getServer().getScheduler().runTask(plugin, () -> VanishCommand.applyHiddenState(plugin, player)); + } VanishCommand.hideVanishedPlayersFrom(plugin, event.getPlayer()); } @EventHandler public void onPlayerQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); - VanishCommand.removeVanished(player); + if (VanishCommand.isVanished(player)) { + event.quitMessage(null); + } + } + + public void shutdown() { + permissionTask.cancel(); + } + + private void checkPermissions() { + for (Player player : plugin.getServer().getOnlinePlayers()) { + if (VanishCommand.isVanished(player) && !player.hasPermission("essentialsc.command.vanish")) { + VanishCommand.restoreVisibility(plugin, player, true); + } + } } } diff --git a/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceListener.java b/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceListener.java index 3c9dd8a..989e83b 100644 --- a/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceListener.java +++ b/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceListener.java @@ -74,10 +74,12 @@ public final class MaintenanceListener implements Listener { return; } - String address = ""; - InetAddress inetAddress = event.getAddress(); - if (inetAddress != null) { - address = inetAddress.getHostAddress(); + String address = EssentialsC.getLangManager().getString("maintenance.status.address-hidden"); + if (maintenanceManager.shouldIncludeAddressInNotification()) { + InetAddress inetAddress = event.getAddress(); + if (inetAddress != null) { + address = inetAddress.getHostAddress(); + } } String message = EssentialsC.getLangManager().getPrefixedString("maintenance.messages.login-blocked", diff --git a/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceManager.java b/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceManager.java index 417cca3..3e94d73 100644 --- a/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceManager.java +++ b/src/main/java/cn/infstar/essentialsC/maintenance/MaintenanceManager.java @@ -1,6 +1,8 @@ package cn.infstar.essentialsC.maintenance; import cn.infstar.essentialsC.EssentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; +import org.bukkit.Bukkit; import org.bukkit.ChatColor; import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; @@ -10,7 +12,6 @@ import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import java.io.File; -import java.io.IOException; import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; @@ -44,6 +45,7 @@ public final class MaintenanceManager { config.addDefault("bypass-permission", "essentialsc.maintenance.bypass"); config.addDefault("notify.enabled", true); config.addDefault("notify.permission", "essentialsc.maintenance.notify"); + config.addDefault("notify.include-address", false); config.addDefault("whitelist.uuids", List.of()); config.addDefault("whitelist.names", List.of()); config.addDefault("motd.enabled", true); @@ -55,8 +57,10 @@ public final class MaintenanceManager { config.addDefault("bossbar.style", "SOLID"); config.addDefault("bossbar.progress", 1.0D); config.options().copyDefaults(true); - save(); refreshBossBar(); + if (isEnabled()) { + kickUnauthorizedPlayers(); + } } public boolean isEnabled() { @@ -67,6 +71,9 @@ public final class MaintenanceManager { config.set("enabled", enabled); save(); refreshBossBar(); + if (enabled) { + kickUnauthorizedPlayers(); + } } public String getBypassPermission() { @@ -81,6 +88,10 @@ public final class MaintenanceManager { return config.getString("notify.permission", "essentialsc.maintenance.notify"); } + public boolean shouldIncludeAddressInNotification() { + return config.getBoolean("notify.include-address", false); + } + public boolean canJoin(Player player) { return player != null && (player.hasPermission(getBypassPermission()) || isWhitelisted(player)); } @@ -105,6 +116,11 @@ public final class MaintenanceManager { return addWhitelistUuid(uuid.toString()); } + Player onlinePlayer = Bukkit.getPlayerExact(normalized); + if (onlinePlayer != null) { + return addWhitelistUuid(onlinePlayer.getUniqueId().toString()); + } + return addWhitelistName(input); } @@ -119,6 +135,11 @@ public final class MaintenanceManager { return removeWhitelistUuid(uuid.toString()); } + Player onlinePlayer = Bukkit.getPlayerExact(normalized); + if (onlinePlayer != null && removeWhitelistUuid(onlinePlayer.getUniqueId().toString())) { + return true; + } + return removeWhitelistName(input); } @@ -168,10 +189,6 @@ public final class MaintenanceManager { if (bossBar == null || player == null || !player.isOnline()) { return; } - if (canJoin(player)) { - bossBar.removePlayer(player); - return; - } bossBar.addPlayer(player); } @@ -181,6 +198,15 @@ public final class MaintenanceManager { } } + private void kickUnauthorizedPlayers() { + String message = getKickMessage(); + for (Player player : List.copyOf(plugin.getServer().getOnlinePlayers())) { + if (!canJoin(player)) { + player.kickPlayer(message); + } + } + } + public void shutdown() { clearBossBar(); } @@ -255,6 +281,9 @@ public final class MaintenanceManager { config.set(path, new ArrayList<>(entries)); save(); refreshBossBar(); + if (isEnabled()) { + kickUnauthorizedPlayers(); + } } return changed; } @@ -313,8 +342,8 @@ public final class MaintenanceManager { private void save() { try { - config.save(configFile); - } catch (IOException e) { + AtomicYamlWriter.save(config, configFile); + } catch (Exception e) { plugin.getLogger().warning("保存 maintenance.yml 失败: " + e.getMessage()); } } diff --git a/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java b/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java index 3da7e53..c8ab8e3 100644 --- a/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java +++ b/src/main/java/cn/infstar/essentialsC/skinbridge/MineSkinGateway.java @@ -18,20 +18,26 @@ 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 static final int MAX_RATE_LIMIT_RETRIES = 3; private final HttpClient httpClient; private final URI queueUri; private final String apiKey; private final String visibility; private final int timeoutSeconds; + private final long minimumSubmitIntervalMillis; private final String userAgent; + private final Object submissionLock = new Object(); + private long lastSubmissionMillis; - public MineSkinGateway(HttpClient httpClient, String endpoint, String apiKey, String visibility, int timeoutSeconds, String userAgent) { + public MineSkinGateway(HttpClient httpClient, String endpoint, String apiKey, String visibility, int timeoutSeconds, + long minimumSubmitIntervalMillis, String userAgent) { this.httpClient = httpClient; this.queueUri = URI.create(normalizeEndpoint(endpoint) + QUEUE_PATH); this.apiKey = apiKey; this.visibility = normalizeVisibility(visibility); this.timeoutSeconds = timeoutSeconds; + this.minimumSubmitIntervalMillis = minimumSubmitIntervalMillis; this.userAgent = userAgent; } @@ -42,9 +48,15 @@ public final class MineSkinGateway implements SkinBridgeGateway { 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")); + HttpResponse response; + synchronized (submissionLock) { + waitForSubmissionInterval(); + try { + response = submitWithRateLimitRetry(requestBody.toString()); + } finally { + lastSubmissionMillis = System.currentTimeMillis(); + } + } JsonObject body = parseResponse(response); if (response.statusCode() == 200) { return parseGeneratedSkin(body); @@ -71,6 +83,10 @@ public final class MineSkinGateway implements SkinBridgeGateway { URI jobUri = URI.create(queueUri + "/" + jobId); while (System.nanoTime() < deadline) { HttpResponse response = send(HttpRequest.newBuilder(jobUri).GET()); + if (response.statusCode() == 429) { + Thread.sleep(readRetryDelayMillis(response, 0)); + continue; + } JsonObject body = parseResponse(response); if (response.statusCode() != 200) { throw apiError("查询 MineSkin 生成任务", response, body); @@ -89,7 +105,43 @@ public final class MineSkinGateway implements SkinBridgeGateway { } Thread.sleep(POLL_INTERVAL_MILLIS); } - throw new IllegalStateException("MineSkin 生成任务超时,请增大 skin-bridge.yml 中的 mineskin.request-timeout-seconds。"); + throw new IllegalStateException("MineSkin 生成任务超时,请增大 config.yml 中的 skin-bridge.mineskin.request-timeout-seconds。"); + } + + private void waitForSubmissionInterval() throws InterruptedException { + long waitMillis = minimumSubmitIntervalMillis - (System.currentTimeMillis() - lastSubmissionMillis); + if (waitMillis > 0L) { + Thread.sleep(waitMillis); + } + } + + private HttpResponse submitWithRateLimitRetry(String requestBody) throws Exception { + HttpResponse response = null; + for (int attempt = 0; attempt <= MAX_RATE_LIMIT_RETRIES; attempt++) { + response = send(HttpRequest.newBuilder(queueUri) + .POST(HttpRequest.BodyPublishers.ofString(requestBody, StandardCharsets.UTF_8)) + .header("Content-Type", "application/json")); + if (response.statusCode() != 429 || attempt == MAX_RATE_LIMIT_RETRIES) { + return response; + } + Thread.sleep(readRetryDelayMillis(response, attempt)); + } + return response; + } + + private long readRetryDelayMillis(HttpResponse response, int attempt) { + long fallback = Math.min(30_000L, 1000L << Math.min(attempt, 5)); + return response.headers().firstValue("Retry-After") + .map(String::trim) + .filter(value -> value.matches("\\d+")) + .map(value -> { + try { + return Math.min(60_000L, Long.parseLong(value) * 1000L); + } catch (NumberFormatException ignored) { + return fallback; + } + }) + .orElse(fallback); } private HttpResponse send(HttpRequest.Builder builder) throws Exception { diff --git a/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java index 84301a1..656001b 100644 --- a/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java +++ b/src/main/java/cn/infstar/essentialsC/skinbridge/SkinBridgeManager.java @@ -1,6 +1,8 @@ package cn.infstar.essentialsC.skinbridge; import cn.infstar.essentialsC.EssentialsC; +import cn.infstar.essentialsC.util.AtomicYamlWriter; +import com.destroystokyo.paper.profile.ProfileProperty; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -8,16 +10,21 @@ import com.google.gson.JsonParser; import org.bukkit.Bukkit; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import java.io.File; 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.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.time.Duration; import java.util.ArrayList; import java.util.Base64; @@ -25,83 +32,131 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicLong; public final class SkinBridgeManager implements Listener { + private static final int WORKER_THREADS = 2; + private static final int MAX_PENDING_LOOKUPS = 100; + private final EssentialsC plugin; private final HttpClient httpClient; - private final ExecutorService executor; + private final ThreadPoolExecutor executor; + private final File generatedCacheFile; private final ConcurrentMap cache = new ConcurrentHashMap<>(); + private final ConcurrentMap generatedSkinCache = new ConcurrentHashMap<>(); private final ConcurrentMap pendingLookups = new ConcurrentHashMap<>(); + private final ConcurrentMap forceRefreshCooldowns = new ConcurrentHashMap<>(); + private final ConcurrentMap loginSkinUrls = 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 boolean logDetectionResults; private volatile int requestTimeoutSeconds; private volatile int cacheMinutes; + private volatile int forceRefreshCooldownSeconds; + private volatile boolean requireCurrentTextureMatch; private volatile long joinDelayTicks; + private volatile Set excludedUuids = Set.of(); + private volatile Set excludedNames = Set.of(); public SkinBridgeManager(EssentialsC plugin) { this.plugin = plugin; + this.generatedCacheFile = new File(plugin.getDataFolder(), "skin-cache.yml"); this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .followRedirects(HttpClient.Redirect.NEVER) .build(); - this.executor = Executors.newFixedThreadPool(2, new SkinBridgeThreadFactory()); + this.executor = new ThreadPoolExecutor( + WORKER_THREADS, + WORKER_THREADS, + 0L, + TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(MAX_PENDING_LOOKUPS), + new SkinBridgeThreadFactory(), + new ThreadPoolExecutor.AbortPolicy() + ); reload(); + loadGeneratedSkinCache(); } public void reload() { configurationGeneration.incrementAndGet(); - FileConfiguration config = plugin.getFeatureConfigManager().getSkinBridgeConfig(); - addDefaults(config); - config.options().copyDefaults(true); - plugin.getFeatureConfigManager().saveSkinBridgeConfig(); + FileConfiguration config = plugin.getConfig(); - enabled = config.getBoolean("enabled", false); debug = plugin.getConfig().getBoolean("debug", false); - sendPlayerMessage = config.getBoolean("send-player-message", true); - requestTimeoutSeconds = clamp(config.getInt("profile-request-timeout-seconds", 5), 1, 30); - String mineSkinEndpoint = config.getString("mineskin.endpoint", "https://api.mineskin.org"); - String mineSkinApiKey = config.getString("mineskin.api-key", "").trim(); - String mineSkinVisibility = config.getString("mineskin.visibility", "unlisted"); - int mineSkinTimeoutSeconds = clamp(config.getInt("mineskin.request-timeout-seconds", 30), 10, 180); - cacheMinutes = clamp(config.getInt("cache-minutes", 120), 5, 10080); - joinDelayTicks = clamp(config.getLong("join-delay-ticks", 20L), 0, 200); + sendPlayerMessage = config.getBoolean("skin-bridge.send-player-message", true); + logDetectionResults = config.getBoolean("skin-bridge.log-detection-results", 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); + long minimumSubmitIntervalMillis = clamp( + config.getLong("skin-bridge.mineskin.minimum-submit-interval-millis", 1000L), 0L, 10000L); + cacheMinutes = clamp(config.getInt("skin-bridge.cache-minutes", 120), 5, 10080); + forceRefreshCooldownSeconds = clamp(config.getInt("skin-bridge.force-refresh-cooldown-seconds", 30), 0, 3600); + requireCurrentTextureMatch = config.getBoolean("skin-bridge.require-current-texture-match", true); + joinDelayTicks = clamp(config.getLong("skin-bridge.join-delay-ticks", 20L), 0, 200); + excludedUuids = loadNormalizedValues(config, "skin-bridge.exclusions.uuids"); + excludedNames = loadNormalizedValues(config, "skin-bridge.exclusions.names"); providers = loadProviders(config); cache.clear(); - gateway = loadGateway(mineSkinEndpoint, mineSkinApiKey, mineSkinVisibility, mineSkinTimeoutSeconds); + gateway = loadGateway(mineSkinEndpoint, mineSkinApiKey, mineSkinVisibility, + mineSkinTimeoutSeconds, minimumSubmitIntervalMillis); - if (enabled && gateway == null) { + if (gateway == null) { plugin.getLogger().warning("SkinBridge 已启用,但未配置有效的 MineSkin API Key,皮肤同步不会执行。"); } } public void shutdown() { configurationGeneration.incrementAndGet(); - cache.clear(); - pendingLookups.clear(); executor.shutdownNow(); + saveGeneratedSkinCache(); + cache.clear(); + generatedSkinCache.clear(); + pendingLookups.clear(); + forceRefreshCooldowns.clear(); + loginSkinUrls.clear(); } @EventHandler public void onPlayerJoin(PlayerJoinEvent event) { + UUID playerId = event.getPlayer().getUniqueId(); + getCurrentSkinUrl(event.getPlayer()).ifPresentOrElse( + skinUrl -> loginSkinUrls.put(playerId, skinUrl), + () -> loginSkinUrls.remove(playerId) + ); queueSync(event.getPlayer(), false); } + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + loginSkinUrls.remove(event.getPlayer().getUniqueId()); + } + public SyncResult queueSync(Player player, boolean force) { - if (!enabled) { - return SyncResult.FEATURE_DISABLED; + UUID playerId = player.getUniqueId(); + if (isExcluded(player)) { + cache.put(playerId, cached(null, null, State.EXCLUDED)); + sendPlayerNotification(playerId, "skin-bridge.notifications.excluded", Map.of()); + if (logDetectionResults) { + plugin.getLogger().info("SkinBridge 已根据排除名单跳过玩家: " + player.getName()); + } + return SyncResult.EXCLUDED; } if (gateway == null) { return SyncResult.DEPENDENCY_MISSING; @@ -110,8 +165,12 @@ public final class SkinBridgeManager implements Listener { return SyncResult.NO_PROVIDERS; } - UUID playerId = player.getUniqueId(); if (force) { + long now = System.currentTimeMillis(); + long expiresAt = forceRefreshCooldowns.getOrDefault(playerId, 0L); + if (expiresAt > now) { + return SyncResult.REFRESH_COOLDOWN; + } cache.remove(playerId); } @@ -124,12 +183,25 @@ public final class SkinBridgeManager implements Listener { } long lookupGeneration = configurationGeneration.get(); + if (!pendingLookups.containsKey(playerId) && pendingLookups.size() >= MAX_PENDING_LOOKUPS) { + plugin.getLogger().warning("SkinBridge 查询队列已满,已跳过玩家: " + player.getName()); + sendPlayerNotification(playerId, "skin-bridge.notifications.queue-full", Map.of()); + return SyncResult.QUEUE_FULL; + } if (!registerPendingLookup(playerId, lookupGeneration)) { return SyncResult.ALREADY_RUNNING; } + if (force && forceRefreshCooldownSeconds > 0) { + forceRefreshCooldowns.put(playerId, + System.currentTimeMillis() + forceRefreshCooldownSeconds * 1000L); + } String playerName = player.getName(); - Bukkit.getScheduler().runTaskLater(plugin, () -> startLookup(playerId, playerName, lookupGeneration), joinDelayTicks); + String loginSkinUrl = loginSkinUrls.computeIfAbsent(playerId, + ignored -> getCurrentSkinUrl(player).orElse("")); + String currentSkinUrl = loginSkinUrl.isEmpty() ? null : loginSkinUrl; + Bukkit.getScheduler().runTaskLater(plugin, + () -> startLookup(playerId, playerName, currentSkinUrl, lookupGeneration), joinDelayTicks); sendPlayerNotification(playerId, "skin-bridge.notifications.detecting", Map.of()); return SyncResult.QUEUED; } @@ -145,8 +217,9 @@ public final class SkinBridgeManager implements Listener { return new Status(State.UNKNOWN, null); } - public boolean isEnabled() { - return enabled; + public int getRemainingForceRefreshCooldownSeconds(Player player) { + long remaining = forceRefreshCooldowns.getOrDefault(player.getUniqueId(), 0L) - System.currentTimeMillis(); + return remaining <= 0L ? 0 : (int) Math.ceil(remaining / 1000.0D); } public boolean isSkinGatewayAvailable() { @@ -158,9 +231,6 @@ public final class SkinBridgeManager implements Listener { } public String getModuleDetail() { - if (!enabled) { - return "配置未启用"; - } if (gateway == null) { return "缺少 MineSkin API Key 或配置无效"; } @@ -170,36 +240,42 @@ public final class SkinBridgeManager implements Listener { return providers.size() + " 个 Provider 已就绪"; } - private void startLookup(UUID playerId, String playerName, long lookupGeneration) { + private void startLookup(UUID playerId, String playerName, String currentSkinUrl, 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); + try { + executor.execute(() -> { + try { + CachedLookup resolved = resolve(playerId, playerName, currentSkinUrl); + 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 (logDetectionResults) { + 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); } - } 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); - } - }); + }); + } catch (RejectedExecutionException exception) { + pendingLookups.remove(playerId, lookupGeneration); + plugin.getLogger().warning("SkinBridge 查询队列拒绝了玩家任务: " + playerName); + sendPlayerNotification(playerId, "skin-bridge.notifications.queue-full", Map.of()); + } } private boolean registerPendingLookup(UUID playerId, long lookupGeneration) { @@ -217,7 +293,7 @@ public final class SkinBridgeManager implements Listener { } } - private CachedLookup resolve(UUID playerId, String playerName) throws Exception { + private CachedLookup resolve(UUID playerId, String playerName, String currentSkinUrl) throws Exception { Exception lastFailure = null; for (SkinProvider provider : providers) { Optional profile; @@ -238,8 +314,29 @@ public final class SkinBridgeManager implements Listener { } ProviderProfile matchedProfile = profile.get(); - GeneratedSkin generatedSkin = currentGateway.generateSkin(matchedProfile.skinUrl(), matchedProfile.model()); - return cached(provider.id(), generatedSkin, State.EXTERNAL); + if (requireCurrentTextureMatch + && (currentSkinUrl == null || !currentSkinUrl.equals(matchedProfile.skinUrl()))) { + if (debug) { + plugin.getLogger().info("SkinBridge 已忽略与当前登录纹理不一致的 Provider: " + provider.name()); + } + continue; + } + if (logDetectionResults) { + plugin.getLogger().info("SkinBridge 已识别玩家 " + playerName + " 的皮肤来源: " + provider.name()); + } + SkinCacheKey cacheKey = new SkinCacheKey(matchedProfile.skinUrl(), matchedProfile.model()); + GeneratedCacheEntry generatedEntry = generatedSkinCache.get(cacheKey); + GeneratedSkin generatedSkin; + if (generatedEntry != null && !generatedEntry.hasExpired()) { + generatedSkin = generatedEntry.skin(); + } else { + generatedSkinCache.remove(cacheKey); + generatedSkin = currentGateway.generateSkin(matchedProfile.skinUrl(), matchedProfile.model()); + generatedSkinCache.put(cacheKey, new GeneratedCacheEntry(generatedSkin, + System.currentTimeMillis() + Duration.ofMinutes(cacheMinutes).toMillis())); + saveGeneratedSkinCache(); + } + return cached(provider.name(), generatedSkin, State.EXTERNAL); } if (lastFailure != null) { @@ -282,8 +379,8 @@ public final class SkinBridgeManager implements Listener { 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 协议。"); + if (!"https".equalsIgnoreCase(skinUri.getScheme())) { + throw new IllegalStateException(provider.id() + " 返回了非 HTTPS 皮肤 URL。"); } SkinModel model = SkinModel.CLASSIC; @@ -321,8 +418,93 @@ public final class SkinBridgeManager implements Listener { throw new IllegalStateException("profile 不包含有效的皮肤 textures 属性。"); } + private Optional getCurrentSkinUrl(Player player) { + try { + for (ProfileProperty property : player.getPlayerProfile().getProperties()) { + if (!"textures".equals(property.getName())) { + continue; + } + String decoded = new String(Base64.getDecoder().decode(property.getValue()), StandardCharsets.UTF_8); + JsonObject textureData = JsonParser.parseString(decoded).getAsJsonObject(); + if (!textureData.has("textures") || !textureData.get("textures").isJsonObject()) { + continue; + } + JsonObject textures = textureData.getAsJsonObject("textures"); + if (!textures.has("SKIN") || !textures.get("SKIN").isJsonObject()) { + continue; + } + String skinUrl = requireString(textures.getAsJsonObject("SKIN"), "url"); + if ("https".equalsIgnoreCase(URI.create(skinUrl).getScheme())) { + return Optional.of(skinUrl); + } + } + } catch (RuntimeException exception) { + if (debug) { + plugin.getLogger().warning("SkinBridge 无法解析玩家当前纹理: " + exception.getMessage()); + } + } + return Optional.empty(); + } + + private void loadGeneratedSkinCache() { + if (!generatedCacheFile.exists()) { + return; + } + FileConfiguration config = YamlConfiguration.loadConfiguration(generatedCacheFile); + ConfigurationSection entries = config.getConfigurationSection("entries"); + if (entries == null) { + return; + } + long now = System.currentTimeMillis(); + for (String id : entries.getKeys(false)) { + String path = "entries." + id; + try { + String skinUrl = config.getString(path + ".skin-url", ""); + SkinModel model = SkinModel.valueOf(config.getString(path + ".model", "CLASSIC")); + long expiresAt = config.getLong(path + ".expires-at", 0L); + String value = config.getString(path + ".value", ""); + String signature = config.getString(path + ".signature", ""); + if (expiresAt > now && !skinUrl.isBlank() && !value.isBlank() && !signature.isBlank()) { + generatedSkinCache.put(new SkinCacheKey(skinUrl, model), + new GeneratedCacheEntry(new GeneratedSkin(value, signature), expiresAt)); + } + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("忽略无效的 SkinBridge 缓存记录: " + id); + } + } + } + + private synchronized void saveGeneratedSkinCache() { + FileConfiguration config = new YamlConfiguration(); + long now = System.currentTimeMillis(); + generatedSkinCache.entrySet().removeIf(entry -> entry.getValue().expiresAtMillis() <= now); + generatedSkinCache.forEach((key, entry) -> { + String path = "entries." + cacheId(key); + config.set(path + ".skin-url", key.skinUrl()); + config.set(path + ".model", key.model().name()); + config.set(path + ".expires-at", entry.expiresAtMillis()); + config.set(path + ".value", entry.skin().value()); + config.set(path + ".signature", entry.skin().signature()); + }); + try { + AtomicYamlWriter.save(config, generatedCacheFile); + } catch (Exception exception) { + plugin.getLogger().warning("保存 skin-cache.yml 失败: " + exception.getMessage()); + } + } + + private String cacheId(SkinCacheKey key) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest((key.skinUrl() + "\n" + key.model().name()).getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hash); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("当前 Java 环境不支持 SHA-256。", exception); + } + } + private List loadProviders(FileConfiguration config) { - ConfigurationSection providersSection = config.getConfigurationSection("providers"); + ConfigurationSection providersSection = config.getConfigurationSection("skin-bridge.providers"); if (providersSection == null) { return List.of(); } @@ -339,9 +521,19 @@ public final class SkinBridgeManager implements Listener { continue; } try { - URI.create(profileUrl.replace("{uuid}", "00000000000000000000000000000000") + URI profileUri = URI.create(profileUrl.replace("{uuid}", "00000000000000000000000000000000") .replace("{uuid-dashed}", "00000000-0000-0000-0000-000000000000")); - loadedProviders.add(new SkinProvider(key, profileUrl, providerSection.getInt("priority", 100))); + if (!"https".equalsIgnoreCase(profileUri.getScheme())) { + plugin.getLogger().warning("SkinBridge Provider " + key + " 必须使用 HTTPS,已跳过。"); + continue; + } + String configuredName = providerSection.getString("name", key); + String providerName = configuredName == null ? key : configuredName.trim(); + if (providerName.isEmpty()) { + providerName = key; + } + loadedProviders.add(new SkinProvider(key, providerName, profileUrl, + providerSection.getInt("priority", 100))); } catch (IllegalArgumentException exception) { plugin.getLogger().warning("SkinBridge Provider " + key + " 的 profile-url 无效,已跳过。"); } @@ -350,12 +542,14 @@ public final class SkinBridgeManager implements Listener { return List.copyOf(loadedProviders); } - private SkinBridgeGateway loadGateway(String endpoint, String apiKey, String visibility, int timeoutSeconds) { + private SkinBridgeGateway loadGateway(String endpoint, String apiKey, String visibility, int timeoutSeconds, + long minimumSubmitIntervalMillis) { if (apiKey.isBlank()) { return null; } try { return new MineSkinGateway(httpClient, endpoint, apiKey, visibility, timeoutSeconds, + minimumSubmitIntervalMillis, "EssentialsC/" + plugin.getDescription().getVersion() + " SkinBridge"); } catch (Exception | LinkageError exception) { plugin.getLogger().warning("加载 MineSkin SkinBridge 适配器失败: " + exception.getMessage()); @@ -363,17 +557,20 @@ public final class SkinBridgeManager implements Listener { } } - private void addDefaults(FileConfiguration config) { - config.addDefault("config-version", 1); - config.addDefault("enabled", false); - config.addDefault("send-player-message", true); - config.addDefault("profile-request-timeout-seconds", 5); - config.addDefault("mineskin.endpoint", "https://api.mineskin.org"); - config.addDefault("mineskin.api-key", ""); - config.addDefault("mineskin.visibility", "unlisted"); - config.addDefault("mineskin.request-timeout-seconds", 30); - config.addDefault("cache-minutes", 120); - config.addDefault("join-delay-ticks", 20); + private Set loadNormalizedValues(FileConfiguration config, String path) { + Set values = ConcurrentHashMap.newKeySet(); + for (String value : config.getStringList(path)) { + String normalized = value == null ? "" : value.trim().toLowerCase(java.util.Locale.ROOT); + if (!normalized.isEmpty()) { + values.add(normalized); + } + } + return Set.copyOf(values); + } + + private boolean isExcluded(Player player) { + return excludedUuids.contains(player.getUniqueId().toString().toLowerCase(java.util.Locale.ROOT)) + || excludedNames.contains(player.getName().toLowerCase(java.util.Locale.ROOT)); } private void applySkin(UUID playerId, CachedLookup resolved, long lookupGeneration) { @@ -445,7 +642,7 @@ public final class SkinBridgeManager implements Listener { return Math.max(min, Math.min(max, value)); } - private record SkinProvider(String id, String profileUrl, int priority) { + private record SkinProvider(String id, String name, String profileUrl, int priority) { private String resolveProfileUrl(UUID playerId) { return profileUrl.replace("{uuid}", playerId.toString().replace("-", "")) .replace("{uuid-dashed}", playerId.toString()); @@ -455,6 +652,15 @@ public final class SkinBridgeManager implements Listener { private record ProviderProfile(String skinUrl, SkinModel model) { } + private record SkinCacheKey(String skinUrl, SkinModel model) { + } + + private record GeneratedCacheEntry(GeneratedSkin skin, long expiresAtMillis) { + private boolean hasExpired() { + return System.currentTimeMillis() >= expiresAtMillis; + } + } + private record CachedLookup(String providerId, GeneratedSkin skin, State state, long expiresAtMillis) { private boolean hasExpired() { return System.currentTimeMillis() >= expiresAtMillis; @@ -474,9 +680,11 @@ public final class SkinBridgeManager implements Listener { QUEUED, CACHED, ALREADY_RUNNING, - FEATURE_DISABLED, + EXCLUDED, + QUEUE_FULL, DEPENDENCY_MISSING, - NO_PROVIDERS + NO_PROVIDERS, + REFRESH_COOLDOWN } public record Status(State state, String providerId) { @@ -484,6 +692,7 @@ public final class SkinBridgeManager implements Listener { public enum State { EXTERNAL, + EXCLUDED, NOT_EXTERNAL, PENDING, UNKNOWN diff --git a/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java index 2152202..986a517 100644 --- a/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java +++ b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestManager.java @@ -1,7 +1,14 @@ package cn.infstar.essentialsC.teleport; import cn.infstar.essentialsC.EssentialsC; +import cn.infstar.essentialsC.api.event.TeleportEvent; +import cn.infstar.essentialsC.api.event.TeleportRequestReceiveEvent; +import cn.infstar.essentialsC.api.event.TeleportRequestReplyEvent; +import cn.infstar.essentialsC.api.event.TeleportRequestSendEvent; +import cn.infstar.essentialsC.api.event.TeleportWarmupCancelledEvent; +import cn.infstar.essentialsC.api.event.TeleportWarmupEvent; import cn.infstar.essentialsC.commands.VanishCommand; +import cn.infstar.essentialsC.util.AtomicYamlWriter; import net.kyori.adventure.title.Title; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -19,12 +26,10 @@ import org.bukkit.permissions.PermissionAttachmentInfo; import org.bukkit.scheduler.BukkitTask; import java.io.File; -import java.io.IOException; import java.time.Duration; import java.time.Instant; import java.util.ArrayDeque; import java.util.ArrayList; -import java.util.Comparator; import java.util.Deque; import java.util.HashMap; import java.util.HashSet; @@ -53,8 +58,11 @@ public final class TeleportRequestManager implements Listener { private final Set invulnerablePlayers = new HashSet<>(); private final Set ignoringRequests = new HashSet<>(); private final File ignoreFile; + private final File cooldownFile; + private BukkitTask cleanupTask; private int timeoutSeconds; + private int maxPendingRequests; private boolean strictTpaRequests; private boolean strictTpaHereRequests; private int warmupSeconds; @@ -75,11 +83,15 @@ public final class TeleportRequestManager implements Listener { public TeleportRequestManager(EssentialsC plugin) { this.plugin = plugin; this.ignoreFile = new File(plugin.getDataFolder(), "teleport-ignore.yml"); + this.cooldownFile = new File(plugin.getDataFolder(), "teleport-cooldowns.yml"); reload(); + loadCooldowns(); + cleanupTask = Bukkit.getScheduler().runTaskTimer(plugin, this::cleanupExpiredState, 1200L, 1200L); } public void reload() { plugin.getConfig().addDefault("tpa.timeout-seconds", 60); + plugin.getConfig().addDefault("tpa.max-pending-requests", 5); plugin.getConfig().addDefault("tpa.strict-tpa-requests", false); plugin.getConfig().addDefault("tpa.strict-tpahere-requests", true); plugin.getConfig().addDefault("tpa.warmup-seconds", 5); @@ -98,9 +110,10 @@ public final class TeleportRequestManager implements Listener { plugin.getConfig().addDefault("tpa.sounds.complete", "entity.enderman.teleport"); plugin.getConfig().set("tpa.warmup-move-threshold", null); plugin.getConfig().options().copyDefaults(true); - plugin.saveConfig(); timeoutSeconds = Math.max(0, plugin.getConfig().getInt("tpa.timeout-seconds", 60)); + maxPendingRequests = Math.max(1, Math.min(100, + plugin.getConfig().getInt("tpa.max-pending-requests", 5))); strictTpaRequests = plugin.getConfig().getBoolean("tpa.strict-tpa-requests", false); strictTpaHereRequests = plugin.getConfig().getBoolean("tpa.strict-tpahere-requests", true); warmupSeconds = Math.max(0, plugin.getConfig().getInt("tpa.warmup-seconds", 5)); @@ -122,6 +135,11 @@ public final class TeleportRequestManager implements Listener { } public void shutdown() { + if (cleanupTask != null) { + cleanupTask.cancel(); + cleanupTask = null; + } + saveCooldowns(); requests.clear(); sendCooldowns.clear(); acceptCooldowns.clear(); @@ -140,16 +158,6 @@ public final class TeleportRequestManager implements Listener { return new CreateRequestResult(CreateRequestStatus.ON_COOLDOWN, null, sendCooldown.seconds()); } - Optional existingRequest = findIncoming(target, requester.getName()); - if (existingRequest.isPresent() - && existingRequest.get().type() == type - && !existingRequest.get().hasExpired()) { - if (applyCooldown) { - startCooldown(requester, sendCooldowns, sendCooldownSeconds); - } - return new CreateRequestResult(CreateRequestStatus.DUPLICATE, existingRequest.get()); - } - TeleportRequest request = new TeleportRequest( requester.getUniqueId(), requester.getName(), @@ -162,6 +170,14 @@ public final class TeleportRequestManager implements Listener { TeleportRequest.Status.PENDING ); + if (applyCooldown) { + TeleportRequestSendEvent sendEvent = new TeleportRequestSendEvent(requester, request); + Bukkit.getPluginManager().callEvent(sendEvent); + if (sendEvent.isCancelled()) { + return new CreateRequestResult(CreateRequestStatus.CANCELLED, request); + } + } + if (isIgnoringRequests(target) || isVanished(target)) { request.setStatus(TeleportRequest.Status.IGNORED); if (applyCooldown) { @@ -170,8 +186,24 @@ public final class TeleportRequestManager implements Listener { return new CreateRequestResult(CreateRequestStatus.IGNORED, request); } + Optional existingRequest = findIncoming(target, requester.getName()); + if (existingRequest.isPresent() + && existingRequest.get().type() == type + && !existingRequest.get().hasExpired()) { + if (applyCooldown) { + startCooldown(requester, sendCooldowns, sendCooldownSeconds); + } + return new CreateRequestResult(CreateRequestStatus.DUPLICATE, existingRequest.get()); + } + + TeleportRequestReceiveEvent receiveEvent = new TeleportRequestReceiveEvent(target, request); + Bukkit.getPluginManager().callEvent(receiveEvent); + if (receiveEvent.isCancelled()) { + return new CreateRequestResult(CreateRequestStatus.CANCELLED, request); + } Deque targetRequests = requests.computeIfAbsent(target.getUniqueId(), ignored -> new ArrayDeque<>()); - targetRequests.addFirst(request); + pruneExpiredRequests(targetRequests); + TeleportRequestQueuePolicy.addFirstBounded(targetRequests, request, maxPendingRequests); if (applyCooldown) { startCooldown(requester, sendCooldowns, sendCooldownSeconds); } @@ -197,15 +229,6 @@ public final class TeleportRequestManager implements Listener { .findFirst(); } - public Optional findOutgoing(Player requester, String targetName) { - return requests.values().stream() - .flatMap(Deque::stream) - .filter(request -> request.requesterId().equals(requester.getUniqueId())) - .filter(request -> !request.hasExpired()) - .filter(request -> targetName == null || request.targetName().equalsIgnoreCase(targetName)) - .max(Comparator.comparingLong(TeleportRequest::expiresAtMillis)); - } - public List getIncomingRequesterNames(Player target, String partial) { String partialLower = partial == null ? "" : partial.toLowerCase(); return requests.getOrDefault(target.getUniqueId(), new ArrayDeque<>()).stream() @@ -242,6 +265,11 @@ public final class TeleportRequestManager implements Listener { } request.setStatus(TeleportRequest.Status.ACCEPTED); + TeleportRequestReplyEvent replyEvent = new TeleportRequestReplyEvent(target, request); + Bukkit.getPluginManager().callEvent(replyEvent); + if (replyEvent.isCancelled()) { + return TeleportResult.CANCELLED; + } Map placeholders = placeholders(request); target.sendMessage(plugin.getLangManager().getPrefixedComponent("tpa.messages.accepted-target", placeholders)); @@ -273,8 +301,9 @@ public final class TeleportRequestManager implements Listener { return TeleportResult.ACCEPTED_WITHOUT_TELEPORT; } - startWarmup(plan, playerWarmupSeconds); - return TeleportResult.WARMING_UP; + return startWarmup(plan, playerWarmupSeconds) + ? TeleportResult.WARMING_UP + : TeleportResult.CANCELLED; } public TeleportResult deny(Player target, TeleportRequest request) { @@ -285,6 +314,11 @@ public final class TeleportRequestManager implements Listener { return TeleportResult.EXPIRED; } request.setStatus(TeleportRequest.Status.DECLINED); + TeleportRequestReplyEvent replyEvent = new TeleportRequestReplyEvent(target, request); + Bukkit.getPluginManager().callEvent(replyEvent); + if (replyEvent.isCancelled()) { + return TeleportResult.CANCELLED; + } Map placeholders = placeholders(request); target.sendMessage(plugin.getLangManager().getPrefixedComponent("tpa.messages.denied-target", placeholders)); Player requester = Bukkit.getPlayer(request.requesterId()); @@ -296,20 +330,6 @@ public final class TeleportRequestManager implements Listener { return TeleportResult.SUCCESS; } - public boolean cancel(TeleportRequest request) { - Deque targetRequests = requests.get(request.targetId()); - if (targetRequests == null) { - return false; - } - - boolean removed = targetRequests.removeIf(candidate -> - candidate.requesterName().equalsIgnoreCase(request.requesterName())); - if (targetRequests.isEmpty()) { - requests.remove(request.targetId()); - } - return removed; - } - public boolean isIgnoringRequests(Player player) { return ignoringRequests.contains(player.getUniqueId()); } @@ -422,13 +442,15 @@ public final class TeleportRequestManager implements Listener { return new TeleportPlan( requester, strictTpaRequests ? currentTarget.getLocation().clone() : null, - strictTpaRequests ? null : currentTarget.getUniqueId() + strictTpaRequests ? null : currentTarget.getUniqueId(), + request ); } return new TeleportPlan( currentTarget, strictTpaHereRequests ? request.requesterLocation().clone() : null, - strictTpaHereRequests ? null : requester.getUniqueId() + strictTpaHereRequests ? null : requester.getUniqueId(), + request ); } @@ -452,9 +474,14 @@ public final class TeleportRequestManager implements Listener { } } - private void startWarmup(TeleportPlan plan, int playerWarmupSeconds) { + private boolean startWarmup(TeleportPlan plan, int playerWarmupSeconds) { Player teleporter = plan.teleporter(); UUID uuid = teleporter.getUniqueId(); + TeleportWarmupEvent warmupEvent = new TeleportWarmupEvent(teleporter, plan.request(), playerWarmupSeconds); + Bukkit.getPluginManager().callEvent(warmupEvent); + if (warmupEvent.isCancelled()) { + return false; + } PendingTeleport pendingTeleport = new PendingTeleport( plan, teleporter.getLocation().clone(), @@ -466,6 +493,7 @@ public final class TeleportRequestManager implements Listener { warmups.put(uuid, pendingTeleport); teleporter.sendMessage(plugin.getLangManager().getPrefixedComponent("tpa.messages.warmup-start", Map.of("seconds", String.valueOf(playerWarmupSeconds)))); + return true; } private void tickWarmup(UUID teleporterId) { @@ -476,16 +504,18 @@ public final class TeleportRequestManager implements Listener { Player teleporter = Bukkit.getPlayer(teleporterId); if (teleporter == null || !teleporter.isOnline()) { - cancelWarmup(teleporterId, null, false); + cancelWarmup(teleporterId, null, false, TeleportWarmupCancelledEvent.Reason.PLAYER_QUIT); return; } if (cancelWarmupOnDamage && warmupDamagedPlayers.contains(teleporterId)) { - cancelWarmup(teleporterId, "tpa.messages.warmup-cancelled-damage", true); + cancelWarmup(teleporterId, "tpa.messages.warmup-cancelled-damage", true, + TeleportWarmupCancelledEvent.Reason.PLAYER_DAMAGE); return; } if (cancelWarmupOnMove && hasMoved(pendingTeleport.startLocation(), teleporter.getLocation())) { - cancelWarmup(teleporterId, "tpa.messages.warmup-cancelled-move", true); + cancelWarmup(teleporterId, "tpa.messages.warmup-cancelled-move", true, + TeleportWarmupCancelledEvent.Reason.PLAYER_MOVE); return; } @@ -531,6 +561,13 @@ public final class TeleportRequestManager implements Listener { return TeleportResult.TARGET_OFFLINE; } + TeleportEvent teleportEvent = new TeleportEvent(teleporter, plan.request(), destination); + Bukkit.getPluginManager().callEvent(teleportEvent); + if (teleportEvent.isCancelled()) { + return TeleportResult.CANCELLED; + } + destination = teleportEvent.getDestination(); + teleporter.leaveVehicle(); teleporter.eject(); teleporter.setFallDistance(0.0F); @@ -646,6 +683,11 @@ public final class TeleportRequestManager implements Listener { } private void cancelWarmup(UUID teleporterId, String messagePath, boolean playSound) { + cancelWarmup(teleporterId, messagePath, playSound, null); + } + + private void cancelWarmup(UUID teleporterId, String messagePath, boolean playSound, + TeleportWarmupCancelledEvent.Reason reason) { PendingTeleport pendingTeleport = warmups.remove(teleporterId); warmupDamagedPlayers.remove(teleporterId); if (pendingTeleport == null) { @@ -663,12 +705,18 @@ public final class TeleportRequestManager implements Listener { playConfiguredSound(player, cancelSound, 1.0F, 1.0F); } } + if (player != null && reason != null) { + int duration = pendingTeleport.initialSeconds(); + Bukkit.getPluginManager().callEvent(new TeleportWarmupCancelledEvent( + player, pendingTeleport.plan().request(), duration, + Math.max(0, duration - pendingTeleport.remainingSeconds()), reason)); + } } private void cancelAllWarmups() { List warmingPlayers = new ArrayList<>(warmups.keySet()); for (UUID warmingPlayer : warmingPlayers) { - cancelWarmup(warmingPlayer, null, false); + cancelWarmup(warmingPlayer, null, false, TeleportWarmupCancelledEvent.Reason.PLUGIN_SHUTDOWN); } } @@ -728,8 +776,13 @@ public final class TeleportRequestManager implements Listener { @EventHandler private void onPlayerQuit(PlayerQuitEvent event) { UUID uuid = event.getPlayer().getUniqueId(); - cancelWarmup(uuid, null, false); + cancelWarmup(uuid, null, false, TeleportWarmupCancelledEvent.Reason.PLAYER_QUIT); clearInvulnerability(uuid); + requests.remove(uuid); + requests.values().removeIf(targetRequests -> { + targetRequests.removeIf(request -> request.requesterId().equals(uuid)); + return targetRequests.isEmpty(); + }); } private boolean hasMoved(Location start, Location current) { @@ -747,6 +800,71 @@ public final class TeleportRequestManager implements Listener { return player.getVelocity().length() >= MOVEMENT_THRESHOLD; } + private void pruneExpiredRequests(Deque targetRequests) { + long retentionMillis = Math.max(60_000L, timeoutSeconds * 1000L); + long now = System.currentTimeMillis(); + targetRequests.removeIf(request -> TeleportRequestQueuePolicy.shouldPrune( + request.expiresAtMillis(), now, retentionMillis)); + } + + private void cleanupExpiredState() { + requests.entrySet().removeIf(entry -> { + pruneExpiredRequests(entry.getValue()); + return entry.getValue().isEmpty(); + }); + long now = System.currentTimeMillis(); + sendCooldowns.values().removeIf(expiresAt -> expiresAt <= now); + acceptCooldowns.values().removeIf(expiresAt -> expiresAt <= now); + saveCooldowns(); + } + + private void loadCooldowns() { + if (!cooldownFile.exists()) { + return; + } + FileConfiguration cooldownConfig = YamlConfiguration.loadConfiguration(cooldownFile); + loadCooldownMap(cooldownConfig, "send", sendCooldowns); + loadCooldownMap(cooldownConfig, "accept", acceptCooldowns); + } + + private void loadCooldownMap(FileConfiguration config, String path, Map destination) { + if (!config.isConfigurationSection(path)) { + return; + } + long now = System.currentTimeMillis(); + for (String key : config.getConfigurationSection(path).getKeys(false)) { + try { + UUID uuid = UUID.fromString(key); + long expiresAt = config.getLong(path + "." + key, 0L); + if (expiresAt > now) { + destination.put(uuid, expiresAt); + } + } catch (IllegalArgumentException ignored) { + plugin.getLogger().warning("忽略无效的 TPA 冷却记录 UUID: " + key); + } + } + } + + private void saveCooldowns() { + FileConfiguration cooldownConfig = new YamlConfiguration(); + long now = System.currentTimeMillis(); + saveCooldownMap(cooldownConfig, "send", sendCooldowns, now); + saveCooldownMap(cooldownConfig, "accept", acceptCooldowns, now); + try { + AtomicYamlWriter.save(cooldownConfig, cooldownFile); + } catch (Exception exception) { + plugin.getLogger().warning("保存 teleport-cooldowns.yml 失败: " + exception.getMessage()); + } + } + + private void saveCooldownMap(FileConfiguration config, String path, Map source, long now) { + source.forEach((uuid, expiresAt) -> { + if (expiresAt > now) { + config.set(path + "." + uuid, expiresAt); + } + }); + } + private void loadIgnoringRequests() { ignoringRequests.clear(); if (!ignoreFile.exists()) { @@ -776,8 +894,8 @@ public final class TeleportRequestManager implements Listener { ignoreConfig.set("ignored." + uuid, true); } try { - ignoreConfig.save(ignoreFile); - } catch (IOException e) { + AtomicYamlWriter.save(ignoreConfig, ignoreFile); + } catch (Exception e) { plugin.getLogger().warning("保存 teleport-ignore.yml 失败: " + e.getMessage()); } } @@ -786,7 +904,8 @@ public final class TeleportRequestManager implements Listener { SUCCESS, DUPLICATE, IGNORED, - ON_COOLDOWN + ON_COOLDOWN, + CANCELLED } public record TeleportResult(Status status, int cooldownSeconds) { @@ -797,6 +916,7 @@ public final class TeleportRequestManager implements Listener { public static final TeleportResult TARGET_OFFLINE = new TeleportResult(Status.TARGET_OFFLINE, 0); public static final TeleportResult ALREADY_WARMING_UP = new TeleportResult(Status.ALREADY_WARMING_UP, 0); public static final TeleportResult ACCEPTED_WITHOUT_TELEPORT = new TeleportResult(Status.ACCEPTED_WITHOUT_TELEPORT, 0); + public static final TeleportResult CANCELLED = new TeleportResult(Status.CANCELLED, 0); public static TeleportResult onCooldown(int seconds) { return new TeleportResult(Status.ON_COOLDOWN, seconds); @@ -810,7 +930,8 @@ public final class TeleportRequestManager implements Listener { TARGET_OFFLINE, ALREADY_WARMING_UP, ACCEPTED_WITHOUT_TELEPORT, - ON_COOLDOWN + ON_COOLDOWN, + CANCELLED } } @@ -902,7 +1023,8 @@ public final class TeleportRequestManager implements Listener { } } - private record TeleportPlan(Player teleporter, Location fixedDestination, UUID dynamicTargetId) { + private record TeleportPlan(Player teleporter, Location fixedDestination, UUID dynamicTargetId, + TeleportRequest request) { } private record OptionalIntCooldown(boolean active, int seconds) { @@ -914,12 +1036,14 @@ public final class TeleportRequestManager implements Listener { private static final class PendingTeleport { private final TeleportPlan plan; private final Location startLocation; + private final int initialSeconds; private int remainingSeconds; private BukkitTask task; private PendingTeleport(TeleportPlan plan, Location startLocation, int remainingSeconds) { this.plan = plan; this.startLocation = startLocation; + this.initialSeconds = remainingSeconds; this.remainingSeconds = remainingSeconds; } @@ -935,6 +1059,10 @@ public final class TeleportRequestManager implements Listener { return remainingSeconds; } + private int initialSeconds() { + return initialSeconds; + } + private void decrementRemainingSeconds() { remainingSeconds--; } diff --git a/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicy.java b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicy.java new file mode 100644 index 0000000..7d839ee --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicy.java @@ -0,0 +1,20 @@ +package cn.infstar.essentialsC.teleport; + +import java.util.Deque; + +final class TeleportRequestQueuePolicy { + + private TeleportRequestQueuePolicy() { + } + + static void addFirstBounded(Deque queue, T value, int maximumSize) { + while (queue.size() >= maximumSize) { + queue.removeLast(); + } + queue.addFirst(value); + } + + static boolean shouldPrune(long expiresAtMillis, long nowMillis, long retentionMillis) { + return expiresAtMillis <= nowMillis - retentionMillis; + } +} diff --git a/src/main/java/cn/infstar/essentialsC/tpsbar/TpsBarManager.java b/src/main/java/cn/infstar/essentialsC/tpsbar/TpsBarManager.java index e97be10..7e60520 100644 --- a/src/main/java/cn/infstar/essentialsC/tpsbar/TpsBarManager.java +++ b/src/main/java/cn/infstar/essentialsC/tpsbar/TpsBarManager.java @@ -7,7 +7,7 @@ import org.bukkit.boss.BarColor; import org.bukkit.boss.BarStyle; import org.bukkit.boss.BossBar; import org.bukkit.command.CommandMap; -import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.command.PluginIdentifiableCommand; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -41,9 +41,9 @@ public final class TpsBarManager implements Listener, TpsBarService { private final Map activeBars = new java.util.HashMap<>(); private BukkitTask updateTask; - private Mode mode; private boolean pluginCommandEnabled; private boolean nativeCommandAvailable; + private boolean nativeDetectionInitialized; private String titleFormat; private String enabledSelfMessage; private String disabledSelfMessage; @@ -54,12 +54,9 @@ public final class TpsBarManager implements Listener, TpsBarService { private String noTargetsMessage; private String nativeDetectedMessage; private String pluginEnabledMessage; - private String pluginForcedButNativeExistsMessage; - private String modeChangedReloadMessage; public TpsBarManager(EssentialsC plugin) { this.plugin = plugin; - addConfigDefaults(); reloadSettings(); } @@ -75,11 +72,8 @@ public final class TpsBarManager implements Listener, TpsBarService { @Override public void reloadSettings() { - FileConfiguration config = plugin.getConfig(); var lang = EssentialsC.getLangManager(); - Mode previousMode = this.mode; - this.mode = Mode.fromString(config.getString("tpsbar.mode", "auto")); this.titleFormat = lang.getString("tpsbar.title-format"); this.enabledSelfMessage = lang.getString("tpsbar.messages.enabled-self"); this.disabledSelfMessage = lang.getString("tpsbar.messages.disabled-self"); @@ -90,26 +84,15 @@ public final class TpsBarManager implements Listener, TpsBarService { this.noTargetsMessage = lang.getString("tpsbar.messages.no-targets"); this.nativeDetectedMessage = lang.getString("tpsbar.messages.native-detected"); this.pluginEnabledMessage = lang.getString("tpsbar.messages.plugin-enabled"); - this.pluginForcedButNativeExistsMessage = lang.getString("tpsbar.messages.plugin-forced-but-native-exists"); - this.modeChangedReloadMessage = lang.getString("tpsbar.messages.mode-changed-reload"); - this.nativeCommandAvailable = detectNativeTpsBar(); - this.pluginCommandEnabled = switch (mode) { - case OFF -> false; - case AUTO -> !nativeCommandAvailable; - case ON -> !nativeCommandAvailable; - }; - - if (previousMode != null && previousMode != mode) { - plugin.getLogger().info(stripColor(modeChangedReloadMessage)); + if (!nativeDetectionInitialized) { + this.nativeCommandAvailable = detectNativeTpsBar(); + this.nativeDetectionInitialized = true; } + this.pluginCommandEnabled = !nativeCommandAvailable; if (nativeCommandAvailable) { - if (mode == Mode.AUTO) { - plugin.getLogger().info(stripColor(nativeDetectedMessage)); - } else if (mode == Mode.ON) { - plugin.getLogger().warning(stripColor(pluginForcedButNativeExistsMessage)); - } + plugin.getLogger().info(stripColor(nativeDetectedMessage)); } else if (pluginCommandEnabled) { plugin.getLogger().info(stripColor(pluginEnabledMessage)); } @@ -320,19 +303,19 @@ public final class TpsBarManager implements Listener, TpsBarService { try { CommandMap commandMap = Bukkit.getCommandMap(); org.bukkit.command.Command command = commandMap.getCommand("tpsbar"); - return command != null; + if (command == null) { + return false; + } + if (command instanceof PluginIdentifiableCommand pluginCommand + && pluginCommand.getPlugin().equals(plugin)) { + return false; + } + return !command.getClass().getName().startsWith("cn.infstar.essentialsC."); } catch (Exception ignored) { return false; } } - private void addConfigDefaults() { - FileConfiguration config = plugin.getConfig(); - config.addDefault("tpsbar.mode", "auto"); - config.options().copyDefaults(true); - plugin.saveConfig(); - } - private String prefixed(String message) { return EssentialsC.getLangManager().getPrefix() + ChatColor.translateAlternateColorCodes('&', message); } @@ -371,20 +354,4 @@ public final class TpsBarManager implements Listener, TpsBarService { return Math.max(min, Math.min(max, value)); } - private enum Mode { - OFF, - AUTO, - ON; - - private static Mode fromString(String value) { - if (value == null) { - return AUTO; - } - return switch (value.toLowerCase(Locale.ROOT)) { - case "off", "false", "disabled" -> OFF; - case "on", "enabled", "plugin" -> ON; - default -> AUTO; - }; - } - } } diff --git a/src/main/java/cn/infstar/essentialsC/util/AtomicYamlWriter.java b/src/main/java/cn/infstar/essentialsC/util/AtomicYamlWriter.java new file mode 100644 index 0000000..3097f47 --- /dev/null +++ b/src/main/java/cn/infstar/essentialsC/util/AtomicYamlWriter.java @@ -0,0 +1,37 @@ +package cn.infstar.essentialsC.util; + +import org.bukkit.configuration.file.FileConfiguration; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; + +public final class AtomicYamlWriter { + + private AtomicYamlWriter() { + } + + public static void save(FileConfiguration configuration, File targetFile) throws IOException { + Path target = targetFile.toPath().toAbsolutePath(); + Path parent = target.toAbsolutePath().getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + + Path temporary = Files.createTempFile(parent, targetFile.getName(), ".tmp"); + try { + Files.writeString(temporary, configuration.saveToString(), StandardCharsets.UTF_8); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (AtomicMoveNotSupportedException ignored) { + Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index e745059..ce3173b 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,5 +1,5 @@ # EssentialsC 主配置 -# 其他配置:modules.yml、maintenance.yml、skin-bridge.yml、blocks-menu.yml +# 其他配置:modules.yml、maintenance.yml、blocks-menu.yml # 消息与颜色:lang/<语言>.yml # 修改后使用 /essc reload 重载;命令注册状态发生变化时建议重启服务器。 @@ -17,13 +17,8 @@ admin-mode: actionbar: interval-ticks: 40 -# TPS 状态栏:off(关闭)/ auto(自动检测)/ on(请求启用) -tpsbar: - mode: auto - -# JEI 配方同步修复;同时受 modules.yml 中的模块开关控制。 +# JEI 配方同步修复;是否加载由 modules.yml 控制。 jei-sync: - enabled: true # 玩家加入后延迟多少 tick 检测客户端品牌。 brand-check-delay-ticks: 20 send-player-message: true @@ -32,12 +27,14 @@ jei-sync: mob-drops: enderman: # 允许末影人掉落搬运的方块。 - enabled: true + allow-drops: true # TPA 传送请求,行为参考 HuskHomes。 tpa: # 请求有效时间,单位为秒。 timeout-seconds: 60 + # 每名接收者最多保留的待处理请求数;参考 EssentialsX,超出时移除最早请求。 + max-pending-requests: 5 # 位置规则 # /tpa:使用目标接受请求时的位置快照。 @@ -70,3 +67,56 @@ tpa: warmup: "block.note_block.banjo" cancelled: "entity.item.break" complete: "entity.enderman.teleport" + +# SkinBridge 外置皮肤站检测与同步;同时受 modules.yml 中的模块开关控制。 +skin-bridge: + # 是否加载由 modules.yml 控制;加载前必须填写 MineSkin API Key。 + # 是否向玩家发送检测与同步结果。 + send-player-message: true + # 是否在控制台记录每次来源检测结果。 + log-detection-results: true + # 玩家加入后延迟多少 tick 开始检测。 + join-delay-ticks: 20 + # 检测结果与生成皮肤的内存缓存时间,单位为分钟。 + cache-minutes: 120 + # 强制刷新同一玩家皮肤的冷却时间,单位为秒。 + force-refresh-cooldown-seconds: 30 + # 仅当 Provider 纹理与玩家登录时携带的纹理一致时,才确认其来自该皮肤站。 + require-current-texture-match: true + # 查询皮肤站 Profile 的超时时间,单位为秒。 + profile-request-timeout-seconds: 5 + + # 排除名单中的玩家不会查询皮肤站,也不会修改当前皮肤。 + exclusions: + uuids: [] + names: [] + + mineskin: + endpoint: "https://api.mineskin.org" + # 只应填写在服务器运行目录中,禁止提交真实密钥。 + api-key: "" + # 可选值:public / unlisted / private + visibility: "unlisted" + # 等待 MineSkin 队列完成的最长时间,单位为秒。 + request-timeout-seconds: 30 + # 两次 MineSkin 生成任务之间的最小间隔,单位为毫秒。 + minimum-submit-interval-millis: 1000 + + # 数值越小,Provider 查询优先级越高;name 会显示在日志和状态命令中。 + providers: + infstar-mc: + enabled: true + name: "InfstarMC" + priority: 10 + profile-url: "https://skin.infstar.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" + littleskin: + enabled: true + name: "LittleSkin" + priority: 20 + profile-url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" + custom-blessing-skin: + # 自建 Blessing Skin 示例。启用前请修改 name 和 profile-url。 + enabled: false + name: "Blessing Skin" + priority: 30 + profile-url: "https://skin.example.com/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index d3de282..8056507 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -28,8 +28,15 @@ messages: glow-usage: '<#FF3300>Error: <#FF7E5E>Usage: /glow [on|off|toggle]' vanish-enabled: '<#00FB9A>You are now vanished.' vanish-disabled: '<#FF7E5E>You are no longer vanished.' + vanish-permission-removed: '<#FFC43B>Your vanish permission was removed, so vanish mode was disabled.' seen-usage: '<#FF3300>Error: <#FF7E5E>Usage: /seen [player]' seen-usage-console: '<#FF3300>Error: <#FF7E5E>Usage: /seen [player]' + seen-header: '<#00FB9A>Player information: {player}' + seen-status-online: 'Status: <#00FB9A>Online' + seen-status-offline: 'Status: <#FF7E5E>Offline' + seen-world: 'World: {world}' + seen-last-online: 'Last online: {time}' + seen-first-joined: 'First joined: {time}' player-not-found: '<#FF3300>Error: <#FF7E5E>Could not find the player {player}.' no-permission-others: '<#FF3300>Error: <#FF7E5E>You do not have permission to affect other players.' @@ -139,20 +146,25 @@ skin-bridge: notifications: detecting: '<#00FB9A>Detecting your skin source…' synced: '<#00FB9A>Skin synchronized through {provider}.' + excluded: 'You are excluded from SkinBridge detection; your current skin was kept.' + queue-full: '<#FFC43B>Skin detection is busy; this check was skipped.' not-external: 'No external skin provider matched; your current skin was kept.' failed: '<#FF3300>Error: <#FF7E5E>Skin detection or synchronization failed. Please try again later.' messages: usage: '<#FF3300>Error: <#FF7E5E>Usage: /essc skin [status|refresh] [player]' - feature-disabled: '<#FF3300>Error: <#FF7E5E>SkinBridge is disabled in skin-bridge.yml.' - dependency-missing: '<#FF3300>Error: <#FF7E5E>No valid MineSkin API key is configured in skin-bridge.yml.' + dependency-missing: '<#FF3300>Error: <#FF7E5E>No valid MineSkin API key is configured in config.yml.' no-providers: '<#FF3300>Error: <#FF7E5E>No valid SkinBridge provider is enabled.' status-external: '<#00FB9A>{player} is identified as an external skin provider player. Provider: {provider}' + status-excluded: '{player} is excluded from SkinBridge detection.' status-not-external: '{player} does not match any configured external skin provider.' status-pending: '<#FFC43B>SkinBridge is still detecting {player}''s login source.' status-unknown: '{player} has not been checked by SkinBridge yet.' refresh-queued: '<#00FB9A>SkinBridge is detecting and refreshing {player}''s skin.' refresh-cached: '{player} is using a valid skin cache entry.' refresh-running: '<#FFC43B>SkinBridge is already processing {player}.' + refresh-excluded: '{player} is excluded from SkinBridge detection; no skin was changed.' + refresh-cooldown: '<#FFC43B>Wait {seconds} seconds before refreshing {player} again.' + queue-full: '<#FFC43B>The SkinBridge lookup queue is full. Please try again later.' blocks-menu: title: '<#00FB9A>Shortcut Menu' @@ -214,6 +226,7 @@ admin-mode: enabled: '<#00FB9A>Admin mode enabled; normal inventory has been saved.' disabled: '<#FF7E5E>Admin mode disabled; normal inventory has been restored.' crash-restored: '<#FFC43B>The previous admin mode session has been restored safely.' + save-failed: '<#FF3300>Error: <#FF7E5E>Could not safely save your inventory; admin mode was not enabled.' tpsbar: title-format: '<#00FB9A>TPS: {tps_1m} | <#00FB9A>MSPT: {mspt} | <#00FB9A>Ping: {ping}' @@ -227,8 +240,6 @@ tpsbar: no-targets: '<#FF3300>Error: <#FF7E5E>No matching players were found to toggle.' native-detected: 'Detected a native /tpsbar command on this server; skipped plugin implementation.' plugin-enabled: 'No native /tpsbar command was detected; plugin implementation enabled.' - plugin-forced-but-native-exists: '<#FFC43B>The configuration requests the plugin TPSBar, but a native /tpsbar already exists, so the plugin implementation was skipped to avoid conflicts.' - mode-changed-reload: '<#FFC43B>TPSBar mode has been updated, but command registration changes require a server restart to fully take effect.' mobdrops-menu: title: '<#00FB9A>Mob Drop Control' @@ -244,6 +255,7 @@ maintenance: status: enabled: '<#FF7E5E>Enabled' disabled: '<#00FB9A>Disabled' + address-hidden: 'hidden' messages: enabled: '<#00FB9A>Maintenance mode enabled.' disabled: '<#FF7E5E>Maintenance mode disabled.' diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index 2e6fcb3..a544181 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -28,8 +28,15 @@ messages: glow-usage: '<#FF3300>错误: <#FF7E5E>用法: /glow [on|off|toggle]' vanish-enabled: '<#00FB9A>你已进入隐身模式.' vanish-disabled: '<#FF7E5E>你已退出隐身模式.' + vanish-permission-removed: '<#FFC43B>你的隐身权限已被移除,隐身模式已自动关闭.' seen-usage: '<#FF3300>错误: <#FF7E5E>用法: /seen [玩家]' seen-usage-console: '<#FF3300>错误: <#FF7E5E>用法: /seen [玩家]' + seen-header: '<#00FB9A>玩家信息: {player}' + seen-status-online: '状态: <#00FB9A>在线' + seen-status-offline: '状态: <#FF7E5E>离线' + seen-world: '所在世界: {world}' + seen-last-online: '最后在线: {time}' + seen-first-joined: '首次加入: {time}' player-not-found: '<#FF3300>错误: <#FF7E5E>没有找到玩家 {player}.' no-permission-others: '<#FF3300>错误: <#FF7E5E>你没有权限影响其他玩家.' @@ -139,20 +146,25 @@ skin-bridge: notifications: detecting: '<#00FB9A>正在检测你的皮肤来源…' synced: '<#00FB9A>已通过 {provider} 同步皮肤.' + excluded: '你位于 SkinBridge 排除名单中,已保留当前皮肤.' + queue-full: '<#FFC43B>当前皮肤检测任务较多,已跳过本次检测.' not-external: '未匹配外置皮肤站,已保留当前皮肤.' failed: '<#FF3300>错误: <#FF7E5E>皮肤检测或同步失败,请稍后重试.' messages: usage: '<#FF3300>错误: <#FF7E5E>用法: /essc skin [status|refresh] [玩家]' - feature-disabled: '<#FF3300>错误: <#FF7E5E>SkinBridge 在 skin-bridge.yml 中未启用.' - dependency-missing: '<#FF3300>错误: <#FF7E5E>未在 skin-bridge.yml 中配置有效的 MineSkin API Key.' + dependency-missing: '<#FF3300>错误: <#FF7E5E>未在 config.yml 中配置有效的 MineSkin API Key.' no-providers: '<#FF3300>错误: <#FF7E5E>没有启用有效的 SkinBridge Provider.' status-external: '<#00FB9A>{player} 已识别为外置皮肤站玩家. Provider: {provider}' + status-excluded: '{player} 位于 SkinBridge 排除名单中.' status-not-external: '{player} 未匹配任何已配置的外置皮肤站.' status-pending: '<#FFC43B>{player} 的皮肤站来源正在检测中.' status-unknown: '{player} 尚未进行 SkinBridge 检测.' refresh-queued: '<#00FB9A>已开始重新检测并刷新 {player} 的皮肤.' refresh-cached: '{player} 正在使用有效的皮肤缓存.' refresh-running: '<#FFC43B>{player} 的皮肤检测任务仍在运行.' + refresh-excluded: '{player} 位于 SkinBridge 排除名单中,未修改皮肤.' + refresh-cooldown: '<#FFC43B>请等待 {seconds} 秒后再刷新 {player} 的皮肤.' + queue-full: '<#FFC43B>SkinBridge 查询队列已满,请稍后重试.' blocks-menu: title: '<#00FB9A>便捷菜单' @@ -214,6 +226,7 @@ admin-mode: enabled: '<#00FB9A>管理模式已开启,普通背包已保存.' disabled: '<#FF7E5E>管理模式已关闭,普通背包已恢复.' crash-restored: '<#FFC43B>上一次管理模式会话已安全恢复.' + save-failed: '<#FF3300>错误: <#FF7E5E>无法安全保存当前背包,管理模式未开启.' tpsbar: title-format: '<#00FB9A>TPS: {tps_1m} | <#00FB9A>MSPT: {mspt} | <#00FB9A>Ping: {ping}' @@ -227,8 +240,6 @@ tpsbar: no-targets: '<#FF3300>错误: <#FF7E5E>没有匹配到可切换的玩家.' native-detected: '检测到服务端已内置 /tpsbar,已跳过插件实现.' plugin-enabled: '未检测到服务端内置 /tpsbar,已启用插件实现.' - plugin-forced-but-native-exists: '<#FFC43B>配置要求启用插件版 TPSBar,但服务端已存在原生 /tpsbar,已跳过插件实现以避免冲突.' - mode-changed-reload: '<#FFC43B>TPSBar 模式已更新,但命令注册状态需要重启服务器后才会完全生效.' mobdrops-menu: title: '<#00FB9A>生物掉落控制' @@ -244,6 +255,7 @@ maintenance: status: enabled: '<#FF7E5E>开启' disabled: '<#00FB9A>关闭' + address-hidden: '已隐藏' messages: enabled: '<#00FB9A>维护模式已开启.' disabled: '<#FF7E5E>维护模式已关闭.' diff --git a/src/main/resources/maintenance.yml b/src/main/resources/maintenance.yml index f3d2db2..45a284e 100644 --- a/src/main/resources/maintenance.yml +++ b/src/main/resources/maintenance.yml @@ -13,6 +13,8 @@ notify: enabled: true # 拥有该权限的在线玩家会收到拦截通知。 permission: "essentialsc.maintenance.notify" + # 是否在通知中显示玩家 IP 地址;出于隐私考虑默认关闭。 + include-address: false whitelist: # 这些 UUID 可以在维护模式下进入服务器。 @@ -32,7 +34,7 @@ kick-message: - "&7请稍后再进入" bossbar: - # 维护模式开启时是否向无绕过权限的在线玩家显示 BossBar。 + # 维护模式开启时是否向所有在线玩家显示 BossBar。 enabled: true title: "&c服务器正在维护" # 可选值:PINK / BLUE / RED / GREEN / YELLOW / PURPLE / WHITE diff --git a/src/main/resources/modules.yml b/src/main/resources/modules.yml index b8deeb3..ddcaf23 100644 --- a/src/main/resources/modules.yml +++ b/src/main/resources/modules.yml @@ -1,7 +1,7 @@ # EssentialsC 模块配置 # # modules.yml 用于控制功能模块是否在运行时加载。 -# 各功能的详细设置位于 config.yml 及对应的独立功能配置文件。 +# 各功能的详细设置位于 config.yml、maintenance.yml 与 blocks-menu.yml。 # # 修改模块开关后可使用 /essc reload 刷新运行期服务和监听器。 # 直连命令的完整热增删仍建议通过重启服务器完成。 @@ -12,20 +12,17 @@ modules: blocks: # 工作台、铁砧等便捷方块命令、/essc blocks 菜单和潜影盒快捷打开。 enabled: true - player: - # 玩家常用命令:飞行、夜视、发光、治疗、喂食、修复、帽子、自杀、隐身、查询玩家和 TPA。 - enabled: true admin-mode: # /essc admin 管理模式,以及独立背包和状态管理。 enabled: true tpsbar: - # 插件版 TPSBar。config.yml 中的 tpsbar.mode 仍会决定遇到原生 /tpsbar 时的行为。 + # 插件版 TPSBar。检测到服务端原生命令时会自动避免冲突。 enabled: true jei-sync: # Fabric / NeoForge JEI 配方同步兼容修复。 enabled: true mob-drops: - # 末影人掉落控制菜单和监听器。默认关闭,用于保留过去标准版的行为。 + # 末影人掉落控制菜单和监听器;具体掉落状态可在游戏内切换。 enabled: false maintenance: # 维护模式。开启维护后可替换 MOTD,并阻止无绕过权限的玩家进入。 diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 8345aab..be62473 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -3,7 +3,7 @@ description: 适用于 Paper 服务端的轻量基础功能插件。 version: ${version} main: cn.infstar.essentialsC.EssentialsC -api-version: '1.21' +api-version: '1.21.11' load: POSTWORLD folia-supported: false @@ -62,6 +62,9 @@ permissions: essentialsc.command.vanish: description: 允许使用 /vanish default: op + essentialsc.vanish.see: + description: 允许看见隐身玩家 + default: op essentialsc.command.seen: description: 允许使用 /seen default: op @@ -120,7 +123,20 @@ permissions: description: 允许管理维护模式 default: op essentialsc.command.skin: - description: 允许管理 SkinBridge 状态与刷新 + description: 允许使用 SkinBridge 管理命令 + default: op + children: + essentialsc.command.skin.status: true + essentialsc.command.skin.refresh: true + essentialsc.command.skin.others: true + essentialsc.command.skin.status: + description: 允许查看 SkinBridge 状态 + default: op + essentialsc.command.skin.refresh: + description: 允许强制刷新自己的皮肤 + default: op + essentialsc.command.skin.others: + description: 允许查看或刷新其他玩家的皮肤 default: op essentialsc.maintenance.bypass: description: 允许在维护模式下进入服务器 @@ -155,6 +171,7 @@ permissions: essentialsc.command.heal: true essentialsc.command.heal.others: true essentialsc.command.vanish: true + essentialsc.vanish.see: true essentialsc.command.seen: true essentialsc.command.feed: true essentialsc.command.feed.others: true @@ -175,6 +192,9 @@ permissions: essentialsc.command.tpsbar.others: true essentialsc.command.maintenance: true essentialsc.command.skin: true + essentialsc.command.skin.status: true + essentialsc.command.skin.refresh: true + essentialsc.command.skin.others: true essentialsc.maintenance.bypass: true essentialsc.maintenance.notify: true essentialsc.shulkerbox.open: true diff --git a/src/main/resources/skin-bridge.yml b/src/main/resources/skin-bridge.yml deleted file mode 100644 index b0bd080..0000000 --- a/src/main/resources/skin-bridge.yml +++ /dev/null @@ -1,64 +0,0 @@ -# ============================================================================ -# EssentialsC - SkinBridge 配置 -# ============================================================================ -# 模块总开关位于 modules.yml:modules.skin-bridge.enabled -# 本文件控制 SkinBridge 内部行为。修改后使用 /essc reload 重载。 - -config-version: 1 - -# ---------------------------------------------------------------------------- -# 基础设置 -# ---------------------------------------------------------------------------- - -# 是否启用外置皮肤站检测与皮肤同步。 -enabled: false - -# 是否在玩家加入时发送检测与同步结果提示。 -send-player-message: true - -# 玩家加入后延迟多少 tick 开始检测,避免登录资料尚未稳定。 -join-delay-ticks: 20 - -# 玩家登录来源与生成皮肤的内存缓存时间,单位为分钟。 -cache-minutes: 120 - -# 查询皮肤站 Yggdrasil Profile 的超时时间,单位为秒。 -profile-request-timeout-seconds: 5 - -# ---------------------------------------------------------------------------- -# MineSkin v2 -# ---------------------------------------------------------------------------- - -mineskin: - # API 地址,一般不需要修改。 - endpoint: "https://api.mineskin.org" - - # MineSkin API Key。只应填写在服务器运行目录,禁止提交到公开仓库。 - api-key: "" - - # 生成结果可见性:public / unlisted / private - visibility: "unlisted" - - # 等待 MineSkin 队列完成的最长时间,单位为秒。 - request-timeout-seconds: 30 - -# ---------------------------------------------------------------------------- -# 外置皮肤站 Provider -# ---------------------------------------------------------------------------- -# 每个一级子节点代表一个皮肤站,节点名称可自由修改,并显示在日志与 -# /essc skin status 中。建议只使用小写英文、数字和连字符,不要使用点号。 -# -# 可用占位符: -# {uuid} 无连字符 UUID -# {uuid-dashed} 标准带连字符 UUID - -providers: - littleskin: - enabled: false - priority: 10 - profile-url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" - - my-blessing-skin: - enabled: false - priority: 20 - profile-url: "https://skin.example.com/api/yggdrasil/sessionserver/session/minecraft/profile/{uuid}?unsigned=false" diff --git a/src/test/java/cn/infstar/essentialsC/ConfigurationResourcesTest.java b/src/test/java/cn/infstar/essentialsC/ConfigurationResourcesTest.java new file mode 100644 index 0000000..56ccfba --- /dev/null +++ b/src/test/java/cn/infstar/essentialsC/ConfigurationResourcesTest.java @@ -0,0 +1,41 @@ +package cn.infstar.essentialsC; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +class ConfigurationResourcesTest { + + @ParameterizedTest + @ValueSource(strings = { + "config.yml", + "modules.yml", + "maintenance.yml", + "blocks-menu.yml", + "paper-plugin.yml", + "lang/zh_CN.yml", + "lang/en_US.yml" + }) + void bundledYamlIsValid(String resourcePath) throws Exception { + try (InputStream input = getClass().getClassLoader().getResourceAsStream(resourcePath)) { + assertNotNull(input, resourcePath); + YamlConfiguration configuration = new YamlConfiguration(); + configuration.load(new InputStreamReader(input, StandardCharsets.UTF_8)); + + if (resourcePath.equals("config.yml")) { + assertEquals(2, configuration.getInt("config-version")); + assertEquals(5, configuration.getInt("tpa.max-pending-requests")); + } + if (resourcePath.equals("paper-plugin.yml")) { + assertEquals("1.21.11", configuration.getString("api-version")); + } + } + } +} diff --git a/src/test/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicyTest.java b/src/test/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicyTest.java new file mode 100644 index 0000000..f4f1582 --- /dev/null +++ b/src/test/java/cn/infstar/essentialsC/teleport/TeleportRequestQueuePolicyTest.java @@ -0,0 +1,32 @@ +package cn.infstar.essentialsC.teleport; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayDeque; +import java.util.Deque; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TeleportRequestQueuePolicyTest { + + @Test + void keepsNewestRequestsWithinConfiguredLimit() { + Deque requests = new ArrayDeque<>(); + + TeleportRequestQueuePolicy.addFirstBounded(requests, "first", 2); + TeleportRequestQueuePolicy.addFirstBounded(requests, "second", 2); + TeleportRequestQueuePolicy.addFirstBounded(requests, "third", 2); + + assertEquals(java.util.List.of("third", "second"), java.util.List.copyOf(requests)); + } + + @Test + void retainsRecentlyExpiredRequestsForUserFeedback() { + long now = 120_000L; + + assertFalse(TeleportRequestQueuePolicy.shouldPrune(90_000L, now, 60_000L)); + assertTrue(TeleportRequestQueuePolicy.shouldPrune(60_000L, now, 60_000L)); + } +}