From 30f60c2849b31de61f32aee993e64f4ac7d93029 Mon Sep 17 00:00:00 2001 From: Purpur Build Date: Sat, 4 Jul 2026 11:50:36 +0800 Subject: [PATCH] Add Purpur and XConomy placeholder compatibility --- README.md | 34 +++- build.gradle.kts | 2 +- src/main/java/com/craftbank/CraftBank.java | 29 ++- .../craftbank/database/DatabaseManager.java | 15 ++ .../com/craftbank/hooks/XConomyExpansion.java | 192 ++++++++++++++++++ .../craftbank/platform/PlatformScheduler.java | 96 +++++++++ src/main/resources/plugin.yml | 2 +- 7 files changed, 356 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/craftbank/hooks/XConomyExpansion.java create mode 100644 src/main/java/com/craftbank/platform/PlatformScheduler.java diff --git a/README.md b/README.md index dc8b034..bae1615 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # 🏦 CraftBank(工艺银行) -> 适用于 **Folia 1.21.8** 的全能经济与银行核心插件 —— 旨在完全替代 XConomy 等传统经济插件。 +> 适用于 **Folia / Purpur 1.21.8** 的全能经济与银行核心插件 —— 旨在完全替代 XConomy 等传统经济插件。 [![Folia](https://img.shields.io/badge/Folia-1.21.8-blue)](https://papermc.io/software/folia) +[![Purpur](https://img.shields.io/badge/Purpur-1.21.8-purple)](https://purpurmc.org/) [![Java](https://img.shields.io/badge/Java-21%2B-orange)](https://adoptium.net/) [![Vault](https://img.shields.io/badge/Vault-required-green)](https://www.spigotmc.org/resources/vault.34315/) -CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模拟的银行系统:实体银行卡、可交易支票、活期储蓄与严格的定期存款(带利息)。全程遵循 **Folia 多核服务端**的线程模型,使用 `AsyncScheduler` / `GlobalRegionScheduler` / `RegionScheduler` 调度,绝不使用已弃用的 `Bukkit.getScheduler()`。 +CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模拟的银行系统:实体银行卡、可交易支票、活期储蓄与严格的定期存款(带利息)。在 Folia 上遵循多核服务端线程模型,使用 `AsyncScheduler` / `GlobalRegionScheduler` / `RegionScheduler` 调度;在 Purpur / Paper 上自动回退到 Bukkit 同步 / 异步任务调度。 --- @@ -15,6 +16,7 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 | 模块 | 说明 | |------|------| | 💵 **核心经济** | 每位玩家独立现金账户;实现 Vault `Economy` 接口并以 `Highest` 优先级注册,接管全服扣款 | +| 🧩 **平台兼容** | 同一个 jar 支持 Folia 与 Purpur/Paper;运行时自动选择正确调度器 | | 💳 **银行卡** | 实体物品,PDC 绑定 UUID + 序列号;右键指定方块开柜;丢失可挂失补办(旧卡作废)| | 📜 **支票** | `/cheque <金额>` 具现化为可流通实体物品;右键兑现入钱包;PDC 防伪 + 防刷 | | 💰 **活期储蓄** | 存取自由,按真实时间戳逐日复利结算(含离线补偿,带封顶)| @@ -65,6 +67,8 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 ## 🔌 PlaceholderAPI 变量 +### CraftBank 原生变量 + | 变量 | 含义 | |------|------| | `%craftbank_cash%` | 现金(纯数字)| @@ -76,6 +80,28 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 | `%craftbank_has_card%` | 是否持有有效银行卡 | | `%craftbank_savings_rate%` | 当前活期日利率百分比 | +### XConomy 兼容变量 + +CraftBank 会额外注册 `xconomy` PlaceholderAPI 扩展,原先使用 XConomy 的变量无需替换即可继续解析。 + +| 变量 | 含义 | +|------|------| +| `%xconomy_balance%` | 现金余额,按 XConomy 默认显示格式输出 | +| `%xconomy_balance_value%` | 现金余额原始数字 | +| `%xconomy_balance_formatted%` | 现金余额缩写格式(k / m)| +| `%xconomy_top_player_<名次>%` | 指定名次的玩家名,例如 `%xconomy_top_player_1%` | +| `%xconomy_top_balance_<名次>%` | 指定名次的余额 | +| `%xconomy_top_balance_value_<名次>%` | 指定名次的余额原始数字 | +| `%xconomy_top_balance_formatted_<名次>%` | 指定名次的余额缩写格式 | +| `%xconomy_top_rank%` | 当前玩家现金排行榜名次 | +| `%xconomy_top_rank_<玩家名>%` | 指定玩家现金排行榜名次 | +| `%xconomy_top_hidden%` | 兼容返回 `0`(CraftBank 不隐藏排行榜)| +| `%xconomy_paypermission%` | 兼容返回 `DEFAULT` | +| `%xconomy_paypermission_global%` | 兼容返回 `1` | +| `%xconomy_paytoggle%` | 兼容返回 `1` | +| `%xconomy_sum_balance%` | 全服现金总额 | +| `%xconomy_sum_balance_value%` | 全服现金总额原始数字 | + --- ## ⚙️ 配置文件 @@ -97,8 +123,8 @@ CraftBank 内置完善的「个人钱包 / 现金」系统,并引入深度模 ## 🤖 Folia 线程安全设计 -- **后台任务**(数据库、`/baltop` 排序、利息扫描)走 `AsyncScheduler`。 -- **玩家相关操作**(扣物品、开 GUI、发消息)从异步切回时调度到 `player.getScheduler()`(实体区域线程)。 +- **Folia 后台任务**(数据库、`/baltop` 排序、利息扫描)走 `AsyncScheduler`;Purpur/Paper 走 Bukkit 异步任务。 +- **Folia 玩家相关操作**(扣物品、开 GUI、发消息)从异步切回时调度到 `player.getScheduler()`(实体区域线程);Purpur/Paper 走 Bukkit 主线程任务。 - 经济数据缓存使用 `ConcurrentHashMap`,单账户余额变更用 `synchronized` 保证原子,抵御来自其它插件异步线程(经 Vault)的并发刷钱。 - 支票兑现在玩家区域线程独占操作物品栏;定期领取用数据库 `UPDATE ... WHERE claimed = 0` 原子置位防重复发放。 diff --git a/build.gradle.kts b/build.gradle.kts index a062078..b545dc9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,7 +4,7 @@ plugins { } group = "com.craftbank" -version = "1.1.0" +version = "1.2.0" // 用当前 JDK(25)编译,通过 --release 21 产出兼容 Java 21 的字节码, // 避免 toolchain 触发额外的 JDK 下载。Folia 要求运行时为 Java 21+。 diff --git a/src/main/java/com/craftbank/CraftBank.java b/src/main/java/com/craftbank/CraftBank.java index 0212449..c7c74bc 100644 --- a/src/main/java/com/craftbank/CraftBank.java +++ b/src/main/java/com/craftbank/CraftBank.java @@ -15,6 +15,7 @@ import com.craftbank.economy.VaultEconomyProvider; import com.craftbank.gui.BankGUI; import com.craftbank.gui.PendingInput; import com.craftbank.hooks.CraftBankExpansion; +import com.craftbank.hooks.XConomyExpansion; import com.craftbank.items.BankCardManager; import com.craftbank.items.ChequeManager; import com.craftbank.items.Keys; @@ -22,9 +23,9 @@ import com.craftbank.listeners.InteractListener; import com.craftbank.listeners.InventoryListener; import com.craftbank.listeners.PlayerConnectionListener; import com.craftbank.model.PlayerAccount; +import com.craftbank.platform.PlatformScheduler; import com.craftbank.util.Msg; import net.milkbowl.vault.economy.Economy; -import org.bukkit.Bukkit; import org.bukkit.command.PluginCommand; import org.bukkit.command.TabExecutor; import org.bukkit.plugin.ServicePriority; @@ -50,6 +51,8 @@ public final class CraftBank extends JavaPlugin { private ChequeManager chequeManager; private BankGUI bankGUI; private VaultEconomyProvider vaultProvider; + private PlatformScheduler scheduler; + private XConomyExpansion xConomyExpansion; private Msg msg; @Override @@ -82,6 +85,7 @@ public final class CraftBank extends JavaPlugin { this.bankCardManager = new BankCardManager(this); this.chequeManager = new ChequeManager(this); this.bankGUI = new BankGUI(this); + this.scheduler = new PlatformScheduler(this); registerVault(); registerCommands(); @@ -95,7 +99,7 @@ public final class CraftBank extends JavaPlugin { runAsync(() -> economyManager.loadBlocking(p.getUniqueId(), p.getName(), true)); } - getLogger().info("CraftBank 已启用 (Folia 模式)。"); + getLogger().info("CraftBank 已启用 (" + (scheduler.isFolia() ? "Folia" : "Purpur/Paper") + " 模式)。"); } @Override @@ -154,7 +158,10 @@ public final class CraftBank extends JavaPlugin { if (getServer().getPluginManager().getPlugin("PlaceholderAPI") != null) { try { new CraftBankExpansion(this).register(); - getLogger().info("已挂接 PlaceholderAPI。"); + this.xConomyExpansion = new XConomyExpansion(this); + this.xConomyExpansion.register(); + runAsync(this.xConomyExpansion::refreshCache); + getLogger().info("已挂接 PlaceholderAPI (craftbank + xconomy 兼容变量)。"); } catch (Throwable t) { getLogger().warning("PlaceholderAPI 挂接失败: " + t.getMessage()); } @@ -164,7 +171,7 @@ public final class CraftBank extends JavaPlugin { private void startBackgroundTasks() { long periodMinutes = 60L; // 后台周期任务: 结算在线玩家活期利息 + 自动结算到期定期存款。 - getServer().getAsyncScheduler().runAtFixedRate(this, task -> { + scheduler.runAsyncRepeating(() -> { for (PlayerAccount account : economyManager.getAllCached()) { bankManager.settleSavingsInterest(account); } @@ -172,13 +179,19 @@ public final class CraftBank extends JavaPlugin { }, periodMinutes, periodMinutes, TimeUnit.MINUTES); // 定期落库脏账户, 降低崩溃丢数据风险。 - getServer().getAsyncScheduler().runAtFixedRate(this, task -> { + scheduler.runAsyncRepeating(() -> { for (PlayerAccount account : economyManager.getAllCached()) { if (account.isDirty()) { databaseManager.saveAccount(account); } } }, 5L, 5L, TimeUnit.MINUTES); + + scheduler.runAsyncRepeating(() -> { + if (xConomyExpansion != null) { + xConomyExpansion.refreshCache(); + } + }, 5L, 5L, TimeUnit.MINUTES); } // --------------------------------------------------------------------- @@ -187,17 +200,17 @@ public final class CraftBank extends JavaPlugin { /** 在异步线程立即执行 (用于数据库读写等纯后台任务)。 */ public void runAsync(Runnable runnable) { - getServer().getAsyncScheduler().runNow(this, task -> runnable.run()); + scheduler.runAsync(runnable); } /** 在指定玩家所在的实体区域线程执行 (用于操作物品、打开 GUI、发消息)。 */ public void runForPlayer(org.bukkit.entity.Player player, Runnable runnable) { - player.getScheduler().run(this, task -> runnable.run(), null); + scheduler.runForPlayer(player, runnable); } /** 在全局区域线程执行。 */ public void runGlobal(Runnable runnable) { - getServer().getGlobalRegionScheduler().run(this, task -> runnable.run()); + scheduler.runGlobal(runnable); } /** 异步持久化某账户 (脏标记已由数据变更方法设置)。 */ diff --git a/src/main/java/com/craftbank/database/DatabaseManager.java b/src/main/java/com/craftbank/database/DatabaseManager.java index 1eb4167..cc649f3 100644 --- a/src/main/java/com/craftbank/database/DatabaseManager.java +++ b/src/main/java/com/craftbank/database/DatabaseManager.java @@ -187,6 +187,21 @@ public class DatabaseManager { return result; } + /** 全服现金总额。 */ + public double getTotalCash() { + String sql = "SELECT SUM(cash) FROM craftbank_players"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement ps = conn.prepareStatement(sql); + ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + return rs.getDouble(1); + } + } catch (SQLException ex) { + plugin.getLogger().log(Level.SEVERE, "读取全服现金总额失败", ex); + } + return 0.0; + } + // --------------------------------------------------------------------- // 定期存款 // --------------------------------------------------------------------- diff --git a/src/main/java/com/craftbank/hooks/XConomyExpansion.java b/src/main/java/com/craftbank/hooks/XConomyExpansion.java new file mode 100644 index 0000000..5083813 --- /dev/null +++ b/src/main/java/com/craftbank/hooks/XConomyExpansion.java @@ -0,0 +1,192 @@ +package com.craftbank.hooks; + +import com.craftbank.CraftBank; +import com.craftbank.model.PlayerAccount; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.ChatColor; +import org.bukkit.OfflinePlayer; +import org.jetbrains.annotations.NotNull; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.text.DecimalFormat; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * XConomy PlaceholderAPI 兼容层, 让原有 %xconomy_*% 变量无需替换即可使用。 + */ +public final class XConomyExpansion extends PlaceholderExpansion { + + private static final int RANKING_SIZE = 100; + + private final CraftBank plugin; + private final DecimalFormat numberFormat = new DecimalFormat("#,##0.00"); + private final List> top = new CopyOnWriteArrayList<>(); + private volatile double sumBalance; + + public XConomyExpansion(CraftBank plugin) { + this.plugin = plugin; + } + + @Override + public @NotNull String getIdentifier() { + return "xconomy"; + } + + @Override + public @NotNull String getAuthor() { + return "CraftBank"; + } + + @Override + public @NotNull String getVersion() { + return plugin.getDescription().getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public String onRequest(OfflinePlayer player, @NotNull String identifier) { + String id = identifier.toLowerCase(); + + if (id.equals("balance") || id.equals("balance_value") || id.equals("balance_formatted")) { + double cash = getCash(player); + return switch (id) { + case "balance_value" -> decimal(cash); + case "balance_formatted" -> formatCompact(cash); + default -> formatDisplay(cash); + }; + } + + if (id.startsWith("top_player_")) { + Integer index = parseIndex(id, "top_player_"); + if (index == null) { + return "[XConomy]Invalid index"; + } + return topName(index); + } + + if (id.equals("top_rank") || id.startsWith("top_rank_")) { + String name = id.equals("top_rank") ? (player == null ? null : player.getName()) + : identifier.substring("top_rank_".length()); + return rankOf(name); + } + + if (id.startsWith("top_balance_formatted_")) { + Integer index = parseIndex(id, "top_balance_formatted_"); + return index == null ? "[XConomy]Invalid index" : topBalance(index, true, false); + } + + if (id.startsWith("top_balance_value_")) { + Integer index = parseIndex(id, "top_balance_value_"); + return index == null ? "[XConomy]Invalid index" : topBalance(index, false, true); + } + + if (id.startsWith("top_balance_")) { + Integer index = parseIndex(id, "top_balance_"); + return index == null ? "[XConomy]Invalid index" : topBalance(index, false, false); + } + + if (id.contains("top_hidden")) { + return "0"; + } + + if (id.contains("paypermission")) { + if (id.contains("_global")) { + return "1"; + } + return player == null ? "Error" : "DEFAULT"; + } + + if (id.equals("paytoggle")) { + return player == null ? "Error" : "1"; + } + + if (id.contains("sum_balance")) { + return id.contains("sum_balance_value") ? decimal(sumBalance) : formatDisplay(sumBalance); + } + + return null; + } + + public void refreshCache() { + top.clear(); + top.addAll(plugin.getDatabaseManager().getTopCash(RANKING_SIZE)); + sumBalance = plugin.getDatabaseManager().getTotalCash(); + } + + private double getCash(OfflinePlayer player) { + if (player == null) { + return 0.0; + } + PlayerAccount account = plugin.getEconomyManager().getCached(player.getUniqueId()); + return account == null ? 0.0 : account.getCash(); + } + + private String topName(int oneBasedIndex) { + if (oneBasedIndex > top.size()) { + return "No data"; + } + return top.get(oneBasedIndex - 1).getKey(); + } + + private String topBalance(int oneBasedIndex, boolean compact, boolean raw) { + if (oneBasedIndex > top.size()) { + return "No data"; + } + double balance = top.get(oneBasedIndex - 1).getValue(); + if (raw) { + return decimal(balance); + } + return compact ? formatCompact(balance) : formatDisplay(balance); + } + + private String rankOf(String name) { + if (name == null) { + return "No data"; + } + for (int i = 0; i < top.size(); i++) { + if (name.equalsIgnoreCase(top.get(i).getKey())) { + return Integer.toString(i + 1); + } + } + return "No data"; + } + + private Integer parseIndex(String id, String prefix) { + String value = id.substring(prefix.length()); + if (!value.matches("\\d+") || value.matches("0+")) { + return null; + } + return Integer.parseInt(value); + } + + private String formatDisplay(double amount) { + return ChatColor.translateAlternateColorCodes('&', numberFormat.format(amount) + " " + + (round(amount).compareTo(BigDecimal.ONE) == 0 ? "dollar" : "dollars")); + } + + private String formatCompact(double amount) { + double abs = Math.abs(amount); + if (abs >= 1_000_000.0) { + return numberFormat.format(amount / 1_000_000.0) + "m dollars"; + } + if (abs >= 1_000.0) { + return numberFormat.format(amount / 1_000.0) + "k dollars"; + } + return formatDisplay(amount); + } + + private String decimal(double amount) { + return round(amount).toString(); + } + + private BigDecimal round(double amount) { + return BigDecimal.valueOf(amount).setScale(2, RoundingMode.DOWN); + } +} diff --git a/src/main/java/com/craftbank/platform/PlatformScheduler.java b/src/main/java/com/craftbank/platform/PlatformScheduler.java new file mode 100644 index 0000000..05f2c56 --- /dev/null +++ b/src/main/java/com/craftbank/platform/PlatformScheduler.java @@ -0,0 +1,96 @@ +package com.craftbank.platform; + +import com.craftbank.CraftBank; +import org.bukkit.entity.Player; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +/** + * 在 Folia 上使用区域调度器, 在 Purpur/Paper 上回退到 Bukkit 调度器。 + */ +public final class PlatformScheduler { + + private final CraftBank plugin; + private final boolean folia; + + public PlatformScheduler(CraftBank plugin) { + this.plugin = plugin; + this.folia = hasMethod(plugin.getServer().getClass(), "getGlobalRegionScheduler") + && hasMethod(plugin.getServer().getClass(), "getAsyncScheduler"); + } + + public boolean isFolia() { + return folia; + } + + public void runAsync(Runnable runnable) { + if (!folia) { + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, runnable); + return; + } + invokeAsync("runNow", new Class[] {org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class}, + plugin, (java.util.function.Consumer) task -> runnable.run()); + } + + public void runForPlayer(Player player, Runnable runnable) { + if (!folia) { + plugin.getServer().getScheduler().runTask(plugin, runnable); + return; + } + try { + Object scheduler = player.getClass().getMethod("getScheduler").invoke(player); + Method run = scheduler.getClass().getMethod("run", + org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, Runnable.class); + run.invoke(scheduler, plugin, (java.util.function.Consumer) task -> runnable.run(), null); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Folia player scheduler invocation failed", ex); + } + } + + public void runGlobal(Runnable runnable) { + if (!folia) { + plugin.getServer().getScheduler().runTask(plugin, runnable); + return; + } + try { + Object scheduler = plugin.getServer().getClass().getMethod("getGlobalRegionScheduler").invoke(plugin.getServer()); + Method run = scheduler.getClass().getMethod("run", + org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class); + run.invoke(scheduler, plugin, (java.util.function.Consumer) task -> runnable.run()); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Folia global scheduler invocation failed", ex); + } + } + + public void runAsyncRepeating(Runnable runnable, long initialDelay, long period, TimeUnit unit) { + if (!folia) { + long initialTicks = Math.max(1L, unit.toSeconds(initialDelay) * 20L); + long periodTicks = Math.max(1L, unit.toSeconds(period) * 20L); + plugin.getServer().getScheduler().runTaskTimerAsynchronously(plugin, runnable, initialTicks, periodTicks); + return; + } + invokeAsync("runAtFixedRate", + new Class[] {org.bukkit.plugin.Plugin.class, java.util.function.Consumer.class, long.class, long.class, TimeUnit.class}, + plugin, (java.util.function.Consumer) task -> runnable.run(), initialDelay, period, unit); + } + + private void invokeAsync(String name, Class[] parameterTypes, Object... args) { + try { + Object scheduler = plugin.getServer().getClass().getMethod("getAsyncScheduler").invoke(plugin.getServer()); + Method method = scheduler.getClass().getMethod(name, parameterTypes); + method.invoke(scheduler, args); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException("Folia async scheduler invocation failed", ex); + } + } + + private static boolean hasMethod(Class type, String name) { + for (Method method : type.getMethods()) { + if (method.getName().equals(name)) { + return true; + } + } + return false; + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2cbf0cc..baa3f28 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -4,7 +4,7 @@ main: com.craftbank.CraftBank api-version: '1.21' folia-supported: true authors: [CraftBank] -description: All-in-one economy and bank core plugin for Folia 1.21.8. +description: All-in-one economy and bank core plugin for Folia/Purpur 1.21.8. softdepend: [Vault, PlaceholderAPI] loadbefore: [Vault]