Document MagicGem migration workflow

This commit is contained in:
Coldsmiles
2026-10-04 14:09:20 +08:00
parent 536c02b57e
commit 95e3ed05ea
22 changed files with 1166 additions and 86 deletions
+458
View File
@@ -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、语言和软依赖功能已测试。
- [ ] 已保存冲突备份和迁移前备份。
- [ ] 已重启服务器确认数据仍然正常。
+21 -15
View File
@@ -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。 1. 停止服务器并备份整个服务器目录,尤其是 `plugins/MagicGem`、`plugins/CMagicGem` 和玩家数据。
2. 将 `CMagicGem-*.jar` 放入服务器的 `plugins` 目录。 2. 使用 Java 21 启动 Paper 1.21.x,将新 JAR 放入 `plugins`,移除旧 MagicGem JAR。不要同时加载新旧 JAR。
3. 保留旧插件数据时,不要删除 `plugins/MagicGem`。CMagicGem 首次启动会把尚不存在的配置安全复制到 `plugins/CMagicGem`,不会修改旧目录。 3. 如果有 `plugins/MagicGem`,新版本会沿用它作为主数据目录,宝石、技能、GUI、语言、配置和已有物品按原路径继续读取。
4. 移除旧 MagicGem JAR 后启动服务器,并检查控制台中的配置加载结果。 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]` - `/mgem reload [gem|skill]`
- `/cmgem give <玩家> <宝石> [数量]` - `/mgem give <玩家> <宝石> [数量]`
- `/cmgem apply <玩家> <宝石> [槽位]` - `/mgem apply <玩家> <宝石> [槽位]`
- `/cmgem remove <玩家> <宝石> [槽位]` - `/mgem remove <玩家> <宝石> [槽位]`
- `/cmgem get`、`/cmgem view`、`/cmgem embed [界面]` - `/mgem get`、`/mgem view`、`/mgem embed [界面]`
- `/cmgem signal <玩家|@all> <信号>` - `/mgem signal <玩家|@all> <信号>`
- `/cmgem material|lore|enchant|nbt` - `/mgem material|lore|enchant|nbt`
## 可选兼容 ## 可选兼容
@@ -32,7 +32,7 @@ import org.bukkit.plugin.java.JavaPlugin;
import java.io.File; import java.io.File;
import java.util.Objects; import java.util.Objects;
public final class CMagicGem extends JavaPlugin { public class CMagicGem extends JavaPlugin {
private volatile PluginSettings settings; private volatile PluginSettings settings;
private GemRegistry gemRegistry; private GemRegistry gemRegistry;
private SkillRegistry skillRegistry; private SkillRegistry skillRegistry;
@@ -83,7 +83,7 @@ public final class CMagicGem extends JavaPlugin {
CMagicGemCommand command = new CMagicGemCommand(gemRegistry, gemService, itemCodec, gemBrowser, CMagicGemCommand command = new CMagicGemCommand(gemRegistry, gemService, itemCodec, gemBrowser,
guiManager, skillEngine, messages, languages, this::reloadScope); guiManager, skillEngine, messages, languages, this::reloadScope);
PluginCommand pluginCommand = Objects.requireNonNull(getCommand("cmgem")); PluginCommand pluginCommand = Objects.requireNonNull(getCommand("mgem"));
pluginCommand.setExecutor(command); pluginCommand.setExecutor(command);
pluginCommand.setTabCompleter(command); pluginCommand.setTabCompleter(command);
getServer().getPluginManager().registerEvents(new GemUseListener(gemService, itemCodec, messages, () -> settings), this); getServer().getPluginManager().registerEvents(new GemUseListener(gemService, itemCodec, messages, () -> settings), this);
@@ -94,7 +94,7 @@ public final class CMagicGem extends JavaPlugin {
+ ", PlayerPoints=" + points.status() + ", PlayerPoints=" + points.status()
+ ", PlaceholderAPI=" + placeholders.status() + ", PlaceholderAPI=" + placeholders.status()
+ ", MythicMobs=" + mythic.status()); + ", MythicMobs=" + mythic.status());
getLogger().info("CMagicGem 已启用,命令 /cmgem,加载 " + gemRegistry.all().size() + " 颗宝石。"); getLogger().info("MagicGem 已启用,命令 /mgem,加载 " + gemRegistry.all().size() + " 颗宝石。");
} }
public void reloadAll() { public void reloadAll() {
@@ -109,6 +109,7 @@ public final class CMagicGem extends JavaPlugin {
reloadConfig(); reloadConfig();
if (languages != null) languages.reload(); if (languages != null) languages.reload();
settings = PluginSettings.load(getConfig()); settings = PluginSettings.load(getConfig());
onSettingsReloaded(settings);
if (economy != null) economy.refresh(); if (economy != null) economy.refresh();
if (points != null) points.refresh(); if (points != null) points.refresh();
if (placeholders != null) placeholders.refresh(); if (placeholders != null) placeholders.refresh();
@@ -123,6 +124,8 @@ public final class CMagicGem extends JavaPlugin {
if (isEnabled()) getServer().getPluginManager().callEvent(new CMagicReloadEvent()); if (isEnabled()) getServer().getPluginManager().callEvent(new CMagicReloadEvent());
} }
protected void onSettingsReloaded(PluginSettings settings) { }
private void saveBundledResource(String path) { private void saveBundledResource(String path) {
File file = new File(getDataFolder(), path); File file = new File(getDataFolder(), path);
if (!file.exists()) saveResource(path, false); if (!file.exists()) saveResource(path, false);
@@ -88,19 +88,19 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter {
} }
private boolean help(CommandSender sender) { private boolean help(CommandSender sender) {
sender.sendMessage(itemCodec.color("&6CMagicGem &7- &f/cmgem")); sender.sendMessage(itemCodec.color("&6MagicGem &7- &f/mgem"));
List<String> help = languages.section("command.help"); List<String> help = languages.section("command.help");
if (help.isEmpty()) help = List.of("&e/cmgem give <玩家> <宝石> [数量]", if (help.isEmpty()) help = List.of("&e/mgem give <玩家> <宝石> [数量]",
"&e/cmgem apply <玩家> <宝石> [槽位]", "&e/cmgem remove <玩家> <宝石> [槽位]", "&e/mgem apply <玩家> <宝石> [槽位]", "&e/mgem remove <玩家> <宝石> [槽位]",
"&e/cmgem list | material | lore | enchant | nbt | reload", "&e/mgem list | material | lore | enchant | nbt | reload",
"&e/cmgem embed [界面] | signal <玩家> <信号>"); "&e/mgem embed [界面] | signal <玩家> <信号>");
help.forEach(line -> sender.sendMessage(itemCodec.color(line))); help.forEach(line -> sender.sendMessage(itemCodec.color(line)));
return true; return true;
} }
private boolean signal(CommandSender sender, String[] args) { private boolean signal(CommandSender sender, String[] args) {
requireAdmin(sender); 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)); String signal = String.join(" ", Arrays.copyOfRange(args, 2, args.length));
if (args[1].equalsIgnoreCase("@all")) { if (args[1].equalsIgnoreCase("@all")) {
Bukkit.getOnlinePlayers().forEach(player -> skillEngine.signal(player, signal)); 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) { private boolean addBlock(CommandSender sender, String[] args) {
requireAdmin(sender); requireAdmin(sender);
if (!(sender instanceof Player player)) throw new IllegalArgumentException(languages.text("command.player_only", "&c该命令只能由玩家执行")); 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(); var block = player.getLocation().clone().subtract(0, 1, 0).getBlock();
String name = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); String name = String.join(" ", Arrays.copyOfRange(args, 1, args.length));
if (!guiManager.addBlock(name, block.getLocation())) throw new IllegalArgumentException("未知界面或保存失败: " + name); if (!guiManager.addBlock(name, block.getLocation())) throw new IllegalArgumentException("未知界面或保存失败: " + name);
@@ -150,12 +150,12 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter {
requireAdmin(sender); requireAdmin(sender);
String scope = args.length < 2 ? "all" : args[1].toLowerCase(Locale.ROOT); String scope = args.length < 2 ? "all" : args[1].toLowerCase(Locale.ROOT);
if (!Set.of("all", "gem", "skill").contains(scope)) 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); reload.accept(scope);
String fallback = switch (scope) { String fallback = switch (scope) {
case "gem" -> "&a宝石配置已重载,共加载 {amount} 颗宝石。"; case "gem" -> "&a宝石配置已重载,共加载 {amount} 颗宝石。";
case "skill" -> "&a技能配置已重载。"; case "skill" -> "&a技能配置已重载。";
default -> "&aCMagicGem 已安全重载,共加载 {amount} 颗宝石。"; default -> "&aMagicGem 已安全重载,共加载 {amount} 颗宝石。";
}; };
sender.sendMessage(itemCodec.color(languages.text("cmagic.command.reload_" + scope, fallback, sender.sendMessage(itemCodec.color(languages.text("cmagic.command.reload_" + scope, fallback,
"amount", registry.all().size()))); "amount", registry.all().size())));
@@ -164,7 +164,7 @@ public final class CMagicGemCommand implements CommandExecutor, TabCompleter {
private boolean give(CommandSender sender, String[] args) { private boolean give(CommandSender sender, String[] args) {
requireAdmin(sender); 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]); Player player = requirePlayer(args[1]);
GemDefinition gem = requireGem(joinGemName(args, 2, args.length > 3 && integer(args[args.length - 1]) ? args.length - 1 : args.length)); 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; 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) { private boolean apply(CommandSender sender, String[] args) {
requireAdmin(sender); 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]); Player player = requirePlayer(args[1]);
String possibleSlot = args[args.length - 1]; String possibleSlot = args[args.length - 1];
EquipmentSlot slot = args.length > 3 ? parseSlot(possibleSlot) : EquipmentSlot.HAND; 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) { private boolean remove(CommandSender sender, String[] args) {
requireAdmin(sender); 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]); Player player = requirePlayer(args[1]);
EquipmentSlot slot = args.length > 3 ? parseSlot(args[args.length - 1]) : EquipmentSlot.HAND; EquipmentSlot slot = args.length > 3 ? parseSlot(args[args.length - 1]) : EquipmentSlot.HAND;
int nameEnd = args.length > 3 && slot != null ? args.length - 1 : args.length; 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) { private boolean hasPermission(CommandSender sender, String permission) {
return sender.hasPermission("cmagicgem.command." + permission) return PermissionPolicy.allows(permission, sender::hasPermission);
|| sender.hasPermission("magicgem.command." + permission);
} }
private Player requirePlayer(String name) { private Player requirePlayer(String name) {
@@ -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<String> 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);
}
}
@@ -76,7 +76,7 @@ public final class LanguageManager {
} }
private String commandName(String value) { private String commandName(String value) {
return value.replace("/mgem", "/cmgem").replace("/magicgem", "/cmagicgem"); return value;
} }
private String safeName(String configured) { private String safeName(String configured) {
@@ -5,6 +5,7 @@ import cn.infstar.cmagicgem.gem.GemRegistry;
import cn.infstar.cmagicgem.migration.LegacyItemMigrator; import cn.infstar.cmagicgem.migration.LegacyItemMigrator;
import cn.infstar.cmagicgem.util.PotionTypes; import cn.infstar.cmagicgem.util.PotionTypes;
import cn.infstar.cmagicgem.util.MaterialResolver; import cn.infstar.cmagicgem.util.MaterialResolver;
import de.tr7zw.changeme.nbtapi.NBT;
import org.bukkit.ChatColor; import org.bukkit.ChatColor;
import org.bukkit.Bukkit; import org.bukkit.Bukkit;
import org.bukkit.Color; import org.bukkit.Color;
@@ -40,6 +41,9 @@ public final class ItemCodec {
private final NamespacedKey dataVersionKey; private final NamespacedKey dataVersionKey;
private final NamespacedKey embeddedKey; private final NamespacedKey embeddedKey;
private final NamespacedKey skillsKey; private final NamespacedKey skillsKey;
private final NamespacedKey cMagicGemNameKey;
private final NamespacedKey cMagicGemEmbeddedKey;
private final NamespacedKey cMagicGemSkillsKey;
private final LegacyItemMigrator legacyMigrator; private final LegacyItemMigrator legacyMigrator;
public ItemCodec(JavaPlugin plugin, GemRegistry registry) { public ItemCodec(JavaPlugin plugin, GemRegistry registry) {
@@ -48,6 +52,9 @@ public final class ItemCodec {
this.dataVersionKey = new NamespacedKey(plugin, "data_version"); this.dataVersionKey = new NamespacedKey(plugin, "data_version");
this.embeddedKey = new NamespacedKey(plugin, "embedded_gems"); this.embeddedKey = new NamespacedKey(plugin, "embedded_gems");
this.skillsKey = new NamespacedKey(plugin, "skills"); 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); this.legacyMigrator = new LegacyItemMigrator(registry);
} }
@@ -58,6 +65,7 @@ public final class ItemCodec {
meta.getPersistentDataContainer().set(gemNameKey, PersistentDataType.STRING, gem.name()); meta.getPersistentDataContainer().set(gemNameKey, PersistentDataType.STRING, gem.name());
meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1); meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1);
item.setItemMeta(meta); item.setItemMeta(meta);
NBT.modify(item, nbt -> { nbt.setString(LegacyItemMigrator.GEM_NAME_KEY, gem.name()); });
return item; return item;
} }
@@ -133,6 +141,7 @@ public final class ItemCodec {
if (item == null || item.getType().isAir() || !item.hasItemMeta()) return Optional.empty(); if (item == null || item.getType().isAir() || !item.hasItemMeta()) return Optional.empty();
ItemMeta meta = item.getItemMeta(); ItemMeta meta = item.getItemMeta();
String name = meta.getPersistentDataContainer().get(gemNameKey, PersistentDataType.STRING); String name = meta.getPersistentDataContainer().get(gemNameKey, PersistentDataType.STRING);
if (name == null) name = meta.getPersistentDataContainer().get(cMagicGemNameKey, PersistentDataType.STRING);
Optional<GemDefinition> direct = registry.find(name); Optional<GemDefinition> direct = registry.find(name);
if (direct.isPresent()) return direct; if (direct.isPresent()) return direct;
LegacyItemMigrator.LegacyData legacy = legacyMigrator.inspect(item); LegacyItemMigrator.LegacyData legacy = legacyMigrator.inspect(item);
@@ -158,7 +167,9 @@ public final class ItemCodec {
public Map<String, Integer> embedded(ItemStack item) { public Map<String, Integer> embedded(ItemStack item) {
if (item == null || !item.hasItemMeta()) return Map.of(); 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<String, Integer> result = new LinkedHashMap<>(); Map<String, Integer> result = new LinkedHashMap<>();
if (encoded == null || encoded.isBlank()) { if (encoded == null || encoded.isBlank()) {
LegacyItemMigrator.LegacyData legacy = legacyMigrator.inspect(item); 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(embeddedKey, PersistentDataType.STRING, encoded.toString());
meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1); meta.getPersistentDataContainer().set(dataVersionKey, PersistentDataType.INTEGER, 1);
item.setItemMeta(meta); 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<String> skills(ItemStack item) { public List<String> skills(ItemStack item) {
if (item == null || !item.hasItemMeta()) return new ArrayList<>(); 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()) { if (value == null || value.isBlank()) {
List<String> legacy = new ArrayList<>(legacyMigrator.inspect(item).skills()); List<String> legacy = new ArrayList<>(legacyMigrator.inspect(item).skills());
if (!legacy.isEmpty()) writeSkills(item, legacy); if (!legacy.isEmpty()) writeSkills(item, legacy);
@@ -218,6 +244,14 @@ public final class ItemCodec {
PersistentDataContainer data = meta.getPersistentDataContainer(); PersistentDataContainer data = meta.getPersistentDataContainer();
data.set(skillsKey, PersistentDataType.STRING, String.join("\n", skills)); data.set(skillsKey, PersistentDataType.STRING, String.join("\n", skills));
item.setItemMeta(meta); 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) { public String color(String value) {
@@ -1,82 +1,248 @@
package cn.infstar.cmagicgem.migration; package cn.infstar.cmagicgem.migration;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects;
import java.util.logging.Level; 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 { public final class LegacyDirectoryImporter {
private static final String LEGACY_PLUGIN_DIRECTORY = "CMagicGem";
private static final List<String> DATA_DIRECTORIES = List.of("gems", "skills", "gui", "languages"); private static final List<String> 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) { 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() { public ImportResult importIfPresent() {
Path pluginsDirectory = plugin.getDataFolder().toPath().toAbsolutePath().getParent(); if (targetDirectory.equals(pluginsDirectory.resolve(LEGACY_PLUGIN_DIRECTORY).normalize())) {
if (pluginsDirectory == null) return new ImportResult(0, false); return ImportResult.none();
Path legacy = pluginsDirectory.resolve("MagicGem"); }
Path target = plugin.getDataFolder().toPath();
if (!Files.isDirectory(legacy) || legacy.equals(target)) return new ImportResult(0, false); Path sourceDirectory = pluginsDirectory.resolve(LEGACY_PLUGIN_DIRECTORY).normalize();
int copied = 0; 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<Conflict> conflicts = new ArrayList<>();
try { try {
Files.createDirectories(target); Files.createDirectories(targetDirectory);
for (String directory : DATA_DIRECTORIES) copied += copyDirectory(legacy.resolve(directory), target.resolve(directory)); for (String directory : DATA_DIRECTORIES) {
boolean configImported = importConfig(legacy.resolve("config.yml"), target.resolve("config.yml")); ImportCounts counts = importDirectory(sourceDirectory.resolve(directory), targetDirectory.resolve(directory),
plugin.getLogger().info("发现旧 MagicGem 数据目录,安全导入 " + copied + " 个文件。原目录保持不变。"); Path.of(directory), conflicts);
return new ImportResult(copied, configImported); copiedFiles += counts.copiedFiles;
} catch (IOException exception) { mergedFiles += counts.mergedFiles;
plugin.getLogger().log(Level.SEVERE, "导入旧 MagicGem 数据失败,未删除任何旧文件", exception); conflictFiles += counts.conflictFiles;
return new ImportResult(copied, false); }
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 { private ImportCounts importDirectory(Path source, Path target, Path sourcePrefix, List<Conflict> conflicts)
if (!Files.isDirectory(source)) return 0; throws IOException, InvalidConfigurationException {
if (!Files.isDirectory(source)) return new ImportCounts(0, 0, 0);
int copied = 0; int copied = 0;
int merged = 0;
int conflicted = 0;
Path normalizedTarget = target.toAbsolutePath().normalize();
try (var paths = Files.walk(source)) { try (var paths = Files.walk(source)) {
for (Path path : paths.toList()) { for (Path path : paths.toList()) {
Path relative = source.relativize(path); Path relative = source.relativize(path);
Path destination = target.resolve(relative).normalize(); Path destination = normalizedTarget.resolve(relative).normalize();
if (!destination.startsWith(target.normalize())) continue; if (!destination.startsWith(normalizedTarget)) continue;
if (Files.isDirectory(path)) Files.createDirectories(destination); if (Files.isDirectory(path)) {
else if (!Files.exists(destination)) { Files.createDirectories(destination);
continue;
}
if (!Files.isRegularFile(path)) continue;
if (!Files.exists(destination)) {
Files.createDirectories(destination.getParent()); Files.createDirectories(destination.getParent());
Files.copy(path, destination, StandardCopyOption.COPY_ATTRIBUTES); Files.copy(path, destination, StandardCopyOption.COPY_ATTRIBUTES);
copied++; copied++;
} continue;
}
}
return copied;
} }
private boolean importConfig(Path source, Path destination) throws IOException { if (isYaml(path)) {
if (!Files.isRegularFile(source)) return false; MergeResult result = mergeYaml(path, destination,
YamlConfiguration oldConfig = YamlConfiguration.loadConfiguration(source.toFile()); sourcePrefix.resolve(source.relativize(path)), false, conflicts);
YamlConfiguration newConfig = YamlConfiguration.loadConfiguration(destination.toFile()); if (result.changed) merged++;
if (result.conflicted) conflicted++;
} else if (Files.mismatch(path, destination) != -1) {
conflicts.add(new Conflict(sourcePrefix.resolve(source.relativize(path)), "<binary content>"));
conflicted++;
}
}
}
return new ImportCounts(copied, merged, conflicted);
}
private MergeResult mergeYaml(Path source, Path destination, Path sourceRelative,
boolean preferSource, List<Conflict> 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<String> 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 (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<String> conflicts) {
boolean changed = false; boolean changed = false;
for (String key : List.of("lang", "workbench", "backpack", "gem-lore", "gem-craft", "skill-engine", for (String key : source.getKeys(false)) {
"skill-engine-lore-color", "potion-global-limit")) { String path = prefix.isEmpty() ? key : prefix + "." + key;
if (oldConfig.contains(key) && !newConfig.contains(key)) { ConfigurationSection sourceSection = source.getConfigurationSection(key);
newConfig.set(key, oldConfig.get(key)); ConfigurationSection destinationSection = destination.getConfigurationSection(key);
if (sourceSection != null && destinationSection == null) {
if (!destination.contains(key)) {
destination.createSection(key);
changed = true; changed = true;
} } else {
} if (preferSource) {
if (oldConfig.contains("lang") && !newConfig.contains("language")) { destination.set(key, null);
newConfig.set("language", oldConfig.getString("lang", "zh_CN")); destination.createSection(key);
changed = true; 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; return changed;
} }
public record ImportResult(int copiedFiles, boolean configImported) { private void writeConflictBackups(Path sourceDirectory, List<Conflict> 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) { }
} }
@@ -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.");
}
}
@@ -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;
}
}
@@ -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(); }
}
@@ -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; }
}
@@ -0,0 +1,8 @@
package pku.yim.magicgem.api.wrappers;
import org.bukkit.inventory.ItemStack;
public interface ISlotWrapper {
void set(ItemStack item);
ItemStack get();
}
@@ -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(); }
}
@@ -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; }
}
+1 -1
View File
@@ -276,7 +276,7 @@ cmagic:
gem_not_found: "&cUnknown gem: {gem}" gem_not_found: "&cUnknown gem: {gem}"
not_embedded: "&cThat gem is not embedded in this item" not_embedded: "&cThat gem is not embedded in this item"
command: command:
reload: "&aCMagicGem reloaded safely; {amount} gems loaded." reload: "&aMagicGem reloaded safely; {amount} gems loaded."
give: "&aGave {player} {amount} {gem}" give: "&aGave {player} {amount} {gem}"
list: "&6Loaded gems (&e{amount}&6):" list: "&6Loaded gems (&e{amount}&6):"
signal_all: "&aSent the skill signal to every online player." signal_all: "&aSent the skill signal to every online player."
+1 -1
View File
@@ -276,7 +276,7 @@ cmagic:
gem_not_found: "&c未知宝石: {gem}" gem_not_found: "&c未知宝石: {gem}"
not_embedded: "&c装备未镶嵌该宝石" not_embedded: "&c装备未镶嵌该宝石"
command: command:
reload: "&aCMagicGem 已安全重载,共加载 {amount} 颗宝石。" reload: "&aMagicGem 已安全重载,共加载 {amount} 颗宝石。"
give: "&a已给予 {player} {amount} 个 {gem}" give: "&a已给予 {player} {amount} 个 {gem}"
list: "&6已加载宝石 (&e{amount}&6):" list: "&6已加载宝石 (&e{amount}&6):"
signal_all: "&a已向所有在线玩家发送技能信号。" signal_all: "&a已向所有在线玩家发送技能信号。"
+12 -6
View File
@@ -1,15 +1,15 @@
name: CMagicGem name: MagicGem
version: '${version}' version: '${version}'
description: Paper 1.21.x compatible MagicGem reimplementation description: Paper 1.21.x compatible MagicGem reimplementation
main: cn.infstar.cmagicgem.CMagicGem main: pku.yim.magicgem.MagicGem
api-version: '1.21' api-version: '1.21'
authors: [ Coldsmile_7 ] authors: [ Coldsmile_7 ]
softdepend: [PlaceholderAPI, Vault, PlayerPoints, MythicMobs, MMOItems, MythicArtifacts, MythicCrucible, Residence, PvPManager, EcoEnchants, GoldenEnchants] softdepend: [PlaceholderAPI, Vault, PlayerPoints, MythicMobs, MMOItems, MythicArtifacts, MythicCrucible, Residence, PvPManager, EcoEnchants, GoldenEnchants]
commands: commands:
cmgem: mgem:
description: CMagicGem main command description: MagicGem main command
usage: /cmgem help usage: /mgem help
aliases: [cmg] aliases: [cmgem, cmg]
permissions: permissions:
cmagicgem.command.admin: cmagicgem.command.admin:
default: op default: op
@@ -17,3 +17,9 @@ permissions:
default: op default: op
cmagicgem.command.embed: cmagicgem.command.embed:
default: op default: op
magicgem.command.admin:
default: op
magicgem.command.view:
default: op
magicgem.command.embed:
default: op
@@ -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"));
}
}
@@ -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<String> 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<String> permissions = Set.of("magicgem.command.view");
assertTrue(PermissionPolicy.allows("view", permissions::contains));
assertFalse(PermissionPolicy.allows("embed", permissions::contains));
}
}
@@ -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());
}
}
@@ -23,13 +23,19 @@ class BundledConfigurationTest {
private final DirectiveParser parser = new DirectiveParser(); private final DirectiveParser parser = new DirectiveParser();
@Test @Test
void pluginDescriptorOnlyRegistersNewCommandNames() { void pluginDescriptorKeepsLegacyCommandAndPermissionCompatibility() {
YamlConfiguration descriptor = YamlConfiguration.loadConfiguration(new File("src/main/resources/plugin.yml")); YamlConfiguration descriptor = YamlConfiguration.loadConfiguration(new File("src/main/resources/plugin.yml"));
assertNotNull(descriptor.getConfigurationSection("commands.cmgem")); assertTrue(descriptor.getString("name").equalsIgnoreCase("MagicGem"));
assertTrue(descriptor.getStringList("commands.cmgem.aliases").contains("cmg")); assertTrue(descriptor.getString("main").equals("pku.yim.magicgem.MagicGem"));
assertNull(descriptor.getConfigurationSection("commands.mgem")); 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")); 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 @Test