Fix migrated gem voucher redemption

This commit is contained in:
Coldsmiles
2026-10-20 02:10:54 +08:00
parent 5cd6ae121f
commit 778ba65a65
16 changed files with 217 additions and 51 deletions
@@ -96,10 +96,10 @@ public class MagicGemPlugin extends JavaPlugin {
}
private void logIntegrationStatus() {
logOptionalPlugin("PlaceholderAPI", "api=" + (placeholders.available() ? "available" : "unavailable"));
logOptionalPlugin("Vault", "economy=" + (economy.available() ? "available" : "unavailable"));
logOptionalPlugin("PlayerPoints", "api=" + (points.available() ? "available" : "unavailable"));
logOptionalPlugin("MythicMobs", "adapter=" + mythic.status());
logOptionalPlugin("PlaceholderAPI", "接口=" + (placeholders.available() ? "可用" : "不可用"));
logOptionalPlugin("Vault", "经济接口=" + (economy.available() ? "可用" : "不可用"));
logOptionalPlugin("PlayerPoints", "接口=" + (points.available() ? "可用" : "不可用"));
logOptionalPlugin("MythicMobs", "适配器=" + mythic.status());
logOptionalPlugin("MMOItems", null);
logOptionalPlugin("MythicArtifacts", null);
logOptionalPlugin("MythicCrucible", null);
@@ -112,16 +112,16 @@ public class MagicGemPlugin extends JavaPlugin {
private void logOptionalPlugin(String name, String adapterStatus) {
Plugin dependency = getServer().getPluginManager().getPlugin(name);
if (dependency == null) {
getLogger().info("Optional integration " + name + ": absent");
getLogger().info("可选插件 " + name + ":未安装");
return;
}
String version = dependency.getDescription().getVersion();
if (!dependency.isEnabled()) {
getLogger().warning("Optional integration " + name + ": present but disabled (version " + version + ")");
getLogger().warning("可选插件 " + name + ":已安装但未启用(版本 " + version + ")");
return;
}
String suffix = adapterStatus == null ? "" : ", " + adapterStatus;
getLogger().info("Optional integration " + name + ": enabled (version " + version + suffix + ")");
String suffix = adapterStatus == null ? "" : "," + adapterStatus;
getLogger().info("可选插件 " + name + ":已启用(版本 " + version + suffix + ")");
}
public void reloadAll() {
@@ -152,7 +152,13 @@ public final class GemRegistry {
public Optional<GemDefinition> find(String name) {
if (name == null) return Optional.empty();
return Optional.ofNullable(gems.get(name.toLowerCase(Locale.ROOT)));
String normalized = name.trim().toLowerCase(Locale.ROOT);
GemDefinition direct = gems.get(normalized);
if (direct != null) return Optional.of(direct);
// Legacy MagicGem items may store the YAML section id instead of Name.
return gems.values().stream()
.filter(gem -> gem.id().equalsIgnoreCase(name.trim()))
.findFirst();
}
public Collection<GemDefinition> all() {
@@ -147,8 +147,11 @@ public final class GemService {
return new GemUseResult(true, successful, target, defaultMessage(successful ? gem.successTip() : gem.failTip(),
languages.text(successful ? "gem.tip.success" : "gem.tip.fail",
successful ? "&a宝石使用成功" : "&c宝石使用失败")));
} catch (RewardException exception) {
return GemUseResult.rejected(target, exception.getMessage());
} catch (RewardException | RuntimeException exception) {
String message = exception.getMessage() == null || exception.getMessage().isBlank()
? "兑换奖励执行失败,请查看服务器日志" : exception.getMessage();
Bukkit.getLogger().warning("MagicGem-Reforged 兑换奖励执行失败:" + message);
return GemUseResult.rejected(target, message);
}
}
@@ -5,6 +5,7 @@ import org.bukkit.plugin.RegisteredServiceProvider;
import org.bukkit.plugin.java.JavaPlugin;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
public final class EconomyBridge {
private final JavaPlugin plugin;
@@ -20,6 +21,9 @@ public final class EconomyBridge {
public void refresh() {
provider = null;
getBalance = null;
deposit = null;
withdraw = null;
try {
Class<?> economy = Class.forName("net.milkbowl.vault.economy.Economy");
RegisteredServiceProvider<?> registration = plugin.getServer().getServicesManager().getRegistration(economy);
@@ -28,7 +32,8 @@ public final class EconomyBridge {
getBalance = economy.getMethod("getBalance", OfflinePlayer.class);
deposit = economy.getMethod("depositPlayer", OfflinePlayer.class, double.class);
withdraw = economy.getMethod("withdrawPlayer", OfflinePlayer.class, double.class);
} catch (ReflectiveOperationException ignored) {
} catch (ReflectiveOperationException | LinkageError exception) {
plugin.getLogger().warning("Vault 经济接口加载失败:" + detail(exception));
}
}
@@ -41,20 +46,47 @@ public final class EconomyBridge {
}
public double balance(OfflinePlayer player) {
if (!available()) refresh();
if (!available()) return 0;
try { return ((Number) getBalance.invoke(provider, player)).doubleValue(); }
catch (ReflectiveOperationException exception) { return 0; }
catch (ReflectiveOperationException | RuntimeException exception) {
plugin.getLogger().warning("读取玩家金币失败:" + detail(exception));
return 0;
}
}
public boolean change(OfflinePlayer player, double amount) {
if (!Double.isFinite(amount) || Math.abs(amount) < 1.0E-9) return true;
if (!available()) refresh();
if (!available()) return false;
if (amount < 0 && balance(player) + amount < 0) return false;
try {
Object response = (amount >= 0 ? deposit : withdraw).invoke(provider, player, Math.abs(amount));
Method successful = response.getClass().getMethod("transactionSuccess");
return (boolean) successful.invoke(response);
} catch (ReflectiveOperationException exception) {
boolean result = (boolean) successful.invoke(response);
if (!result) {
plugin.getLogger().warning("Vault 经济插件拒绝交易:玩家=" + player.getName()
+ ",金额=" + amount + ",原因=" + responseError(response));
}
return result;
} catch (ReflectiveOperationException | RuntimeException exception) {
plugin.getLogger().warning("修改玩家金币失败:" + detail(exception));
return false;
}
}
private String detail(Throwable exception) {
Throwable cause = exception instanceof InvocationTargetException target && target.getCause() != null
? target.getCause() : exception;
return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage();
}
private String responseError(Object response) {
try {
Object message = response.getClass().getField("errorMessage").get(response);
return message == null || message.toString().isBlank() ? "未提供原因" : message.toString();
} catch (ReflectiveOperationException ignored) {
return "未提供原因";
}
}
}
@@ -71,8 +71,9 @@ public final class MythicBridge {
}
public String status() {
if (!available()) return "unavailable";
return "items=" + itemManager.getClass().getName() + ", skillApi=" + skillApiAvailable();
if (!available()) return "不可用";
return "物品接口=" + itemManager.getClass().getSimpleName() + ",技能接口="
+ (skillApiAvailable() ? "可用" : "不可用");
}
public Optional<ItemStack> item(String name) {
@@ -5,6 +5,7 @@ import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import java.lang.reflect.Method;
import java.lang.reflect.InvocationTargetException;
import java.util.UUID;
public final class PlayerPointsBridge {
@@ -21,16 +22,23 @@ public final class PlayerPointsBridge {
public void refresh() {
api = null;
look = null;
give = null;
take = null;
Plugin dependency = plugin.getServer().getPluginManager().getPlugin("PlayerPoints");
if (dependency == null || !dependency.isEnabled()) return;
try {
api = dependency.getClass().getMethod("getAPI").invoke(dependency);
look = api.getClass().getMethod("look", UUID.class);
give = api.getClass().getMethod("give", UUID.class, int.class);
take = api.getClass().getMethod("take", UUID.class, int.class);
} catch (ReflectiveOperationException exception) {
Method getApi = findMethod(dependency.getClass(), "getAPI");
if (getApi == null) throw new NoSuchMethodException("PlayerPoints.getAPI()");
api = getApi.invoke(dependency);
look = findMethod(api.getClass(), "look", UUID.class);
give = findMethod(api.getClass(), "give", UUID.class, int.class);
take = findMethod(api.getClass(), "take", UUID.class, int.class);
if (api == null || look == null || give == null || take == null)
throw new NoSuchMethodException("PlayerPoints API 缺少 look/give/take 方法");
} catch (ReflectiveOperationException | RuntimeException exception) {
api = null;
plugin.getLogger().warning("PlayerPoints 存在但接口不可用: " + exception.getMessage());
plugin.getLogger().warning("PlayerPoints 接口加载失败:" + detail(exception));
}
}
@@ -43,23 +51,47 @@ public final class PlayerPointsBridge {
}
public boolean change(Player player, int amount) {
if (amount == 0) return true;
if (!available()) refresh();
if (!available()) return false;
try {
int balance = ((Number) look.invoke(api, player.getUniqueId())).intValue();
if (amount < 0 && balance + amount < 0) return false;
Object result = (amount >= 0 ? give : take).invoke(api, player.getUniqueId(), Math.abs(amount));
return !(result instanceof Boolean value) || value;
} catch (ReflectiveOperationException exception) {
if (result instanceof Boolean value && !value) {
plugin.getLogger().warning("PlayerPoints 拒绝交易:玩家=" + player.getName()
+ ",金额=" + amount + ",当前点券=" + balance);
return false;
}
return true;
} catch (ReflectiveOperationException | RuntimeException exception) {
plugin.getLogger().warning("修改玩家点券失败:" + detail(exception));
return false;
}
}
public int balance(Player player) {
if (!available()) refresh();
if (!available()) return 0;
try {
return ((Number) look.invoke(api, player.getUniqueId())).intValue();
} catch (ReflectiveOperationException exception) {
} catch (ReflectiveOperationException | RuntimeException exception) {
plugin.getLogger().warning("读取玩家点券失败:" + detail(exception));
return 0;
}
}
private Method findMethod(Class<?> type, String name, Class<?>... parameters) {
for (Class<?> current = type; current != null; current = current.getSuperclass()) {
try { return current.getMethod(name, parameters); }
catch (NoSuchMethodException ignored) { }
}
return null;
}
private String detail(Throwable exception) {
Throwable cause = exception instanceof InvocationTargetException target && target.getCause() != null
? target.getCause() : exception;
return cause.getMessage() == null ? cause.getClass().getSimpleName() : cause.getMessage();
}
}
@@ -42,8 +42,10 @@ public final class ItemCodec {
private final NamespacedKey embeddedKey;
private final NamespacedKey skillsKey;
private final LegacyItemMigrator legacyMigrator;
private final JavaPlugin plugin;
public ItemCodec(JavaPlugin plugin, GemRegistry registry) {
this.plugin = plugin;
this.registry = registry;
this.gemNameKey = new NamespacedKey(plugin, "gem_name");
this.dataVersionKey = new NamespacedKey(plugin, "data_version");
@@ -147,6 +149,8 @@ public final class ItemCodec {
return migrated;
}
}
Optional<GemDefinition> fallback = displayFallback(item, meta);
if (fallback.isPresent()) return fallback;
if (!allowLore || !meta.hasLore()) return Optional.empty();
List<String> lore = meta.getLore();
if (lore == null) return Optional.empty();
@@ -158,6 +162,30 @@ public final class ItemCodec {
return Optional.empty();
}
/**
* Old MagicGem items can lose their custom NBT when copied through a server
* version converter. The display name and material are still stable for the
* bundled vouchers, so use them as a last-resort migration identity.
*/
private Optional<GemDefinition> displayFallback(ItemStack item, ItemMeta meta) {
String display = meta.getDisplayName();
if (display == null || display.isBlank()) return Optional.empty();
String normalizedDisplay = strip(display);
for (GemDefinition gem : registry.all()) {
if (gem.display() == null || gem.display().isBlank() || gem.material() != item.getType()) continue;
if (!strip(gem.display()).equals(normalizedDisplay)) continue;
plugin.getLogger().info("检测到缺少旧版标识的宝石物品,已按显示名称迁移:" + gem.name());
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 Optional.of(gem);
}
return Optional.empty();
}
public Map<String, Integer> embedded(ItemStack item) {
if (item == null || !item.hasItemMeta()) return Map.of();
PersistentDataContainer data = item.getItemMeta().getPersistentDataContainer();
@@ -49,6 +49,19 @@ public final class ItemMatcher {
return parts.length == 1 || nbt.getString(parts[0]).equals(parts[1]);
});
}
// The legacy configuration uses a leading underscore for suffix matches,
// for example _AXE means every material whose name ends with _AXE.
return matchesMaterial(material, requirement);
}
boolean matchesMaterial(Material material, String rawRequirement) {
if (material == null || rawRequirement == null) return false;
String requirement = rawRequirement.toUpperCase(Locale.ROOT);
if (requirement.startsWith("_") && requirement.length() > 1)
return material.name().endsWith(requirement);
// The original checker treated AXE as a name suffix without requiring
// a separator, so PICKAXE also matched the AXE requirement.
if (requirement.equals("AXE") && material.name().endsWith("AXE")) return true;
if (material.name().equals(requirement) || material.name().endsWith('_' + requirement)) return true;
return switch (requirement) {
case "ARMOR" -> material.name().endsWith("_HELMET") || material.name().endsWith("_CHESTPLATE")
@@ -78,7 +78,9 @@ public final class GemUseListener implements Listener {
ItemStack target = targetSlot == null ? null : get(event.getPlayer().getInventory(), targetSlot);
GemUseResult result = gemService.use(event.getPlayer(), gem, target);
if (result.consumed() && used != null) {
used.setAmount(used.getAmount() - 1);
EquipmentSlot hand = event.getHand() == null ? EquipmentSlot.HAND : event.getHand();
ItemStack held = get(event.getPlayer().getInventory(), hand);
if (held != null && !held.getType().isAir()) held.setAmount(Math.max(0, held.getAmount() - 1));
if (result.target() != null && targetSlot != null) set(event.getPlayer().getInventory(), targetSlot, result.target());
}
messages.send(event.getPlayer(), result.message());
@@ -37,18 +37,18 @@ public final class LegacyDirectoryImporter {
public ImportResult importIfPresent() {
Path sourceDirectory = pluginsDirectory.resolve(LEGACY_PLUGIN_DIRECTORY).normalize();
if (targetDirectory.equals(sourceDirectory)) {
logger.info("MagicGem migration skipped because the legacy source and target directories are identical: "
logger.info("MagicGem 迁移已跳过:旧版源目录与当前插件目录相同:"
+ sourceDirectory);
return ImportResult.none();
}
if (!Files.isDirectory(sourceDirectory)) {
logger.info("MagicGem migration source not found at " + sourceDirectory + "; no legacy import is needed.");
logger.info("未找到 MagicGem 旧版数据目录:" + sourceDirectory + ",无需迁移。");
return ImportResult.none();
}
Path marker = targetDirectory.resolve(MARKER);
if (Files.exists(marker)) {
logger.info("MagicGem migration already completed; skipping legacy import (marker: " + marker + ").");
logger.info("MagicGem 迁移已完成,跳过重复导入(标记文件:" + marker + ")。");
return new ImportResult(0, 0, true);
}
@@ -56,7 +56,7 @@ public final class LegacyDirectoryImporter {
int conflictFiles = 0;
List<Conflict> conflicts = new ArrayList<>();
try {
logger.info("Starting MagicGem migration: source=" + sourceDirectory + ", target=" + targetDirectory);
logger.info("开始迁移 MagicGem 数据:源目录=" + sourceDirectory + ",目标目录=" + targetDirectory);
Files.createDirectories(targetDirectory);
for (String directory : DATA_DIRECTORIES) {
ImportCounts counts = importDirectory(sourceDirectory.resolve(directory),
@@ -76,16 +76,14 @@ public final class LegacyDirectoryImporter {
writeConflictBackups(sourceDirectory, conflicts);
Files.writeString(marker, "Imported from plugins/MagicGem. Source directory remains unchanged.\n");
for (Conflict conflict : conflicts) {
logger.warning("MagicGem Reforged kept the target file for " + conflict.relativePath()
+ "; the original file was saved under migration-backup/MagicGem.");
logger.warning("MagicGem-Reforged 保留了目标文件 " + conflict.relativePath()
+ ";旧版文件已备份到 migration-backup/MagicGem。");
}
logger.info("Imported original plugins/MagicGem into " + targetDirectory.getFileName()
+ ": " + copiedFiles + " files copied, " + conflictFiles + " conflicts. "
+ "The original directory was left untouched.");
logger.info("已将旧版 plugins/MagicGem 导入 " + targetDirectory.getFileName()
+ ":复制 " + copiedFiles + " 个文件,发现 " + conflictFiles + " 个冲突;旧版目录未被修改。");
return new ImportResult(copiedFiles, conflictFiles, false);
} catch (IOException exception) {
logger.log(Level.SEVERE, "Could not finish importing original plugins/MagicGem. "
+ "The original directory was not deleted; migration will be retried on the next startup.", exception);
logger.log(Level.SEVERE, "导入旧版 plugins/MagicGem 未完成;旧版目录未被删除,插件将在下次启动时重试迁移。", exception);
return new ImportResult(copiedFiles, conflictFiles, false);
}
}
@@ -39,28 +39,36 @@ public final class LegacyItemMigrator {
}
private void readExact(ReadableNBT nbt, Collector collector) {
if (nbt.hasTag(GEM_NAME_KEY) && nbt.getType(GEM_NAME_KEY) == NBTType.NBTTagString) {
registry.find(nbt.getString(GEM_NAME_KEY)).ifPresent(gem -> collector.identity = gem.name());
String identityKey = findKey(nbt, GEM_NAME_KEY);
if (identityKey != null && nbt.getType(identityKey) == NBTType.NBTTagString) {
registry.find(nbt.getString(identityKey)).ifPresent(gem -> collector.identity = gem.name());
}
if (nbt.hasTag(EMBEDDED_GEMS_KEY) && nbt.getType(EMBEDDED_GEMS_KEY) == NBTType.NBTTagCompound) {
ReadableNBT embedded = nbt.getCompound(EMBEDDED_GEMS_KEY);
String embeddedKey = findKey(nbt, EMBEDDED_GEMS_KEY);
if (embeddedKey != null && nbt.getType(embeddedKey) == NBTType.NBTTagCompound) {
ReadableNBT embedded = nbt.getCompound(embeddedKey);
if (embedded != null) for (String name : embedded.getKeys()) {
NBTType type = embedded.getType(name);
if (!numeric(type)) continue;
registry.find(name).ifPresent(gem -> collector.embedded.put(gem.name(), number(embedded, name, type)));
}
}
if (nbt.hasTag(SKILLS_KEY) && nbt.getType(SKILLS_KEY) == NBTType.NBTTagList) {
var skills = nbt.getStringList(SKILLS_KEY);
String skillsKey = findKey(nbt, SKILLS_KEY);
if (skillsKey != null && nbt.getType(skillsKey) == NBTType.NBTTagList) {
var skills = nbt.getStringList(skillsKey);
for (int index = 0; index < skills.size(); index++) collector.skills.add(skills.get(index));
}
}
private String findKey(ReadableNBT nbt, String expected) {
if (nbt == null) return null;
for (String key : nbt.getKeys()) if (key.equalsIgnoreCase(expected)) return key;
return null;
}
private void scan(ReadableNBT nbt, Collector collector, int depth) {
if (nbt == null || depth > 12) return;
for (String key : nbt.getKeys()) {
if (key.equalsIgnoreCase(GEM_NAME_KEY) || key.equalsIgnoreCase(EMBEDDED_GEMS_KEY)
|| key.equalsIgnoreCase(SKILLS_KEY)) continue;
if (key.equalsIgnoreCase(EMBEDDED_GEMS_KEY) || key.equalsIgnoreCase(SKILLS_KEY)) continue;
NBTType type = nbt.getType(key);
try {
if (type == NBTType.NBTTagString) {
@@ -153,6 +153,10 @@ public final class RewardEngine {
private void validatePlayerRewards(RewardContext context, List<Directive> directives) throws RewardException {
Player player = context.player();
// Vault/PlayerPoints may register their services after this plugin enables.
// Refresh before validation so a late service registration cannot reject a valid exchange.
if (!economy.available()) economy.refresh();
if (!points.available()) points.refresh();
double moneyBalance = economy.available() ? economy.balance(player) : 0;
int pointBalance = points.available() ? points.balance(player) : 0;
int levels = player.getLevel();
@@ -1090,13 +1094,11 @@ public final class RewardEngine {
private void money(RewardContext context, Directive directive) throws RewardException {
double amount = amount(context, directive);
if (!economy.available()) throw new RewardException("Vault 经济服务不可用");
if (!economy.change(context.player(), amount)) throw new RewardException("金币不足或经济操作失败");
}
private void point(RewardContext context, Directive directive) throws RewardException {
int amount = (int) Math.round(amount(context, directive));
if (!points.available()) throw new RewardException("PlayerPoints 不可用");
if (!points.change(context.player(), amount)) throw new RewardException("点券不足或操作失败");
}
+1 -1
View File
@@ -63,6 +63,6 @@ public class MagicGem extends MagicGemPlugin {
/** 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.");
sender.sendMessage("MagicGem-Reforged 未配置授权服务;当前版本无需授权。");
}
}
-1
View File
@@ -67,7 +67,6 @@ Version: 1.05
Name: 精准时运切换宝石
Require:
- AXE
- PICKAXE
- SHOVEL
Display: "&8&l&k||&a&l精准/时运切换宝石&8&l&k||"
Tips:
@@ -0,0 +1,33 @@
package cn.infstar.magicgem.item;
import org.bukkit.Material;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ItemMatcherTest {
private final ItemMatcher matcher = new ItemMatcher(null);
@Test
void supportsLegacySuffixRequirements() {
assertTrue(matcher.matchesMaterial(Material.DIAMOND_AXE, "_AXE"));
assertTrue(matcher.matchesMaterial(Material.NETHERITE_AXE, "_AXE"));
assertFalse(matcher.matchesMaterial(Material.DIAMOND_SWORD, "_AXE"));
}
@Test
void preservesOriginalAxeAliasForPickaxes() {
assertTrue(matcher.matchesMaterial(Material.DIAMOND_AXE, "AXE"));
assertTrue(matcher.matchesMaterial(Material.DIAMOND_PICKAXE, "AXE"));
assertFalse(matcher.matchesMaterial(Material.DIAMOND_SHOVEL, "AXE"));
}
@Test
void supportsExplicitMaterialRequirementsUsedByBundledGems() {
assertTrue(matcher.matchesMaterial(Material.SHIELD, "SHIELD"));
assertTrue(matcher.matchesMaterial(Material.FLINT_AND_STEEL, "FLINT_AND_STEEL"));
assertTrue(matcher.matchesMaterial(Material.DIAMOND_PICKAXE, "PICKAXE"));
assertFalse(matcher.matchesMaterial(Material.DIAMOND_PICKAXE, "TRIDENT"));
}
}
@@ -131,6 +131,15 @@ class BundledConfigurationTest {
void precisionFortuneSwitchGemAcceptsPickaxes() {
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(
new File("src/main/resources/gems/FunctionGem.yml"));
assertTrue(yaml.getStringList("内置-精准时运切换宝石.Require").contains("PICKAXE"));
assertTrue(yaml.getStringList("内置-精准时运切换宝石.Require").contains("AXE"));
assertTrue(yaml.getStringList("内置-精准时运切换宝石.Require").contains("SHOVEL"));
}
@Test
void efficiencyGemsAcceptPickaxes() {
YamlConfiguration yaml = YamlConfiguration.loadConfiguration(
new File("src/main/resources/gems/EnchantGem.yml"));
assertTrue(yaml.getStringList("内置-效率宝石.Require").contains("AXE"));
assertTrue(yaml.getStringList("内置-真效率宝石.Require").contains("AXE"));
}
}