diff --git a/src/main/java/org/Little_100/projecte/bedrock/BedrockFormUtil.java b/src/main/java/org/Little_100/projecte/bedrock/BedrockFormUtil.java new file mode 100644 index 0000000..5ca681e --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/BedrockFormUtil.java @@ -0,0 +1,138 @@ +package org.Little_100.projecte.bedrock; + +import org.Little_100.projecte.ProjectE; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.Form; +import org.geysermc.floodgate.api.FloodgateApi; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; + +/** + * 基岩版 Form UI 的通用工具类。 + * + * 架构说明: + * Velocity 代理 + Floodgate 后端模式下, 我们的插件跑在后端, + * 后端没有 Geyser API, 但有 Floodgate API。 + * 所以用 FloodgateApi 来判断玩家是否是基岩版玩家。 + * 发送 Form 用 Cumulus (Cumulus 在 Geyser/Floodgate 任何一方都能工作)。 + * + * 职责: + * - 检测玩家是否是 Floodgate 识别的基岩版玩家 + * - 安全地发送 Cumulus 表单 + * - 维护玩家的 Form Session Token, 防止 Form 重放/双击攻击 + */ +public final class BedrockFormUtil { + + private BedrockFormUtil() {} + + // ==================== Floodgate / Cumulus 检测 ==================== + + private static Boolean floodgateApiAvailable = null; + + public static boolean isFloodgateApiAvailable() { + if (floodgateApiAvailable == null) { + try { + Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + floodgateApiAvailable = true; + } catch (ClassNotFoundException e) { + floodgateApiAvailable = false; + } + } + return floodgateApiAvailable; + } + + /** + * 判断玩家是否是基岩版玩家 (通过 Floodgate 识别)。 + * 任何线程都可调用, 不抛异常。Floodgate 未加载时返回 false。 + */ + public static boolean isBedrockPlayer(Player player) { + if (player == null) return false; + return isBedrockPlayer(player.getUniqueId()); + } + + public static boolean isBedrockPlayer(UUID uuid) { + if (uuid == null) return false; + if (!isFloodgateApiAvailable()) return false; + try { + FloodgateApi api = FloodgateApi.getInstance(); + if (api == null) return false; + return api.isFloodgatePlayer(uuid); + } catch (Throwable t) { + return false; + } + } + + // 保留旧方法名以防别的代码调用 (兼容) + public static boolean isGeyserApiAvailable() { + return isFloodgateApiAvailable(); + } + + // ==================== 表单发送 ==================== + + /** + * 发送 Cumulus 表单给基岩版玩家。主线程调用。 + * + * Floodgate 直接支持 Cumulus Form 发送, 通过 FloodgatePlayer.sendForm。 + * + * @return true 发送成功; false 玩家非基岩版 / Floodgate 未加载 / 发送异常 + */ + public static boolean sendForm(Player player, Form form) { + if (!isBedrockPlayer(player)) return false; + if (form == null) return false; + try { + FloodgateApi api = FloodgateApi.getInstance(); + if (api == null) return false; + org.geysermc.floodgate.api.player.FloodgatePlayer fp = + api.getPlayer(player.getUniqueId()); + if (fp == null) return false; + fp.sendForm(form); + return true; + } catch (Throwable t) { + ProjectE.getInstance().getLogger().log( + Level.WARNING, + "Failed to send Bedrock form to " + player.getName(), + t + ); + return false; + } + } + + // ==================== 防重放 Session Token ==================== + + private static final ConcurrentHashMap currentSession = new ConcurrentHashMap<>(); + + public static UUID newSession(Player player) { + UUID token = UUID.randomUUID(); + currentSession.put(player.getUniqueId(), token); + return token; + } + + public static boolean isSessionValid(Player player, UUID token) { + if (player == null || token == null) return false; + UUID current = currentSession.get(player.getUniqueId()); + return token.equals(current); + } + + public static void clearSession(UUID playerUuid) { + if (playerUuid != null) { + currentSession.remove(playerUuid); + } + } + + // ==================== 显示辅助 ==================== + + public static String formatEmc(long emc) { + return String.format("%,d", emc); + } + + public static String getDisplayName(ItemStack item) { + if (item == null) return ""; + if (item.hasItemMeta() && item.getItemMeta().hasDisplayName()) { + return item.getItemMeta().getDisplayName(); + } + return item.getType().name().toLowerCase().replace('_', ' '); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/TransmutationMainForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/TransmutationMainForm.java new file mode 100644 index 0000000..dfab105 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/TransmutationMainForm.java @@ -0,0 +1,45 @@ +package org.Little_100.projecte.bedrock.transmutation; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.buy.BuyListForm; +import org.Little_100.projecte.bedrock.transmutation.sell.SellEntryForm; +import org.bukkit.entity.Player; +import org.geysermc.cumulus.form.SimpleForm; + +import java.util.UUID; + +/** + * 基岩版转换桌主菜单。入口: /projecte opentable 或 /opentable + */ +public final class TransmutationMainForm { + + private TransmutationMainForm() {} + + public static void open(Player player) { + if (player == null || !player.isOnline()) return; + + UUID token = BedrockFormUtil.newSession(player); + + long emc = ProjectE.getInstance().getDatabaseManager().getPlayerEmc(player.getUniqueId()); + + SimpleForm form = SimpleForm.builder() + .title("§5§l转换桌") + .content("§f你的 EMC: §e" + BedrockFormUtil.formatEmc(emc) + "\n\n§7请选择操作:") + .button("§a§l出售物品\n§7将物品转换为 EMC") + .button("§b§l购买物品\n§7使用 EMC 获取已学习的物品") + .button("§c关闭") + .validResultHandler(response -> { + // 主菜单不需要 session 检查(顶层入口), 但子菜单需要 + if (!player.isOnline()) return; + switch (response.clickedButtonId()) { + case 0 -> SellEntryForm.open(player); + case 1 -> BuyListForm.open(player, 0, ""); + case 2 -> { /* 关闭 */ } + } + }) + .build(); + + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyAmountForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyAmountForm.java new file mode 100644 index 0000000..ed2fd67 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyAmountForm.java @@ -0,0 +1,86 @@ +package org.Little_100.projecte.bedrock.transmutation.buy; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.managers.EmcManager; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.CustomForm; + +/** + * 自定义购买数量。 + * + * 防刷: + * - slider 上限受 MAX_BUY_AMOUNT (EmcManager.MAX_BUY_AMOUNT=576) 和 maxAffordable 双重约束 + * - 回调接收数量后再 clamp 一次, 防止客户端恶意伪造超限值 + * - EMC 扣除最终仍在 EmcManager.buyItem 执行, 那里还有一次完整校验 + */ +public final class BuyAmountForm { + + private static final int IDX_SLIDER = 1; + + private BuyAmountForm() {} + + public static void open(Player player, String itemKey, int returnPage, String returnFilter) { + if (player == null || !player.isOnline()) return; + ProjectE plugin = ProjectE.getInstance(); + + ItemStack sample = plugin.getItemStackFromKey(itemKey); + if (sample == null) { + player.sendMessage("§c物品不可用"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + long unitEmc = plugin.getEmcManager().getEmc(itemKey); + long playerEmc = plugin.getDatabaseManager().getPlayerEmc(player.getUniqueId()); + + if (unitEmc <= 0) { + player.sendMessage("§c该物品无 EMC 值"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + // 计算上限: min(MAX_BUY_AMOUNT, playerEmc/unitEmc, maxStack*9) + int maxAffordable = (int) Math.min(playerEmc / unitEmc, EmcManager.MAX_BUY_AMOUNT); + maxAffordable = Math.min(maxAffordable, sample.getMaxStackSize() * 9); + + if (maxAffordable < 1) { + player.sendMessage("§cEMC 不足, 连 1 个都买不起。"); + BuyChoiceForm.open(player, itemKey, returnPage, returnFilter); + return; + } + + String itemName = BedrockFormUtil.getDisplayName(sample); + String labelText = "§f物品: §e" + itemName + "\n" + + "§f单价: §e" + BedrockFormUtil.formatEmc(unitEmc) + " EMC\n" + + "§f你的 EMC: §e" + BedrockFormUtil.formatEmc(playerEmc) + "\n" + + "§f最多可买: §e" + maxAffordable + " §f个\n\n" + + "§7拖动滑块选择数量:"; + + final int max = maxAffordable; + CustomForm form = CustomForm.builder() + .title("§b§l购买 " + itemName) + .label(labelText) + .slider("数量", 1, max, 1, 1) + .validResultHandler(response -> { + if (!player.isOnline()) return; + + int amount = (int) response.asSlider(IDX_SLIDER); + // [防御]: clamp, 防客户端发送超限值 + if (amount < 1) amount = 1; + if (amount > max) amount = max; + if (amount > EmcManager.MAX_BUY_AMOUNT) amount = EmcManager.MAX_BUY_AMOUNT; + + BuyConfirmForm.openConfirm(player, itemKey, amount, returnPage, returnFilter); + }) + .closedOrInvalidResultHandler(() -> { + if (player.isOnline()) { + BuyChoiceForm.open(player, itemKey, returnPage, returnFilter); + } + }) + .build(); + + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyChoiceForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyChoiceForm.java new file mode 100644 index 0000000..e23374e --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyChoiceForm.java @@ -0,0 +1,168 @@ +package org.Little_100.projecte.bedrock.transmutation.buy; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.ModalForm; +import org.geysermc.cumulus.form.SimpleForm; + +/** + * 购买数量选择 (三按钮 + 自定义)。 + * + * 按钮: + * - 买 1 个 + * - 买 1 组 (maxStack) + * - 自定义数量... + * - 返回 + * + * 贤者之石特判: 强制 1 个, 已有则不允许打开 + * + * 防刷说明: + * - 本 Form 只做选择, 真正 EMC 扣除在 BuyConfirmForm -> EmcManager.buyItem + * - 溢出检查也统一在 EmcManager.buyItem 里做 + */ +public final class BuyChoiceForm { + + private BuyChoiceForm() {} + + public static void open(Player player, String itemKey, int returnPage, String returnFilter) { + if (player == null || !player.isOnline()) return; + ProjectE plugin = ProjectE.getInstance(); + + ItemStack sample = plugin.getItemStackFromKey(itemKey); + if (sample == null) { + player.sendMessage("§c物品不可用"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + long unitEmc = plugin.getEmcManager().getEmc(itemKey); + if (unitEmc <= 0) { + player.sendMessage("§c该物品无 EMC 值"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + long playerEmc = plugin.getDatabaseManager().getPlayerEmc(player.getUniqueId()); + int maxStack = sample.getMaxStackSize(); + + // 贤者之石特判 + boolean isPhilStone = plugin.isPhilosopherStone(sample); + if (isPhilStone) { + if (player.getInventory().containsAtLeast(plugin.getPhilosopherStone(), 1)) { + ModalForm already = ModalForm.builder() + .title("§c已拥有贤者之石") + .content("§7你已经拥有贤者之石, 不能再购买一个。") + .button1("§a返回") + .button2("§c关闭") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) { + BuyListForm.open(player, returnPage, returnFilter); + } + }) + .build(); + BedrockFormUtil.sendForm(player, already); + return; + } + // 贤者之石直接进最终确认 (只能 1 个) + if (playerEmc < unitEmc) { + notEnoughAlert(player, unitEmc, playerEmc, returnPage, returnFilter); + return; + } + BuyConfirmForm.openConfirm(player, itemKey, 1, returnPage, returnFilter); + return; + } + + // 普通物品: 3 按钮 + long costOne = unitEmc; + // 买一组的成本: 溢出检查 (unitEmc * maxStack) + long costStack; + if (unitEmc > Long.MAX_VALUE / maxStack) { + costStack = Long.MAX_VALUE; // 会被 canAffordStack 判定为 false + } else { + costStack = unitEmc * maxStack; + } + + boolean canAffordOne = playerEmc >= costOne; + boolean canAffordStack = playerEmc >= costStack; + + String itemName = BedrockFormUtil.getDisplayName(sample); + String content = "§f物品: §e" + itemName + "\n" + + "§f单价: §e" + BedrockFormUtil.formatEmc(unitEmc) + " EMC\n" + + "§f你的 EMC: §e" + BedrockFormUtil.formatEmc(playerEmc) + "\n\n" + + "§7请选择购买数量:"; + + SimpleForm.Builder builder = SimpleForm.builder() + .title("§b§l购买 " + itemName) + .content(content); + + // 按钮 0: 买 1 + if (canAffordOne) { + builder.button("§a买 1 个\n§7-" + BedrockFormUtil.formatEmc(costOne) + " EMC"); + } else { + builder.button("§8买 1 个 §c(EMC 不足)"); + } + + // 按钮 1: 买 1 组 + if (canAffordStack) { + builder.button("§a买 1 组 (" + maxStack + " 个)\n§7-" + BedrockFormUtil.formatEmc(costStack) + " EMC"); + } else { + builder.button("§8买 1 组 §c(EMC 不足)"); + } + + // 按钮 2: 自定义 + builder.button("§e自定义数量..."); + + // 按钮 3: 返回 + builder.button("§7返回列表"); + + final boolean finalCanAffordOne = canAffordOne; + final boolean finalCanAffordStack = canAffordStack; + final int finalMaxStack = maxStack; + + builder.validResultHandler(response -> { + if (!player.isOnline()) return; + switch (response.clickedButtonId()) { + case 0 -> { + if (finalCanAffordOne) { + BuyConfirmForm.openConfirm(player, itemKey, 1, returnPage, returnFilter); + } else { + BuyListForm.open(player, returnPage, returnFilter); + } + } + case 1 -> { + if (finalCanAffordStack) { + BuyConfirmForm.openConfirm(player, itemKey, finalMaxStack, returnPage, returnFilter); + } else { + BuyListForm.open(player, returnPage, returnFilter); + } + } + case 2 -> BuyAmountForm.open(player, itemKey, returnPage, returnFilter); + case 3 -> BuyListForm.open(player, returnPage, returnFilter); + } + }); + + BedrockFormUtil.sendForm(player, builder.build()); + } + + private static void notEnoughAlert(Player player, long unitEmc, long playerEmc, + int returnPage, String returnFilter) { + ModalForm notEnough = ModalForm.builder() + .title("§cEMC 不足") + .content("§f贤者之石价格: §e" + BedrockFormUtil.formatEmc(unitEmc) + " EMC\n" + + "§f你的 EMC: §e" + BedrockFormUtil.formatEmc(playerEmc) + "\n\n" + + "§c你的 EMC 不足以购买贤者之石。") + .button1("§a返回") + .button2("§c关闭") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) { + BuyListForm.open(player, returnPage, returnFilter); + } + }) + .build(); + BedrockFormUtil.sendForm(player, notEnough); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyConfirmForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyConfirmForm.java new file mode 100644 index 0000000..d08e302 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyConfirmForm.java @@ -0,0 +1,177 @@ +package org.Little_100.projecte.bedrock.transmutation.buy; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.Little_100.projecte.managers.EmcManager; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.ModalForm; + +import java.util.UUID; + +/** + * 购买最终确认。 + * + * 防刷核心: + * 1. Session token 防并发 Form 双重购买 + * 2. 真正的 EMC 扣除和物品发放由 EmcManager.buyItem 独立校验执行: + * - 内部重新查 playerEmc, 防止 UI 显示陈旧 + * - 内部做溢出检查 + * - 内部做贤者之石二次购买检查 + * - UI 显示的预览只是展示, 不影响真实操作 + */ +public final class BuyConfirmForm { + + private BuyConfirmForm() {} + + public static void openConfirm(Player player, String itemKey, int amount, + int returnPage, String returnFilter) { + if (player == null || !player.isOnline()) return; + ProjectE plugin = ProjectE.getInstance(); + + // 参数范围校验 (UI 第一道防线) + if (amount < 1 || amount > EmcManager.MAX_BUY_AMOUNT) { + player.sendMessage("§c数量无效"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + ItemStack sample = plugin.getItemStackFromKey(itemKey); + if (sample == null) { + player.sendMessage("§c物品不可用"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + + long unitEmc = plugin.getEmcManager().getEmc(itemKey); + long playerEmc = plugin.getDatabaseManager().getPlayerEmc(player.getUniqueId()); + + // 溢出检查 + if (unitEmc <= 0 || amount > Long.MAX_VALUE / unitEmc) { + player.sendMessage("§c购买金额异常"); + BuyListForm.open(player, returnPage, returnFilter); + return; + } + long totalCost = unitEmc * amount; + + if (playerEmc < totalCost) { + showNotEnough(player, returnPage, returnFilter); + return; + } + + String itemName = BedrockFormUtil.getDisplayName(sample); + String content = "§f物品: §e" + itemName + "\n" + + "§f数量: §e" + amount + "\n" + + "§f单价: §e" + BedrockFormUtil.formatEmc(unitEmc) + " EMC\n" + + "§f总价: §c-" + BedrockFormUtil.formatEmc(totalCost) + " EMC\n\n" + + "§f购买前: §e" + BedrockFormUtil.formatEmc(playerEmc) + " EMC\n" + + "§f购买后: §e" + BedrockFormUtil.formatEmc(playerEmc - totalCost) + " EMC\n\n" + + "§7确认购买吗?"; + + // 生成 session token 绑定此次确认 + UUID token = BedrockFormUtil.newSession(player); + + ModalForm confirm = ModalForm.builder() + .title("§6§l确认购买") + .content(content) + .button1("§a§l确认购买") + .button2("§c取消") + .validResultHandler(response -> { + if (!player.isOnline()) return; + + // Session 校验 + if (!BedrockFormUtil.isSessionValid(player, token)) { + player.sendMessage("§c此次购买已失效 (可能打开了新的界面), 请重新尝试。"); + return; + } + + if (response.clickedButtonId() != 0) { + BuyChoiceForm.open(player, itemKey, returnPage, returnFilter); + return; + } + + // 执行购买 (EmcManager.buyItem 内部会再次校验一切) + EmcManager.BuyResult result = plugin.getEmcManager() + .buyItem(player, itemKey, amount); + + showResult(player, result, itemName, returnPage, returnFilter); + }) + .build(); + + BedrockFormUtil.sendForm(player, confirm); + } + + private static void showResult(Player player, EmcManager.BuyResult result, + String itemName, int returnPage, String returnFilter) { + String title; + String content; + + switch (result.getStatus()) { + case SUCCESS -> { + long newEmc = ProjectE.getInstance().getDatabaseManager() + .getPlayerEmc(player.getUniqueId()); + title = "§a§l购买成功"; + content = "§f物品: §e" + itemName + " §7x " + result.getActualAmount() + "\n" + + "§f花费: §c-" + BedrockFormUtil.formatEmc(result.getCost()) + " EMC\n\n" + + "§f当前余额: §e" + BedrockFormUtil.formatEmc(newEmc) + " EMC"; + } + case NOT_ENOUGH_EMC -> { + title = "§c购买失败"; + content = "§7EMC 不足。需要 §e" + BedrockFormUtil.formatEmc(result.getCost()) + " §7EMC。"; + } + case ITEM_NOT_AVAILABLE -> { + title = "§c购买失败"; + content = "§7该物品无法生成, 可能是插件数据问题。请联系管理员。"; + } + case ALREADY_OWNED -> { + title = "§c购买失败"; + content = "§7你已经拥有贤者之石, 不能再买一个。"; + } + case INVALID_AMOUNT -> { + title = "§c购买失败"; + content = "§7数量无效或金额溢出。"; + } + default -> { + title = "§c购买失败"; + content = "§7未知错误。"; + } + } + + // 新 session + BedrockFormUtil.newSession(player); + + ModalForm resultForm = ModalForm.builder() + .title(title) + .content(content) + .button1("§a继续购物") + .button2("§7返回主菜单") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) { + BuyListForm.open(player, returnPage, returnFilter); + } else { + TransmutationMainForm.open(player); + } + }) + .build(); + + BedrockFormUtil.sendForm(player, resultForm); + } + + private static void showNotEnough(Player player, int returnPage, String returnFilter) { + ModalForm form = ModalForm.builder() + .title("§cEMC 不足") + .content("§7你的 EMC 不足以完成购买。") + .button1("§a返回") + .button2("§c关闭") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) { + BuyListForm.open(player, returnPage, returnFilter); + } + }) + .build(); + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyListForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyListForm.java new file mode 100644 index 0000000..001ca54 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuyListForm.java @@ -0,0 +1,222 @@ +package org.Little_100.projecte.bedrock.transmutation.buy; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.Little_100.projecte.compatibility.scheduler.SchedulerAdapter; +import org.Little_100.projecte.managers.EmcManager; +import org.Little_100.projecte.managers.SearchLanguageManager; +import org.Little_100.projecte.storage.DatabaseManager; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.ModalForm; +import org.geysermc.cumulus.form.SimpleForm; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * 购买物品列表 (分页, 带异步加载)。 + * + * 唯一使用异步的 Form: + * 1. 异步线程读 learned_items + 玩家 EMC + * 2. 完成后切回主线程构建 Form 并发送 + * + * 防刷说明: + * - 这只是一个展示列表, 真正的 EMC 扣除在 BuyConfirmForm + EmcManager.buyItem + * - 所以这里不需要 session 校验 + * - 但点击物品进入下一级时, 会为下一级生成 token + */ +public final class BuyListForm { + + private static final int PAGE_SIZE = 20; + + private BuyListForm() {} + + public static void open(Player player, int page, String filter) { + if (player == null || !player.isOnline()) return; + + UUID uuid = player.getUniqueId(); + SchedulerAdapter scheduler = ProjectE.getInstance().getSchedulerAdapter(); + + scheduler.runTaskAsynchronously(() -> { + DatabaseManager db = ProjectE.getInstance().getDatabaseManager(); + List learnedKeys = db.getLearnedItems(uuid); + long playerEmc = db.getPlayerEmc(uuid); + + scheduler.runTask(() -> { + if (player.isOnline()) { + buildAndSend(player, learnedKeys, playerEmc, page, filter); + } + }); + }); + } + + private static void buildAndSend(Player player, List learnedKeys, + long playerEmc, int page, String filter) { + EmcManager emcManager = ProjectE.getInstance().getEmcManager(); + SearchLanguageManager searchLang = ProjectE.getInstance().getSearchLanguageManager(); + + List filtered = filterKeys(learnedKeys, filter, searchLang, emcManager); + + if (filtered.isEmpty()) { + handleEmpty(player, filter); + return; + } + + int totalPages = Math.max(1, (int) Math.ceil(filtered.size() / (double) PAGE_SIZE)); + page = Math.max(0, Math.min(page, totalPages - 1)); + int from = page * PAGE_SIZE; + int to = Math.min(from + PAGE_SIZE, filtered.size()); + + String title; + if (filter == null || filter.isEmpty()) { + title = "§b§l购买物品 §7(" + (page + 1) + "/" + totalPages + ")"; + } else { + title = "§b§l搜索: " + filter + " §7(" + (page + 1) + "/" + totalPages + ")"; + } + + String content = "§f你的 EMC: §e" + BedrockFormUtil.formatEmc(playerEmc) + "\n" + + "§f已学习: §e" + learnedKeys.size() + " §f种\n" + + "§7点击物品选择数量购买:"; + + SimpleForm.Builder builder = SimpleForm.builder().title(title).content(content); + + // 第 0 个按钮: 搜索 + builder.button("§e§l🔍 搜索物品"); + + List pageKeys = new ArrayList<>(); + for (int i = from; i < to; i++) { + String key = filtered.get(i); + long unitEmc = emcManager.getEmc(key); + ItemStack sample = ProjectE.getInstance().getItemStackFromKey(key); + String name = sample != null ? BedrockFormUtil.getDisplayName(sample) : key; + + builder.button("§f" + name + "\n§7" + BedrockFormUtil.formatEmc(unitEmc) + " EMC/个"); + pageKeys.add(key); + } + + boolean hasPrev = page > 0; + boolean hasNext = page < totalPages - 1; + if (hasPrev) builder.button("§a« 上一页"); + if (hasNext) builder.button("§a下一页 »"); + builder.button("§7返回主菜单"); + + final int currentPage = page; + final String currentFilter = filter; + final boolean finalHasPrev = hasPrev; + final boolean finalHasNext = hasNext; + final int itemCount = pageKeys.size(); + + builder.validResultHandler(response -> { + if (!player.isOnline()) return; + + int id = response.clickedButtonId(); + + if (id == 0) { + BuySearchForm.open(player); + return; + } + int idx = id - 1; + + if (idx < itemCount) { + String chosenKey = pageKeys.get(idx); + BuyChoiceForm.open(player, chosenKey, currentPage, currentFilter); + return; + } + idx -= itemCount; + + if (finalHasPrev && idx == 0) { + open(player, currentPage - 1, currentFilter); + return; + } + if (finalHasPrev) idx--; + + if (finalHasNext && idx == 0) { + open(player, currentPage + 1, currentFilter); + return; + } + TransmutationMainForm.open(player); + }); + + BedrockFormUtil.sendForm(player, builder.build()); + } + + private static void handleEmpty(Player player, String filter) { + String content; + if (filter == null || filter.isEmpty()) { + content = "§7你还没有学习任何物品。\n§7先通过出售物品解锁它们吧!"; + } else { + content = "§7没有找到匹配 '§e" + filter + "§7' 的已学物品。"; + } + + ModalForm empty = ModalForm.builder() + .title("§e无结果") + .content(content) + .button1("§a返回主菜单") + .button2("§7重新搜索") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) TransmutationMainForm.open(player); + else BuySearchForm.open(player); + }) + .build(); + BedrockFormUtil.sendForm(player, empty); + } + + /** 过滤已学习物品 key 列表, 复用 Java 版搜索逻辑 */ + private static List filterKeys(List allKeys, String filter, + SearchLanguageManager searchLang, + EmcManager emcManager) { + List withEmc = new ArrayList<>(); + for (String key : allKeys) { + if (emcManager.getEmc(key) > 0) withEmc.add(key); + } + + if (filter == null || filter.isEmpty()) return withEmc; + + String searchLower = filter.toLowerCase(); + Map matchingIds = searchLang.findMatchingIds(searchLower); + + List result = new ArrayList<>(); + if (!matchingIds.isEmpty()) { + for (String key : withEmc) { + ItemStack sample = ProjectE.getInstance().getItemStackFromKey(key); + if (sample == null) continue; + + String typeLower = sample.getType().name().toLowerCase(); + String minecraftId = "item.minecraft." + typeLower; + String blockId = "block.minecraft." + typeLower; + String displayName = sample.getItemMeta() != null + && sample.getItemMeta().hasDisplayName() + ? sample.getItemMeta().getDisplayName().toLowerCase() + : typeLower; + + if (matchingIds.containsKey(minecraftId) || matchingIds.containsKey(blockId) + || displayName.contains(searchLower) + || typeLower.contains(searchLower) + || typeLower.replace("_", " ").contains(searchLower)) { + result.add(key); + } + } + } else { + for (String key : withEmc) { + ItemStack sample = ProjectE.getInstance().getItemStackFromKey(key); + if (sample == null) continue; + + String displayName = sample.getItemMeta() != null + && sample.getItemMeta().hasDisplayName() + ? sample.getItemMeta().getDisplayName().toLowerCase() : ""; + String typeName = sample.getType().name().toLowerCase(); + + if (displayName.contains(searchLower) || typeName.contains(searchLower) + || typeName.replace("_", " ").contains(searchLower)) { + result.add(key); + } + } + } + return result; + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuySearchForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuySearchForm.java new file mode 100644 index 0000000..676f7bd --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/buy/BuySearchForm.java @@ -0,0 +1,55 @@ +package org.Little_100.projecte.bedrock.transmutation.buy; + +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.bukkit.entity.Player; +import org.geysermc.cumulus.form.CustomForm; + +/** + * 搜索关键字输入。 + * + * 比 Java 版(关窗口+聊天输入)体验更好: 直接 input 组件, 不用离开 UI。 + * 搜索本身不涉及交易, 无需安全校验。 + */ +public final class BuySearchForm { + + private static final int IDX_INPUT = 1; + + /** 搜索关键字最大长度, 防止构造超长字符串浪费资源 */ + private static final int MAX_KEYWORD_LENGTH = 64; + + private BuySearchForm() {} + + public static void open(Player player) { + if (player == null || !player.isOnline()) return; + + String labelText = "§7输入物品关键字进行搜索。\n\n" + + "§f例如: §e钻石§7, §e铁§7, §e剑§7, §e矿§7\n" + + "§7支持中英文, 留空返回完整列表。"; + + CustomForm form = CustomForm.builder() + .title("§d§l搜索已学物品") + .label(labelText) + .input("关键字", "例如: diamond / 钻石", "") + .validResultHandler(response -> { + if (!player.isOnline()) return; + + String keyword = response.asInput(IDX_INPUT); + if (keyword == null) keyword = ""; + keyword = keyword.trim(); + + // 长度截断 + if (keyword.length() > MAX_KEYWORD_LENGTH) { + keyword = keyword.substring(0, MAX_KEYWORD_LENGTH); + } + + BuyListForm.open(player, 0, keyword); + }) + .closedOrInvalidResultHandler(() -> { + if (player.isOnline()) TransmutationMainForm.open(player); + }) + .build(); + + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/BulkSellForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/BulkSellForm.java new file mode 100644 index 0000000..c6e52b7 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/BulkSellForm.java @@ -0,0 +1,353 @@ +package org.Little_100.projecte.bedrock.transmutation.sell; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.Little_100.projecte.managers.EmcManager; +import org.Little_100.projecte.util.ShulkerBoxUtil; +import org.bukkit.block.ShulkerBox; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.geysermc.cumulus.form.CustomForm; +import org.geysermc.cumulus.form.ModalForm; +import org.geysermc.cumulus.response.CustomFormResponse; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +/** + * 批量出售背包物品。 + * + * 防刷设计 (核心): + * 1. 扫描结果只作预览显示, 不作扣除依据 + * 2. 确认执行时, 对每个 slot 做"当前物品是否仍与 snapshot 一致"校验: + * - 物品 isSimilar 且数量相同才扣 + * - 不一致跳过(玩家可能已挪走) + * 3. EMC 最终加成由 sellItems 根据"实际扣到的物品"重新计算, 不信任扫描的 estimatedEmc + * 4. Session token 防双开 Form 并发确认 + * + * 保护开关 (默认): + * - 仅扫描快捷栏 关 + * - 保护命名物品 开 + * - 保护附魔物品 开 + * - 保护潜影盒 开 + */ +public final class BulkSellForm { + + // CustomForm 组件索引 + private static final int IDX_TOGGLE_HOTBAR_ONLY = 1; + private static final int IDX_TOGGLE_PROTECT_NAMED = 2; + private static final int IDX_TOGGLE_PROTECT_ENCHANTED = 3; + private static final int IDX_TOGGLE_PROTECT_SHULKER = 4; + private static final int IDX_TOGGLE_CONFIRM = 5; + + private BulkSellForm() {} + + public static void open(Player player) { + if (player == null || !player.isOnline()) return; + SellFilter defaultFilter = new SellFilter(false, true, true, true); + openWithFilter(player, defaultFilter); + } + + private static void openWithFilter(Player player, SellFilter filter) { + ScanResult preview = scan(player, filter); + String labelText = buildScanLabel(preview); + + // 每次打开 Form 都生成新 session token + UUID token = BedrockFormUtil.newSession(player); + + CustomForm form = CustomForm.builder() + .title("§6§l批量出售") + .label(labelText) + .toggle("§f仅扫描快捷栏", filter.hotbarOnly) + .toggle("§f保护命名物品", filter.protectNamed) + .toggle("§f保护附魔物品", filter.protectEnchanted) + .toggle("§f保护潜影盒", filter.protectShulker) + .toggle("§c§l确认出售 §7(勾选后点提交)", false) + .validResultHandler(response -> handleSubmit(player, response, token)) + .closedOrInvalidResultHandler(() -> { + if (player.isOnline()) SellEntryForm.open(player); + }) + .build(); + + BedrockFormUtil.sendForm(player, form); + } + + private static void handleSubmit(Player player, CustomFormResponse response, UUID token) { + if (!player.isOnline()) return; + + // [防御]: session 校验 + if (!BedrockFormUtil.isSessionValid(player, token)) { + player.sendMessage("§c此次操作已失效 (可能打开了新的界面), 请重新尝试。"); + return; + } + + SellFilter filter = new SellFilter( + response.asToggle(IDX_TOGGLE_HOTBAR_ONLY), + response.asToggle(IDX_TOGGLE_PROTECT_NAMED), + response.asToggle(IDX_TOGGLE_PROTECT_ENCHANTED), + response.asToggle(IDX_TOGGLE_PROTECT_SHULKER) + ); + boolean confirmed = response.asToggle(IDX_TOGGLE_CONFIRM); + + if (!confirmed) { + // 没勾选确认 -> 重开 (带当前开关状态, 预览更新) + openWithFilter(player, filter); + return; + } + + ScanResult scanForPreview = scan(player, filter); + + if (scanForPreview.snapshots.isEmpty()) { + ModalForm empty = ModalForm.builder() + .title("§e无可出售物品") + .content("§7按当前保护条件, 没有可以出售的物品。") + .button1("§a调整条件") + .button2("§c取消") + .validResultHandler(resp -> { + if (!player.isOnline()) return; + if (resp.clickedButtonId() == 0) openWithFilter(player, filter); + }) + .build(); + BedrockFormUtil.sendForm(player, empty); + return; + } + + // 最终确认 (ModalForm) + String content = "§f即将出售 §e" + scanForPreview.snapshots.size() + "§f 种物品 (共 §e" + + scanForPreview.totalAmount + "§f 个)\n" + + "§f预计获得: §a+" + BedrockFormUtil.formatEmc(scanForPreview.estimatedEmc) + " EMC\n\n" + + "§c⚠ 物品将被消耗, 此操作不可撤销"; + + // 新 session token 给最终确认 + UUID finalToken = BedrockFormUtil.newSession(player); + + ModalForm finalConfirm = ModalForm.builder() + .title("§6§l最终确认") + .content(content) + .button1("§a§l确认出售") + .button2("§c取消") + .validResultHandler(resp -> { + if (!player.isOnline()) return; + if (!BedrockFormUtil.isSessionValid(player, finalToken)) { + player.sendMessage("§c此次操作已失效, 请重新尝试。"); + return; + } + if (resp.clickedButtonId() != 0) { + openWithFilter(player, filter); + return; + } + executeSell(player, filter); + }) + .build(); + + BedrockFormUtil.sendForm(player, finalConfirm); + } + + /** + * 执行出售。 + * + * 关键安全点: 不用扫描时的 ItemStack 快照去扣除, + * 而是在此时重新读每个 slot, 对比 isSimilar+amount 一致才扣。 + * 这样即使玩家在确认期间挪动了物品, 也不会扣错或扣多。 + */ + private static void executeSell(Player player, SellFilter filter) { + PlayerInventory inv = player.getInventory(); + EmcManager emcManager = ProjectE.getInstance().getEmcManager(); + + List itemsToSell = new ArrayList<>(); + int totalAmount = 0; + + // 扫描范围 + int endSlot = filter.hotbarOnly ? 9 : 36; + + for (int i = 0; i < endSlot; i++) { + ItemStack item = inv.getItem(i); + if (item == null || item.getType().isAir()) continue; + + // 当前保护规则 (用最新背包状态重判断, 不信任旧 snapshot) + if (!passesFilter(item, filter)) continue; + + long emc = emcManager.calculateSellEmcFor(item); + if (emc <= 0) continue; + + // 扣掉这个 slot (clone 保留物品元数据) + itemsToSell.add(item.clone()); + totalAmount += item.getAmount(); + inv.setItem(i, null); + } + + if (itemsToSell.isEmpty()) { + player.sendMessage("§c背包物品已变化或保护条件变化, 出售取消。"); + SellEntryForm.open(player); + return; + } + + // 调用核心方法 (内部再次做溢出检查) + EmcManager.SellResult result = emcManager.sellItems(player, itemsToSell); + + // 退回被拒绝的物品 + for (ItemStack rejected : result.getRejected()) { + HashMap remaining = inv.addItem(rejected); + for (ItemStack drop : remaining.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), drop); + } + } + + if (!result.isEmcCredited() && result.getTotalEmc() > 0) { + player.sendMessage("§c出售异常: EMC 未能入账, 请联系管理员。"); + } + + showResult(player, result, itemsToSell.size(), totalAmount); + } + + private static boolean passesFilter(ItemStack item, SellFilter filter) { + if (filter.protectNamed && item.hasItemMeta() + && item.getItemMeta().hasDisplayName()) { + return false; + } + if (filter.protectEnchanted && item.hasItemMeta() + && item.getItemMeta().hasEnchants()) { + return false; + } + if (filter.protectShulker && item.getItemMeta() instanceof BlockStateMeta bsm + && bsm.getBlockState() instanceof ShulkerBox) { + return false; + } + // 潜影盒内部有无 EMC 物品: 和 Java 版一样跳过 + if (item.getItemMeta() instanceof BlockStateMeta bsm + && bsm.getBlockState() instanceof ShulkerBox + && ShulkerBoxUtil.getFirstItemWithoutEmc(item) != null) { + return false; + } + return true; + } + + private static void showResult(Player player, EmcManager.SellResult result, + int kinds, int totalAmount) { + StringBuilder sb = new StringBuilder(); + if (result.isEmcCredited()) { + sb.append("§a§l出售完成!\n\n"); + } else { + sb.append("§e§l出售部分完成\n\n"); + } + sb.append("§f卖出 §e").append(kinds).append("§f 种 §e") + .append(totalAmount).append("§f 个物品\n"); + sb.append("§f获得: §a+").append(BedrockFormUtil.formatEmc(result.getTotalEmc())) + .append(" EMC\n"); + + if (!result.getNewlyLearned().isEmpty()) { + sb.append("\n§d§l✨ 学会了 ").append(result.getNewlyLearned().size()).append(" 种新物品!"); + } + + long newEmc = ProjectE.getInstance().getDatabaseManager().getPlayerEmc(player.getUniqueId()); + sb.append("\n\n§f当前余额: §e").append(BedrockFormUtil.formatEmc(newEmc)).append(" EMC"); + + BedrockFormUtil.newSession(player); + + ModalForm resultForm = ModalForm.builder() + .title("§a§l出售完成") + .content(sb.toString()) + .button1("§a再来一次") + .button2("§7返回主菜单") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) open(player); + else TransmutationMainForm.open(player); + }) + .build(); + BedrockFormUtil.sendForm(player, resultForm); + } + + // ==================== 预览扫描 ==================== + + /** 预览扫描: 只做统计用于显示, 不用于扣除 */ + private static ScanResult scan(Player player, SellFilter filter) { + PlayerInventory inv = player.getInventory(); + EmcManager emcManager = ProjectE.getInstance().getEmcManager(); + + List snapshots = new ArrayList<>(); + int totalAmount = 0; + long estimatedEmc = 0; + int noEmcKinds = 0; + + int endSlot = filter.hotbarOnly ? 9 : 36; + + for (int i = 0; i < endSlot; i++) { + ItemStack item = inv.getItem(i); + if (item == null || item.getType().isAir()) continue; + + if (!passesFilter(item, filter)) continue; + + long emc = emcManager.calculateSellEmcFor(item); + if (emc <= 0) { + noEmcKinds++; + continue; + } + + snapshots.add(item.clone()); + totalAmount += item.getAmount(); + + // 累加时也做溢出保护 + long newTotal = estimatedEmc + emc; + if (newTotal < estimatedEmc) { + // 估算溢出, 停止累加, 给个 long 最大值让用户看到"太多了" + estimatedEmc = Long.MAX_VALUE; + break; + } + estimatedEmc = newTotal; + } + + return new ScanResult(snapshots, totalAmount, estimatedEmc, noEmcKinds); + } + + private static String buildScanLabel(ScanResult result) { + StringBuilder sb = new StringBuilder(); + sb.append("§l§6扫描结果§r\n"); + sb.append("§f可出售物品: §a").append(result.snapshots.size()).append(" §f种 §7(§f") + .append(result.totalAmount).append(" §7个)\n"); + if (result.noEmcKinds > 0) { + sb.append("§f无 EMC 跳过: §c").append(result.noEmcKinds).append(" §f种\n"); + } + sb.append("§f预计获得: §a+").append(BedrockFormUtil.formatEmc(result.estimatedEmc)) + .append(" EMC\n\n"); + sb.append("§7调整保护开关后, 勾选 '确认出售' 并提交。"); + return sb.toString(); + } + + // ==================== 值对象 ==================== + + private static class SellFilter { + final boolean hotbarOnly; + final boolean protectNamed; + final boolean protectEnchanted; + final boolean protectShulker; + + SellFilter(boolean hotbarOnly, boolean protectNamed, + boolean protectEnchanted, boolean protectShulker) { + this.hotbarOnly = hotbarOnly; + this.protectNamed = protectNamed; + this.protectEnchanted = protectEnchanted; + this.protectShulker = protectShulker; + } + } + + private static class ScanResult { + final List snapshots; + final int totalAmount; + final long estimatedEmc; + final int noEmcKinds; + + ScanResult(List snapshots, int totalAmount, + long estimatedEmc, int noEmcKinds) { + this.snapshots = snapshots; + this.totalAmount = totalAmount; + this.estimatedEmc = estimatedEmc; + this.noEmcKinds = noEmcKinds; + } + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/QuickSellForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/QuickSellForm.java new file mode 100644 index 0000000..d20fd5c --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/QuickSellForm.java @@ -0,0 +1,178 @@ +package org.Little_100.projecte.bedrock.transmutation.sell; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.Little_100.projecte.managers.EmcManager; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.geysermc.cumulus.form.ModalForm; + +import java.util.List; +import java.util.UUID; + +/** + * 快速出售手持物品。 + * + * 防刷设计: + * - Session token 防双开 Form 并发点确认 + * - 确认回调时重新读主手物品, 和打开时的 snapshot 比对: + * - 物品类型 isSimilar + * - 数量一致 + * 若不一致(玩家在确认期间换了物品), 拒绝出售。 + * - EMC 计算由 EmcManager.sellItems 内部再做一次, UI 的预估值仅作显示。 + */ +public final class QuickSellForm { + + private QuickSellForm() {} + + public static void open(Player player) { + if (player == null || !player.isOnline()) return; + + ItemStack inHand = player.getInventory().getItemInMainHand(); + + if (inHand == null || inHand.getType().isAir()) { + showSimpleAlert(player, "§c无法出售", + "§7你的主手上没有任何物品。\n§7请先拿起一个想出售的物品再来。", + () -> SellEntryForm.open(player)); + return; + } + + EmcManager emcManager = ProjectE.getInstance().getEmcManager(); + long estimatedEmc = emcManager.calculateSellEmcFor(inHand); + + if (estimatedEmc <= 0) { + showSimpleAlert(player, "§c物品无 EMC 值", + "§f" + BedrockFormUtil.getDisplayName(inHand) + + "§7 没有 EMC 值, 无法出售。\n\n" + + "§7(若为潜影盒, 请检查内部物品是否全部有 EMC 值)", + () -> SellEntryForm.open(player)); + return; + } + + // 做一份 snapshot 用于确认时比对 + ItemStack snapshot = inHand.clone(); + int snapshotAmount = inHand.getAmount(); + long currentEmc = ProjectE.getInstance().getDatabaseManager().getPlayerEmc(player.getUniqueId()); + + String content = "§f物品: §e" + BedrockFormUtil.getDisplayName(inHand) + "\n" + + "§f数量: §e" + snapshotAmount + "\n" + + "§f预计获得: §a+" + BedrockFormUtil.formatEmc(estimatedEmc) + " EMC\n" + + "§f出售后余额: §e~" + BedrockFormUtil.formatEmc(currentEmc + estimatedEmc) + " EMC\n\n" + + "§c⚠ 物品将被消耗, 此操作不可撤销"; + + // 生成 session token 绑定此次确认 + UUID token = BedrockFormUtil.newSession(player); + + ModalForm confirm = ModalForm.builder() + .title("§6§l确认出售") + .content(content) + .button1("§a§l确认出售") + .button2("§c取消") + .validResultHandler(response -> { + if (!player.isOnline()) return; + + // [防御 3]: Session token 校验, 防重放/双击 + if (!BedrockFormUtil.isSessionValid(player, token)) { + player.sendMessage("§c此次出售已失效 (可能打开了新的界面), 请重新尝试。"); + return; + } + + if (response.clickedButtonId() != 0) { + SellEntryForm.open(player); + return; + } + + // [防御 1]: 重新取主手, 校验和 snapshot 一致 + ItemStack current = player.getInventory().getItemInMainHand(); + if (current == null || current.getType().isAir() + || !current.isSimilar(snapshot) + || current.getAmount() != snapshotAmount) { + player.sendMessage("§c主手物品已变化, 出售取消 (防作弊保护)。"); + SellEntryForm.open(player); + return; + } + + // 执行: 先扣主手物品, 再调 sellItems + player.getInventory().setItemInMainHand(null); + EmcManager.SellResult result = ProjectE.getInstance() + .getEmcManager() + .sellItems(player, List.of(current)); + + // 退回被拒绝的 (极端情况) + for (ItemStack rejected : result.getRejected()) { + java.util.HashMap remaining = + player.getInventory().addItem(rejected); + for (ItemStack drop : remaining.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), drop); + } + } + + if (!result.isEmcCredited() && result.getTotalEmc() > 0) { + player.sendMessage("§c出售异常: EMC 未能入账, 请联系管理员。"); + } + + showResult(player, result, current, snapshotAmount); + }) + .build(); + + BedrockFormUtil.sendForm(player, confirm); + } + + private static void showResult(Player player, EmcManager.SellResult result, + ItemStack soldItem, int amount) { + StringBuilder sb = new StringBuilder(); + if (result.getTotalEmc() > 0 && result.isEmcCredited()) { + sb.append("§a出售成功!\n\n"); + } else { + sb.append("§e出售部分完成\n\n"); + } + sb.append("§f物品: §e").append(BedrockFormUtil.getDisplayName(soldItem)) + .append(" §7x ").append(amount).append("\n"); + sb.append("§f获得: §a+").append(BedrockFormUtil.formatEmc(result.getTotalEmc())) + .append(" EMC\n"); + + if (!result.getNewlyLearned().isEmpty()) { + sb.append("\n§d§l✨ 学会了 ").append(result.getNewlyLearned().size()).append(" 种新物品!"); + } + + long newEmc = ProjectE.getInstance().getDatabaseManager().getPlayerEmc(player.getUniqueId()); + sb.append("\n\n§f当前余额: §e").append(BedrockFormUtil.formatEmc(newEmc)).append(" EMC"); + + // 生成新 session + BedrockFormUtil.newSession(player); + + ModalForm resultForm = ModalForm.builder() + .title("§a§l出售完成") + .content(sb.toString()) + .button1("§a继续出售") + .button2("§7返回主菜单") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0) { + SellEntryForm.open(player); + } else { + TransmutationMainForm.open(player); + } + }) + .build(); + + BedrockFormUtil.sendForm(player, resultForm); + } + + private static void showSimpleAlert(Player player, String title, String content, Runnable onBack) { + ModalForm form = ModalForm.builder() + .title(title) + .content(content) + .button1("§a返回") + .button2("§c关闭") + .validResultHandler(response -> { + if (!player.isOnline()) return; + if (response.clickedButtonId() == 0 && onBack != null) { + onBack.run(); + } + }) + .build(); + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/SellEntryForm.java b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/SellEntryForm.java new file mode 100644 index 0000000..b566317 --- /dev/null +++ b/src/main/java/org/Little_100/projecte/bedrock/transmutation/sell/SellEntryForm.java @@ -0,0 +1,36 @@ +package org.Little_100.projecte.bedrock.transmutation.sell; + +import org.Little_100.projecte.ProjectE; +import org.Little_100.projecte.bedrock.BedrockFormUtil; +import org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm; +import org.bukkit.entity.Player; +import org.geysermc.cumulus.form.SimpleForm; + +public final class SellEntryForm { + + private SellEntryForm() {} + + public static void open(Player player) { + if (player == null || !player.isOnline()) return; + + long emc = ProjectE.getInstance().getDatabaseManager().getPlayerEmc(player.getUniqueId()); + + SimpleForm form = SimpleForm.builder() + .title("§a§l出售物品") + .content("§f你的 EMC: §e" + BedrockFormUtil.formatEmc(emc) + "\n\n§7选择出售方式:") + .button("§e§l快速出售手持物品\n§7出售你主手持有的物品") + .button("§6§l批量出售背包物品\n§7扫描背包按条件批量出售") + .button("§7返回主菜单") + .validResultHandler(response -> { + if (!player.isOnline()) return; + switch (response.clickedButtonId()) { + case 0 -> QuickSellForm.open(player); + case 1 -> BulkSellForm.open(player); + case 2 -> TransmutationMainForm.open(player); + } + }) + .build(); + + BedrockFormUtil.sendForm(player, form); + } +} diff --git a/src/main/java/org/Little_100/projecte/gui/GUIListener.java b/src/main/java/org/Little_100/projecte/gui/GUIListener.java index 4ffcb3f..e6eafda 100644 --- a/src/main/java/org/Little_100/projecte/gui/GUIListener.java +++ b/src/main/java/org/Little_100/projecte/gui/GUIListener.java @@ -163,42 +163,9 @@ private void updateSellButton(TransmutationGUI gui) { } private long calculateItemSellEmc(ItemStack item) { - if (item == null || item.getType().isAir()) { - return 0; - } - - EmcManager emcManager = ProjectE.getInstance().getEmcManager(); - KleinStarManager kleinStarManager = ProjectE.getInstance().getKleinStarManager(); - long itemEmc = emcManager.getEmc(item); - - // 处理潜影盒 - if (item.getItemMeta() instanceof BlockStateMeta - && ((BlockStateMeta) item.getItemMeta()).getBlockState() instanceof ShulkerBox) { - if (ShulkerBoxUtil.getFirstItemWithoutEmc(item) == null) { - return (itemEmc + ShulkerBoxUtil.getTotalEmcOfContents(item)) * item.getAmount(); - } - return 0; // 如果潜影盒内有物品没有EMC,则整个潜影盒不能出售 - } - - // 处理卡莱恩之星 - if (kleinStarManager.isKleinStar(item)) { - long baseEmc = emcManager.getEmc(emcManager.getItemKey(item)); - long storedEmc = kleinStarManager.getStoredEmc(item); - return (baseEmc + storedEmc) * item.getAmount(); - } - - if (item.getItemMeta() instanceof Damageable) { - Damageable damageable = (Damageable) item.getItemMeta(); - int maxDurability = item.getType().getMaxDurability(); - if (maxDurability > 0) { - int currentDamage = damageable.getDamage(); - double durabilityPercentage = (double) (maxDurability - currentDamage) / maxDurability; - long durabilityAdjustedEmc = (long) Math.max(1, itemEmc * durabilityPercentage); - return durabilityAdjustedEmc * item.getAmount(); - } - } - - return itemEmc * item.getAmount(); + // 保留方法签名以防其他地方调用, 转发到 EmcManager 的新方法 + // (业务逻辑已抽到 EmcManager.calculateSellEmcFor, Java 版和基岩版共用) + return ProjectE.getInstance().getEmcManager().calculateSellEmcFor(item); } private void handleBuyScreenClick(InventoryClickEvent event, TransmutationGUI gui) { diff --git a/src/main/java/org/Little_100/projecte/managers/CommandManager.java b/src/main/java/org/Little_100/projecte/managers/CommandManager.java index f9bb35d..5da4520 100644 --- a/src/main/java/org/Little_100/projecte/managers/CommandManager.java +++ b/src/main/java/org/Little_100/projecte/managers/CommandManager.java @@ -259,6 +259,13 @@ private void handleOpenTable(CommandSender sender, String commandName) { return; } + // 基岩版玩家走 Form UI, Java 版玩家走原 Inventory GUI + // Geyser 未装或玩家是 Java 版时, isBedrockPlayer 返回 false, 自然走原路径 + if (org.Little_100.projecte.bedrock.BedrockFormUtil.isBedrockPlayer(player)) { + org.Little_100.projecte.bedrock.transmutation.TransmutationMainForm.open(player); + return; + } + new TransmutationGUI(player).open(); } diff --git a/src/main/java/org/Little_100/projecte/managers/EmcManager.java b/src/main/java/org/Little_100/projecte/managers/EmcManager.java index 9e74cb1..bd0f5ef 100644 --- a/src/main/java/org/Little_100/projecte/managers/EmcManager.java +++ b/src/main/java/org/Little_100/projecte/managers/EmcManager.java @@ -5,10 +5,16 @@ import org.Little_100.projecte.compatibility.version.VersionAdapter; import org.Little_100.projecte.devices.*; import org.Little_100.projecte.storage.DatabaseManager; +import org.Little_100.projecte.tools.kleinstar.KleinStarManager; import org.Little_100.projecte.util.Constants; +import org.Little_100.projecte.util.ShulkerBoxUtil; import org.bukkit.Bukkit; import org.bukkit.NamespacedKey; +import org.bukkit.block.ShulkerBox; +import org.bukkit.entity.Player; import org.bukkit.inventory.*; +import org.bukkit.inventory.meta.BlockStateMeta; +import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.persistence.PersistentDataContainer; import org.bukkit.persistence.PersistentDataType; @@ -714,4 +720,301 @@ public void clearCache() { emcCache.clear(); lockedItems.clear(); } + + // ======================================================================== + // 基岩版 Form UI 共用业务逻辑 + // ======================================================================== + // 下述方法是 Java 版 GUIListener 和 基岩版 Form 共用的业务核心。 + // 所有的防刷取 EMC 安全检查都在这里做, 不信任 UI 层的任何计算/判断。 + // + // 线程约束: 必须在主线程调用。 + // ======================================================================== + + /** + * 计算单个物品可出售的 EMC 值。 + * + * 完整复现 GUIListener.calculateItemSellEmc 的行为: + * - 潜影盒: 全内容有 EMC -> (盒 EMC + 内容总 EMC) * 堆叠数量 + * 有任一无 EMC 物品 -> 返回 0 + * - Klein Star: (基础 EMC + 存储 EMC) * 堆叠数量 + * - 有耐久物品的耐久修正由 getEmc() 内部处理 + * - 普通物品: 单个 EMC * 堆叠数量 + * + * 安全设计: + * - 不信任调用方传入的任何预计值, 每次都重新计算 + * - 乘法前做溢出检查, 溢出返回 0 (等同于物品无 EMC) + */ + public long calculateSellEmcFor(ItemStack item) { + if (item == null || item.getType().isAir()) { + return 0; + } + + KleinStarManager kleinStarManager = plugin.getKleinStarManager(); + int amount = item.getAmount(); + if (amount <= 0) return 0; + + // 潜影盒 + if (item.getItemMeta() instanceof BlockStateMeta + && ((BlockStateMeta) item.getItemMeta()).getBlockState() instanceof ShulkerBox) { + if (ShulkerBoxUtil.getFirstItemWithoutEmc(item) == null) { + long itemEmc = getEmc(item); + long contentsEmc = ShulkerBoxUtil.getTotalEmcOfContents(item); + long perBox = safeAdd(itemEmc, contentsEmc); + if (perBox <= 0) return 0; + return safeMultiply(perBox, amount); + } + return 0; + } + + // Klein Star + if (kleinStarManager.isKleinStar(item)) { + long baseEmc = getEmc(getItemKey(item)); + long storedEmc = kleinStarManager.getStoredEmc(item); + long perItem = safeAdd(baseEmc, storedEmc); + if (perItem <= 0) return 0; + return safeMultiply(perItem, amount); + } + + // 普通物品 (耐久修正已经由 getEmc 处理过) + long itemEmc = getEmc(item); + if (itemEmc <= 0) return 0; + return safeMultiply(itemEmc, amount); + } + + /** + * 批量出售物品, 执行扣除/加 EMC/登记学习。 + * + * 这是统一的业务入口。Java 版 GUIListener.handleTransaction 和 + * 基岩版 BulkSellForm / QuickSellForm 都通过这个方法来执行出售。 + * + * 线程: 主线程 + * + * 安全设计: + * - 每件物品独立计算 EMC, 不信任调用方的估算值 + * - 使用 safeAdd 累加总 EMC, 防整数溢出 + * - 加到玩家账户时, 先重新查当前 EMC (可能期间变化), 再叠加 + * - 溢出时拒绝整笔加成, 退回所有物品 (极端保守) + * + * @param player 玩家 + * @param items 要出售的物品 (可包含无 EMC 物品, 方法内会分离) + * @return 结果对象 + */ + public SellResult sellItems(Player player, java.util.List items) { + if (player == null || items == null || items.isEmpty()) { + return new SellResult(0L, java.util.Collections.emptyList(), + java.util.Collections.emptyList(), false); + } + + long totalEmc = 0; + java.util.List rejected = new java.util.ArrayList<>(); + java.util.List newlyLearned = new java.util.ArrayList<>(); + + java.util.UUID uuid = player.getUniqueId(); + + for (ItemStack item : items) { + if (item == null || item.getType().isAir()) continue; + + long emcValue = calculateSellEmcFor(item); + if (emcValue <= 0) { + // 无 EMC 或计算溢出 -> 退回 + rejected.add(item); + continue; + } + + // 总 EMC 加法溢出检查 + long newTotal = safeAdd(totalEmc, emcValue); + if (newTotal < totalEmc) { + // 溢出! 本次出售中止, 剩余物品全部退回 + rejected.add(item); + plugin.getLogger().warning( + "Sell EMC overflow for player " + player.getName() + ", transaction aborted"); + continue; + } + totalEmc = newTotal; + + // 登记学习 + String itemKey = getItemKey(item); + if (!databaseManager.isLearned(uuid, itemKey)) { + newlyLearned.add(itemKey); + } + databaseManager.addLearnedItem(uuid, itemKey); + + // 潜影盒内部物品也登记学习 + if (item.getItemMeta() instanceof BlockStateMeta bsm + && bsm.getBlockState() instanceof ShulkerBox sb) { + for (ItemStack content : sb.getInventory().getContents()) { + if (content != null && !content.getType().isAir()) { + String contentKey = getItemKey(content); + if (!databaseManager.isLearned(uuid, contentKey)) { + newlyLearned.add(contentKey); + } + databaseManager.addLearnedItem(uuid, contentKey); + } + } + } + } + + // 加到玩家账户 (重新读当前值, 不信任任何缓存) + boolean emcCredited = false; + if (totalEmc > 0) { + long currentEmc = databaseManager.getPlayerEmc(uuid); + long newEmc = safeAdd(currentEmc, totalEmc); + if (newEmc < currentEmc) { + // 账户余额溢出: 极端情况, 拒绝本次加成, 物品不退回(已经扣了) + // 但这几乎不可能, long 最大值约 9 * 10^18 + plugin.getLogger().severe( + "Player EMC overflow for " + player.getName() + + " (current=" + currentEmc + ", adding=" + totalEmc + ")"); + } else { + databaseManager.setPlayerEmc(uuid, newEmc); + emcCredited = true; + } + } + + return new SellResult(totalEmc, rejected, newlyLearned, emcCredited); + } + + /** + * 购买指定数量的一种物品。 + * + * 统一购买入口。Java 版 GUIListener.handleBuyScreenClick 和 + * 基岩版 BuyConfirmForm 都通过这个方法执行购买。 + * + * 线程: 主线程 + * + * 安全设计: + * - amount 必须 >= 1, <= MAX_BUY_AMOUNT + * - 乘法做溢出检查 + * - 再次查询玩家 EMC, 不信任 UI 层的显示值 + * - 贤者之石特判: 玩家已拥有则拒绝 + * - 若物品无法从 key 还原, 拒绝并不扣 EMC + * + * @param player 玩家 + * @param itemKey 物品 key + * @param amount 数量 + * @return BuyResult + */ + public BuyResult buyItem(Player player, String itemKey, int amount) { + if (player == null || itemKey == null) { + return new BuyResult(BuyStatus.INVALID_AMOUNT, 0, 0); + } + if (amount < 1 || amount > MAX_BUY_AMOUNT) { + return new BuyResult(BuyStatus.INVALID_AMOUNT, 0, 0); + } + + long unitEmc = getEmc(itemKey); + if (unitEmc <= 0) { + return new BuyResult(BuyStatus.ITEM_NOT_AVAILABLE, 0, 0); + } + + // 溢出保护: amount * unitEmc 不能溢出 + if (amount > Long.MAX_VALUE / unitEmc) { + return new BuyResult(BuyStatus.INVALID_AMOUNT, 0, 0); + } + long totalCost = unitEmc * amount; + + // 先构造物品, 若无法还原则立刻拒绝, 不扣 EMC + ItemStack purchased = plugin.getItemStackFromKey(itemKey); + if (purchased == null) { + return new BuyResult(BuyStatus.ITEM_NOT_AVAILABLE, totalCost, 0); + } + + // 贤者之石二次购买检查 + if (plugin.isPhilosopherStone(purchased)) { + if (amount != 1) { + return new BuyResult(BuyStatus.INVALID_AMOUNT, totalCost, 0); + } + if (player.getInventory().containsAtLeast(plugin.getPhilosopherStone(), 1)) { + return new BuyResult(BuyStatus.ALREADY_OWNED, totalCost, 0); + } + } + + // 再次查当前玩家 EMC, 不信任任何缓存/UI 值 + java.util.UUID uuid = player.getUniqueId(); + long playerEmc = databaseManager.getPlayerEmc(uuid); + if (playerEmc < totalCost) { + return new BuyResult(BuyStatus.NOT_ENOUGH_EMC, totalCost, 0); + } + + // 扣 EMC (写入就是 synchronized) + databaseManager.setPlayerEmc(uuid, playerEmc - totalCost); + + // 给物品 + purchased.setAmount(amount); + java.util.HashMap remaining = player.getInventory().addItem(purchased); + if (!remaining.isEmpty()) { + for (ItemStack drop : remaining.values()) { + player.getWorld().dropItemNaturally(player.getLocation(), drop); + } + } + + return new BuyResult(BuyStatus.SUCCESS, totalCost, amount); + } + + // ====== 安全工具方法 ====== + + /** 一次购买允许的最大数量 (9 组 * 64 = 576) */ + public static final int MAX_BUY_AMOUNT = 9 * 64; + + /** 安全加法, 溢出时返回 Long.MIN_VALUE (调用方可以检测) */ + private static long safeAdd(long a, long b) { + if (a <= 0 || b <= 0) return a + b; + if (a > Long.MAX_VALUE - b) return Long.MIN_VALUE; + return a + b; + } + + /** 安全乘法, 溢出时返回 0 */ + private static long safeMultiply(long value, int amount) { + if (value <= 0 || amount <= 0) return 0; + if (value > Long.MAX_VALUE / amount) return 0; + return value * amount; + } + + // ====== 结果对象 ====== + + public static class SellResult { + private final long totalEmc; + private final java.util.List rejected; + private final java.util.List newlyLearned; + private final boolean emcCredited; + + public SellResult(long totalEmc, java.util.List rejected, + java.util.List newlyLearned, boolean emcCredited) { + this.totalEmc = totalEmc; + this.rejected = rejected; + this.newlyLearned = newlyLearned; + this.emcCredited = emcCredited; + } + + public long getTotalEmc() { return totalEmc; } + public java.util.List getRejected() { return rejected; } + public java.util.List getNewlyLearned() { return newlyLearned; } + /** EMC 是否真的记到账户了 (溢出时为 false) */ + public boolean isEmcCredited() { return emcCredited; } + } + + public static class BuyResult { + private final BuyStatus status; + private final long cost; + private final int actualAmount; + + public BuyResult(BuyStatus status, long cost, int actualAmount) { + this.status = status; + this.cost = cost; + this.actualAmount = actualAmount; + } + + public BuyStatus getStatus() { return status; } + public long getCost() { return cost; } + public int getActualAmount() { return actualAmount; } + public boolean isSuccess() { return status == BuyStatus.SUCCESS; } + } + + public enum BuyStatus { + SUCCESS, + NOT_ENOUGH_EMC, + ITEM_NOT_AVAILABLE, + INVALID_AMOUNT, + ALREADY_OWNED + } } \ No newline at end of file diff --git a/src/main/java/org/Little_100/projecte/storage/DatabaseManager.java b/src/main/java/org/Little_100/projecte/storage/DatabaseManager.java index 375e0e0..33c3cd6 100644 --- a/src/main/java/org/Little_100/projecte/storage/DatabaseManager.java +++ b/src/main/java/org/Little_100/projecte/storage/DatabaseManager.java @@ -109,7 +109,7 @@ private void createTables() { } } - public void setEmc(String itemKey, long emc) { + public synchronized void setEmc(String itemKey, long emc) { setEmc(itemKey, emc, false); } @@ -119,7 +119,7 @@ public void setEmc(String itemKey, long emc) { * @param emc EMC值 * @param locked 是否锁定 */ - public void setEmc(String itemKey, long emc, boolean locked) { + public synchronized void setEmc(String itemKey, long emc, boolean locked) { String sql = "INSERT OR REPLACE INTO emc_values (item_key, emc, locked) VALUES (?, ?, ?);"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, itemKey); @@ -145,7 +145,7 @@ public boolean setEmcIfNotLocked(String itemKey, long emc) { return true; } - public long getEmc(String itemKey) { + public synchronized long getEmc(String itemKey) { String sql = "SELECT emc FROM emc_values WHERE item_key = ?;"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, itemKey); @@ -183,7 +183,7 @@ public boolean isEmcLocked(String itemKey) { * @param itemKey 物品键 * @return 是否存在记录 */ - public boolean hasEmcRecord(String itemKey) { + public synchronized boolean hasEmcRecord(String itemKey) { String sql = "SELECT 1 FROM emc_values WHERE item_key = ?;"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, itemKey); @@ -227,7 +227,7 @@ public void clearUnlockedEmcValues() { } } - public long getPlayerEmc(UUID playerUuid) { + public synchronized long getPlayerEmc(UUID playerUuid) { String sql = "SELECT emc FROM player_emc WHERE player_uuid = ?;"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, playerUuid.toString()); @@ -241,7 +241,7 @@ public long getPlayerEmc(UUID playerUuid) { return 0; } - public void setPlayerEmc(UUID playerUuid, long emc) { + public synchronized void setPlayerEmc(UUID playerUuid, long emc) { String sql = "INSERT OR REPLACE INTO player_emc (player_uuid, emc) VALUES (?, ?);"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, playerUuid.toString()); @@ -252,7 +252,7 @@ public void setPlayerEmc(UUID playerUuid, long emc) { } } - public void addLearnedItem(UUID playerUuid, String itemKey) { + public synchronized void addLearnedItem(UUID playerUuid, String itemKey) { String sql = "INSERT OR IGNORE INTO learned_items (player_uuid, item_key) VALUES (?, ?);"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, playerUuid.toString()); @@ -271,7 +271,7 @@ public boolean learnItem(UUID playerUuid, String itemKey) { return true; } - public java.util.List getLearnedItems(UUID playerUuid) { + public synchronized java.util.List getLearnedItems(UUID playerUuid) { java.util.List learnedItems = new java.util.ArrayList<>(); String sql = "SELECT item_key FROM learned_items WHERE player_uuid = ?;"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { @@ -286,7 +286,7 @@ public java.util.List getLearnedItems(UUID playerUuid) { return learnedItems; } - public boolean isLearned(UUID playerUuid, String itemKey) { + public synchronized boolean isLearned(UUID playerUuid, String itemKey) { String sql = "SELECT 1 FROM learned_items WHERE player_uuid = ? AND item_key = ? LIMIT 1;"; try (PreparedStatement pstmt = connection.prepareStatement(sql)) { pstmt.setString(1, playerUuid.toString());