4 Commits

Author SHA1 Message Date
Purpur Build 8febf11d63 Update release target to Gitea 2026-07-04 12:23:08 +08:00
Purpur Build 30f60c2849 Add Purpur and XConomy placeholder compatibility 2026-07-04 12:20:18 +08:00
nachonekoawa 5d0595e1c0 删除 AGENTS.md 2026-07-03 21:06:20 +08:00
nachonekoawa 9b382320a5 删除 CLAUDE.md 2026-07-02 10:38:48 +08:00
9 changed files with 362 additions and 20 deletions
+3 -3
View File
@@ -4,8 +4,8 @@
## 仓库
- 远端:私有仓库 `dmz7p4xmqh-coder/CraftBank`(默认分支 `main`)。
- `gh``git` 已安装并登录,可直接用于推送与发行
- 远端:私有 Gitea 仓库 `https://gitea.kernelneko.top/nachonekoawa/CraftBank`(默认分支 `main`)。
- `git` 已安装;Gitea 推送与发行使用用户提供的访问令牌,运行时通过环境变量或临时请求头使用,严禁把 token 写入仓库、README 或 git remote
## 每次改动后的固定流程
@@ -14,7 +14,7 @@
1. **构建验证**:运行 `./gradlew shadowJar`,确认编译通过并产出 `build/libs/CraftBank-<version>.jar`
2. **更新 README**:若改动影响功能、指令、权限、配置项或 PlaceholderAPI 变量,同步更新 `README.md`
3. **提交并推送**到 `main`
4. **发布 Release**`gh release create` 创建新发行版,附上编译好的 jar。
4. **发布 Release**在 Gitea 创建新发行版,附上编译好的 jar。
## 版本号约定(语义化版本)
+3 -3
View File
@@ -4,8 +4,8 @@
## 仓库
- 远端:私有仓库 `dmz7p4xmqh-coder/CraftBank`(默认分支 `main`)。
- `gh``git` 已安装并登录,可直接用于推送与发行
- 远端:私有 Gitea 仓库 `https://gitea.kernelneko.top/nachonekoawa/CraftBank`(默认分支 `main`)。
- `git` 已安装;Gitea 推送与发行使用用户提供的访问令牌,运行时通过环境变量或临时请求头使用,严禁把 token 写入仓库、README 或 git remote
## 每次改动后的固定流程
@@ -14,7 +14,7 @@
1. **构建验证**:运行 `./gradlew shadowJar`,确认编译通过并产出 `build/libs/CraftBank-<version>.jar`
2. **更新 README**:若改动影响功能、指令、权限、配置项或 PlaceholderAPI 变量,同步更新 `README.md`
3. **提交并推送**到 `main`
4. **发布 Release**`gh release create` 创建新发行版,附上编译好的 jar。
4. **发布 Release**在 Gitea 创建新发行版,附上编译好的 jar。
## 版本号约定(语义化版本)
+30 -4
View File
@@ -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` 原子置位防重复发放。
+1 -1
View File
@@ -4,7 +4,7 @@ plugins {
}
group = "com.craftbank"
version = "1.1.0"
version = "1.2.0"
// 用当前 JDK25)编译,通过 --release 21 产出兼容 Java 21 的字节码,
// 避免 toolchain 触发额外的 JDK 下载。Folia 要求运行时为 Java 21+。
+21 -8
View File
@@ -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);
}
/** 异步持久化某账户 (脏标记已由数据变更方法设置)。 */
@@ -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;
}
// ---------------------------------------------------------------------
// 定期存款
// ---------------------------------------------------------------------
@@ -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<Map.Entry<String, Double>> 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);
}
}
@@ -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<Object>) 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<Object>) 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<Object>) 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<Object>) 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;
}
}
+1 -1
View File
@@ -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]