第二十七批:修线上验收4个bug——箱子搬运认铜箱族、/json选色语义改状态、样式4按钮+发送回显JSON、封禁公示改读banned-players.json
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m28s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s

1. 箱子搬运不生效:26.2新增铜箱族(COPPER_CHEST/WAXED_/WEATHERED_/OXIDIZED_/EXPOSED_ 共8种),
   原判定只认 CHEST/TRAPPED_CHEST,拆铜箱第一步就早退交原版散落。改 isChestMaterial()
   (*_CHEST 且非 ENDER_CHEST,走枚举名不引用常量);快照/回填改走 Container 接口
   getSnapshotInventory();打包箱沿用来源材质;setDropItems 之外补 BlockDropItemEvent 兜底抑制。
2. /json 设置绿色输出白色:颜色原是 append 进 text 末尾,「先打字后选色」对已发文字无效,
   「先选色后打字」色码裸挂段首被 toTellrawJson 丢弃。改为选色是状态(pendingColor),
   空内容只记住、输入时前置;已有内容则剥码后整段按新色+样式重建。
3. /json 补加粗/斜体/下划线/删除线 4 个样式按钮(格位18/19/20/23),对应 JSON bold/italic/
   underlined/strikethrough 布尔字段,预览项与输入按钮显示当前开关态。
4. /json 发送后(成功与失败都)把实际指令与生成的 JSON 原样折行回显执行者,仅本人可见。
5. 封禁公示数据源改为原版 banned-players.json(用户明令唯一源):每轮重读文件到内存解析,
   按 created 过滤插件数据目录创建时间之前的历史封禁;缺失/为空/语法坏/过滤空一律闭嘴。
   同时删掉 resolveStartCutoff 的「距今不足7天就不过滤」守卫 —— 插件刚装上服时它退化成
   不过滤,正是「一个都没封却公示一堆人」的第二重根因。
