From 95e3ed05eaf04e769bae8ce2317823844ee6db78 Mon Sep 17 00:00:00 2001 From: Coldsmiles Date: Sun, 4 Oct 2026 14:09:20 +0800 Subject: [PATCH] Document MagicGem migration workflow --- MIGRATION.md | 458 ++++++++++++++++++ README.md | 36 +- .../java/cn/infstar/cMagicGem/CMagicGem.java | 9 +- .../cMagicGem/command/CMagicGemCommand.java | 27 +- .../cMagicGem/command/PermissionPolicy.java | 18 + .../cMagicGem/config/LanguageManager.java | 2 +- .../cn/infstar/cMagicGem/item/ItemCodec.java | 38 +- .../migration/LegacyDirectoryImporter.java | 244 ++++++++-- src/main/java/pku/yim/magicgem/MagicGem.java | 68 +++ .../pku/yim/magicgem/api/PlayerSlotType.java | 93 ++++ .../magicgem/api/wrappers/CursorWrapper.java | 15 + .../api/wrappers/DummyGemWrapper.java | 14 + .../magicgem/api/wrappers/ISlotWrapper.java | 8 + .../api/wrappers/InventoryClickWrapper.java | 15 + .../api/wrappers/PlayerInventoryWrapper.java | 19 + src/main/resources/languages/en_US.yml | 2 +- src/main/resources/languages/zh_CN.yml | 2 +- src/main/resources/plugin.yml | 18 +- .../api/PlayerSlotTypeCompatibilityTest.java | 27 ++ .../command/PermissionPolicyTest.java | 27 ++ .../LegacyDirectoryImporterTest.java | 98 ++++ .../skill/BundledConfigurationTest.java | 14 +- 22 files changed, 1166 insertions(+), 86 deletions(-) create mode 100644 MIGRATION.md create mode 100644 src/main/java/cn/infstar/cMagicGem/command/PermissionPolicy.java create mode 100644 src/main/java/pku/yim/magicgem/MagicGem.java create mode 100644 src/main/java/pku/yim/magicgem/api/PlayerSlotType.java create mode 100644 src/main/java/pku/yim/magicgem/api/wrappers/CursorWrapper.java create mode 100644 src/main/java/pku/yim/magicgem/api/wrappers/DummyGemWrapper.java create mode 100644 src/main/java/pku/yim/magicgem/api/wrappers/ISlotWrapper.java create mode 100644 src/main/java/pku/yim/magicgem/api/wrappers/InventoryClickWrapper.java create mode 100644 src/main/java/pku/yim/magicgem/api/wrappers/PlayerInventoryWrapper.java create mode 100644 src/test/java/cn/infstar/cmagicgem/api/PlayerSlotTypeCompatibilityTest.java create mode 100644 src/test/java/cn/infstar/cmagicgem/command/PermissionPolicyTest.java create mode 100644 src/test/java/cn/infstar/cmagicgem/migration/LegacyDirectoryImporterTest.java diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..f83efd5 --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,458 @@ +# MagicGem 迁移指南 + +本文说明如何从原版 MagicGem 或之前的 CMagicGem 重实现迁移到当前版本。迁移范围包括插件 JAR、配置文件、宝石、技能、GUI、语言文件,以及玩家物品中的宝石数据。 + +当前版本面向 Minecraft/Paper 1.21.x(包括 1.21.11),使用 Java 21。插件保留原插件的非授权功能,不实现网络授权或机器绑定。 + +## 迁移前的目录和插件身份 + +当前插件的身份仍然是 `MagicGem`: + +```yaml +name: MagicGem +main: pku.yim.magicgem.MagicGem +``` + +主命令是 `/mgem`,别名是 `/cmgem` 和 `/cmg`。新旧 JAR 不能同时运行,因为它们使用相同的插件名、数据目录和命令。 + +常见的数据目录有四种情况: + +```text +# 原版 MagicGem +plugins/ +└─ MagicGem/ + +# 之前的 CMagicGem 重实现 +plugins/ +└─ CMagicGem/ + +# 两个目录同时存在 +plugins/ +├─ MagicGem/ +└─ CMagicGem/ + +# 全新安装 +plugins/ +``` + +当前版本始终把 `plugins/MagicGem` 作为有效目录。`plugins/CMagicGem` 只作为旧数据来源导入。 + +## 第一次迁移前的准备 + +1. 停止服务器,不能在服务器运行时替换 JAR 或修改插件数据。 +2. 备份整个服务器,至少包括以下内容: + + ```text + plugins/MagicGem/ + plugins/CMagicGem/ + world/ + world_nether/ + world_the_end/ + ``` + + 玩家背包、末影箱、容器和世界区块中的物品数据不在插件目录中,只备份插件目录不足以完整回滚。 +3. 移除旧版 MagicGem JAR 和旧版 CMagicGem JAR。 +4. 将当前版本 JAR 放入 `plugins/`。 +5. 保留 `plugins/MagicGem` 和 `plugins/CMagicGem`,不要在第一次启动前删除旧目录。 +6. 使用 Java 21 启动 Paper 1.21.x。 + +## 启动时的执行顺序 + +当前插件启动时会按照以下顺序工作: + +1. Paper 读取 `plugin.yml`,以 `MagicGem` 身份加载插件。 +2. `onLoad()` 预加载 NBT API。 +3. 执行旧目录导入器,检查 `plugins/CMagicGem`。 +4. 如果缺少默认 `config.yml`,创建当前版本的默认配置。 +5. 只为不存在的文件写入内置宝石、技能、GUI 和语言资源。 +6. 读取语言配置。 +7. 初始化宝石注册表、技能注册表、GUI、物品编解码器和软依赖集成。 +8. 执行完整 reload,读取配置并加载宝石、技能和 GUI。 +9. 注册 `/mgem` 命令和事件监听器。 +10. 输出集成状态和已加载宝石数量。 + +迁移必须发生在宝石和技能注册表加载之前。物品里保存的是宝石名或技能名,完整定义仍然来自 `gems/*.yml` 和 `skills/*.yml`。 + +## 情况一:只有原版 `plugins/MagicGem` + +这种情况下不需要复制或重命名目录。当前版本会直接沿用: + +```text +plugins/MagicGem/config.yml +plugins/MagicGem/gems/*.yml +plugins/MagicGem/skills/*.yml +plugins/MagicGem/gui/*.yml +plugins/MagicGem/languages/*.yml +``` + +迁移器发现当前数据目录就是 `plugins/MagicGem` 后会跳过目录导入。已有配置不会被默认配置覆盖,已有资源文件也不会被内置资源覆盖。 + +旧配置中的以下写法都可以读取: + +```yaml +lang: zh_CN +``` + +或: + +```yaml +language: zh_CN +``` + +操作流程就是停止服务器、替换 JAR、保留 `plugins/MagicGem`、启动服务器。 + +## 情况二:只有 `plugins/CMagicGem` + +如果不存在 `plugins/MagicGem`,但存在 `plugins/CMagicGem`,当前版本会创建 `plugins/MagicGem`,并导入以下内容: + +```text +CMagicGem/gems → MagicGem/gems +CMagicGem/skills → MagicGem/skills +CMagicGem/gui → MagicGem/gui +CMagicGem/languages → MagicGem/languages +CMagicGem/config.yml → MagicGem/config.yml +``` + +旧的 `plugins/CMagicGem` 源目录不会被删除,也不会被修改。 + +如果旧目录没有 `config.yml`,插件会在后续启动阶段创建当前版本默认配置。如果旧目录有配置而目标目录没有配置,插件会先创建默认配置,再用旧配置中的值覆盖对应默认项,同时保留新版本新增的配置项。 + +## 情况三:两个目录同时存在 + +当两个目录都存在时: + +```text +plugins/MagicGem/ # 有效目录,优先级高 +plugins/CMagicGem/ # 旧目录,作为补充来源 +``` + +迁移器只从 `CMagicGem` 补充目标目录中缺少的文件和配置项。`MagicGem` 中已经存在的内容优先保留。 + +例如旧文件是: + +```yaml +Shared: + Name: Old Name + Material: DIAMOND + OldOnly: retained +``` + +当前目录中的文件是: + +```yaml +Shared: + Name: Current Name + Material: DIAMOND + CurrentOnly: retained +``` + +合并后的目标文件是: + +```yaml +Shared: + Name: Current Name + Material: DIAMOND + OldOnly: retained + CurrentOnly: retained +``` + +也就是说,目标目录中的 `Name` 优先,旧目录独有的 `OldOnly` 会补入,目标目录独有的 `CurrentOnly` 也会保留。 + +## 文件合并规则 + +迁移器处理以下目录: + +```text +gems/ +skills/ +gui/ +languages/ +``` + +每个文件按照以下规则处理: + +| 情况 | 行为 | +| --- | --- | +| 目标文件不存在 | 从 `CMagicGem` 复制到 `MagicGem` | +| YAML 文件同名且内容不同 | 递归合并,目标值优先 | +| YAML 中只有旧目录存在的配置项 | 补入目标文件 | +| YAML 中双方值不同 | 保留 `MagicGem` 值,记录冲突 | +| 非 YAML 文件内容相同 | 跳过 | +| 非 YAML 文件内容不同 | 保留目标文件,记录冲突 | +| 目标文件已存在 | 不直接覆盖 | + +YAML 合并可以处理同一个文件中不同的宝石、技能或语言段落,而不是简单地按文件覆盖。 + +## `config.yml` 合并规则 + +如果目标目录中没有 `config.yml`: + +1. 先生成当前版本默认配置。 +2. 导入旧配置中的字段。 +3. 旧配置存在的值覆盖默认值。 +4. 旧配置不存在的新字段继续保留默认值。 + +例如当前默认配置是: + +```yaml +language: zh_CN +workbench: true +backpack: false +``` + +旧配置是: + +```yaml +lang: en_US +backpack: true +``` + +最终结果会保留旧设置和新默认项,效果类似: + +```yaml +language: en_US +lang: en_US +workbench: true +backpack: true +``` + +如果目标配置已经存在,则目标配置优先,旧配置中目标没有的字段会补入: + +```yaml +# MagicGem/config.yml +language: zh_CN +backpack: false +``` + +```yaml +# CMagicGem/config.yml +language: en_US +backpack: true +old-option: kept +``` + +最终目标配置会保持: + +```yaml +language: zh_CN +backpack: false +old-option: kept +``` + +## 冲突备份 + +发生冲突时,旧源文件会保存到: + +```text +plugins/MagicGem/migration-backup/CMagicGem/ +``` + +例如: + +```text +plugins/CMagicGem/gems/Shared.yml +``` + +会备份为: + +```text +plugins/MagicGem/migration-backup/CMagicGem/gems/Shared.yml +``` + +配置文件冲突则会备份为: + +```text +plugins/MagicGem/migration-backup/CMagicGem/config.yml +``` + +控制台会列出冲突字段,并说明 `MagicGem` 的值被保留。旧源目录本身保持不变。 + +如果源文件和目标文件内容完全相同,不会被当作冲突,也不会产生额外备份。 + +## 迁移完成标记 + +完整迁移成功后会创建: + +```text +plugins/MagicGem/.migration-cmagicgem-v1.complete +``` + +该标记用于防止每次服务器启动都重复执行相同迁移。 + +如果第一次迁移过程中发生 IO 错误或 YAML 格式错误: + +- 不会写入完成标记; +- 不会删除旧源目录; +- 控制台会输出错误日志; +- 下次启动会再次尝试迁移。 + +如果迁移完成后又修改了 `plugins/CMagicGem`,这些新修改不会自动再次导入,因为完成标记已经存在。需要重新导入时,应先停止服务器、备份当前 `plugins/MagicGem`、删除完成标记,再重新启动。 + +## 玩家物品的迁移方式 + +插件目录迁移和玩家物品迁移是两个独立过程。 + +当前版本不会在启动时遍历所有玩家背包并一次性重写全部物品,而是采用兼容读取和延迟迁移。物品在被识别、镶嵌、拆卸、强化或执行技能相关操作时,才会逐步写入当前格式。 + +物品可能包含以下数据格式: + +```text +当前 MagicGem PDC +旧 CMagicGem PDC +原版 MagicGem NBT +Lore 标识 +``` + +当前版本会依次尝试读取: + +```text +magicgem:gem_name +cmagicgem:gem_name +MAGICGEM_NAME +``` + +如果允许使用 Lore 识别,还会把宝石 Lore 作为备用依据。 + +### 宝石名称 + +原版物品通常包含: + +```text +MAGICGEM_NAME +``` + +当前版本会使用这个名称去查找已经加载的宝石定义。识别成功后会写入当前 PDC: + +```text +magicgem:gem_name +``` + +原版 `MAGICGEM_NAME` 作为兼容字段继续保留。新创建的宝石也会同时写入当前标识和原版名称字段。 + +如果物品中的宝石名称存在,但 `gems/*.yml` 没有迁移或加载失败,插件无法恢复完整宝石定义。 + +### 镶嵌内容 + +原版镶嵌数据通常位于: + +```text +MAGICGEM_EMBED +``` + +它是一个包含宝石名称和数量的复合 NBT,例如: + +```text +{ + "AttributeGem": 2, + "EnchantGem": 1 +} +``` + +当前版本会按以下顺序读取: + +1. `magicgem:embedded_gems`; +2. `cmagicgem:embedded_gems`; +3. `MAGICGEM_EMBED`; +4. 兼容旧格式的嵌套 NBT。 + +读取旧 NBT 后,物品在后续使用时会写入当前 PDC,同时继续写入兼容的 `MAGICGEM_EMBED`。执行镶嵌或拆卸时,这个受插件管理的字段会被重新整理;其他无关物品数据不会被主动删除。 + +### 技能列表 + +原版技能数据通常位于: + +```text +MAGICGEM_SKILL +``` + +当前版本会按以下顺序读取: + +```text +magicgem:skills +cmagicgem:skills +MAGICGEM_SKILL +``` + +读取成功后,技能名称会按当前格式保存,并继续写入兼容的 `MAGICGEM_SKILL`。技能是否能实际执行,还取决于对应的 `skills/*.yml` 是否已经加载。 + +## 启动后的检查 + +迁移成功时,控制台应出现类似日志: + +```text +Imported plugins/CMagicGem into plugins/MagicGem: X files copied, Y files merged, Z files with conflicts. +``` + +插件正常启用时,应出现类似日志: + +```text +MagicGem 已启用,命令 /mgem,加载 N 颗宝石。 +``` + +建议执行以下检查: + +```text +/mgem view +/mgem get +/mgem embed +/mgem reload +``` + +还应实际测试: + +- 旧宝石是否可以识别; +- 旧镶嵌装备是否显示正确; +- 拆卸和重新镶嵌是否正常; +- 技能是否能够触发; +- GUI 是否可以打开; +- 中文或英文语言是否正确; +- Vault、PlayerPoints、PlaceholderAPI、MythicMobs 等软依赖是否正常; +- 服务器重启后数据是否仍然存在。 + +如果日志显示宝石数量明显少于旧版本,应优先检查 `plugins/MagicGem/gems/` 中的 YAML 是否完整,以及控制台是否有宝石解析错误。 + +## 回滚方式 + +最安全的回滚方式是停止服务器并恢复迁移前的完整备份: + +1. 停止服务器。 +2. 备份当前运行后的日志和数据,方便排查问题。 +3. 恢复旧版 MagicGem JAR。 +4. 恢复原来的 `plugins/MagicGem`。 +5. 必要时恢复玩家数据和世界备份。 +6. 启动服务器验证。 + +如果只有 `plugins/CMagicGem`,迁移器不会删除它,因此它可以继续作为旧配置来源。但是如果新版本运行期间已经改变了玩家物品或世界数据,仍然建议恢复完整服务器备份,而不是只恢复插件 JAR。 + +## 当前实现的注意事项 + +配置文件包含: + +```yaml +legacy-migration: true +``` + +但当前启动代码在读取配置前就调用目录导入器,因此这个开关目前不会阻止 `CMagicGem` 目录迁移。正常升级不需要修改它;如果以后需要支持关闭迁移,应将导入调用调整到配置加载之后,并显式检查该设置。 + +另外,目录识别使用的旧目录名是准确的: + +```text +plugins/CMagicGem +``` + +其他自定义目录名不会被自动扫描。需要使用其他目录名时,应在停服并备份后手动改名为 `CMagicGem`,或者手动合并文件。 + +## 迁移完成检查清单 + +- [ ] 已停止服务器并备份插件、世界和玩家数据。 +- [ ] 已移除旧版 MagicGem/CMagicGem JAR。 +- [ ] 新 JAR 已放入 `plugins/`。 +- [ ] `plugins/MagicGem` 已保留。 +- [ ] `plugins/CMagicGem` 在确认迁移成功前已保留。 +- [ ] 控制台显示迁移结果或明确显示没有旧目录。 +- [ ] 没有未处理的 YAML 解析错误。 +- [ ] 宝石数量与预期一致。 +- [ ] 旧宝石、镶嵌装备和技能已实际测试。 +- [ ] GUI、语言和软依赖功能已测试。 +- [ ] 已保存冲突备份和迁移前备份。 +- [ ] 已重启服务器确认数据仍然正常。 diff --git a/README.md b/README.md index 62d856a..e0ec65c 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,31 @@ -# CMagicGem +# MagicGem -CMagicGem 是 MagicGem 的 Paper 1.21.x 重实现,目标运行范围为 Minecraft/Paper 1.21–1.21.11。项目使用 Java 21 和 Paper API,不包含原插件的网络授权逻辑。 +MagicGem 是面向 Minecraft/Paper 1.21.x(包括 1.21.11)的插件重实现,使用 Java 21 和 Paper API。它保留原插件的非授权功能,不实现网络授权或机器绑定。 -## 安装 +## 安装与迁移 -1. 使用 Java 21 启动 Paper 1.21.x。 -2. 将 `CMagicGem-*.jar` 放入服务器的 `plugins` 目录。 -3. 保留旧插件数据时,不要删除 `plugins/MagicGem`。CMagicGem 首次启动会把尚不存在的配置安全复制到 `plugins/CMagicGem`,不会修改旧目录。 -4. 移除旧 MagicGem JAR 后启动服务器,并检查控制台中的配置加载结果。 +1. 停止服务器并备份整个服务器目录,尤其是 `plugins/MagicGem`、`plugins/CMagicGem` 和玩家数据。 +2. 使用 Java 21 启动 Paper 1.21.x,将新 JAR 放入 `plugins`,移除旧 MagicGem JAR。不要同时加载新旧 JAR。 +3. 如果有 `plugins/MagicGem`,新版本会沿用它作为主数据目录,宝石、技能、GUI、语言、配置和已有物品按原路径继续读取。 +4. 如果还保留先前重实现的 `plugins/CMagicGem`,新版本会将其中的 `gems`、`skills`、`gui`、`languages` 和 `config.yml` 导入 `plugins/MagicGem`。缺失文件和 YAML 配置项会自动补入。 +5. 两个目录有同名冲突时,`plugins/MagicGem` 的值优先;冲突的 `CMagicGem` 原文件副本会写入 `plugins/MagicGem/migration-backup/CMagicGem`。源 `plugins/CMagicGem` 保持不变,迁移完成后会写入 `.migration-cmagicgem-v1.complete` 标记,避免每次启动重复导入。 +6. 启动后检查控制台的导入数量、冲突提示和配置加载结果。确认一切正常前,请保留备份和旧数据目录。 -主命令是 `/cmgem`,别名是 `/cmg`。插件不会注册 `/mgem`,因此可与旧插件并存进行迁移核验。 +原版 `MagicGem` 数据已经在 `plugins/MagicGem` 时无需复制或重命名目录。物品读取兼容原版 `MAGICGEM_*` NBT 和先前 `CMagicGem` 的 PDC 数据,并继续写入原版 NBT 字段以兼容升级过程。 + +完整迁移步骤、目录合并规则、冲突备份、物品兼容和回滚流程见 [MIGRATION.md](MIGRATION.md)。 + +主命令是 `/mgem`,并提供 `/cmgem` 和 `/cmg` 别名。 ## 命令 -- `/cmgem reload [gem|skill]` -- `/cmgem give <玩家> <宝石> [数量]` -- `/cmgem apply <玩家> <宝石> [槽位]` -- `/cmgem remove <玩家> <宝石> [槽位]` -- `/cmgem get`、`/cmgem view`、`/cmgem embed [界面]` -- `/cmgem signal <玩家|@all> <信号>` -- `/cmgem material|lore|enchant|nbt` +- `/mgem reload [gem|skill]` +- `/mgem give <玩家> <宝石> [数量]` +- `/mgem apply <玩家> <宝石> [槽位]` +- `/mgem remove <玩家> <宝石> [槽位]` +- `/mgem get`、`/mgem view`、`/mgem embed [界面]` +- `/mgem signal <玩家|@all> <信号>` +- `/mgem material|lore|enchant|nbt` ## 可选兼容 diff --git a/src/main/java/cn/infstar/cMagicGem/CMagicGem.java b/src/main/java/cn/infstar/cMagicGem/CMagicGem.java index 4165494..e3b3c36 100644 --- a/src/main/java/cn/infstar/cMagicGem/CMagicGem.java +++ b/src/main/java/cn/infstar/cMagicGem/CMagicGem.java @@ -32,7 +32,7 @@ import org.bukkit.plugin.java.JavaPlugin; import java.io.File; import java.util.Objects; -public final class CMagicGem extends JavaPlugin { +public class CMagicGem extends JavaPlugin { private volatile PluginSettings settings; private GemRegistry gemRegistry; private SkillRegistry skillRegistry; @@ -83,7 +83,7 @@ public final class CMagicGem extends JavaPlugin { CMagicGemCommand command = new CMagicGemCommand(gemRegistry, gemService, itemCodec, gemBrowser, guiManager, skillEngine, messages, languages, this::reloadScope); - PluginCommand pluginCommand = Objects.requireNonNull(getCommand("cmgem")); + PluginCommand pluginCommand = Objects.requireNonNull(getCommand("mgem")); pluginCommand.setExecutor(command); pluginCommand.setTabCompleter(command); getServer().getPluginManager().registerEvents(new GemUseListener(gemService, itemCodec, messages, () -> settings), this); @@ -94,7 +94,7 @@ public final class CMagicGem extends JavaPlugin { + ", PlayerPoints=" + points.status() + ", PlaceholderAPI=" + placeholders.status() + ", MythicMobs=" + mythic.status()); - getLogger().info("CMagicGem 已启用,命令 /cmgem,加载 " + gemRegistry.all().size() + " 颗宝石。"); + getLogger().info("MagicGem 已启用,命令 /mgem,加载 " + gemRegistry.all().size() + " 颗宝石。"); } public void reloadAll() { @@ -109,6 +109,7 @@ public final class CMagicGem extends JavaPlugin { reloadConfig(); if (languages != null) languages.reload(); settings = PluginSettings.load(getConfig()); + onSettingsReloaded(settings); if (economy != null) economy.refresh(); if (points != null) points.refresh(); if (placeholders != null) placeholders.refresh(); @@ -123,6 +124,8 @@ public final class CMagicGem extends JavaPlugin { if (isEnabled()) getServer().getPluginManager().callEvent(new CMagicReloadEvent()); } + protected void onSettingsReloaded(PluginSettings settings) { } + private void saveBundledResource(String path) { File file = new File(getDataFolder(), path); if (!file.exists()) saveResource(path, false); diff --git a/src/main/java/cn/infstar/cMagicGem/command/CMagicGemCommand.java b/src/main/java/cn/infstar/cMagicGem/command/CMagicGemCommand.java index 87a6fc4..2654dea 100644 --- a/src/main/java/cn/infstar/cMagicGem/command/CMagicGemCommand.java +++ b/src/main/java/cn/infstar/cMagicGem/command/CMagicGemCommand.java @@ -88,19 +88,19 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { } private boolean help(CommandSender sender) { - sender.sendMessage(itemCodec.color("&6CMagicGem &7- &f/cmgem")); + sender.sendMessage(itemCodec.color("&6MagicGem &7- &f/mgem")); List help = languages.section("command.help"); - if (help.isEmpty()) help = List.of("&e/cmgem give <玩家> <宝石> [数量]", - "&e/cmgem apply <玩家> <宝石> [槽位]", "&e/cmgem remove <玩家> <宝石> [槽位]", - "&e/cmgem list | material | lore | enchant | nbt | reload", - "&e/cmgem embed [界面] | signal <玩家> <信号>"); + if (help.isEmpty()) help = List.of("&e/mgem give <玩家> <宝石> [数量]", + "&e/mgem apply <玩家> <宝石> [槽位]", "&e/mgem remove <玩家> <宝石> [槽位]", + "&e/mgem list | material | lore | enchant | nbt | reload", + "&e/mgem embed [界面] | signal <玩家> <信号>"); help.forEach(line -> sender.sendMessage(itemCodec.color(line))); return true; } private boolean signal(CommandSender sender, String[] args) { requireAdmin(sender); - if (args.length < 3) throw new IllegalArgumentException("用法: /cmgem signal <玩家> <信号>"); + if (args.length < 3) throw new IllegalArgumentException("用法: /mgem signal <玩家> <信号>"); String signal = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); if (args[1].equalsIgnoreCase("@all")) { Bukkit.getOnlinePlayers().forEach(player -> skillEngine.signal(player, signal)); @@ -129,7 +129,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { private boolean addBlock(CommandSender sender, String[] args) { requireAdmin(sender); if (!(sender instanceof Player player)) throw new IllegalArgumentException(languages.text("command.player_only", "&c该命令只能由玩家执行")); - if (args.length < 2) throw new IllegalArgumentException("用法: /cmgem addblock <界面>"); + if (args.length < 2) throw new IllegalArgumentException("用法: /mgem addblock <界面>"); var block = player.getLocation().clone().subtract(0, 1, 0).getBlock(); String name = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); if (!guiManager.addBlock(name, block.getLocation())) throw new IllegalArgumentException("未知界面或保存失败: " + name); @@ -150,12 +150,12 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { requireAdmin(sender); String scope = args.length < 2 ? "all" : args[1].toLowerCase(Locale.ROOT); if (!Set.of("all", "gem", "skill").contains(scope)) - throw new IllegalArgumentException("用法: /cmgem reload [gem|skill]"); + throw new IllegalArgumentException("用法: /mgem reload [gem|skill]"); reload.accept(scope); String fallback = switch (scope) { case "gem" -> "&a宝石配置已重载,共加载 {amount} 颗宝石。"; case "skill" -> "&a技能配置已重载。"; - default -> "&aCMagicGem 已安全重载,共加载 {amount} 颗宝石。"; + default -> "&aMagicGem 已安全重载,共加载 {amount} 颗宝石。"; }; sender.sendMessage(itemCodec.color(languages.text("cmagic.command.reload_" + scope, fallback, "amount", registry.all().size()))); @@ -164,7 +164,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { private boolean give(CommandSender sender, String[] args) { requireAdmin(sender); - if (args.length < 3) throw new IllegalArgumentException("用法: /cmgem give <玩家> <宝石> [数量]"); + if (args.length < 3) throw new IllegalArgumentException("用法: /mgem give <玩家> <宝石> [数量]"); Player player = requirePlayer(args[1]); GemDefinition gem = requireGem(joinGemName(args, 2, args.length > 3 && integer(args[args.length - 1]) ? args.length - 1 : args.length)); int amount = args.length > 3 && integer(args[args.length - 1]) ? Integer.parseInt(args[args.length - 1]) : 1; @@ -176,7 +176,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { private boolean apply(CommandSender sender, String[] args) { requireAdmin(sender); - if (args.length < 3) throw new IllegalArgumentException("用法: /cmgem apply <玩家> <宝石> [槽位]"); + if (args.length < 3) throw new IllegalArgumentException("用法: /mgem apply <玩家> <宝石> [槽位]"); Player player = requirePlayer(args[1]); String possibleSlot = args[args.length - 1]; EquipmentSlot slot = args.length > 3 ? parseSlot(possibleSlot) : EquipmentSlot.HAND; @@ -192,7 +192,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { private boolean remove(CommandSender sender, String[] args) { requireAdmin(sender); - if (args.length < 3) throw new IllegalArgumentException("用法: /cmgem remove <玩家> <宝石> [槽位]"); + if (args.length < 3) throw new IllegalArgumentException("用法: /mgem remove <玩家> <宝石> [槽位]"); Player player = requirePlayer(args[1]); EquipmentSlot slot = args.length > 3 ? parseSlot(args[args.length - 1]) : EquipmentSlot.HAND; int nameEnd = args.length > 3 && slot != null ? args.length - 1 : args.length; @@ -295,8 +295,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter { } private boolean hasPermission(CommandSender sender, String permission) { - return sender.hasPermission("cmagicgem.command." + permission) - || sender.hasPermission("magicgem.command." + permission); + return PermissionPolicy.allows(permission, sender::hasPermission); } private Player requirePlayer(String name) { diff --git a/src/main/java/cn/infstar/cMagicGem/command/PermissionPolicy.java b/src/main/java/cn/infstar/cMagicGem/command/PermissionPolicy.java new file mode 100644 index 0000000..f79a3e0 --- /dev/null +++ b/src/main/java/cn/infstar/cMagicGem/command/PermissionPolicy.java @@ -0,0 +1,18 @@ +package cn.infstar.cmagicgem.command; + +import java.util.Locale; +import java.util.function.Predicate; + +/** Keeps the legacy MagicGem admin node's "all permissions" behavior. */ +final class PermissionPolicy { + private PermissionPolicy() { } + + static boolean allows(String permission, Predicate hasPermission) { + String requested = permission.toLowerCase(Locale.ROOT); + boolean admin = hasPermission.test("magicgem.command.admin") + || hasPermission.test("cmagicgem.command.admin"); + if (admin) return true; + return hasPermission.test("magicgem.command." + requested) + || hasPermission.test("cmagicgem.command." + requested); + } +} diff --git a/src/main/java/cn/infstar/cMagicGem/config/LanguageManager.java b/src/main/java/cn/infstar/cMagicGem/config/LanguageManager.java index 876aec6..908dcdd 100644 --- a/src/main/java/cn/infstar/cMagicGem/config/LanguageManager.java +++ b/src/main/java/cn/infstar/cMagicGem/config/LanguageManager.java @@ -76,7 +76,7 @@ public final class LanguageManager { } private String commandName(String value) { - return value.replace("/mgem", "/cmgem").replace("/magicgem", "/cmagicgem"); + return value; } private String safeName(String configured) { diff --git a/src/main/java/cn/infstar/cMagicGem/item/ItemCodec.java b/src/main/java/cn/infstar/cMagicGem/item/ItemCodec.java index bf7f44a..a83d17d 100644 --- a/src/main/java/cn/infstar/cMagicGem/item/ItemCodec.java +++ b/src/main/java/cn/infstar/cMagicGem/item/ItemCodec.java @@ -5,6 +5,7 @@ import cn.infstar.cmagicgem.gem.GemRegistry; import cn.infstar.cmagicgem.migration.LegacyItemMigrator; import cn.infstar.cmagicgem.util.PotionTypes; import cn.infstar.cmagicgem.util.MaterialResolver; +import de.tr7zw.changeme.nbtapi.NBT; import org.bukkit.ChatColor; import org.bukkit.Bukkit; import org.bukkit.Color; @@ -40,6 +41,9 @@ public final class ItemCodec { private final NamespacedKey dataVersionKey; private final NamespacedKey embeddedKey; private final NamespacedKey skillsKey; + private final NamespacedKey cMagicGemNameKey; + private final NamespacedKey cMagicGemEmbeddedKey; + private final NamespacedKey cMagicGemSkillsKey; private final LegacyItemMigrator legacyMigrator; public ItemCodec(JavaPlugin plugin, GemRegistry registry) { @@ -48,6 +52,9 @@ public final class ItemCodec { this.dataVersionKey = new NamespacedKey(plugin, "data_version"); this.embeddedKey = new NamespacedKey(plugin, "embedded_gems"); this.skillsKey = new NamespacedKey(plugin, "skills"); + this.cMagicGemNameKey = new NamespacedKey("cmagicgem", "gem_name"); + this.cMagicGemEmbeddedKey = new NamespacedKey("cmagicgem", "embedded_gems"); + this.cMagicGemSkillsKey = new NamespacedKey("cmagicgem", "skills"); this.legacyMigrator = new LegacyItemMigrator(registry); } @@ -58,6 +65,7 @@ public final class ItemCodec { meta.getPersistentDataContainer().set(gemNameKey, PersistentDataType.STRING, gem.name()); meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1); item.setItemMeta(meta); + NBT.modify(item, nbt -> { nbt.setString(LegacyItemMigrator.GEM_NAME_KEY, gem.name()); }); return item; } @@ -133,6 +141,7 @@ public final class ItemCodec { if (item == null || item.getType().isAir() || !item.hasItemMeta()) return Optional.empty(); ItemMeta meta = item.getItemMeta(); String name = meta.getPersistentDataContainer().get(gemNameKey, PersistentDataType.STRING); + if (name == null) name = meta.getPersistentDataContainer().get(cMagicGemNameKey, PersistentDataType.STRING); Optional direct = registry.find(name); if (direct.isPresent()) return direct; LegacyItemMigrator.LegacyData legacy = legacyMigrator.inspect(item); @@ -158,7 +167,9 @@ public final class ItemCodec { public Map embedded(ItemStack item) { if (item == null || !item.hasItemMeta()) return Map.of(); - String encoded = item.getItemMeta().getPersistentDataContainer().get(embeddedKey, PersistentDataType.STRING); + PersistentDataContainer data = item.getItemMeta().getPersistentDataContainer(); + String encoded = data.get(embeddedKey, PersistentDataType.STRING); + if (encoded == null) encoded = data.get(cMagicGemEmbeddedKey, PersistentDataType.STRING); Map result = new LinkedHashMap<>(); if (encoded == null || encoded.isBlank()) { LegacyItemMigrator.LegacyData legacy = legacyMigrator.inspect(item); @@ -194,11 +205,26 @@ public final class ItemCodec { meta.getPersistentDataContainer().set(embeddedKey, PersistentDataType.STRING, encoded.toString()); meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1); item.setItemMeta(meta); + NBT.modify(item, nbt -> { + String legacyKey = LegacyItemMigrator.EMBEDDED_GEMS_KEY; + if (gems.isEmpty()) { + nbt.removeKey(legacyKey); + return; + } + var legacy = nbt.getOrCreateCompound(legacyKey); + for (String name : legacy.getKeys()) legacy.removeKey(name); + gems.forEach((name, count) -> { + if (count != null && count > 0) legacy.setInteger(name, count); + }); + if (legacy.getKeys().isEmpty()) nbt.removeKey(legacyKey); + }); } public List skills(ItemStack item) { if (item == null || !item.hasItemMeta()) return new ArrayList<>(); - String value = item.getItemMeta().getPersistentDataContainer().get(skillsKey, PersistentDataType.STRING); + PersistentDataContainer data = item.getItemMeta().getPersistentDataContainer(); + String value = data.get(skillsKey, PersistentDataType.STRING); + if (value == null) value = data.get(cMagicGemSkillsKey, PersistentDataType.STRING); if (value == null || value.isBlank()) { List legacy = new ArrayList<>(legacyMigrator.inspect(item).skills()); if (!legacy.isEmpty()) writeSkills(item, legacy); @@ -218,6 +244,14 @@ public final class ItemCodec { PersistentDataContainer data = meta.getPersistentDataContainer(); data.set(skillsKey, PersistentDataType.STRING, String.join("\n", skills)); item.setItemMeta(meta); + NBT.modify(item, nbt -> { + String legacyKey = LegacyItemMigrator.SKILLS_KEY; + nbt.removeKey(legacyKey); + if (!skills.isEmpty()) { + var legacy = nbt.getStringList(legacyKey); + skills.forEach(legacy::add); + } + }); } public String color(String value) { diff --git a/src/main/java/cn/infstar/cMagicGem/migration/LegacyDirectoryImporter.java b/src/main/java/cn/infstar/cMagicGem/migration/LegacyDirectoryImporter.java index 05b1dbb..077ffb9 100644 --- a/src/main/java/cn/infstar/cMagicGem/migration/LegacyDirectoryImporter.java +++ b/src/main/java/cn/infstar/cMagicGem/migration/LegacyDirectoryImporter.java @@ -1,82 +1,248 @@ package cn.infstar.cmagicgem.migration; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.java.JavaPlugin; -import java.io.File; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.logging.Level; +import java.util.logging.Logger; +/** Imports files left by the previous CMagicGem reimplementation without touching the source directory. */ public final class LegacyDirectoryImporter { + private static final String LEGACY_PLUGIN_DIRECTORY = "CMagicGem"; private static final List DATA_DIRECTORIES = List.of("gems", "skills", "gui", "languages"); - private final JavaPlugin plugin; + private static final String MARKER = ".migration-cmagicgem-v1.complete"; + + private final Path pluginsDirectory; + private final Path targetDirectory; + private final Runnable saveDefaultConfig; + private final Logger logger; public LegacyDirectoryImporter(JavaPlugin plugin) { - this.plugin = plugin; + this(plugin.getDataFolder().toPath().toAbsolutePath().normalize().getParent(), + plugin.getDataFolder().toPath().toAbsolutePath().normalize(), + plugin::saveDefaultConfig, plugin.getLogger()); + } + + LegacyDirectoryImporter(Path pluginsDirectory, Path targetDirectory, + Runnable saveDefaultConfig, Logger logger) { + this.pluginsDirectory = Objects.requireNonNull(pluginsDirectory).toAbsolutePath().normalize(); + this.targetDirectory = Objects.requireNonNull(targetDirectory).toAbsolutePath().normalize(); + this.saveDefaultConfig = Objects.requireNonNull(saveDefaultConfig); + this.logger = Objects.requireNonNull(logger); } public ImportResult importIfPresent() { - Path pluginsDirectory = plugin.getDataFolder().toPath().toAbsolutePath().getParent(); - if (pluginsDirectory == null) return new ImportResult(0, false); - Path legacy = pluginsDirectory.resolve("MagicGem"); - Path target = plugin.getDataFolder().toPath(); - if (!Files.isDirectory(legacy) || legacy.equals(target)) return new ImportResult(0, false); - int copied = 0; + if (targetDirectory.equals(pluginsDirectory.resolve(LEGACY_PLUGIN_DIRECTORY).normalize())) { + return ImportResult.none(); + } + + Path sourceDirectory = pluginsDirectory.resolve(LEGACY_PLUGIN_DIRECTORY).normalize(); + if (!Files.isDirectory(sourceDirectory)) return ImportResult.none(); + Path marker = targetDirectory.resolve(MARKER); + if (Files.exists(marker)) return new ImportResult(0, 0, 0, false, true); + + int copiedFiles = 0; + int mergedFiles = 0; + int conflictFiles = 0; + boolean configImported = false; + List conflicts = new ArrayList<>(); try { - Files.createDirectories(target); - for (String directory : DATA_DIRECTORIES) copied += copyDirectory(legacy.resolve(directory), target.resolve(directory)); - boolean configImported = importConfig(legacy.resolve("config.yml"), target.resolve("config.yml")); - plugin.getLogger().info("发现旧 MagicGem 数据目录,安全导入 " + copied + " 个文件。原目录保持不变。"); - return new ImportResult(copied, configImported); - } catch (IOException exception) { - plugin.getLogger().log(Level.SEVERE, "导入旧 MagicGem 数据失败,未删除任何旧文件", exception); - return new ImportResult(copied, false); + Files.createDirectories(targetDirectory); + for (String directory : DATA_DIRECTORIES) { + ImportCounts counts = importDirectory(sourceDirectory.resolve(directory), targetDirectory.resolve(directory), + Path.of(directory), conflicts); + copiedFiles += counts.copiedFiles; + mergedFiles += counts.mergedFiles; + conflictFiles += counts.conflictFiles; + } + + Path sourceConfig = sourceDirectory.resolve("config.yml"); + Path targetConfig = targetDirectory.resolve("config.yml"); + if (Files.isRegularFile(sourceConfig)) { + boolean targetConfigExisted = Files.isRegularFile(targetConfig); + if (!targetConfigExisted) saveDefaultConfig.run(); + MergeResult result = mergeYaml(sourceConfig, targetConfig, Path.of("config.yml"), + !targetConfigExisted, conflicts); + configImported = result.changed; + if (result.changed) mergedFiles++; + if (result.conflicted) conflictFiles++; + } + + writeConflictBackups(sourceDirectory, conflicts); + Files.writeString(marker, "Imported from plugins/CMagicGem. Source directory remains unchanged.\n"); + if (!conflicts.isEmpty()) { + for (Conflict conflict : conflicts) { + logger.warning("CMagicGem migration kept the MagicGem value for " + + conflict.sourceRelative() + " (" + conflict.key() + ")" + + "; the original source file was saved under migration-backup/CMagicGem."); + } + } + logger.info("Imported plugins/CMagicGem into plugins/MagicGem: " + copiedFiles + " files copied, " + + mergedFiles + " files merged, " + conflictFiles + " files with conflicts. " + + "The source directory was left untouched."); + return new ImportResult(copiedFiles, mergedFiles, conflictFiles, configImported, false); + } catch (IOException | InvalidConfigurationException exception) { + logger.log(Level.SEVERE, "Could not finish importing plugins/CMagicGem. No source files were deleted; " + + "the migration will be retried on the next startup.", exception); + return new ImportResult(copiedFiles, mergedFiles, conflictFiles, configImported, false); } } - private int copyDirectory(Path source, Path target) throws IOException { - if (!Files.isDirectory(source)) return 0; + private ImportCounts importDirectory(Path source, Path target, Path sourcePrefix, List conflicts) + throws IOException, InvalidConfigurationException { + if (!Files.isDirectory(source)) return new ImportCounts(0, 0, 0); int copied = 0; + int merged = 0; + int conflicted = 0; + Path normalizedTarget = target.toAbsolutePath().normalize(); try (var paths = Files.walk(source)) { for (Path path : paths.toList()) { Path relative = source.relativize(path); - Path destination = target.resolve(relative).normalize(); - if (!destination.startsWith(target.normalize())) continue; - if (Files.isDirectory(path)) Files.createDirectories(destination); - else if (!Files.exists(destination)) { + Path destination = normalizedTarget.resolve(relative).normalize(); + if (!destination.startsWith(normalizedTarget)) continue; + if (Files.isDirectory(path)) { + Files.createDirectories(destination); + continue; + } + if (!Files.isRegularFile(path)) continue; + if (!Files.exists(destination)) { Files.createDirectories(destination.getParent()); Files.copy(path, destination, StandardCopyOption.COPY_ATTRIBUTES); copied++; + continue; + } + + if (isYaml(path)) { + MergeResult result = mergeYaml(path, destination, + sourcePrefix.resolve(source.relativize(path)), false, conflicts); + if (result.changed) merged++; + if (result.conflicted) conflicted++; + } else if (Files.mismatch(path, destination) != -1) { + conflicts.add(new Conflict(sourcePrefix.resolve(source.relativize(path)), "")); + conflicted++; } } } - return copied; + return new ImportCounts(copied, merged, conflicted); } - private boolean importConfig(Path source, Path destination) throws IOException { - if (!Files.isRegularFile(source)) return false; - YamlConfiguration oldConfig = YamlConfiguration.loadConfiguration(source.toFile()); - YamlConfiguration newConfig = YamlConfiguration.loadConfiguration(destination.toFile()); - boolean changed = false; - for (String key : List.of("lang", "workbench", "backpack", "gem-lore", "gem-craft", "skill-engine", - "skill-engine-lore-color", "potion-global-limit")) { - if (oldConfig.contains(key) && !newConfig.contains(key)) { - newConfig.set(key, oldConfig.get(key)); - changed = true; + private MergeResult mergeYaml(Path source, Path destination, Path sourceRelative, + boolean preferSource, List conflicts) + throws IOException, InvalidConfigurationException { + if (!Files.exists(destination)) { + Files.createDirectories(destination.getParent()); + Files.copy(source, destination, StandardCopyOption.COPY_ATTRIBUTES); + return new MergeResult(true, false); + } + + YamlConfiguration sourceYaml = new YamlConfiguration(); + sourceYaml.load(source.toFile()); + YamlConfiguration destinationYaml = new YamlConfiguration(); + destinationYaml.load(destination.toFile()); + List fileConflicts = new ArrayList<>(); + boolean changed = mergeSections(sourceYaml, destinationYaml, "", preferSource, fileConflicts); + if (source.getFileName().toString().equalsIgnoreCase("config.yml")) { + String legacyLanguage = sourceYaml.getString("language", sourceYaml.getString("lang")); + if (legacyLanguage != null && (preferSource || !destinationYaml.contains("language"))) { + if (!Objects.equals(destinationYaml.getString("language"), legacyLanguage)) { + destinationYaml.set("language", legacyLanguage); + changed = true; + } } } - if (oldConfig.contains("lang") && !newConfig.contains("language")) { - newConfig.set("language", oldConfig.getString("lang", "zh_CN")); - changed = true; + if (changed) destinationYaml.save(destination.toFile()); + for (String conflict : fileConflicts) conflicts.add(new Conflict(sourceRelative, conflict)); + return new MergeResult(changed, !fileConflicts.isEmpty()); + } + + private boolean mergeSections(ConfigurationSection source, ConfigurationSection destination, + String prefix, boolean preferSource, List conflicts) { + boolean changed = false; + for (String key : source.getKeys(false)) { + String path = prefix.isEmpty() ? key : prefix + "." + key; + ConfigurationSection sourceSection = source.getConfigurationSection(key); + ConfigurationSection destinationSection = destination.getConfigurationSection(key); + if (sourceSection != null && destinationSection == null) { + if (!destination.contains(key)) { + destination.createSection(key); + changed = true; + } else { + if (preferSource) { + destination.set(key, null); + destination.createSection(key); + changed = true; + } else { + conflicts.add(path); + } + } + destinationSection = destination.getConfigurationSection(key); + if (destinationSection != null) { + changed |= mergeSections(sourceSection, destinationSection, path, preferSource, conflicts); + } + continue; + } + if (sourceSection == null && destinationSection != null) { + if (preferSource) { + destination.set(key, source.get(key)); + changed = true; + } else { + conflicts.add(path); + } + continue; + } + if (sourceSection != null) { + changed |= mergeSections(sourceSection, destinationSection, path, preferSource, conflicts); + continue; + } + + Object sourceValue = source.get(key); + if (!destination.contains(key)) { + destination.set(key, sourceValue); + changed = true; + } else if (!Objects.equals(sourceValue, destination.get(key))) { + if (preferSource) { + destination.set(key, sourceValue); + changed = true; + } else { + conflicts.add(path); + } + } } - if (changed) newConfig.save(destination.toFile()); return changed; } - public record ImportResult(int copiedFiles, boolean configImported) { + private void writeConflictBackups(Path sourceDirectory, List conflicts) throws IOException { + for (Path relative : conflicts.stream().map(Conflict::sourceRelative).distinct().toList()) { + Path sourceFile = sourceDirectory.resolve(relative).normalize(); + if (!sourceFile.startsWith(sourceDirectory) || !Files.isRegularFile(sourceFile)) continue; + Path backup = targetDirectory.resolve("migration-backup").resolve(LEGACY_PLUGIN_DIRECTORY).resolve(relative); + Files.createDirectories(backup.getParent()); + Files.copy(sourceFile, backup, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES); + } } + + private boolean isYaml(Path path) { + String name = path.getFileName().toString().toLowerCase(java.util.Locale.ROOT); + return name.endsWith(".yml") || name.endsWith(".yaml"); + } + + public record ImportResult(int copiedFiles, int mergedFiles, int conflictFiles, + boolean configImported, boolean alreadyMigrated) { + private static ImportResult none() { + return new ImportResult(0, 0, 0, false, false); + } + } + + private record ImportCounts(int copiedFiles, int mergedFiles, int conflictFiles) { } + private record MergeResult(boolean changed, boolean conflicted) { } + private record Conflict(Path sourceRelative, String key) { } } diff --git a/src/main/java/pku/yim/magicgem/MagicGem.java b/src/main/java/pku/yim/magicgem/MagicGem.java new file mode 100644 index 0000000..55a77c8 --- /dev/null +++ b/src/main/java/pku/yim/magicgem/MagicGem.java @@ -0,0 +1,68 @@ +package pku.yim.magicgem; + +import cn.infstar.cmagicgem.CMagicGem; +import cn.infstar.cmagicgem.config.PluginSettings; +import org.bukkit.Bukkit; +import org.bukkit.command.CommandSender; + +/** Legacy entry point retained for plugins that resolve MagicGem by class name. */ +public final class MagicGem extends CMagicGem { + public static MagicGem instance; + public static boolean placeholderapi; + public static boolean MMCorMMA; + public static boolean isArtifact; + public static boolean workbench; + public static boolean backpack; + public static boolean gemLore; + public static boolean gemCraft; + public static boolean skillEngine; + + public static MagicGem getInstance() { + return instance; + } + + @Override + public void onEnable() { + instance = this; + super.onEnable(); + } + + @Override + public void onDisable() { + try { + super.onDisable(); + } finally { + if (instance == this) instance = null; + } + } + + @Override + protected void onSettingsReloaded(PluginSettings settings) { + placeholderapi = getServer().getPluginManager().getPlugin("PlaceholderAPI") != null; + MMCorMMA = getServer().getPluginManager().getPlugin("MythicMobs") != null; + isArtifact = getServer().getPluginManager().getPlugin("MythicArtifacts") != null + || getServer().getPluginManager().getPlugin("MythicCrucible") != null; + workbench = settings.workbench(); + backpack = settings.backpack(); + gemLore = settings.gemLore(); + gemCraft = settings.gemCraft(); + skillEngine = settings.skillEngine(); + } + + public void info(String message) { + getLogger().info(message); + } + + public void disablePlugin() { + getServer().getPluginManager().disablePlugin(this); + } + + public boolean isPrimaryThread() { + return Bukkit.isPrimaryThread(); + } + + /** Network license activation belonged to the retired vendor service. */ + public void license(CommandSender sender) { + sender.sendMessage("MagicGem license service is not configured in this build."); + } +} diff --git a/src/main/java/pku/yim/magicgem/api/PlayerSlotType.java b/src/main/java/pku/yim/magicgem/api/PlayerSlotType.java new file mode 100644 index 0000000..146237a --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/PlayerSlotType.java @@ -0,0 +1,93 @@ +package pku.yim.magicgem.api; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; + +import java.util.Locale; + +/** Slot names retained for plugins compiled against MagicGem's public API. */ +public enum PlayerSlotType { + HELMET(5, "HEAD"), + CHESTPLATE(6, "CHEST"), + LEGGINGS(7, "LEGS"), + BOOTS(8, "FEET"), + OFFHAND(45, "OFFHAND"), + MAINHAND(-1, "HAND"); + + private final int slot; + private final String mythicSlot; + + PlayerSlotType(int slot, String mythicSlot) { + this.slot = slot; + this.mythicSlot = mythicSlot; + } + + public static PlayerSlotType getByName(String name) { + if (name == null) return null; + return switch (name.trim().toLowerCase(Locale.ROOT).replace('-', '_').replace(' ', '_')) { + case "helmet", "head" -> HELMET; + case "chest", "chestplate" -> CHESTPLATE; + case "legs", "leggings" -> LEGGINGS; + case "boots", "feet" -> BOOTS; + case "offhand" -> OFFHAND; + case "mainhand", "hand" -> MAINHAND; + default -> null; + }; + } + + public static PlayerSlotType getById(int id) { + for (PlayerSlotType type : values()) if (type.slot == id) return type; + return null; + } + + public static boolean isAir(ItemStack item) { + return item == null || item.getType().isAir(); + } + + public static PlayerSlotType matchType(ItemStack item) { + if (isAir(item)) return null; + return matchType(item.getType().name()); + } + + public static PlayerSlotType matchType(String material) { + if (material == null) return null; + String name = material.toUpperCase(Locale.ROOT); + if (name.endsWith("_HELMET") || name.endsWith("_HEAD")) return HELMET; + if (name.endsWith("_CHESTPLATE")) return CHESTPLATE; + if (name.endsWith("_LEGGINGS")) return LEGGINGS; + if (name.endsWith("_BOOTS")) return BOOTS; + if (name.equals("ELYTRA")) return CHESTPLATE; + if (name.equals("SHIELD")) return OFFHAND; + return null; + } + + public String toMMSlot() { + return mythicSlot; + } + + public ItemStack pickInvItem(PlayerInventory inventory) { + return switch (this) { + case HELMET -> inventory.getHelmet(); + case CHESTPLATE -> inventory.getChestplate(); + case LEGGINGS -> inventory.getLeggings(); + case BOOTS -> inventory.getBoots(); + case OFFHAND -> inventory.getItemInOffHand(); + case MAINHAND -> inventory.getItemInMainHand(); + }; + } + + public void setInvItem(PlayerInventory inventory, ItemStack item) { + switch (this) { + case HELMET -> inventory.setHelmet(item); + case CHESTPLATE -> inventory.setChestplate(item); + case LEGGINGS -> inventory.setLeggings(item); + case BOOTS -> inventory.setBoots(item); + case OFFHAND -> inventory.setItemInOffHand(item); + case MAINHAND -> inventory.setItemInMainHand(item); + } + } + + public int getSlot() { + return slot; + } +} diff --git a/src/main/java/pku/yim/magicgem/api/wrappers/CursorWrapper.java b/src/main/java/pku/yim/magicgem/api/wrappers/CursorWrapper.java new file mode 100644 index 0000000..5ae05b8 --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/wrappers/CursorWrapper.java @@ -0,0 +1,15 @@ +package pku.yim.magicgem.api.wrappers; + +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; + +public class CursorWrapper implements ISlotWrapper { + private final Player player; + + public CursorWrapper(Player player) { + this.player = player; + } + + @Override public void set(ItemStack item) { player.setItemOnCursor(item); } + @Override public ItemStack get() { return player.getItemOnCursor(); } +} diff --git a/src/main/java/pku/yim/magicgem/api/wrappers/DummyGemWrapper.java b/src/main/java/pku/yim/magicgem/api/wrappers/DummyGemWrapper.java new file mode 100644 index 0000000..b1ba44d --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/wrappers/DummyGemWrapper.java @@ -0,0 +1,14 @@ +package pku.yim.magicgem.api.wrappers; + +import org.bukkit.inventory.ItemStack; + +public class DummyGemWrapper implements ISlotWrapper { + private ItemStack item; + + public DummyGemWrapper(ItemStack item) { + this.item = item; + } + + @Override public void set(ItemStack item) { this.item = item; } + @Override public ItemStack get() { return item; } +} diff --git a/src/main/java/pku/yim/magicgem/api/wrappers/ISlotWrapper.java b/src/main/java/pku/yim/magicgem/api/wrappers/ISlotWrapper.java new file mode 100644 index 0000000..cb66992 --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/wrappers/ISlotWrapper.java @@ -0,0 +1,8 @@ +package pku.yim.magicgem.api.wrappers; + +import org.bukkit.inventory.ItemStack; + +public interface ISlotWrapper { + void set(ItemStack item); + ItemStack get(); +} diff --git a/src/main/java/pku/yim/magicgem/api/wrappers/InventoryClickWrapper.java b/src/main/java/pku/yim/magicgem/api/wrappers/InventoryClickWrapper.java new file mode 100644 index 0000000..e8da8b2 --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/wrappers/InventoryClickWrapper.java @@ -0,0 +1,15 @@ +package pku.yim.magicgem.api.wrappers; + +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.ItemStack; + +public class InventoryClickWrapper implements ISlotWrapper { + private final InventoryClickEvent event; + + public InventoryClickWrapper(InventoryClickEvent event) { + this.event = event; + } + + @Override public void set(ItemStack item) { event.setCurrentItem(item); } + @Override public ItemStack get() { return event.getCurrentItem(); } +} diff --git a/src/main/java/pku/yim/magicgem/api/wrappers/PlayerInventoryWrapper.java b/src/main/java/pku/yim/magicgem/api/wrappers/PlayerInventoryWrapper.java new file mode 100644 index 0000000..e23fbc3 --- /dev/null +++ b/src/main/java/pku/yim/magicgem/api/wrappers/PlayerInventoryWrapper.java @@ -0,0 +1,19 @@ +package pku.yim.magicgem.api.wrappers; + +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import pku.yim.magicgem.api.PlayerSlotType; + +public class PlayerInventoryWrapper implements ISlotWrapper { + private final PlayerInventory inventory; + private final PlayerSlotType slot; + + public PlayerInventoryWrapper(PlayerInventory inventory, PlayerSlotType slot) { + this.inventory = inventory; + this.slot = slot; + } + + @Override public void set(ItemStack item) { slot.setInvItem(inventory, item); } + @Override public ItemStack get() { return slot.pickInvItem(inventory); } + public PlayerSlotType getSlot() { return slot; } +} diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 3fe3278..815fc9e 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -276,7 +276,7 @@ cmagic: gem_not_found: "&cUnknown gem: {gem}" not_embedded: "&cThat gem is not embedded in this item" command: - reload: "&aCMagicGem reloaded safely; {amount} gems loaded." + reload: "&aMagicGem reloaded safely; {amount} gems loaded." give: "&aGave {player} {amount} {gem}" list: "&6Loaded gems (&e{amount}&6):" signal_all: "&aSent the skill signal to every online player." diff --git a/src/main/resources/languages/zh_CN.yml b/src/main/resources/languages/zh_CN.yml index a01e115..09fecc3 100644 --- a/src/main/resources/languages/zh_CN.yml +++ b/src/main/resources/languages/zh_CN.yml @@ -276,7 +276,7 @@ cmagic: gem_not_found: "&c未知宝石: {gem}" not_embedded: "&c装备未镶嵌该宝石" command: - reload: "&aCMagicGem 已安全重载,共加载 {amount} 颗宝石。" + reload: "&aMagicGem 已安全重载,共加载 {amount} 颗宝石。" give: "&a已给予 {player} {amount} 个 {gem}" list: "&6已加载宝石 (&e{amount}&6):" signal_all: "&a已向所有在线玩家发送技能信号。" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 0d9da1a..a35f978 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,15 +1,15 @@ -name: CMagicGem +name: MagicGem version: '${version}' description: Paper 1.21.x compatible MagicGem reimplementation -main: cn.infstar.cmagicgem.CMagicGem +main: pku.yim.magicgem.MagicGem api-version: '1.21' authors: [ Coldsmile_7 ] softdepend: [PlaceholderAPI, Vault, PlayerPoints, MythicMobs, MMOItems, MythicArtifacts, MythicCrucible, Residence, PvPManager, EcoEnchants, GoldenEnchants] commands: - cmgem: - description: CMagicGem main command - usage: /cmgem help - aliases: [cmg] + mgem: + description: MagicGem main command + usage: /mgem help + aliases: [cmgem, cmg] permissions: cmagicgem.command.admin: default: op @@ -17,3 +17,9 @@ permissions: default: op cmagicgem.command.embed: default: op + magicgem.command.admin: + default: op + magicgem.command.view: + default: op + magicgem.command.embed: + default: op diff --git a/src/test/java/cn/infstar/cmagicgem/api/PlayerSlotTypeCompatibilityTest.java b/src/test/java/cn/infstar/cmagicgem/api/PlayerSlotTypeCompatibilityTest.java new file mode 100644 index 0000000..28e5dfb --- /dev/null +++ b/src/test/java/cn/infstar/cmagicgem/api/PlayerSlotTypeCompatibilityTest.java @@ -0,0 +1,27 @@ +package cn.infstar.cmagicgem.api; + +import org.junit.jupiter.api.Test; +import pku.yim.magicgem.api.PlayerSlotType; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class PlayerSlotTypeCompatibilityTest { + @Test + void legacyIdsAndMythicSlotNamesArePreserved() { + assertEquals(PlayerSlotType.MAINHAND, PlayerSlotType.getById(-1)); + assertEquals(PlayerSlotType.HELMET, PlayerSlotType.getById(5)); + assertEquals(PlayerSlotType.OFFHAND, PlayerSlotType.getById(45)); + assertEquals("HAND", PlayerSlotType.MAINHAND.toMMSlot()); + assertEquals(PlayerSlotType.CHESTPLATE, PlayerSlotType.getByName("CHEST")); + assertNull(PlayerSlotType.getById(0)); + } + + @Test + void materialMatchingUsesTheArmorSlot() { + assertEquals(PlayerSlotType.HELMET, PlayerSlotType.matchType("DIAMOND_HELMET")); + assertEquals(PlayerSlotType.CHESTPLATE, PlayerSlotType.matchType("ELYTRA")); + assertEquals(PlayerSlotType.OFFHAND, PlayerSlotType.matchType("SHIELD")); + assertNull(PlayerSlotType.matchType("MAINHAND")); + } +} diff --git a/src/test/java/cn/infstar/cmagicgem/command/PermissionPolicyTest.java b/src/test/java/cn/infstar/cmagicgem/command/PermissionPolicyTest.java new file mode 100644 index 0000000..1ecf35e --- /dev/null +++ b/src/test/java/cn/infstar/cmagicgem/command/PermissionPolicyTest.java @@ -0,0 +1,27 @@ +package cn.infstar.cmagicgem.command; + +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PermissionPolicyTest { + @Test + void legacyAdminPermissionIncludesViewAndEmbed() { + Set permissions = Set.of("magicgem.command.admin"); + + assertTrue(PermissionPolicy.allows("view", permissions::contains)); + assertTrue(PermissionPolicy.allows("embed", permissions::contains)); + assertTrue(PermissionPolicy.allows("admin", permissions::contains)); + } + + @Test + void specificPermissionsDoNotGrantOtherCommands() { + Set permissions = Set.of("magicgem.command.view"); + + assertTrue(PermissionPolicy.allows("view", permissions::contains)); + assertFalse(PermissionPolicy.allows("embed", permissions::contains)); + } +} diff --git a/src/test/java/cn/infstar/cmagicgem/migration/LegacyDirectoryImporterTest.java b/src/test/java/cn/infstar/cmagicgem/migration/LegacyDirectoryImporterTest.java new file mode 100644 index 0000000..54ef456 --- /dev/null +++ b/src/test/java/cn/infstar/cmagicgem/migration/LegacyDirectoryImporterTest.java @@ -0,0 +1,98 @@ +package cn.infstar.cmagicgem.migration; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +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 LegacyDirectoryImporterTest { + @TempDir + Path temporaryDirectory; + + @Test + void mergesOldDirectoryWithoutOverwritingTargetAndKeepsConflictBackup() throws Exception { + Path plugins = Files.createDirectory(temporaryDirectory.resolve("plugins")); + Path source = Files.createDirectories(plugins.resolve("CMagicGem/gems")); + Path target = Files.createDirectories(plugins.resolve("MagicGem/gems")); + Path oldGem = source.resolve("Shared.yml"); + Path currentGem = target.resolve("Shared.yml"); + Files.writeString(oldGem, "Shared:\n Name: Old Name\n Material: DIAMOND\n OldOnly: retained\n"); + Files.writeString(currentGem, "Shared:\n Name: Current Name\n Material: DIAMOND\n CurrentOnly: retained\n"); + Files.writeString(source.resolve("Custom.yml"), "Custom:\n Name: Imported\n"); + Files.writeString(source.getParent().resolve("config.yml"), "language: en_US\nbackpack: true\nold-option: kept\n"); + Files.writeString(target.getParent().resolve("config.yml"), "language: zh_CN\nbackpack: false\n"); + + LegacyDirectoryImporter importer = importer(plugins, plugins.resolve("MagicGem"), () -> { }); + LegacyDirectoryImporter.ImportResult result = importer.importIfPresent(); + + YamlConfiguration mergedGem = load(currentGem); + YamlConfiguration mergedConfig = load(target.getParent().resolve("config.yml")); + assertEquals("Current Name", mergedGem.getString("Shared.Name")); + assertEquals("retained", mergedGem.getString("Shared.OldOnly")); + assertEquals("retained", mergedGem.getString("Shared.CurrentOnly")); + assertTrue(Files.isRegularFile(target.resolve("Custom.yml"))); + assertEquals("zh_CN", mergedConfig.getString("language")); + assertEquals(false, mergedConfig.getBoolean("backpack")); + assertEquals("kept", mergedConfig.getString("old-option")); + assertTrue(Files.isRegularFile(plugins.resolve("MagicGem/migration-backup/CMagicGem/gems/Shared.yml"))); + assertEquals("Old Name", load(oldGem).getString("Shared.Name")); + assertTrue(result.copiedFiles() >= 1); + assertTrue(result.mergedFiles() >= 2); + assertTrue(result.conflictFiles() >= 1); + + LegacyDirectoryImporter.ImportResult repeated = importer.importIfPresent(); + assertTrue(repeated.alreadyMigrated()); + assertEquals(0, repeated.copiedFiles()); + } + + @Test + void oldConfigurationOverridesBundledDefaultsWhenTargetHasNoConfig() throws Exception { + Path plugins = Files.createDirectory(temporaryDirectory.resolve("plugins")); + Path source = Files.createDirectories(plugins.resolve("CMagicGem")); + Path target = plugins.resolve("MagicGem"); + Files.writeString(source.resolve("config.yml"), "lang: en_US\nbackpack: true\n"); + Runnable createDefaults = () -> { + try { + Files.createDirectories(target); + Files.writeString(target.resolve("config.yml"), "language: zh_CN\nbackpack: false\nworkbench: true\n"); + } catch (IOException exception) { + throw new IllegalStateException(exception); + } + }; + + LegacyDirectoryImporter.ImportResult result = importer(plugins, target, createDefaults).importIfPresent(); + + YamlConfiguration config = load(target.resolve("config.yml")); + assertEquals("en_US", config.getString("language")); + assertEquals(true, config.getBoolean("backpack")); + assertEquals(true, config.getBoolean("workbench")); + assertTrue(result.configImported()); + } + + @Test + void originalMagicGemDirectoryIsTreatedAsTheActiveTarget() throws Exception { + Path plugins = Files.createDirectory(temporaryDirectory.resolve("plugins")); + Path target = Files.createDirectory(plugins.resolve("MagicGem")); + + LegacyDirectoryImporter.ImportResult result = importer(plugins, target, () -> { }).importIfPresent(); + + assertFalse(result.alreadyMigrated()); + assertFalse(Files.exists(target.resolve(".migration-cmagicgem-v1.complete"))); + } + + private LegacyDirectoryImporter importer(Path plugins, Path target, Runnable saveDefaults) { + return new LegacyDirectoryImporter(plugins, target, saveDefaults, Logger.getLogger("migration-test")); + } + + private YamlConfiguration load(Path path) { + return YamlConfiguration.loadConfiguration(path.toFile()); + } +} diff --git a/src/test/java/cn/infstar/cmagicgem/skill/BundledConfigurationTest.java b/src/test/java/cn/infstar/cmagicgem/skill/BundledConfigurationTest.java index e252716..df2bebc 100644 --- a/src/test/java/cn/infstar/cmagicgem/skill/BundledConfigurationTest.java +++ b/src/test/java/cn/infstar/cmagicgem/skill/BundledConfigurationTest.java @@ -23,13 +23,19 @@ class BundledConfigurationTest { private final DirectiveParser parser = new DirectiveParser(); @Test - void pluginDescriptorOnlyRegistersNewCommandNames() { + void pluginDescriptorKeepsLegacyCommandAndPermissionCompatibility() { YamlConfiguration descriptor = YamlConfiguration.loadConfiguration(new File("src/main/resources/plugin.yml")); - assertNotNull(descriptor.getConfigurationSection("commands.cmgem")); - assertTrue(descriptor.getStringList("commands.cmgem.aliases").contains("cmg")); - assertNull(descriptor.getConfigurationSection("commands.mgem")); + assertTrue(descriptor.getString("name").equalsIgnoreCase("MagicGem")); + assertTrue(descriptor.getString("main").equals("pku.yim.magicgem.MagicGem")); + assertNotNull(descriptor.getConfigurationSection("commands.mgem")); + assertTrue(descriptor.getStringList("commands.mgem.aliases").contains("cmg")); + assertTrue(descriptor.getStringList("commands.mgem.aliases").contains("cmgem")); + assertNull(descriptor.getConfigurationSection("commands.cmgem")); assertNull(descriptor.getConfigurationSection("commands.magicgem")); + assertNotNull(descriptor.getConfigurationSection("permissions.magicgem.command.admin")); + assertNotNull(descriptor.getConfigurationSection("permissions.magicgem.command.view")); + assertNotNull(descriptor.getConfigurationSection("permissions.magicgem.command.embed")); } @Test