第二十七批:修线上验收4个bug——箱子搬运认铜箱族、/json选色语义改状态、样式4按钮+发送回显JSON、封禁公示改读banned-players.json
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:
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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); // 失败也回显,方便改完权限再试
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user