第二十六批:箱子搬运(精准采集斧拆箱2秒窗口打包+放置回填)、/json消息构造器GUI(tellraw/标题/字幕+颜色+原版报错接管)、背包备份改每小时0/15/30/45分准点
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s

This commit is contained in:
youpaishidifu
2026-10-16 19:03:47 +08:00
parent 6f831f065b
commit cbc3cb4db7
7 changed files with 1080 additions and 15 deletions
@@ -0,0 +1,275 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.BlockState;
import org.bukkit.block.data.type.Chest;
import org.bukkit.enchantments.Enchantment;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* 箱子搬运(26批,复刻 Chest Transport mod 玩法)
*
* 触发条件(全部满足才接管,否则一律交原版):
* 1. 领地内(AreaProtection.getArea 命中)
* 2. 权限级别为 ADMIN / OWNER / MEMBER(领地主/授权成员/管理员)
* 3. 主手是斧头(*_AXE)且带精准采集魔咒
*
* 2 秒窗口语义(2026-10-16 用户确认):
* 「连续破坏是2个箱子1组,中间断了,如果是大箱子,就打包被拆的那一半的内容」
* - 首破挂起观察 2 秒(40 tick):不立即给打包箱,删原版掉落
* - 2 秒内同玩家再拆一个箱子 → 成组,一并交付 2 个打包箱
* - 超时(链断)→ 单独交付那 1 个;大箱子天然只快照被拆那一半
* (Chest.getBlockInventory() 只含被拆半边的 27 格)
*
* 打包箱:Material.CHEST / TRAPPED_CHEST + 名称标识 + PDC 存 InventorySerializer
* base64 内容;setMaxStackSize(1) 防不同内容堆叠混淆。
* 放置回填:BlockPlaceEvent 后 1 tick 方块状态就绪再灌回 27 格。
*/
public class ChestCarryManager implements Listener {
private final Main main;
private final Plugin plugin;
private final NamespacedKeyRef carryKey;
/** 组队窗口 2 秒 = 40 tick */
private static final long WINDOW_TICKS = 40L;
private static final long WINDOW_MS = 2000L;
/** PDC key 的小包装(避免与 block data 的 Chest 类名混淆的命名负担) */
private static final class NamespacedKeyRef {
final org.bukkit.NamespacedKey key;
NamespacedKeyRef(Plugin plugin, String name) {
this.key = new org.bukkit.NamespacedKey(plugin, name);
}
}
/** 首破挂起中的搬运记录 */
private static final class PendingCarry {
final UUID player;
final String worldName;
final Location dropLoc; // 超时且玩家离线时的兜底投递点
final boolean trapped; // 来源是否陷阱箱
final String halfName; // 小箱子 / 大箱子·左半 / 大箱子·右半
final ItemStack[] contents; // 被拆半边 27 格快照
final long ts; // 挂起时刻(组队窗口判定)
PendingCarry(UUID player, String worldName, Location dropLoc,
boolean trapped, String halfName, ItemStack[] contents, long ts) {
this.player = player;
this.worldName = worldName;
this.dropLoc = dropLoc;
this.trapped = trapped;
this.halfName = halfName;
this.contents = contents;
this.ts = ts;
}
}
/** 玩家 UUID → 挂起中的搬运(每人最多 1 条) */
private final Map<UUID, PendingCarry> pending = new ConcurrentHashMap<>();
public ChestCarryManager(Main main) {
this.main = main;
this.plugin = main;
this.carryKey = new NamespacedKeyRef(main, "chest_carry_data");
}
// ==================== 拆箱:拦截 → 挂起/成组 ====================
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onChestBreak(BlockBreakEvent e) {
Material mat = e.getBlock().getType();
if (mat != Material.CHEST && mat != Material.TRAPPED_CHEST) return;
Player p = e.getPlayer();
// 手持:主手必须是精准采集斧头,否则交原版
ItemStack hand = p.getInventory().getItemInMainHand();
if (hand == null || hand.getType() == Material.AIR) return;
if (!hand.getType().name().endsWith("_AXE")) return;
if (!hand.containsEnchantment(Enchantment.SILK_TOUCH)) return;
// 领地 + 权限:非领地、或非 ADMIN/OWNER/MEMBER 一律交原版
AreaProtection ap = main.areaProtection;
if (ap == null) return;
Block b = e.getBlock();
AreaProtection.AreaConfig ac =
ap.getArea(b.getWorld().getName(), b.getX(), b.getY(), b.getZ());
if (ac == null) return;
AreaProtection.PermissionLevel lv = ap.getPermissionLevel(p, ac);
if (lv != AreaProtection.PermissionLevel.ADMIN
&& lv != AreaProtection.PermissionLevel.OWNER
&& lv != AreaProtection.PermissionLevel.MEMBER) return;
// 快照被拆半边内容(大箱子 getBlockInventory 只含这半边 27 格)
BlockState st = b.getState();
if (!(st instanceof org.bukkit.block.Chest)) return;
org.bukkit.block.Chest chest = (org.bukkit.block.Chest) st;
ItemStack[] snap = chest.getBlockInventory().getContents();
// 全空箱不接管:交原版掉箱子本体,无物品洪水
if (isEmpty(snap)) return;
// 大/小箱子归属名(TRAPPED_CHEST 的 block data 也是 Chest 类)
String halfName = "小箱子";
org.bukkit.block.data.BlockData bd = b.getBlockData();
if (bd instanceof Chest) {
Chest.Type t = ((Chest) bd).getType();
if (t == Chest.Type.LEFT) halfName = "大箱子·左半";
else if (t == Chest.Type.RIGHT) halfName = "大箱子·右半";
}
boolean trapped = (mat == Material.TRAPPED_CHEST);
// 接管:删原版大量掉落
e.setDropItems(false);
UUID id = p.getUniqueId();
PendingCarry prev = pending.remove(id);
long now = System.currentTimeMillis();
if (prev != null && (now - prev.ts) <= WINDOW_MS) {
// 成组:2 箱 1 组,一并交付
deliver(p, prev);
deliver(p, new PendingCarry(id, b.getWorld().getName(),
p.getLocation(), trapped, halfName, snap, now));
} else {
// 首破:挂起观察 2 秒,等第二破成组或超时单独交付
PendingCarry cur = new PendingCarry(id, b.getWorld().getName(),
p.getLocation(), trapped, halfName, snap, now);
pending.put(id, cur);
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
PendingCarry pc = pending.get(id);
if (pc != cur) return; // 已成组交付或被新记录替换
pending.remove(id);
Player online = Bukkit.getPlayer(id);
if (online != null && online.isOnline()) {
deliver(online, pc);
} else {
// 玩家离线兜底:打包箱掉在原地
try {
World w = Bukkit.getWorld(pc.worldName);
if (w != null) {
w.dropItemNaturally(pc.dropLoc,
buildPackedChest(pc.trapped, pc.halfName, pc.contents));
}
} catch (Exception ex) {
plugin.getLogger().warning(
"[箱子搬运] 离线兜底掉落失败: " + ex.getMessage());
}
}
}, WINDOW_TICKS);
}
}
// ==================== 放置:回填内容 ====================
@EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true)
public void onChestPlace(BlockPlaceEvent e) {
ItemStack it = e.getItemInHand();
if (it == null) return;
if (it.getType() != Material.CHEST && it.getType() != Material.TRAPPED_CHEST) return;
if (!it.hasItemMeta()) return;
PersistentDataContainer pdc = it.getItemMeta().getPersistentDataContainer();
if (!pdc.has(carryKey.key, PersistentDataType.STRING)) return;
String data = pdc.get(carryKey.key, PersistentDataType.STRING);
if (data == null || data.isEmpty()) return;
Block placed = e.getBlockPlaced();
// 1 tick 后方块状态就绪再回填(含与邻半合并成大箱子的情况)
plugin.getServer().getScheduler().runTask(plugin, () -> {
BlockState st = placed.getState();
if (!(st instanceof org.bukkit.block.Chest)) return;
try {
ItemStack[] items = InventorySerializer.fromBase64(data);
org.bukkit.block.Chest chest = (org.bukkit.block.Chest) st;
Inventory inv = chest.getBlockInventory();
inv.clear();
int n = Math.min(items.length, 27);
for (int i = 0; i < n; i++) {
if (items[i] != null) inv.setItem(i, items[i]);
}
} catch (Exception ex) {
plugin.getLogger().warning("[箱子搬运] 放置回填失败: " + ex.getMessage());
}
});
}
// ==================== 内部工具 ====================
/** 交付一个打包箱给玩家(背包满则掉脚下),并提示 */
private void deliver(Player p, PendingCarry c) {
try {
ItemStack packed = buildPackedChest(c.trapped, c.halfName, c.contents);
HashMap<Integer, ItemStack> left = p.getInventory().addItem(packed);
if (!left.isEmpty()) {
left.values().forEach(it ->
p.getWorld().dropItemNaturally(p.getLocation(), it));
}
p.sendMessage("§a[箱子搬运] §7已打包 §f" + c.halfName
+ "§7(" + countItems(c.contents) + " 件物品),放下自动回填");
} catch (Exception ex) {
// 序列化失败兜底:内容直接掉落,绝不吞物
p.sendMessage("§c[箱子搬运] §7打包失败,物品已直接掉落");
for (ItemStack it : c.contents) {
if (it != null && !it.getType().isAir()) {
p.getWorld().dropItemNaturally(p.getLocation(), it);
}
}
plugin.getLogger().warning("[箱子搬运] 打包失败: " + ex.getMessage());
}
}
/** 构造打包箱物品:名称标识 + 防堆叠 + PDC 内容 */
private ItemStack buildPackedChest(boolean trapped, String halfName,
ItemStack[] contents) throws Exception {
ItemStack it = new ItemStack(trapped ? Material.TRAPPED_CHEST : Material.CHEST);
ItemMeta meta = it.getItemMeta();
meta.setDisplayName("§b§l打包箱 §7(" + halfName + ")");
List<String> lore = new ArrayList<>();
lore.add("§7内含 §f" + countItems(contents) + " §7件物品");
lore.add("§a放置后 §7自动回填内容");
meta.setLore(lore);
// 不同内容绝不堆叠,否则 PDC 串味
meta.setMaxStackSize(1);
meta.getPersistentDataContainer().set(carryKey.key,
PersistentDataType.STRING, InventorySerializer.toBase64(contents));
it.setItemMeta(meta);
return it;
}
private static boolean isEmpty(ItemStack[] a) {
if (a == null || a.length == 0) return true;
for (ItemStack i : a) {
if (i != null && !i.getType().isAir()) return false;
}
return true;
}
private static int countItems(ItemStack[] a) {
int n = 0;
for (ItemStack i : a) {
if (i != null && !i.getType().isAir()) n += i.getAmount();
}
return n;
}
}
@@ -401,7 +401,7 @@ public class DatabaseManager {
+ "time INTEGER"
+ ")");
// 背包自动滚动备份表(15分钟定时):每位玩家保留最近 3 份
// 背包自动滚动备份表(每小时 0/15/30/45 分准点定时):每位玩家保留最近 3 份
st.execute("CREATE TABLE IF NOT EXISTS "
+ "inv_backup_auto ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
@@ -3014,7 +3014,7 @@ public class DatabaseManager {
return deleted;
}
// ==================== 背包自动滚动备份(15分钟定时)====================
// ==================== 背包自动滚动备份(每小时 0/15/30/45 分准点定时)====================
/**
* 保存背包自动备份 —— 追加写入,每位玩家仅保留最近 3 份。
@@ -10,7 +10,7 @@ import java.io.IOException;
* 背包滚动自动备份。
* <p>
* ★ 功能:
* 1. 由 Main 的 15 分钟定时任务驱动,遍历所有在线玩家。
* 1. 由 Main 的准点定时任务驱动(第26批:每小时 0/15/30/45 分墙钟对齐,不再按启动时刻漂移),遍历所有在线玩家。
* 2. 在 PVP 竞技场 / 练习场(测试场)的玩家不备份 —— 避免竞技场装备覆盖真实背包。
* 3. 序列化 主背包 / 护甲 / 副手(extra) / 等级 / 经验,写入 inv_backup_auto 表。
* 4. 每玩家保留最近 3 份(滚动覆盖),由 DatabaseManager.saveAutoBackup 负责清理。
@@ -22,7 +22,7 @@ public class InventoryAutoBackup {
private final Main plugin;
/** 备份周期:15 分钟(单位 tick) */
/** 备份周期:15 分钟(单位 tick)。★ 第26批起 Main 改墙钟对齐调度(:00/:15/:30/:45 分),此常量仅作周期语义参考,不再直接作调度入参。 */
public static final long INTERVAL_TICKS = 15L * 60L * 20L;
public InventoryAutoBackup(Main plugin) {
@@ -0,0 +1,700 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryDragEvent;
import org.bukkit.event.player.AsyncPlayerChatEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* /json 消息构造器 GUI(26批 任务170)。
*
* <p><b>功能</b>:
* <ul>
* <li>27 格小箱子面板:3 种模式(tellraw 原生 JSON 消息 / 大标题 / 小标题)</li>
* <li>颜色行 = 自定义颜色输入 + 调色板(解码配置.md 自定义色优先,内置常用色补足)</li>
* <li>文字经聊天栏捕获(GUI 内开不了聊天,先关面板再输入)</li>
* <li>发送 = 以玩家自己身份 dispatchCommand(tellraw/title 天然受原版权限约束,
* OP/管理员可全服广播,普通玩家与直接敲原版指令行为一致)</li>
* </ul>
*
* <p><b>触发</b>:/json 指令;或原版 title/tellraw 报错时由 Main.onUnknownCommand
* 调 {@link #tryTakeover} 接管并提取用户原文预填。
*
* <p><b>颜色合法性</b>(仅枚举 MC 原生支持,Wiki 所列):
* 16 色英文名 / #RRGGBB 十六进制 / R G B 数值;其余一律「颜色配置无效」。
*
* <p><b>解码配置.md</b>:启动自动释放到插件目录,表格 split 取 cells[length-1],
* 「配置区域开始/结束」标记之间逐行解析;无效色收集后提示。
*/
public class JsonMessageGui implements Listener {
private final Main plugin;
static final String TITLE = "§6§lJSON消息构造器";
// ==================== 模式 ====================
private static final int MODE_TELLRAW = 0;
private static final int MODE_TITLE = 1;
private static final int MODE_SUBTITLE = 2;
// ==================== MC 原生 16 色(Wiki 所列) ====================
private static final Set<String> NATIVE_COLORS = new LinkedHashSet<>(Arrays.asList(
"black", "dark_blue", "dark_green", "dark_aqua",
"dark_red", "dark_purple", "gold", "gray",
"dark_gray", "blue", "green", "aqua",
"red", "light_purple", "yellow", "white"));
/** 内置常用色(调色板补位用) */
private static final List<String> BUILTIN_COMMON = Arrays.asList(
"red", "gold", "yellow", "green",
"aqua", "blue", "light_purple", "white");
// ==================== 格位布局 ====================
private static final int SLOT_MODE_TELLRAW = 1;
private static final int SLOT_MODE_TITLE = 3;
private static final int SLOT_MODE_SUBTITLE = 5;
private static final int SLOT_HELP = 7;
private static final int SLOT_CUSTOM_COLOR = 9;
private static final int SLOT_PALETTE_FIRST = 10; // 10-17 共 8 格调色板
private static final int SLOT_PALETTE_LAST = 17;
private static final int SLOT_PREVIEW = 21;
private static final int SLOT_INPUT_TEXT = 22;
private static final int SLOT_CLEAR = 25;
private static final int SLOT_SEND = 26;
// ==================== 每玩家会话 ====================
private static final class Session {
int mode = MODE_TELLRAW;
/** 已确认的文本段(含 § 颜色码) */
final StringBuilder text = new StringBuilder();
/** 当前等待聊天捕获的输入类型:null=无 */
String capture; // "text" | "customcolor"
Session(int mode) { this.mode = mode; }
}
private final Map<UUID, Session> sessions = new ConcurrentHashMap<>();
/** 调色板(md 自定义色优先,内置常用色补足到 8 格) */
private final List<String> palette = new ArrayList<>();
/** md 中解析出的无效色(提示用) */
private final List<String> invalidColors = new ArrayList<>();
public JsonMessageGui(Main plugin) {
this.plugin = plugin;
loadPalette();
}
// ==================== 解码配置.md 解析 ====================
/** 重新加载调色板:md 自定义色(配置区域标记间表格 cells[length-1])+ 内置补足 */
private void loadPalette() {
palette.clear();
invalidColors.clear();
File f = new File(plugin.getDataFolder(), "json解码配置/解码配置.md");
if (f.exists()) {
boolean inRegion = false;
try (BufferedReader br = new BufferedReader(new InputStreamReader(
new FileInputStream(f), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
String t = line.trim();
if (t.startsWith("##")) {
// 标题行携带区域开关
inRegion = t.contains("配置区域开始");
if (t.contains("配置区域结束")) inRegion = false;
continue;
}
if (!inRegion) continue;
if (!t.startsWith("|")) continue;
String[] cells = t.split("\\|");
if (cells.length < 2) continue;
String raw = cells[cells.length - 1].trim();
// 表头/分隔行跳过
if (raw.isEmpty() || raw.equals("---")
|| raw.equals("颜色代码")) continue;
if (isValidColor(raw)) {
String norm = normalizeColor(raw);
if (!palette.contains(norm)) palette.add(norm);
} else {
invalidColors.add(raw);
plugin.getLogger().warning(
"[JSON构造器] 颜色配置无效: " + raw);
}
}
} catch (Exception ex) {
plugin.getLogger().warning(
"[JSON构造器] 解码配置.md 读取失败: " + ex.getMessage());
}
}
// 内置常用色补足到 8 格
for (String c : BUILTIN_COMMON) {
if (palette.size() >= SLOT_PALETTE_LAST - SLOT_PALETTE_FIRST + 1) break;
if (!palette.contains(c)) palette.add(c);
}
}
// ==================== 颜色校验(仅 MC 原生支持) ====================
/** 16色英文名 / #RRGGBB / R G B 数值 → 合法;其余 false */
static boolean isValidColor(String s) {
if (s == null) return false;
String t = s.trim().toLowerCase(Locale.ROOT);
if (t.isEmpty()) return false;
if (NATIVE_COLORS.contains(t)) return true;
// 十六进制 #RRGGBB(也接受不带#的6位hex)
if (t.matches("#?[0-9a-f]{6}")) return true;
// R G B:R255 G128 B0(大小写不敏感,逗号/空格分隔均可)
if (t.matches("(?i)r\\s*\\d{1,3}\\s*[,;,;]?\\s*g\\s*\\d{1,3}\\s*[,;,;]?\\s*b\\s*\\d{1,3}")) {
Matcher m = Pattern.compile("(?i)(\\d{1,3})[^0-9]*(\\d{1,3})[^0-9]*(\\d{1,3})")
.matcher(t);
if (m.find()) {
return inByte(m.group(1)) && inByte(m.group(2)) && inByte(m.group(3));
}
}
return false;
}
private static boolean inByte(String n) {
int v = Integer.parseInt(n);
return v >= 0 && v <= 255;
}
/** 合法色归一化:hex 统一带#小写;RGB 转 #RRGGBB;英文名小写 */
static String normalizeColor(String s) {
String t = s.trim().toLowerCase(Locale.ROOT);
if (NATIVE_COLORS.contains(t)) return t;
if (t.matches("#?[0-9a-f]{6}")) {
return t.startsWith("#") ? t : ("#" + t);
}
Matcher m = Pattern.compile("(?i)(\\d{1,3})[^0-9]*(\\d{1,3})[^0-9]*(\\d{1,3})")
.matcher(t);
if (m.find()) {
return String.format("#%02x%02x%02x",
Integer.parseInt(m.group(1)),
Integer.parseInt(m.group(2)),
Integer.parseInt(m.group(3)));
}
return t;
}
// ==================== 入口 ====================
/** /json 指令入口 */
public void open(Player p) {
Session s = sessions.computeIfAbsent(
p.getUniqueId(), k -> new Session(MODE_TELLRAW));
s.capture = null;
if (!invalidColors.isEmpty()) {
p.sendMessage("§e[JSON构造器] §7解码配置.md 中 §c"
+ invalidColors.size() + " §7个颜色配置无效(仅支持 MC 原生色名/十六进制/RGB),已忽略");
}
p.openInventory(build(s));
}
/**
* 原版 title/tellraw 报错接管(挂在 Main.onUnknownCommand)。
*
* @param body 已去掉开头斜杠的命令行
* @return true = 已接管(打开GUI预填原文),false = 不归本构造器管
*/
public boolean tryTakeover(Player p, String body) {
if (body.isEmpty()) return false;
String lower = body.toLowerCase(Locale.ROOT);
int mode;
String payload;
if (lower.startsWith("tellraw ")) {
mode = MODE_TELLRAW;
payload = body.substring(8).trim();
} else if (lower.startsWith("title ")) {
// title <target> title|subtitle|actionbar <json...>
String rest = body.substring(6).trim();
int sp1 = rest.indexOf(' ');
if (sp1 < 0) return false;
String afterTarget = rest.substring(sp1 + 1).trim();
int sp2 = afterTarget.indexOf(' ');
String kind = (sp2 < 0 ? afterTarget : afterTarget.substring(0, sp2))
.toLowerCase(Locale.ROOT);
if (kind.equals("title")) {
mode = MODE_TITLE;
} else if (kind.equals("subtitle")) {
mode = MODE_SUBTITLE;
} else {
return false; // actionbar 不在3模式内
}
payload = sp2 < 0 ? "" : afterTarget.substring(sp2 + 1).trim();
} else {
return false;
}
// 提取用户原文(JSON 里 text 字段,或原样串)
String extracted = extractUserText(payload);
Session s = sessions.computeIfAbsent(p.getUniqueId(), k -> new Session(mode));
s.mode = mode;
s.capture = null;
s.text.setLength(0);
if (!extracted.isEmpty()) s.text.append(extracted);
p.openInventory(build(s));
p.sendMessage("§e[JSON构造器] §7已接管原版报错,你的原文已填入,"
+ "选颜色/改内容后点§a发送§7即可(也可继续§f/json§7打开本面板)");
return true;
}
/** 从 tellraw/title 的 JSON 载荷里提取用户原文 text;解析不了就原样返回 */
static String extractUserText(String payload) {
if (payload == null || payload.isEmpty()) return "";
// 最常见 {"text":"..."} 或 {"text":"...","extra":[...]}
Matcher m = Pattern.compile("\"text\"\\s*:\\s*\"((?:[^\"\\\\]|\\\\.)*)\"")
.matcher(payload);
StringBuilder sb = new StringBuilder();
while (m.find()) {
String seg = m.group(1)
.replace("\\\"", "\"")
.replace("\\\\", "\\")
.replace("\\n", "\n");
sb.append(seg);
}
if (sb.length() > 0) return sb.toString();
// 不是JSON(比如玩家直接打了中文)→ 原样
if (!payload.startsWith("{")) return payload;
return "";
}
// ==================== GUI 构建 ====================
private Inventory build(Session s) {
Inventory inv = Bukkit.createInventory(null, 27, TITLE);
inv.setItem(SLOT_MODE_TELLRAW, modeItem(
Material.PAPER, "tellraw 原生JSON消息",
s.mode == MODE_TELLRAW,
"全服广播一条 JSON 消息", "对应 /tellraw @a ..."));
inv.setItem(SLOT_MODE_TITLE, modeItem(
Material.GOLD_BLOCK, "大标题",
s.mode == MODE_TITLE,
"全服屏幕中央大字", "对应 /title @a title ..."));
inv.setItem(SLOT_MODE_SUBTITLE, modeItem(
Material.NAME_TAG, "小标题",
s.mode == MODE_SUBTITLE,
"全服屏幕中央小字(大字下方)", "对应 /title @a subtitle ..."));
inv.setItem(SLOT_HELP, simple(Material.BOOK,
"§f使用说明",
"§71. 选模式 → 2. 点「输入内容」",
"§7在聊天栏打字(自动收回面板)",
"§3点击颜色按钮§7可给当前内容上色",
"§7发送以你自己的身份执行原版指令,",
"§7权限与直接敲 /tellraw、/title 完全一致"));
inv.setItem(SLOT_CUSTOM_COLOR, simple(Material.BLAZE_POWDER,
"§b自定义颜色",
"§7点击后在聊天栏输入颜色:",
"§f#RRGGBB §7/ §fR255 G128 B0 §7/ §fred",
"§c仅支持 MC 原生色,否则提示「颜色配置无效」"));
// 调色板 10-17
for (int i = 0; i < (SLOT_PALETTE_LAST - SLOT_PALETTE_FIRST + 1); i++) {
int slot = SLOT_PALETTE_FIRST + i;
if (i < palette.size()) {
String c = palette.get(i);
inv.setItem(slot, simple(colorIcon(c),
colorName(c) + "§7:点击上色",
"§7代码 §f" + c));
} else {
inv.setItem(slot, simple(Material.LIGHT_GRAY_STAINED_GLASS_PANE,
"§8空位", "§8(解码配置.md 可加自定义色)"));
}
}
inv.setItem(SLOT_PREVIEW, previewItem(s));
inv.setItem(SLOT_INPUT_TEXT, simple(Material.WRITABLE_BOOK,
"§a输入内容",
"§7点击后在聊天栏输入文字",
"§7(不会真的发到公屏)"));
inv.setItem(SLOT_CLEAR, simple(Material.LAVA_BUCKET,
"§c清空内容", "§7清空已输入的文本"));
inv.setItem(SLOT_SEND, simple(Material.EMERALD_BLOCK,
"§a§l发送", "§7以你的身份执行对应原版指令"));
return inv;
}
private ItemStack previewItem(Session s) {
String shown = s.text.length() == 0 ? "§8(空)" : clip(s.text.toString(), 40);
return simple(Material.HONEYCOMB, "§e当前内容预览", "§r" + shown);
}
private static String clip(String s, int max) {
String flat = s.replace('§', '&');
return flat.length() <= max ? flat : flat.substring(0, max) + "…";
}
private static String colorName(String code) {
if (code.startsWith("#")) return "§" + hexToClosestLegacy(code) + "自定义" + code;
// 16色名 → 对应 legacy 色显示
String legacy = nativeToLegacy(code);
return "§" + legacy + code;
}
private static String nativeToLegacy(String name) {
switch (name) {
case "black": return "0";
case "dark_blue": return "1";
case "dark_green": return "2";
case "dark_aqua": return "3";
case "dark_red": return "4";
case "dark_purple": return "5";
case "gold": return "6";
case "gray": return "7";
case "dark_gray": return "8";
case "blue": return "9";
case "green": return "a";
case "aqua": return "b";
case "red": return "c";
case "light_purple": return "d";
case "yellow": return "e";
default: return "f";
}
}
/** hex → 最接近的 legacy 色码(图标显示用) */
private static char hexToClosestLegacy(String hex) {
String h = hex.substring(1);
int r = Integer.parseInt(h.substring(0, 2), 16);
int g = Integer.parseInt(h.substring(2, 4), 16);
int b = Integer.parseInt(h.substring(4, 6), 16);
if (r < 64 && g < 64 && b < 64) return '8';
if (r > 192 && g > 192 && b > 192) return 'f';
if (r > g && r > b) return g > 128 ? '6' : 'c';
if (g > r && g > b) return 'a';
if (b > r && b > g) return '9';
return '7';
}
/** 调色板色对应的图标材料(16色用染料;hex用玻璃板) */
private static Material colorIcon(String code) {
switch (code) {
case "black": return Material.BLACK_DYE;
case "dark_blue": return Material.BLUE_DYE;
case "dark_green": return Material.GREEN_DYE;
case "dark_aqua": return Material.CYAN_DYE;
case "dark_red": return Material.RED_DYE;
case "dark_purple": return Material.PURPLE_DYE;
case "gold": return Material.ORANGE_DYE;
case "gray": return Material.GRAY_DYE;
case "dark_gray": return Material.LIGHT_GRAY_DYE;
case "blue": return Material.LIGHT_BLUE_DYE;
case "green": return Material.LIME_DYE;
case "aqua": return Material.LIGHT_BLUE_DYE;
case "red": return Material.RED_DYE;
case "light_purple": return Material.MAGENTA_DYE;
case "yellow": return Material.YELLOW_DYE;
case "white": return Material.WHITE_DYE;
default: return Material.WHITE_STAINED_GLASS_PANE;
}
}
private static ItemStack modeItem(Material mat, String name, boolean selected,
String... lore) {
ItemStack it = simple(mat, (selected ? "§a✔ " : "§7") + name, lore);
ItemMeta meta = it.getItemMeta();
List<String> l = new ArrayList<>(meta.getLore() == null
? java.util.Collections.emptyList() : meta.getLore());
l.add(selected ? "§a(当前模式)" : "§e点击切换");
meta.setLore(l);
it.setItemMeta(meta);
return it;
}
private static ItemStack simple(Material mat, String name, String... lore) {
ItemStack it = new ItemStack(mat);
ItemMeta meta = it.getItemMeta();
meta.setDisplayName(name);
if (lore != null && lore.length > 0) {
meta.setLore(Arrays.asList(lore));
}
it.setItemMeta(meta);
return it;
}
// ==================== 点击处理 ====================
@EventHandler
public void onClick(InventoryClickEvent e) {
if (!TITLE.equals(e.getView().getTitle())) return;
e.setCancelled(true);
if (!(e.getWhoClicked() instanceof Player)) return;
Player p = (Player) e.getWhoClicked();
Session s = sessions.get(p.getUniqueId());
if (s == null) return;
int slot = e.getRawSlot();
if (slot < 0 || slot >= 27) return;
switch (slot) {
case SLOT_MODE_TELLRAW:
s.mode = MODE_TELLRAW; refresh(p, s); break;
case SLOT_MODE_TITLE:
s.mode = MODE_TITLE; refresh(p, s); break;
case SLOT_MODE_SUBTITLE:
s.mode = MODE_SUBTITLE; refresh(p, s); break;
case SLOT_CUSTOM_COLOR:
beginCapture(p, s, "customcolor"); break;
case SLOT_INPUT_TEXT:
beginCapture(p, s, "text"); break;
case SLOT_CLEAR:
s.text.setLength(0);
p.sendMessage("§e[JSON构造器] §7内容已清空");
refresh(p, s); break;
case SLOT_SEND:
doSend(p, s); break;
default:
if (slot >= SLOT_PALETTE_FIRST && slot <= SLOT_PALETTE_LAST) {
int idx = slot - SLOT_PALETTE_FIRST;
if (idx < palette.size()) {
appendColor(p, s, palette.get(idx));
}
}
break;
}
}
/** 拖拽进GUI一律无效 */
@EventHandler
public void onDrag(InventoryDragEvent e) {
if (TITLE.equals(e.getView().getTitle())) e.setCancelled(true);
}
private void refresh(Player p, Session s) {
Inventory inv = build(s);
// 原地重建:先关再开会闪,直接替换内容更稳
Inventory cur = p.getOpenInventory().getTopInventory();
if (cur != null && cur.getSize() == 27
&& TITLE.equals(p.getOpenInventory().getTitle())) {
cur.setContents(inv.getContents());
} else {
p.openInventory(inv);
}
}
// ==================== 聊天捕获 ====================
private void beginCapture(Player p, Session s, String type) {
s.capture = type;
p.closeInventory();
if (type.equals("text")) {
p.sendMessage("§e[JSON构造器] §7请在聊天栏输入内容(§c不会发到公屏§7),"
+ "§f空消息§7或§f/qj取消§7可放弃");
} else {
p.sendMessage("§e[JSON构造器] §7请在聊天栏输入颜色,如 §f#ff0000§7、"
+ "§fR255 G0 B0§7、§fred§7;§f空消息§7可放弃");
}
}
/**
* 聊天捕获。★ AsyncPlayerChatEvent 在异步线程,
* 严禁在此直接开库存/改背包 —— 一律 runTask 切主线程。
*/
@EventHandler(priority = EventPriority.LOWEST)
public void onCaptureChat(AsyncPlayerChatEvent e) {
Player p = e.getPlayer();
Session s = sessions.get(p.getUniqueId());
if (s == null || s.capture == null) return;
// 捕获期间该玩家的话一律不上公屏
e.setCancelled(true);
String type = s.capture;
String msg = e.getMessage();
s.capture = null;
plugin.getServer().getScheduler().runTask(plugin, () -> {
if (!p.isOnline()) return;
if (msg.isEmpty() || msg.equals("/qj")) {
p.sendMessage("§e[JSON构造器] §7已取消输入");
} else if (type.equals("text")) {
s.text.append(msg);
p.sendMessage("§a[JSON构造器] §7已加入内容:§f" + clip(msg, 60));
} else {
// 自定义颜色
if (isValidColor(msg)) {
appendColor(p, s, normalizeColor(msg));
} else {
p.sendMessage("§c[JSON构造器] 颜色配置无效:§f" + msg
+ "§7(仅支持 MC 原生16色名 / #RRGGBB / R G B)");
}
}
p.openInventory(build(s));
});
}
@EventHandler
public void onQuit(PlayerQuitEvent e) {
sessions.remove(e.getPlayer().getUniqueId());
}
/** 颜色写入当前文本(legacy § 码,hex 用 §x§R§R§G§G§B§B) */
private void appendColor(Player p, Session s, String color) {
if (color.startsWith("#")) {
s.text.append('§').append('x');
for (char c : color.substring(1).toCharArray()) {
s.text.append('§').append(Character.toLowerCase(c));
}
} else {
s.text.append('§').append(nativeToLegacy(color));
}
p.sendMessage("§a[JSON构造器] §7已上色 §f" + color);
refresh(p, s);
}
// ==================== 发送 ====================
private void doSend(Player p, Session s) {
if (s.text.length() == 0) {
p.sendMessage("§c[JSON构造器] 内容为空,先点「输入内容」");
return;
}
String json = toTellrawJson(s.text.toString());
String cmd;
switch (s.mode) {
case MODE_TITLE:
cmd = "title @a title " + json;
break;
case MODE_SUBTITLE:
cmd = "title @a subtitle " + json;
break;
default:
cmd = "tellraw @a " + json;
break;
}
p.closeInventory();
// 以玩家自己身份执行:权限模型与原版指令完全一致
boolean ok = false;
try {
ok = Bukkit.dispatchCommand(p, cmd);
} catch (Throwable t) {
plugin.getLogger().warning(
"[JSON构造器] 指令执行异常: " + t.getMessage());
}
if (ok) {
p.sendMessage("§a[JSON构造器] §7已发送("
+ (s.mode == MODE_TITLE ? "大标题"
: s.mode == MODE_SUBTITLE ? "小标题" : "tellraw消息") + ")");
} else {
p.sendMessage("§c[JSON构造器] 发送失败:权限不足或指令被拒绝");
}
}
/**
* 文本(含 § 颜色码)→ tellraw JSON。
* § 颜色切段:每段一个 extra 元素;§x hex 保留为 color:#hex;
* §l/§o/§n/§m/§k/§r 样式码透传为 bold/italic/underlined/strikethrough/obfuscated/reset。
*/
static String toTellrawJson(String text) {
StringBuilder out = new StringBuilder("{\"text\":\"\",\"extra\":[");
boolean first = true;
List<String[]> parts = new ArrayList<>(); // {text,color,bits}
StringBuilder sb = new StringBuilder();
String color = null;
int flags = 0; // 1 bold 2 italic 4 under 8 strike 16 obf
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c == '§' && i + 1 < text.length()) {
char code = Character.toLowerCase(text.charAt(++i));
if (sb.length() > 0) {
parts.add(new String[]{sb.toString(), color, String.valueOf(flags)});
sb.setLength(0);
}
switch (code) {
case '0': color = "black"; flags = 0; break;
case '1': color = "dark_blue"; flags = 0; break;
case '2': color = "dark_green"; flags = 0; break;
case '3': color = "dark_aqua"; flags = 0; break;
case '4': color = "dark_red"; flags = 0; break;
case '5': color = "dark_purple"; flags = 0; break;
case '6': color = "gold"; flags = 0; break;
case '7': color = "gray"; flags = 0; break;
case '8': color = "dark_gray"; flags = 0; break;
case '9': color = "blue"; flags = 0; break;
case 'a': color = "green"; flags = 0; break;
case 'b': color = "aqua"; flags = 0; break;
case 'c': color = "red"; flags = 0; break;
case 'd': color = "light_purple"; flags = 0; break;
case 'e': color = "yellow"; flags = 0; break;
case 'f': color = "white"; flags = 0; break;
case 'x': {
// §x§R§R§G§G§B§B
StringBuilder hx = new StringBuilder("#");
while (i + 2 < text.length()
&& text.charAt(i + 1) == '§'
&& isHexChar(text.charAt(i + 2))) {
hx.append(text.charAt(i + 2));
i += 2;
}
color = hx.length() == 7 ? hx.toString() : color;
break;
}
case 'l': flags |= 1; break;
case 'o': flags |= 2; break;
case 'n': flags |= 4; break;
case 'm': flags |= 8; break;
case 'k': flags |= 16; break;
case 'r': color = null; flags = 0; break;
default: break; // 未知码忽略
}
} else if (c == '\n') {
sb.append("\\n");
} else if (c == '"' || c == '\\') {
// JSON 字符串转义,防玩家输入引号/反斜杠打坏整条指令
sb.append('\\').append(c);
} else {
sb.append(c);
}
}
if (sb.length() > 0) {
parts.add(new String[]{sb.toString(), color, String.valueOf(flags)});
}
for (String[] seg : parts) {
if (!first) out.append(',');
first = false;
out.append("{\"text\":\"").append(seg[0]).append("\"");
if (seg[1] != null) out.append(",\"color\":\"").append(seg[1]).append('"');
int f = Integer.parseInt(seg[2]);
if ((f & 1) != 0) out.append(",\"bold\":true");
if ((f & 2) != 0) out.append(",\"italic\":true");
if ((f & 4) != 0) out.append(",\"underlined\":true");
if ((f & 8) != 0) out.append(",\"strikethrough\":true");
if ((f & 16) != 0) out.append(",\"obfuscated\":true");
out.append('}');
}
out.append("]}");
return out.toString();
}
private static boolean isHexChar(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
}
}
+82 -11
View File
@@ -191,6 +191,10 @@ public class Main extends JavaPlugin
public AchievementEffectGuard achievementEffectGuard;
public LandRecordManager landRecordManager;
public InventoryAutoBackup inventoryAutoBackup;
/** 26批:箱子搬运(精准采集斧拆箱→2秒窗口打包→放置回填) */
public ChestCarryManager chestCarryManager;
/** 26批:/json JSON消息构造器(tellraw/大标题/小标题 + 颜色 + 报错接管) */
public JsonMessageGui jsonGui;
private TeleportManager teleportMgr;
private HomeManager homeMgr;
private DeathReport deathReport;
@@ -388,6 +392,16 @@ public class Main extends JavaPlugin
saveResource("illegal_domains.txt", false);
getLogger().info("[配置] 已自动释放 illegal_domains.txt 到插件目录");
}
// ★ 26批:自动释放 json解码配置/解码配置.md(调色板自定义色,仅不存在时)
try {
File jsonCfg = new File(getDataFolder(), "json解码配置/解码配置.md");
if (!jsonCfg.exists()) {
saveResource("json解码配置/解码配置.md", false);
getLogger().info("[配置] 已自动释放 json解码配置/解码配置.md");
}
} catch (Throwable t) {
getLogger().warning("[配置] 解码配置.md 释放失败: " + t.getMessage());
}
// ★ 任务94:根目录5类文件归位(必须在任何 Manager 打开文件/DB 之前)
migrateRootDataFiles();
// 启动时清理.sdf1临时文件
@@ -674,6 +688,14 @@ public class Main extends JavaPlugin
areaGUIManager = new AreaGUIManager(this, areaProtection);
getServer().getPluginManager()
.registerEvents(areaGUIManager, this);
// 26批:箱子搬运(依赖 areaProtection 权限链)
chestCarryManager = new ChestCarryManager(this);
getServer().getPluginManager()
.registerEvents(chestCarryManager, this);
// 26批:/json JSON消息构造器(含解码配置.md 调色板)
jsonGui = new JsonMessageGui(this);
getServer().getPluginManager()
.registerEvents(jsonGui, this);
// 区域防护CLI
areaCLIManager = new AreaCLIManager(this, areaProtection);
@@ -703,16 +725,37 @@ public class Main extends JavaPlugin
// 15.1.1 ====MC官方中文翻译加载(异步下载 zh_cn.json)====
OfficialTranslations.init(this);
// 15.2 ====背包滚动自动备份(每15分钟)====
// 15.2 ====背包滚动自动备份(每小时 0/15/30/45 分准点)====
inventoryAutoBackup = new InventoryAutoBackup(this);
getServer().getScheduler().runTaskTimer(this,
() -> {
if (inventoryAutoBackup != null) {
inventoryAutoBackup.runBackup();
}
},
InventoryAutoBackup.INTERVAL_TICKS, // 首跑延迟 15 分钟
InventoryAutoBackup.INTERVAL_TICKS); // 之后每 15 分钟
// ★ 第26批:改准点备份。原 runTaskTimer 从【启动时刻】起算 15 分钟周期,
// 落点永远带启动偏移(如 10:07/10:22/10:37…)。改为墙钟自对齐、每轮
// 执行完按【执行时刻】重排下一个 900000ms 边界的 runTaskLater
// (同 OpWhiteListManager.scheduleAligned 范式),无论 TPS 高低、服务器
// 何时启动,都精确落在每小时第 0/15/30/45 分(period=900000ms=15 分钟)。
final Runnable[] invBackupTick = new Runnable[1];
invBackupTick[0] = () -> {
try {
if (inventoryAutoBackup != null) {
inventoryAutoBackup.runBackup();
}
} catch (Throwable t) {
getLogger().warning("[自动备份] 定时任务异常(下轮照常): " + t.getMessage());
} finally {
if (isEnabled()) {
long now = System.currentTimeMillis();
long next = (now / 900000L + 1) * 900000L;
getServer().getScheduler().runTaskLater(this, invBackupTick[0],
Math.max(1L, (next - now + 49L) / 50L));
}
}
};
// 首跑:同样对齐到下一个 0/15/30/45 分墙钟边界(不再等固定 15 分钟)
{
long now = System.currentTimeMillis();
long next = (now / 900000L + 1) * 900000L;
getServer().getScheduler().runTaskLater(this, invBackupTick[0],
Math.max(1L, (next - now + 49L) / 50L));
}
// 15.3 ====领地记录每日清理(保留 7 天)====
// 每天 0 点附近跑一次:用 24h 周期,首跑延迟到下一个整点对齐不强制,
@@ -4465,6 +4508,21 @@ public class Main extends JavaPlugin
return;
}
// ---- 1.5) 原版 title/tellraw 报错 -> /json 构造器接管并提取用户原文 ----
//(26批 任务170:玩家 JSON 写错时不再干看原版报错,直接拉起可视化编辑器)
if (isPlayer && jsonGui != null) {
String lowerBody = body.toLowerCase(java.util.Locale.ROOT);
if (lowerBody.startsWith("tellraw ")
|| lowerBody.startsWith("title ")) {
if (jsonGui.tryTakeover((Player) sender, body)) {
suppressUnknown(e);
getLogger().info("[无感切换] title/tellraw 报错已接管进JSON构造器: \""
+ body + "\"");
return;
}
}
}
// ---- 2) 指令根存在,但参数不全/语法写错 -> 中文友好报错 ----
//(2026-10-04 新功能,详见 buildFriendlySyntaxError 注释)
if (looksLikeCommandAttempt(body)) {
@@ -8876,6 +8934,19 @@ public class Main extends JavaPlugin
return true;
}
// ===== /json:JSON消息构造器(tellraw/大标题/小标题 + 颜色)=====
// 全员可用:发送以玩家自身身份执行原版指令,权限天然受原版约束
if (cmdName.equals("json")) {
if (!(sender instanceof Player)) {
sender.sendMessage("§c仅玩家可用");
return true;
}
if (jsonGui != null) {
jsonGui.open((Player) sender);
}
return true;
}
// /垃圾清理
if (cmdName.equals("垃圾清理")) {
@@ -9601,7 +9672,7 @@ public class Main extends JavaPlugin
}
}
if (!autoBks.isEmpty()) {
sender.sendMessage("§6[自动备份](游戏中每15分钟保存)");
sender.sendMessage("§6[自动备份](每小时 0/15/30/45 分准点保存)");
for (Map<String, Object> b : autoBks) {
int id = ((Number) b.get("id")).intValue();
long st = b.get("save_time") != null
@@ -9749,7 +9820,7 @@ public class Main extends JavaPlugin
// 自动备份
if (!autoList.isEmpty()) {
sender.sendMessage("§6[自动备份](游戏中每15分钟保存)");
sender.sendMessage("§6[自动备份](每小时 0/15/30/45 分准点保存)");
for (Map<String, Object> b : autoList) {
int bidi = ((Number) b.get("id")).intValue();
long st = b.get("save_time") != null
@@ -0,0 +1,16 @@
## ----配置区域开始----
| 颜色代码 |
| --- |
| red |
| aqua |
| |
## ----配置区域结束----
## json解码规则说明
### 一、怎么触发?
使用`json`指令触发,或在原版`title、tellraw`产生报错时,插件介入。
### 二、颜色代码怎么配置?
支持填入`16进制`颜色代码(比如#000000)、或者颜色`英文名`(比如red)、也可以是rgb。比如(R0 G0 B0)
+3
View File
@@ -193,3 +193,6 @@ commands:
cleartake:
description: 清空DB队列中未回应的web同步任务(管理员)
usage: /cleartake
json:
description: JSON消息构造器(tellraw/大标题/小标题+颜色)
usage: /json