From 8d73722b6a880c8b8a72a88bdbb3c878c3446fe0 Mon Sep 17 00:00:00 2001 From: youpaishidifu <1@ypshidifu.cn> Date: Wed, 14 Oct 2026 18:04:48 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AC=AC=E5=8D=81=E5=85=AD=E6=89=B9=EF=BC=9A?= =?UTF-8?q?=E9=9B=AA=E7=90=83=E8=8F=9C=E5=8D=95SN=E6=A0=A1=E9=AA=8C+?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=86=85=E8=BD=AE=E8=AF=A2=E5=B0=81=E7=A6=81?= =?UTF-8?q?=E6=92=AD=E6=8A=A5(BanBroadcastManager)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/Sdf1_login/BanBroadcastManager.java | 381 ++++++++++++++++++ src/main/java/Sdf1_login/Main.java | 54 +++ src/main/resources/ban/规则配置.md | 26 ++ 3 files changed, 461 insertions(+) create mode 100644 src/main/java/Sdf1_login/BanBroadcastManager.java create mode 100644 src/main/resources/ban/规则配置.md diff --git a/src/main/java/Sdf1_login/BanBroadcastManager.java b/src/main/java/Sdf1_login/BanBroadcastManager.java new file mode 100644 index 0000000..7235937 --- /dev/null +++ b/src/main/java/Sdf1_login/BanBroadcastManager.java @@ -0,0 +1,381 @@ +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.configuration.file.YamlConfiguration; +import org.bukkit.scheduler.BukkitRunnable; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +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; +import java.util.Random; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * ★ 第十六批:游戏内轮询封禁播报(耻辱台) + * + * 配置文件(plugins/Sdf1_login/ban/ 目录): + * - *.json 直接作为配置文件; + * - 规则配置.md 优先解 ```json 代码块,解不出再解 markdown 表格; + * - 解码全失败 → 备份原文件 + 释放一份新的。 + * 配置丢失 → 自动补发(saveResource 自愈)。 + * + * 键值:轮播最低间隔(秒) / 轮播最大间隔(秒) / 播报模式 + * 播报模式:true=由近到远、false=由远到近、0=关闭; + * 表格值:由近到远 / 由远到近 / 0。 + * 间隔或模式为 0 → 关闭播报。 + */ +public class BanBroadcastManager { + + private final Main plugin; + private final Random rnd = new Random(); + + private File banDir; + private File mdFile; + private File jsonFile; // *.json 配置(存在则优先) + + private int minInterval = 60; + private int maxInterval = 180; + private int mode = 1; // 0=关闭 1=由近到远 2=由远到近 + private int pageSize = 9; // 每轮播报条数(耻辱台一页) + + public BanBroadcastManager(Main plugin) { + this.plugin = plugin; + } + + // ==================== 生命周期 ==================== + + public void start() { + banDir = new File(plugin.getDataFolder(), "ban"); + mdFile = new File(banDir, "规则配置.md"); + jsonFile = findJsonConfig(); + ensureConfigExists(); + reloadConfig(); + scheduleNext(); + plugin.getLogger().info("[封禁播报] 已启动,模式=" + modeName() + + " 间隔=" + minInterval + "~" + maxInterval + "s 每页" + pageSize + "条"); + } + + public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ } + + // ==================== 配置:自愈 + 双模式解析 ==================== + + /** 找 ban/ 目录下任意 *.json 配置文件 */ + private File findJsonConfig() { + File[] fs = banDir == null ? null : banDir.listFiles( + (d, n) -> n.toLowerCase(Locale.ROOT).endsWith(".json")); + return (fs != null && fs.length > 0) ? fs[0] : null; + } + + /** 配置文件丢失就补一份(自愈) */ + private void ensureConfigExists() { + try { + if (jsonFile != null) { + if (!banDir.exists()) banDir.mkdirs(); + if (!jsonFile.exists()) { + plugin.saveResource("ban/" + jsonFile.getName(), false); + log("配置文件丢失,已自愈补发: " + jsonFile.getName()); + } + return; + } + if (!mdFile.exists()) { + banDir.mkdirs(); + plugin.saveResource("ban/规则配置.md", false); + log("配置文件丢失,已自愈补发: 规则配置.md"); + } + } catch (Exception e) { + warn("自愈补发失败: " + e.getMessage()); + } + } + + /** 重新加载配置(json 优先 → md json块 → md 表格 → 备份重置) */ + public void reloadConfig() { + try { + if (jsonFile == null) jsonFile = findJsonConfig(); + if (jsonFile != null && jsonFile.exists()) { + String txt = readFile(jsonFile); + if (parseJsonText(txt)) { log("已加载 json 配置: " + jsonFile.getName()); return; } + backupAndReset(jsonFile, jsonFile.getName()); + return; + } + if (!mdFile.exists()) { ensureConfigExists(); } + String md = readFile(mdFile); + if (md != null) { + String jsonBlock = extractJsonBlock(md); + if (jsonBlock != null && parseJsonText(jsonBlock)) { + log("已加载 md 内嵌 json 配置: 规则配置.md"); return; + } + if (parseTableText(md)) { log("已加载 md 表格配置: 规则配置.md"); return; } + } + // 全部解码失败 → 备份原文件 + 释放一份新的 + backupAndReset(mdFile, "规则配置.md"); + } catch (Exception e) { + warn("reloadConfig 异常: " + e.getMessage()); + } + } + + /** 从 md 提取 ```json 代码块内容(不含围栏) */ + private String extractJsonBlock(String md) { + Pattern p = Pattern.compile("```json\\s*\\n(.*?)\\n\\s*```", Pattern.DOTALL); + Matcher m = p.matcher(md); + return m.find() ? m.group(1).trim() : null; + } + + /** + * 解析 json 文本(键:最小间隔/最大间隔/播报模式)。 + * 出厂 规则配置.md 的 json 块值为空字符串而表格有真值(文档语义:二者选一填), + * 因此仅当 json 里出现「非空间隔值」才认 json 生效; + * 只有播报模式、没有任何间隔键的纯 json 也认生效(间隔走默认)。 + * @return true = json 配置生效;false = 视为未配置,调用方回退表格 + */ + private boolean parseJsonText(String txt) { + try { + JsonObject o = JsonParser.parseString(txt).getAsJsonObject(); + boolean hasIntervalKey = o.has("最小间隔") || o.has("最大间隔") + || o.has("轮播最低间隔") || o.has("轮播最大间隔"); + boolean anyInterval = false; + Integer v; + if ((v = jsonInt(o, "最小间隔")) != null) { minInterval = v; anyInterval = true; } + if ((v = jsonInt(o, "最大间隔")) != null) { maxInterval = v; anyInterval = true; } + if ((v = jsonInt(o, "轮播最低间隔")) != null) { minInterval = v; anyInterval = true; } + if ((v = jsonInt(o, "轮播最大间隔")) != null) { maxInterval = v; anyInterval = true; } + boolean modePresent = false; + if (o.has("播报模式") && !o.get("播报模式").isJsonNull()) { + mode = parseMode(o.get("播报模式").getAsString()); + modePresent = true; + } + if (anyInterval) { normalize(); return true; } + // 没有可用间隔值:纯「只有播报模式且无间隔键」的 json 也算生效 + if (modePresent && !hasIntervalKey) { normalize(); return true; } + return false; + } catch (Exception e) { + return false; + } + } + + /** json 取间隔值:键存在且非空才返回(空串/null → null,留给表格或默认值) */ + private Integer jsonInt(JsonObject o, String key) { + try { + if (!o.has(key) || o.get(key).isJsonNull()) return null; + String s = o.get(key).getAsString(); + if (s == null || s.trim().isEmpty()) return null; + int r = toSec(s); + return r >= 0 ? r : null; + } catch (Exception e) { + return null; + } + } + + /** 解析 md 表格(| 名字 | 说明 | 参数值 |) */ + private boolean parseTableText(String md) { + boolean any = false; + for (String line : md.split("\n")) { + String t = line.trim(); + if (!t.startsWith("|") || t.startsWith("| ---") || t.startsWith("|---")) continue; + String[] cells = t.split("\\|"); + if (cells.length < 4) continue; + String key = cells[1].trim(); + String val = cells[cells.length - 2].trim(); + if (key.equals("轮播最低间隔")) { minInterval = toSec(val); any = true; } + else if (key.equals("轮播最大间隔")) { maxInterval = toSec(val); any = true; } + else if (key.equals("播报模式")) { mode = parseMode(val); any = true; } + } + if (any) normalize(); + return any; + } + + /** 播报模式文本/布尔/数字 → 0/1/2 */ + private int parseMode(String s) { + if (s == null) return 1; + String v = s.trim(); + if (v.equals("0") || v.equalsIgnoreCase("false")) return 2; // false=由远到近 + if (v.equals("1") || v.equalsIgnoreCase("true")) return 1; // true=由近到远 + if (v.contains("近到远")) return 1; // 由近到远 + if (v.contains("远到近")) return 2; // 由远到近 + return 1; + } + + /** 秒数文本 → int(容错"60s"/"60秒"/"60") */ + private int toSec(String s) { + String v = s == null ? "" : s.trim().replaceAll("[^0-9]", ""); + if (v.isEmpty()) return 0; + try { return Integer.parseInt(v); } catch (Exception e) { return 0; } + } + + /** 归一化:模式为0 或 最大最小间隔都为0 → 关闭;最大<最低 → 补齐(对齐配置说明语义) */ + private void normalize() { + if (minInterval < 0) minInterval = 0; + if (maxInterval < 0) maxInterval = 0; + if (maxInterval > 0 && minInterval > maxInterval) maxInterval = minInterval; + if (mode != 0 && mode != 1 && mode != 2) mode = 1; + if (mode == 0 || (minInterval == 0 && maxInterval == 0)) mode = 0; + } + + // ==================== 调度 ==================== + + /** 排下一次窗口期:随机 [min,max] 秒后播报,再自我reschedule */ + private void scheduleNext() { + int delaySec; + if (mode == 0) { + // 播报关闭时仍每 60 秒自检一次:配置文件被删则自愈补发、 + // 运行中把 0 改回正常值可自动恢复播报(无需重启) + delaySec = 60; + } else { + int span = maxInterval - minInterval; + delaySec = minInterval + (span > 0 ? rnd.nextInt(span + 1) : 0); + } + long delayTicks = Math.max(20L, delaySec * 20L); + new BukkitRunnable() { + @Override + public void run() { + if (!plugin.isEnabled()) return; + // ★ 自愈 + 热重载:每次窗口期前检查配置存在性并重读 + ensureConfigExists(); + reloadConfig(); + if (mode != 0) { + try { broadcastOnce(); } + catch (Exception e) { warn("播报异常: " + e.getMessage()); } + } + scheduleNext(); + } + }.runTaskLater(plugin, delayTicks); + } + + // ==================== 播报 ==================== + + /** 取当前生效封禁条目(未过期),按创建时间排序 */ + @SuppressWarnings("unchecked") + private List> collectBans() { + List> list = new ArrayList<>(); + try { + BanList nb = Bukkit.getBanList(BanList.Type.NAME); + // getEntries() 实际返回 Set,按 Collection 中转强转(防 ClassCastException) + for (BanEntry e : (Collection>) (Collection) nb.getEntries()) { + Date exp = e.getExpiration(); + if (exp != null && exp.getTime() <= System.currentTimeMillis()) continue; + list.add(e); + } + } catch (Exception e) { + warn("读取封禁名单失败: " + e.getMessage()); + } + return list; + } + + /** 轮播当前页并广播给全服 */ + private void broadcastOnce() { + List> 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); + }); + + int total = bans.size(); + int start = cursor % total; + int end = Math.min(start + pageSize, total); + List> 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永久"; + Bukkit.broadcastMessage("§c☠ §f" + target + " §7- " + reason + + " §8| §7封于§f" + when + " §8| " + expStr); + } + Bukkit.broadcastMessage("§7§o请各位玩家引以为戒,遵守服务器规则。"); + log("本轮已播报 " + page.size() + "/" + total + " 条封禁"); + } + + /** 轮播游标(下一页起点) */ + private int cursor = 0; + + // ==================== 管理员命令 ==================== + + /** 手动重载配置并汇报当前生效值 */ + public void report(CommandSender to) { + reloadConfig(); + to.sendMessage("§a[封禁播报] §7模式=§f" + modeName() + + " §7间隔=§f" + minInterval + "~" + maxInterval + "s" + + " §7每页=§f" + pageSize + "条" + + (mode == 0 ? " §c(已关闭)" : "")); + } + + private String modeName() { + return mode == 0 ? "关闭" : (mode == 1 ? "由近到远" : "由远到近"); + } + + // ==================== 工具 ==================== + + private String readFile(File f) { + if (f == null || !f.exists()) return null; + try (BufferedReader br = new BufferedReader(new InputStreamReader( + new FileInputStream(f), StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line).append('\n'); + return sb.toString(); + } catch (Exception e) { + warn("读取失败 " + f.getName() + ": " + e.getMessage()); + return null; + } + } + + /** 备份原配置文件 + 释放一份新的(自愈) */ + private void backupAndReset(File f, String resourceName) { + try { + if (f != null && f.exists()) { + String bak = f.getAbsolutePath() + ".bak." + System.currentTimeMillis(); + copyFile(f, new File(bak)); + warn("配置解码失败,已备份原文件: " + new File(bak).getName()); + } + // 先删旧文件再从 jar 释放新的 + if (f != null && f.exists()) f.delete(); + plugin.saveResource("ban/" + resourceName, false); + log("已释放一份新配置: " + resourceName); + } catch (Exception e) { + warn("备份/重置失败: " + e.getMessage()); + } + } + + private void copyFile(File src, File dst) throws IOException { + try (FileInputStream in = new FileInputStream(src); + FileOutputStream out = new FileOutputStream(dst)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + } + } + + private void log(String s) { plugin.getLogger().info("[封禁播报] " + s); } + private void warn(String s) { plugin.getLogger().warning("[封禁播报] " + s); } +} diff --git a/src/main/java/Sdf1_login/Main.java b/src/main/java/Sdf1_login/Main.java index 75a4c2b..5778dcc 100644 --- a/src/main/java/Sdf1_login/Main.java +++ b/src/main/java/Sdf1_login/Main.java @@ -557,6 +557,9 @@ public class Main extends JavaPlugin // 全程无 ERROR 只有 SSL 断路器 WARN —— 日志抓不到卡点栈,靠它下次复现 // 直接把主线程栈打进日志定位) new MainThreadWatchdog(this).start(); + // ★ 第十六批:游戏内轮询封禁播报(耻辱台) + // 配置 ban/规则配置.md 或 ban/*.json,丢失自愈补发、解码失败备份重置 + new BanBroadcastManager(this).start(); afk.startCheck(); startLoginReminder(); startTimeoutCheck(); @@ -3388,6 +3391,51 @@ public class Main extends JavaPlugin } } + /** + * ★ 第十六批:雪球菜单 SN 校验(仅雪球开 GUI 路径调用) + * 规则: + * - 无 SN(历史雪球)→ 放行; + * - SN 未登记 → 拒绝(数据异常/伪造); + * - 状态 illegal(锁定)→ 拒绝; + * - 登记持有者 ≠ 实际持有者 → 拒绝; + * - active/lost 且持有者一致 → 放行。 + * 校验异常时放行(防 DB 抖动误伤),只记日志。 + * @return true=放行可打开主 GUI;false=已提示并拒绝 + */ + private boolean verifyMenuSnowballSn(Player p) { + try { + if (snManager == null) return true; + ItemStack it = p.getInventory().getItemInMainHand(); + if (!isMenuSnowball(it)) it = p.getInventory().getItemInOffHand(); + String sn = snManager.readSn(it); + if (sn == null || sn.isEmpty()) return true; // 历史雪球无 SN,放行 + java.util.Map row = snManager.getSn(sn); + if (row == null) { + p.sendMessage("§c§l[菜单] §f菜单雪球校验失败:SN 未登记,请联系管理员"); + getLogger().info("[菜单] 雪球SN校验拒绝: " + p.getName() + " SN=" + sn + " 未登记"); + return false; + } + String st = row.get("status") == null ? "" : String.valueOf(row.get("status")); + if (SnManager.ST_ILLEGAL.equals(st)) { + p.sendMessage("§c§l[菜单] §f该菜单雪球已被锁定,无法打开菜单"); + getLogger().info("[菜单] 雪球SN校验拒绝: " + p.getName() + " SN=" + sn + " 已锁定"); + return false; + } + String owner = row.get("owner") == null ? "" : String.valueOf(row.get("owner")); + String me = p.getName(); + // 持有者精确匹配(离线模式大小写不同=不同玩家) + if (!owner.isEmpty() && !owner.equals(me)) { + p.sendMessage("§c§l[菜单] §f该菜单雪球不属于你,无法打开菜单"); + getLogger().info("[菜单] 雪球SN校验拒绝: " + me + " 持有 " + owner + " 的 SN=" + sn); + return false; + } + return true; + } catch (Exception e) { + getLogger().warning("[菜单] 雪球SN校验异常(放行): " + e.getClass().getSimpleName() + ": " + e.getMessage()); + return true; + } + } + private boolean isMenuSnowball(ItemStack item) { if (item == null || item.getType() @@ -3544,6 +3592,12 @@ public class Main extends JavaPlugin if (isMenuSnowball(hand) || isMenuSnowball(offhand)) { e.setCancelled(true); + // ★ 第十六批:雪球菜单打开 GUI 前校验 SN + // 被锁定 / SN登记持有者与实际持有者不符 → 拒绝打开主 GUI + // 主指令 /sdf1_login 不走这里,不受影响 + if (!verifyMenuSnowballSn(p)) { + return; + } gui.openMain(p); return; } diff --git a/src/main/resources/ban/规则配置.md b/src/main/resources/ban/规则配置.md new file mode 100644 index 0000000..453f844 --- /dev/null +++ b/src/main/resources/ban/规则配置.md @@ -0,0 +1,26 @@ +## ----配置区开始---- + +| 名字 | 功能说明 | 参数值 | +| --- | --- | --- | +| 轮播最低间隔 | 最短是多少秒一次(建议阈值60,太低很吵) | 60 | +| 轮播最大间隔 | 最长多少秒一次(建议阈值300以内,太长起不到效果) | 180 | +| 播报模式 | 是由远到近(即从第一个封禁的玩家开始报到最后一个),还是又近到远(从最新一个被封禁的玩家播报到第一个被封禁的玩家)。此项填0或最大最小间隔都为0表示关闭播报 | 由近到远 | + +```json +{ + "最小间隔":"", + "最大间隔":"", + "播报模式":true, + "解释说明":{ + "播报模式":"true表示开启播报又近到远,false表示开启播报由远到近。0为关闭", + "最小间隔":"最短是多少秒一次(建议阈值60,太低很吵)", + "最大间隔":"最长多少秒一次(建议阈值300以内,太长起不到效果)" + } +} +``` + +## ----配置区域结束---- + +## 功能说明 +- 此功能为服务器打造一个不可拆解的耻辱台,展示所有被封禁的玩家。json代码块的参数值和md表格是2选1,你可以删了表格,留json则表示用json作为配置值,也可以删了json用表格。小白用户推荐使用md,因为最简单,专业人员推荐使用json,因为最清晰,没有注释(注释是另起一行,不在配置块)。
+- 同时兼容.json直接作为配置文件 \ No newline at end of file