This commit is contained in:
youpaishidifu
2026-10-16 23:18:54 +08:00
parent cbc3cb4db7
commit d7d5ce7039
3 changed files with 534 additions and 81 deletions
+226 -44
View File
@@ -2,8 +2,6 @@ package Sdf1_login;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.BanEntry;
import org.bukkit.BanList;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.event.EventHandler;
@@ -23,7 +21,6 @@ import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.List;
import java.util.Locale;
@@ -41,6 +38,14 @@ import java.util.regex.Pattern;
* 配置丢失 → 自动补发(saveResource 自愈);
* 已存在的 md → 安全升级自动补齐模板新增行(safeUpgradeDoc,第二十一批)。
*
* ★ 第二十七批:数据源改为原版 banned-players.json(用户明令「这是 MC 唯一的封禁数据来源」)。
* 线上事故:旧实现读 Bukkit.getBanList(NAME) 的内存名单,而内存名单在重启/被
Web 后台批量改写后与磁盘 banned-players.json 严重不一致(文件里 [] 空,内存里
却有几十条)→ 玩家看到「一个都没封却公示一堆人」。
* 现改为:每轮播报都重新读 <worldContainer>/banned-players.json 到内存,
* 解析每条的 created,过滤掉插件数据目录创建时间之前的全部历史封禁;
* 文件缺失 / 语法坏 / 过滤后为空 → 一律闭嘴不播,绝不拿兜底假数据说话。
*
* 键值:轮播最低间隔(秒) / 轮播最大间隔(秒) / 播报模式 / 播报起点(插件安装之前的封禁不播)
* 播报模式:true=由近到远、false=由远到近、0=关闭;
* 表格值:由近到远 / 由远到近 / 0。
@@ -83,6 +88,32 @@ public class BanBroadcastManager implements Listener {
*/
public long getStartCutoff() { return resolveStartCutoff(); }
// ==================== 27批:banned-players.json 数据模型 ====================
/** banned-players.json 的一条记录(只取公示需要的字段) */
private static final class JsonBan {
final String name;
final String reason;
final String source;
final long createdMs;
/** 到期毫秒;0 = 永久 */
final long expiresMs;
JsonBan(String name, String reason, String source,
long createdMs, long expiresMs) {
this.name = name;
this.reason = reason;
this.source = source;
this.createdMs = createdMs;
this.expiresMs = expiresMs;
}
}
/** 上次成功读到的原始文件签名(内容+长度),用于「数据有没有变」判定与日志 */
private String lastFileSig = null;
private int lastFileRawCount = 0;
private int lastFileKeptCount = 0;
// ==================== 生命周期 ====================
public void start() {
@@ -115,9 +146,13 @@ public class BanBroadcastManager implements Listener {
}
} catch (Exception ignored) {}
long cut0 = resolveStartCutoff();
File bf0 = bannedPlayersFile();
plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName()
+ " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限"));
+ " 起点=" + (cut0 > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut0)) : "不限")
+ " 数据源=" + (bf0 == null ? "未定位" : bf0.getPath()));
List<JsonBan> boot = collectBans();
log("启动首筛:banned-players.json 有效封禁 " + boot.size() + " 条");
}
public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ }
@@ -442,9 +477,15 @@ public class BanBroadcastManager implements Listener {
/**
* 取播报起点(毫秒):>0 表示只播该时刻之后创建的封禁。
* 自动模式取插件数据目录(plugins/Sdf1_login)的创建时间 = 插件安装时间;
* 文件系统给不出可信创建时间时(缓存值距今不足 7 天,多半是 mtime 兜底)
* → 返回 0 不过滤,避免把插件安装之后的封禁也一并排除。
*
* <p>★ 第二十七批:自动模式取插件数据目录(plugins/Sdf1_login)的创建时间
* = 插件安装上服务器的时间,插件上服之前的全部历史封禁一律不播。
*
* <p>旧实现有「距今不足 7 天就放弃过滤」的守卫(当年担心 mtime 兜底不可信),
* 结果插件刚装上服务器时自动退化成「不过滤」,把上线前几年的历史封禁全播了出来
* —— 这正是用户看到的「一个都没封却公示一堆人」。
* 现改为无条件采用 creationTime:只有文件系统根本给不出创建时间(返回 0 /
* 抛异常)才退化为不过滤,宁可少播也不误播历史数据。
*/
private long resolveStartCutoff() {
if (!startCutoffAuto) return startCutoffMs;
@@ -456,11 +497,16 @@ public class BanBroadcastManager implements Listener {
long t = java.nio.file.Files.readAttributes(df.toPath(),
java.nio.file.attribute.BasicFileAttributes.class)
.creationTime().toMillis();
if (t > 0 && System.currentTimeMillis() - t > 7L * 24 * 3600 * 1000) {
autoCutoffMs = t;
}
if (t > 0) autoCutoffMs = t;
}
} catch (Exception ignored) { }
} catch (Exception e) {
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
+ e.getMessage());
}
if (autoCutoffMs <= 0) {
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
}
}
return autoCutoffMs;
}
@@ -510,65 +556,195 @@ public class BanBroadcastManager implements Listener {
// ==================== 播报 ====================
/** 取当前生效封禁条目(未过期),按创建时间排序 */
@SuppressWarnings("unchecked")
private List<BanEntry<?>> collectBans() {
List<BanEntry<?>> list = new ArrayList<>();
/**
* ★ 27批(用户明令的唯一数据源):每轮都重新读原版 banned-players.json,
* 加载到内存做筛选 —— 不再依赖任何内存名单,杜绝「文件是空的却公示一堆人」。
*
* <p>流程:定位文件 → 读全文 → 内存解析 → 过滤(过期 / created 早于播报起点)
* → 返回。文件缺失、为空、语法坏、过滤后一条不剩,一律返回空列表(=闭嘴)。
*
* <p>created/expires 两种格式都兼容:
* ISO「2026-10-16T13:32:58Z」、带时区「2026-10-16 21:06:20 +0800」、
* 「yyyy-MM-dd HH:mm:ss」、以及纯数字时间戳(秒/毫秒);
* 「forever」/空 = 永久。解析不了的条目按 created=0 处理,
* 起点过滤时会被当成历史封禁排除(宁可漏播不误播)。
*/
private List<JsonBan> collectBans() {
List<JsonBan> list = new ArrayList<>();
File f = bannedPlayersFile();
if (f == null || !f.exists()) {
log("未找到 banned-players.json,本轮闭嘴不播");
lastFileSig = null;
lastFileRawCount = 0;
lastFileKeptCount = 0;
return list;
}
String txt = readFile(f);
if (txt == null || txt.trim().isEmpty() || txt.trim().equals("[]")) {
log("banned-players.json 为空(" + f.length() + " 字节),本轮闭嘴不播");
lastFileSig = sigOf(f, txt);
lastFileRawCount = 0;
lastFileKeptCount = 0;
return list;
}
// 内存加载:解析成 JsonBan 列表
List<JsonBan> raw = new ArrayList<>();
try {
BanList nb = Bukkit.getBanList(BanList.Type.NAME);
// getEntries() 实际返回 Set,按 Collection 中转强转(防 ClassCastException)
// ★ 播报起点:插件安装之前的封禁(历史封禁)不播报,与插件管辖范围对齐
long cutoff = resolveStartCutoff();
for (BanEntry<?> e : (Collection<BanEntry<?>>) (Collection<?>) nb.getEntries()) {
Date exp = e.getExpiration();
if (exp != null && exp.getTime() <= System.currentTimeMillis()) continue;
Date created = e.getCreated();
if (cutoff > 0 && created != null && created.getTime() < cutoff) continue;
list.add(e);
com.google.gson.JsonElement root = JsonParser.parseString(txt);
if (!root.isJsonArray()) {
warn("banned-players.json 根节点不是数组,本轮闭嘴不播");
lastFileSig = sigOf(f, txt);
return list;
}
long nowMs = System.currentTimeMillis();
for (com.google.gson.JsonElement el : root.getAsJsonArray()) {
if (el == null || !el.isJsonObject()) continue;
com.google.gson.JsonObject o = el.getAsJsonObject();
String name = str(o, "name");
if (name.isEmpty()) name = str(o, "uuid");
if (name.isEmpty()) continue;
raw.add(new JsonBan(name, str(o, "reason"), str(o, "source"),
parseBanTime(str(o, "created")),
parseBanTime(str(o, "expires"))));
}
} catch (Exception e) {
warn("读取封禁名单失败: " + e.getMessage());
warn("banned-players.json 解析失败(" + e.getMessage()
+ "),本轮闭嘴不播");
lastFileSig = sigOf(f, txt);
return list;
}
// 内存筛选:未过期 + created 晚于播报起点(起点=插件数据目录创建时间)
long nowMs2 = System.currentTimeMillis();
long cutoff = resolveStartCutoff();
int expiredCnt = 0, historyCnt = 0, timeUnknownCnt = 0;
for (JsonBan b : raw) {
if (b.expiresMs > 0 && b.expiresMs <= nowMs2) { expiredCnt++; continue; }
if (b.createdMs <= 0) { timeUnknownCnt++; } // created 解析不了
if (cutoff > 0 && b.createdMs < cutoff) { historyCnt++; continue; }
list.add(b);
}
// 「每次都检查是不是最新」:文件签名变了才打日志,避免刷屏
String sig = sigOf(f, txt);
lastFileRawCount = raw.size();
lastFileKeptCount = list.size();
if (!sig.equals(lastFileSig)) {
lastFileSig = sig;
log("已重读 banned-players.json: 原始 " + raw.size() + " 条"
+ " → 过滤后 " + list.size() + " 条(起点 "
+ (cutoff > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cutoff)) : "§c未取到·不过滤")
+ ";过期 " + expiredCnt + ",历史 " + historyCnt
+ ",时间未知 " + timeUnknownCnt + ",文件 " + sig + ")");
}
return list;
}
/** 原版封禁名单文件:<worldContainer>/banned-players.json(MC 唯一封禁数据源) */
private File bannedPlayersFile() {
try {
File wc = Bukkit.getWorldContainer();
if (wc != null) {
return new File(wc, "banned-players.json");
}
} catch (Exception e) {
warn("取世界容器失败: " + e.getMessage());
}
return plugin.getDataFolder() == null ? null
: new File(plugin.getDataFolder().getParentFile() == null
? plugin.getDataFolder()
: plugin.getDataFolder().getParentFile().getParentFile(),
"banned-players.json");
}
/** 文件内容签名(长度+哈希),用于「数据有没有变」判定 */
private String sigOf(File f, String txt) {
int h = txt == null ? 0 : txt.hashCode();
return f.length() + "B/" + Integer.toHexString(h);
}
private static String str(com.google.gson.JsonObject o, String k) {
try {
if (o == null || !o.has(k) || o.get(k).isJsonNull()) return "";
String s = o.get(k).getAsString();
return s == null ? "" : s;
} catch (Exception e) {
return "";
}
}
/**
* banned-players.json 的时间字段 → 毫秒;解析不了返回 0。
* 兼容:ISO8601(带 Z/时区) / "yyyy-MM-dd HH:mm:ss Z" / "yyyy-MM-dd HH:mm:ss"
* / "yyyy-MM-dd" / 纯数字(10位=秒,13位=毫秒)。
*/
private long parseBanTime(String s) {
if (s == null) return 0L;
String v = s.trim();
if (v.isEmpty()) return 0L;
if (v.equalsIgnoreCase("forever") || v.equals("永久")) return 0L;
// 纯数字时间戳
if (v.matches("\\d{10,13}")) {
long n = Long.parseLong(v);
return v.length() <= 10 ? n * 1000L : n;
}
String[] pats = {
"yyyy-MM-dd'T'HH:mm:ss.SSSXXX",
"yyyy-MM-dd'T'HH:mm:ssXXX",
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
"yyyy-MM-dd'T'HH:mm:ss'Z'",
"yyyy-MM-dd'T'HH:mm:ss",
"yyyy-MM-dd HH:mm:ss Z",
"yyyy-MM-dd HH:mm:ss ZZZZ",
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy-MM-dd"};
for (String pt : pats) {
try {
java.text.ParsePosition pos = new java.text.ParsePosition(0);
Date d = new SimpleDateFormat(pt, Locale.ROOT).parse(v, pos);
if (d != null && pos.getIndex() == v.length()) return d.getTime();
} catch (Exception ignored) { }
}
return 0L;
}
/** 轮播当前页并广播给全服 */
private void broadcastOnce() {
List<BanEntry<?>> bans = collectBans();
List<JsonBan> bans = collectBans();
if (bans.isEmpty()) { log("当前无生效封禁,跳过本轮"); return; }
// 由近到远:最新封禁在前;由远到近:最早封禁在前
bans.sort((a, b) -> {
long ta = a.getCreated() != null ? a.getCreated().getTime() : 0L;
long tb = b.getCreated() != null ? b.getCreated().getTime() : 0L;
return mode == 1 ? Long.compare(tb, ta) : Long.compare(ta, tb);
});
bans.sort((a, b) -> mode == 1
? Long.compare(b.createdMs, a.createdMs) // 由近到远
: Long.compare(a.createdMs, b.createdMs)); // 由远到近
int total = bans.size();
int start = cursor % total;
int end = Math.min(start + pageSize, total);
List<BanEntry<?>> page = bans.subList(start, end);
List<JsonBan> page = bans.subList(start, end);
cursor = end % total;
String head = "§c§l⚠ §6违规封禁公示 §7(共§f" + total + "§7人, 第§f"
+ (start / pageSize + 1) + "§7/" + ((total + pageSize - 1) / pageSize) + "§7页)";
Bukkit.broadcastMessage(head);
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
for (BanEntry<?> e : page) {
String target = e.getTarget();
String reason = e.getReason() != null && !e.getReason().isEmpty()
? e.getReason() : "违反服务器规则";
String when;
Date created = e.getCreated();
when = created != null ? df.format(created) : "未知时间";
Date exp = e.getExpiration();
String expStr = exp != null ? ("§7至§f" + df.format(exp)) : "§c永久";
for (JsonBan e : page) {
String target = e.name;
String reason = (e.reason != null && !e.reason.isEmpty())
? e.reason : "违反服务器规则";
String when = e.createdMs > 0 ? df.format(new Date(e.createdMs)) : "时间未知";
String expStr = e.expiresMs > 0 ? ("§7至§f" + df.format(new Date(e.expiresMs)))
: "§c永久";
Bukkit.broadcastMessage("§c☠ §f" + target + " §7- " + reason
+ " §8| §7封于§f" + when + " §8| " + expStr);
}
Bukkit.broadcastMessage("§7§o请各位玩家引以为戒,遵守服务器规则。");
log("本轮已播报 " + page.size() + "/" + total + " 条封禁(本次随机间隔 "
+ lastDelaySec + "s,区间 " + minInterval + "~" + maxInterval + "s)");
log("本轮已播报 " + page.size() + "/" + total + " 条封禁"
+ "(本次随机间隔 " + lastDelaySec + "s,区间 "
+ minInterval + "~" + maxInterval + "s;banned-players.json 原始 "
+ lastFileRawCount + " 条,过滤后 " + lastFileKeptCount + " 条)");
}
/** 轮播游标(下一页起点) */
@@ -586,6 +762,12 @@ public class BanBroadcastManager implements Listener {
+ " §7起点=§f" + (cut > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(cut)) : "不限")
+ (startCutoffAuto ? "§7(自动)" : "")
+ (mode == 0 ? " §c(已关闭)" : ""));
File bf = bannedPlayersFile();
List<JsonBan> now = collectBans();
to.sendMessage("§a[封禁播报] §7数据源=§f" + (bf == null ? "未定位"
: bf.getName() + (bf.exists() ? "(§f" + bf.length() + "§7字节)" : "(§c不存在§7)"))
+ " §7有效封禁=§f" + now.size() + "§7条"
+ (now.isEmpty() ? " §8(无数据,保持沉默)" : ""));
}
private String modeName() {
+84 -24
View File
@@ -13,6 +13,7 @@ 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.BlockDropItemEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
@@ -25,6 +26,7 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@@ -43,9 +45,16 @@ import java.util.concurrent.ConcurrentHashMap;
* - 超时(链断)→ 单独交付那 1 个;大箱子天然只快照被拆那一半
* (Chest.getBlockInventory() 只含被拆半边的 27 格)
*
* 打包箱:Material.CHEST / TRAPPED_CHEST + 名称标识 + PDC 存 InventorySerializer
* 打包箱:沿用来源箱子材质 + 名称标识 + PDC 存 InventorySerializer
* base64 内容;setMaxStackSize(1) 防不同内容堆叠混淆。
* 放置回填:BlockPlaceEvent 后 1 tick 方块状态就绪再灌回 27 格。
*
* ★ 第二十七批线上事故修复("拆箱物品照炸开"):
* 26.2 新增铜箱族(COPPER_CHEST / EXPOSED_ / WEATHERED_ / OXIDIZED_ /
* WAXED_ 等 8 种),原判定只认 CHEST / TRAPPED_CHEST → 玩家拆铜箱时
* 整条判定链第一步就早退,交原版散落。改为「凡 *_CHEST 且非末影箱」全收。
* 材质一律走字符串判定(不引用 Material.COPPER_CHEST 之类常量)→
* 跨服务端 build 零风险。
*/
public class ChestCarryManager implements Listener {
@@ -57,6 +66,9 @@ public class ChestCarryManager implements Listener {
private static final long WINDOW_TICKS = 40L;
private static final long WINDOW_MS = 2000L;
/** 接管后该位置的掉落抑制时长(tick):setDropItems 之外的第二道保险 */
private static final long DROP_SUPPRESS_TICKS = 60L;
/** PDC key 的小包装(避免与 block data 的 Chest 类名混淆的命名负担) */
private static final class NamespacedKeyRef {
final org.bukkit.NamespacedKey key;
@@ -70,17 +82,17 @@ public class ChestCarryManager implements Listener {
final UUID player;
final String worldName;
final Location dropLoc; // 超时且玩家离线时的兜底投递点
final boolean trapped; // 来源是否陷阱箱
final Material sourceMat; // 来源箱子材质(打包箱沿用,铜箱拆出来还是铜箱)
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) {
Material sourceMat, String halfName, ItemStack[] contents, long ts) {
this.player = player;
this.worldName = worldName;
this.dropLoc = dropLoc;
this.trapped = trapped;
this.sourceMat = sourceMat;
this.halfName = halfName;
this.contents = contents;
this.ts = ts;
@@ -90,6 +102,9 @@ public class ChestCarryManager implements Listener {
/** 玩家 UUID → 挂起中的搬运(每人最多 1 条) */
private final Map<UUID, PendingCarry> pending = new ConcurrentHashMap<>();
/** 已接管但仍需拦掉落的位置(BlockDropItemEvent 兜底,tick 后自动放行) */
private final Set<Location> suppressDrops = ConcurrentHashMap.newKeySet();
public ChestCarryManager(Main main) {
this.main = main;
this.plugin = main;
@@ -101,7 +116,7 @@ public class ChestCarryManager implements Listener {
@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;
if (!isChestMaterial(mat)) return;
Player p = e.getPlayer();
// 手持:主手必须是精准采集斧头,否则交原版
@@ -122,11 +137,23 @@ public class ChestCarryManager implements Listener {
&& lv != AreaProtection.PermissionLevel.OWNER
&& lv != AreaProtection.PermissionLevel.MEMBER) return;
// 快照被拆半边内容(大箱子 getBlockInventory 只含这半边 27 格)
// ★ 27批:走 Container 接口 + getSnapshotInventory() ——
// 26.2 的 Chest 只覆盖木箱/陷阱箱,铜箱族同样实现 Container;
// getSnapshotInventory() 返回「被拆那半边」的独立快照(大箱子天然只含半边 27 格),
// 不用像 getBlockInventory() 那样先判断是否双箱。
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 (!(st instanceof org.bukkit.block.Container)) {
plugin.getLogger().fine("[箱子搬运] 非容器方块,跳过: " + mat);
return;
}
ItemStack[] snap;
try {
snap = ((org.bukkit.block.Container) st).getSnapshotInventory().getContents();
} catch (Exception ex) {
plugin.getLogger().warning(
"[箱子搬运] 读取箱子快照失败: " + ex.getMessage());
return;
}
// 全空箱不接管:交原版掉箱子本体,无物品洪水
if (isEmpty(snap)) return;
@@ -139,10 +166,11 @@ public class ChestCarryManager implements Listener {
if (t == Chest.Type.LEFT) halfName = "大箱子·左半";
else if (t == Chest.Type.RIGHT) halfName = "大箱子·右半";
}
boolean trapped = (mat == Material.TRAPPED_CHEST);
// 接管:删原版大量掉落
// 接管:删原版大量掉落(setDropItems + BlockDropItemEvent 双保险)
e.setDropItems(false);
markSuppressDrop(b.getLocation());
UUID id = p.getUniqueId();
PendingCarry prev = pending.remove(id);
@@ -151,11 +179,11 @@ public class ChestCarryManager implements Listener {
// 成组:2 箱 1 组,一并交付
deliver(p, prev);
deliver(p, new PendingCarry(id, b.getWorld().getName(),
p.getLocation(), trapped, halfName, snap, now));
p.getLocation(), mat, halfName, snap, now));
} else {
// 首破:挂起观察 2 秒,等第二破成组或超时单独交付
PendingCarry cur = new PendingCarry(id, b.getWorld().getName(),
p.getLocation(), trapped, halfName, snap, now);
p.getLocation(), mat, halfName, snap, now);
pending.put(id, cur);
plugin.getServer().getScheduler().runTaskLater(plugin, () -> {
PendingCarry pc = pending.get(id);
@@ -170,7 +198,7 @@ public class ChestCarryManager implements Listener {
World w = Bukkit.getWorld(pc.worldName);
if (w != null) {
w.dropItemNaturally(pc.dropLoc,
buildPackedChest(pc.trapped, pc.halfName, pc.contents));
buildPackedChest(pc.sourceMat, pc.halfName, pc.contents));
}
} catch (Exception ex) {
plugin.getLogger().warning(
@@ -187,7 +215,7 @@ public class ChestCarryManager implements Listener {
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 (!isChestMaterial(it.getType())) return;
if (!it.hasItemMeta()) return;
PersistentDataContainer pdc = it.getItemMeta().getPersistentDataContainer();
if (!pdc.has(carryKey.key, PersistentDataType.STRING)) return;
@@ -198,13 +226,12 @@ public class ChestCarryManager implements Listener {
// 1 tick 后方块状态就绪再回填(含与邻半合并成大箱子的情况)
plugin.getServer().getScheduler().runTask(plugin, () -> {
BlockState st = placed.getState();
if (!(st instanceof org.bukkit.block.Chest)) return;
if (!(st instanceof org.bukkit.block.Container)) return;
try {
ItemStack[] items = InventorySerializer.fromBase64(data);
org.bukkit.block.Chest chest = (org.bukkit.block.Chest) st;
Inventory inv = chest.getBlockInventory();
Inventory inv = ((org.bukkit.block.Container) st).getInventory();
inv.clear();
int n = Math.min(items.length, 27);
int n = Math.min(items.length, inv.getSize());
for (int i = 0; i < n; i++) {
if (items[i] != null) inv.setItem(i, items[i]);
}
@@ -219,7 +246,7 @@ public class ChestCarryManager implements Listener {
/** 交付一个打包箱给玩家(背包满则掉脚下),并提示 */
private void deliver(Player p, PendingCarry c) {
try {
ItemStack packed = buildPackedChest(c.trapped, c.halfName, c.contents);
ItemStack packed = buildPackedChest(c.sourceMat, c.halfName, c.contents);
HashMap<Integer, ItemStack> left = p.getInventory().addItem(packed);
if (!left.isEmpty()) {
left.values().forEach(it ->
@@ -239,12 +266,14 @@ public class ChestCarryManager implements Listener {
}
}
/** 构造打包箱物品:名称标识 + 防堆叠 + PDC 内容 */
private ItemStack buildPackedChest(boolean trapped, String halfName,
/** 构造打包箱物品:名称标识 + 防堆叠 + PDC 内容(沿用来源箱子材质) */
private ItemStack buildPackedChest(Material sourceMat, String halfName,
ItemStack[] contents) throws Exception {
ItemStack it = new ItemStack(trapped ? Material.TRAPPED_CHEST : Material.CHEST);
ItemStack it = new ItemStack(sourceMat != null ? sourceMat : Material.CHEST);
ItemMeta meta = it.getItemMeta();
meta.setDisplayName("§b§l打包箱 §7(" + halfName + ")");
String matLabel = sourceMat == null ? "木箱"
: sourceMat.name().toLowerCase(java.util.Locale.ROOT);
meta.setDisplayName("§b§l打包箱 §7(" + halfName + " §8· " + matLabel + ")");
List<String> lore = new ArrayList<>();
lore.add("§7内含 §f" + countItems(contents) + " §7件物品");
lore.add("§a放置后 §7自动回填内容");
@@ -257,6 +286,37 @@ public class ChestCarryManager implements Listener {
return it;
}
/**
* 可搬运的箱子材质:凡 *_CHEST 且非末影箱全收。
* 26.2 起存在铜箱族(COPPER_CHEST / EXPOSED_ / WEATHERED_ / OXIDIZED_ / WAXED_ 等),
* 用枚举名后缀判定可一并覆盖,且不引用具体常量,跨服务端 build 不会 NoSuchFieldError。
*/
private static boolean isChestMaterial(Material mat) {
if (mat == null || mat.isAir()) return false;
String n = mat.name();
return n.endsWith("_CHEST") && !n.equals("ENDER_CHEST");
}
/** 登记一个待抑制掉落的位置(tick 后自动放行,避免误伤其它掉落) */
private void markSuppressDrop(Location loc) {
suppressDrops.add(loc);
plugin.getServer().getScheduler().runTaskLater(plugin,
() -> suppressDrops.remove(loc), DROP_SUPPRESS_TICKS);
}
/**
* 掉落兜底抑制:部分版本/服务器路径下箱子内容经 loot table 走
* BlockDropItemEvent 抛出,setDropItems(false) 拦不住 —— 这里补一刀,
* 只对本次已接管(登记过)的方块位置生效,其它掉落一律不碰。
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onChestDropItem(BlockDropItemEvent e) {
if (suppressDrops.isEmpty()) return;
if (suppressDrops.remove(e.getBlock().getLocation())) {
e.setCancelled(true);
}
}
private static boolean isEmpty(ItemStack[] a) {
if (a == null || a.length == 0) return true;
for (ItemStack i : a) {
+224 -13
View File
@@ -87,6 +87,12 @@ public class JsonMessageGui implements Listener {
private static final int SLOT_CLEAR = 25;
private static final int SLOT_SEND = 26;
// ★ 27批:样式按钮(原空闲格)—— 加粗/斜体/下划线/删除线
private static final int SLOT_STYLE_BOLD = 18;
private static final int SLOT_STYLE_ITALIC = 19;
private static final int SLOT_STYLE_UNDERLINE = 20;
private static final int SLOT_STYLE_STRIKE = 23;
// ==================== 每玩家会话 ====================
private static final class Session {
int mode = MODE_TELLRAW;
@@ -94,6 +100,15 @@ public class JsonMessageGui implements Listener {
final StringBuilder text = new StringBuilder();
/** 当前等待聊天捕获的输入类型:null=无 */
String capture; // "text" | "customcolor"
/**
* ★ 27批:先点颜色、后输入内容时的待应用颜色(MC legacy 码,如 "§a")。
* 线上事故:颜色只是 append 进 text,"先选色再打字"时色码落在文字前面
* 被 toTellrawJson 切成一个空 extra 段 → 屏幕上完全看不到颜色(显示白)。
* 改为暂存颜色,等文字进来时前置到该段文字之前。
*/
String pendingColor;
/** 已开启的样式开关:1=加粗 2=斜体 4=下划线 8=删除线 */
int styleFlags;
Session(int mode) { this.mode = mode; }
}
@@ -257,6 +272,8 @@ public class JsonMessageGui implements Listener {
s.mode = mode;
s.capture = null;
s.text.setLength(0);
s.pendingColor = null;
s.styleFlags = 0;
if (!extracted.isEmpty()) s.text.append(extracted);
p.openInventory(build(s));
@@ -307,7 +324,10 @@ public class JsonMessageGui implements Listener {
"§f使用说明",
"§71. 选模式 → 2. 点「输入内容」",
"§7在聊天栏打字(自动收回面板)",
"§3点击颜色按钮§7可给当前内容上色",
"§3点颜色§7:空内容时记住颜色,打字时带上;",
"§7已有内容时整段重新染色",
"§3四个样式按钮§7:加粗/斜体/下划线/删除线",
"§7发送成功会把 JSON 原样回给你(仅本人可见)",
"§7发送以你自己的身份执行原版指令,",
"§7权限与直接敲 /tellraw、/title 完全一致"));
@@ -331,11 +351,24 @@ public class JsonMessageGui implements Listener {
}
}
// ★ 27批:样式开关 4 连(点一下切换开/关,直接作用于后续文字)
inv.setItem(SLOT_STYLE_BOLD, styleItem(Material.BLAZE_POWDER,
"加粗", 1, s.styleFlags));
inv.setItem(SLOT_STYLE_ITALIC, styleItem(Material.FEATHER,
"斜体", 2, s.styleFlags));
inv.setItem(SLOT_STYLE_UNDERLINE, styleItem(Material.NAME_TAG,
"下划线", 4, s.styleFlags));
inv.setItem(SLOT_STYLE_STRIKE, styleItem(Material.STICK,
"删除线", 8, s.styleFlags));
inv.setItem(SLOT_PREVIEW, previewItem(s));
inv.setItem(SLOT_INPUT_TEXT, simple(Material.WRITABLE_BOOK,
"§a输入内容",
"§7点击后在聊天栏输入文字",
"§7(不会真的发到公屏)"));
"§7(不会真的发到公屏)",
(s.pendingColor != null
? "§7当前色:§f" + legacyPreview(s.pendingColor)
: "§8尚未选色(可先选颜色再打字)")));
inv.setItem(SLOT_CLEAR, simple(Material.LAVA_BUCKET,
"§c清空内容", "§7清空已输入的文本"));
inv.setItem(SLOT_SEND, simple(Material.EMERALD_BLOCK,
@@ -345,7 +378,31 @@ public class JsonMessageGui implements Listener {
private ItemStack previewItem(Session s) {
String shown = s.text.length() == 0 ? "§8(空)" : clip(s.text.toString(), 40);
return simple(Material.HONEYCOMB, "§e当前内容预览", "§r" + shown);
return simple(Material.HONEYCOMB, "§e当前内容预览", "§r" + shown,
styleStateLine(s));
}
/** 样式开启动态一行(让玩家看得见当前样式开关) */
private static String styleStateLine(Session s) {
StringBuilder sb = new StringBuilder("§8样式: ");
int f = s.styleFlags;
sb.append((f & 1) != 0 ? "§a粗" : "§7粗").append(' ')
.append((f & 2) != 0 ? "§a斜" : "§7斜").append(' ')
.append((f & 4) != 0 ? "§a下划" : "§7下划").append(' ')
.append((f & 8) != 0 ? "§a删除" : "§7删除");
return sb.toString();
}
/** legacy § 码 → 可读标签(仅 GUI 文案用) */
private static String legacyPreview(String legacy) {
if (legacy == null || legacy.isEmpty()) return "(无)";
return legacy.replace('§', '&');
}
/** 把纯文本按 legacy 色码渲染(仅用于聊天栏回显) */
private static String colorPreview(String plain, String colorCode) {
if (colorCode == null || colorCode.isEmpty()) return "§r";
return colorCode + "§r";
}
private static String clip(String s, int max) {
@@ -418,6 +475,25 @@ public class JsonMessageGui implements Listener {
}
}
/** 样式开关按钮:亮起=已开,灰暗=未开 */
private static ItemStack styleItem(Material mat, String name, int bit, int flags) {
boolean on = (flags & bit) != 0;
return simple(mat,
(on ? "§a✔ " : "§7") + (on ? "§l" : "") + name,
on ? "§a已开启 §7— 再次点击关闭" : "§e点击开启",
"§7对应 JSON 字段 §f" + styleJsonField(bit),
"§7样式作用于之后输入的文字");
}
private static String styleJsonField(int bit) {
switch (bit) {
case 1: return "\"bold\":true";
case 2: return "\"italic\":true";
case 4: return "\"underlined\":true";
default: return "\"strikethrough\":true";
}
}
private static ItemStack modeItem(Material mat, String name, boolean selected,
String... lore) {
ItemStack it = simple(mat, (selected ? "§a✔ " : "§7") + name, lore);
@@ -465,9 +541,27 @@ public class JsonMessageGui implements Listener {
beginCapture(p, s, "customcolor"); break;
case SLOT_INPUT_TEXT:
beginCapture(p, s, "text"); break;
case SLOT_STYLE_BOLD:
s.styleFlags ^= 1;
p.sendMessage(styleMsg("加粗", (s.styleFlags & 1) != 0));
refresh(p, s); break;
case SLOT_STYLE_ITALIC:
s.styleFlags ^= 2;
p.sendMessage(styleMsg("斜体", (s.styleFlags & 2) != 0));
refresh(p, s); break;
case SLOT_STYLE_UNDERLINE:
s.styleFlags ^= 4;
p.sendMessage(styleMsg("下划线", (s.styleFlags & 4) != 0));
refresh(p, s); break;
case SLOT_STYLE_STRIKE:
s.styleFlags ^= 8;
p.sendMessage(styleMsg("删除线", (s.styleFlags & 8) != 0));
refresh(p, s); break;
case SLOT_CLEAR:
s.text.setLength(0);
p.sendMessage("§e[JSON构造器] §7内容已清空");
s.pendingColor = null;
s.styleFlags = 0;
p.sendMessage("§e[JSON构造器] §7内容/颜色/样式已全部清空");
refresh(p, s); break;
case SLOT_SEND:
doSend(p, s); break;
@@ -475,7 +569,7 @@ public class JsonMessageGui implements Listener {
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));
pickColor(p, s, palette.get(idx));
}
}
break;
@@ -533,12 +627,20 @@ public class JsonMessageGui implements Listener {
if (msg.isEmpty() || msg.equals("/qj")) {
p.sendMessage("§e[JSON构造器] §7已取消输入");
} else if (type.equals("text")) {
// ★ 27批:先把待用颜色/样式冠在这段文字之前 ——
// 这样「先选色后打字」也能真正带上颜色(此前色码裸挂在段首被丢弃)
String style = styleToLegacy(s.styleFlags);
if (s.pendingColor != null || !style.isEmpty()) {
s.text.append(s.pendingColor == null ? "" : s.pendingColor);
s.text.append(style);
}
s.text.append(msg);
p.sendMessage("§a[JSON构造器] §7已加入内容:§f" + clip(msg, 60));
p.sendMessage("§a[JSON构造器] §7已加入内容:"
+ colorPreview(msg, s.pendingColor) + clip(msg, 60));
} else {
// 自定义颜色
if (isValidColor(msg)) {
appendColor(p, s, normalizeColor(msg));
applyCustomColor(p, s, normalizeColor(msg));
} else {
p.sendMessage("§c[JSON构造器] 颜色配置无效:§f" + msg
+ "§7(仅支持 MC 原生16色名 / #RRGGBB / R G B)");
@@ -553,22 +655,127 @@ public class JsonMessageGui implements Listener {
sessions.remove(e.getPlayer().getUniqueId());
}
/** 颜色写入当前文本(legacy § 码,hex 用 §x§R§R§G§G§B§B) */
private void appendColor(Player p, Session s, String color) {
/** 颜色 → legacy § 码串(hex 用 §x§R§R§G§G§B§B) */
private static String colorToLegacy(String color) {
StringBuilder sb = new StringBuilder();
if (color.startsWith("#")) {
s.text.append('§').append('x');
sb.append('§').append('x');
for (char c : color.substring(1).toCharArray()) {
s.text.append('§').append(Character.toLowerCase(c));
sb.append('§').append(Character.toLowerCase(c));
}
} else {
s.text.append('§').append(nativeToLegacy(color));
sb.append('§').append(nativeToLegacy(color));
}
p.sendMessage("§a[JSON构造器] §7已上色 §f" + color);
return sb.toString();
}
/** 已开启样式的 legacy 码串(加粗§l/斜体§o/下划线§n/删除线§m) */
private static String styleToLegacy(int flags) {
StringBuilder sb = new StringBuilder();
if ((flags & 1) != 0) sb.append('§').append('l');
if ((flags & 2) != 0) sb.append('§').append('o');
if ((flags & 4) != 0) sb.append('§').append('n');
if ((flags & 8) != 0) sb.append('§').append('m');
return sb.toString();
}
private static String styleMsg(String name, boolean on) {
return "§a[JSON构造器] §7样式 §f" + name + " §7已"
+ (on ? "§a开启" : "§c关闭");
}
/**
* ★ 27批 线上事故修复:「设置绿色,输出白色」。
*
* <p>原实现把颜色码直接 append 到 text 末尾。当玩家「先输入文字、再点颜色」时,
* 色码只是给已发出的文字「下个位置」上色——对已输入的内容没有任何效果;
* 而「先点颜色、再输入文字」时色码落在文字之前,被 toTellrawJson 切成一个空的
* extra 段被丢弃,表现同样是全白。两种顺序都拿不到颜色。
*
* <p>现语义改为「选色是状态,不是追加」:
* <ul>
* <li>text 为空 → 只记 pendingColor,等输入时前置到该段文字;</li>
* <li>text 已有内容 → 重写整个内容:每个可见文字段冠以「当前色 + 当前样式」;</li>
* <li>已开样式同时以 § 码形式注入,保证 toTellrawJson 能转成 JSON 布尔字段。</li>
* </ul>
*/
private void pickColor(Player p, Session s, String color) {
String code = colorToLegacy(color);
String style = styleToLegacy(s.styleFlags);
if (s.text.length() == 0) {
s.pendingColor = code;
p.sendMessage("§a[JSON构造器] §7已选色 §f" + color
+ "§7,输入内容时自动带上");
refresh(p, s);
return;
}
// 先剥掉旧色码得到裸文本,再按新颜色整体重建
String plain = stripCodes(s.text.toString());
s.pendingColor = code;
applyColorToPlain(p, s, plain, code, style);
p.sendMessage("§a[JSON构造器] §7已把已有内容染成 §f" + color);
refresh(p, s);
}
/** 去掉所有 § 颜色/样式码,得到裸文本(重染色前先剥干净) */
private static String stripCodes(String s) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '§' && i + 1 < s.length()) { i++; continue; }
sb.append(c);
}
return sb.toString();
}
/** 把裸文本按给定颜色 + 样式重建为 text(每段一个「色码+样式码+文字」) */
private void applyColorToPlain(Player p, Session s, String plain,
String colorCode, String style) {
s.text.setLength(0);
if (plain.isEmpty()) {
s.pendingColor = colorCode;
return;
}
// 按行切段,逐段冠色 —— 换行也能保持颜色
String[] lines = plain.split("\\n", -1);
for (int i = 0; i < lines.length; i++) {
if (i > 0) s.text.append('\n');
if (lines[i].isEmpty()) continue;
s.text.append(colorCode).append(style).append(lines[i]);
}
}
/** 自定义颜色输入校验通过后统一走这里(与调色板同语义) */
private void applyCustomColor(Player p, Session s, String color) {
pickColor(p, s, color);
}
// ==================== 发送 ====================
/**
* 把本次实际执行的原版指令 + 生成的 JSON 原样回显给执行者(仅本人可见)。
* 长 JSON 按 180 字符一行折行,避免超长行被客户端吞掉;
* 同时给出该 JSON 的最小可复制形态,方便照着敲进游戏。
*/
private void echoJson(Player p, String cmd, String json) {
p.sendMessage("§8§m ");
p.sendMessage("§b[JSON构造器] §7以下是你刚发送的 JSON(§f仅你可见§7,可直接复制):");
p.sendMessage("§8指令 §7: §f/" + cmd);
for (String line : wrap(json, 180)) {
p.sendMessage("§8JSON §7: §e" + line);
}
p.sendMessage("§8§m ");
}
private static List<String> wrap(String s, int width) {
List<String> out = new ArrayList<>();
if (s == null || s.isEmpty()) return out;
for (int i = 0; i < s.length(); i += width) {
out.add(s.substring(i, Math.min(s.length(), i + width)));
}
return out;
}
private void doSend(Player p, Session s) {
if (s.text.length() == 0) {
p.sendMessage("§c[JSON构造器] 内容为空,先点「输入内容」");
@@ -600,8 +807,12 @@ public class JsonMessageGui implements Listener {
p.sendMessage("§a[JSON构造器] §7已发送("
+ (s.mode == MODE_TITLE ? "大标题"
: s.mode == MODE_SUBTITLE ? "小标题" : "tellraw消息") + ")");
// ★ 27批(用户要求):把生成的 JSON 原样回给执行者,便于学习 JSON 写法。
// 只私发给本人,不刷公屏;分多行发,长度也不受聊天栏截断影响。
echoJson(p, cmd, json);
} else {
p.sendMessage("§c[JSON构造器] 发送失败:权限不足或指令被拒绝");
echoJson(p, cmd, json); // 失败也回显,方便改完权限再试
}
}