Compare commits

...
109 Commits
Author SHA1 Message Date
youpaishidifu 61fdd106b2 更新预置公告内容
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-22 14:55:30 +08:00
youpaishidifu 22e4d262dc 第42批:修复垃圾站启停状态热重载断链(日志喊了true→false但垃圾站照跑)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 4m10s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-22 14:28:17 +08:00
youpaishidifu a5cb5afba7 第41批收尾:正则层残留 Unicode 转义全部字面化(可见字符写字面量/不可见字符用具名常量)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-22 13:05:46 +08:00
youpaishidifu 855e45cb7f 第41批补:修 INVISIBLE 正则字符类开括号丢失 + 清 IDE inspection(空if体/未使用字段注释)
- ChatFilterManager INVISIBLE 字符类 '[' 被清理脚本误删 → 补回(否则不可见字符剔除失效)
- ConfigManager 删死分支(恒空 if)+ else-if 空体改 continue(语义等价,21场景验证)
- garbageTag/smtpConfiguringPlayer 加注释说明为历史遗留字段(保留兼容,不删)
2026-10-22 12:29:14 +08:00
youpaishidifu ac64aee365 第41批:全量转译 \uXXXX 转义为可读字面量/UnicodeChars具名常量 + 修复IDE插坏代码
- 新增 UnicodeChars 常量表:不可见字符(零宽/BOM/全角空格/U+0001)统一用具名常量
- 可见字符直接写字面量(§、:、全角数字、汉字区间等),读代码无需心算码位
- 正则层转义(\\uXXXX)保持原样并补注释说明,避免改变正则语义
- 注释里的 \uXXXX 改双写(javac 预处理期生效,注释躲不掉)
- 修复IDE插坏的 ConfigManager/UnicodeChars 括号
2026-10-22 12:17:04 +08:00
youpaishidifu 14db0aa10c 第39批:垃圾站轮次提示恒发+口径改为刚开启的新一轮(原倒垃圾轮整轮静默+播报差一轮) 2026-10-22 11:18:09 +08:00
youpaishidifu d0821b1ebe 第38批:公告.md json块更新器修正(新键补到顶层最前而非末尾+尾逗号容错+防选块跳到示例块)+公告板尾逗号容错
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-22 10:34:35 +08:00
youpaishidifu 77f8289c64 第37批:通用更新器补md内嵌json块缺键合并 + 垃圾站保留轮数热更
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 3m22s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
bug1: safeUpgradeDoc只认配置区标记且只补表格行,公告.md头部json块新增键永不合并 → 新增mergeMdJsonBlock(第一个可解析json围栏块缺键补入,前置##标题配对防错位,.bak逐字节校验+写后复解析校验+失败还原),返回值组合jsonMerged,模板无配置区时静默;bug2: 计时器每tick只重读cleanupInterval → 同步重读maxRounds,改垃圾站_保留轮数即时生效
2026-10-21 23:46:03 +08:00
youpaishidifu 2a1ce02e71 第36批:垃圾站播报改周期内轮次(不再显示2000+)+血条加上轮清扫/倒垃圾数+公告板开关与翻页(开关状态/翻页时间)+公告键兼容中文消息颜色+color继承最近一条+修复顶层标量配置致解析全空
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 3m25s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-21 22:46:50 +08:00
youpaishidifu 04678b29b0 第35批:公告计分板(公告.md json块解码/排序位冲突自动进位/15字截断换行/链接可点击/颜色三形态)+PVP侧边栏共存+垃圾站播报加轮次信息
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m57s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-21 21:39:52 +08:00
youpaishidifu f084304cda 第34批:垃圾站倒计时改墙钟唯一时间源(血条/抢救/指令±0秒)+ 保留轮次改N-k余数语义(每N轮才倒一次垃圾)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-21 18:49:28 +08:00
youpaishidifu d9fe2818ab 第33批:垃圾箱保留次数差一修复+血条按剩余时间分段+拉黑到期自动解除与耻辱台插播+拉黑硬拦截
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- 垃圾箱保留轮数:deleteRound=currentRound+1-maxRounds(旧公式少算1,R=1要到第3次清理才删第1批)
- 血条色段改纯剩余时间:>=180绿/30~180黄(快动作了注意安全控好量)/<=30红;Q拦截改末5秒拦第一次、1秒窗口内第二次放行
- 黑名单时长:parseExpireMs 识别相对时长(30分/2小时/3天/1周)带双护栏;5秒定时器新增 sweepExpired 扫墙钟到期→自动解除+删md行+回响PHP
- 耻辱台:applyBlacklist 即时插播拉黑公示(轮播6页x110~400s短时拉黑轮不到)+轮播排序黑名单置顶
- 拉黑硬拦截:不能打开任何容器/不能拾取物品/不能与村民交易(2秒节流提示)
- addblack 帮助与黑名单.md 模板补相对时长格式说明
2026-10-21 17:05:38 +08:00
youpaishidifu 8311c412a1 第32批:垃圾站血条与启发式开关解耦;拉黑信号回响PHP(CDK双链路拦截+syncBlacklist名单同步)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-21 15:09:06 +08:00
youpaishidifu 311138b3a6 第31批(7条修正):血条色段改墙钟绝对时刻(3:00黄/4:30红)、罪人签名移到消息尾部、抢救限最后1分钟、插件设置.txt双路增量自动重载(无变化静默)、/sdf1_login reload补loadSettings、兑换码web关闭直接返回
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m3s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-21 12:25:07 +08:00
youpaishidifu 55d24c2d99 修正plugin.yml的语法错误
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-21 10:58:20 +08:00
youpaishidifu 3cd1999b85 第30批:群控黑名单(软折磨)+ 垃圾箱抢救模式 + 顶部血条软提醒
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
【一、群控黑名单 BlacklistManager(新增)】
- 数据源:plugins/Sdf1_login/black/黑名单.md 配置区表格
  | 玩家名字 | 拉黑时长 | 理由 |
- 时长口径:空/0/终身=永久;yyyy-MM-dd HH:mm=到点;仅日期=当天23:59:59
- 5秒增量重载(mtime+size 变化才解析),加行5秒生效、删行5秒恢复
- 拉黑处罚三件套:冻结债券账户 + 撤销全部领地管理员身份(admin→visitor)
  + 备份背包并清空(异步落库快照后回主线程清空)
- 解除拉黑两件套:解冻债券 + 从最新自动备份恢复背包
- 新增 DatabaseManager.getLatestAutoBackup(inv_backup_auto 按 save_time 倒序取1)
- 新增 AreaProtection.demoteBlacklistAdmins(normName 定位逐行 UPDATE)

【二、黑名单拦截点】
- CDKManager.redeem 入口:fail:blacklisted(联控 Sdf1 插件 fail:blacklisted 分支)
- WebManager.handleWebLogin / autoGenerateWebLoginToken:禁止签发 PHP 后端 token
- AreaProtection:创建/confirm_create/quickCreateLand 禁止建领地;
  addvisitor 禁止把黑名单玩家加进授权名单
- AreaCLIManager:showLandManage/togglePerm/toggleAdmin/clearPlayerPerm 禁止管理领地
- Main:onBlockBreak/onBlockPlace2/onBlockPlace 禁止破坏与放置方块
- InventoryAutoBackup:黑名单玩家跳过自动备份(保护拉黑前快照)
- BanBroadcastManager:collectBlacklistBans 并入耻辱台公示(独立数据源)
- 聊天消息末尾追加「----我是罪人user」称号

【三、垃圾箱抢救模式 + 顶部血条】
- 指令 /垃圾抢救:跳过本轮清理并重新计时(countdown 提为字段)
- 顶部 BossBar 常驻倒计时:前60%浅绿安全 / 60%~90%黄色警告
  「垃圾箱快动作了,别丢了」/ 最后10%红色危险
- PlayerDropItemEvent:红区发小标题警告;最后10秒单击Q被拦截、
  2秒内双击Q确认放行,其他时段完全不干涉
- 插件设置.txt 新增「垃圾站_启发式拦截」开关(默认true,关闭后撤血条不提示不拦截,
  仅重载后控制台打一条 warn)

【四、指令】
- /addblack <玩家> [时长] [理由]、/removeblack <玩家>
- 插件.yml 注册 addblack/removeblack/垃圾抢救

【五、Paper 26.x 适配】
- BossBar 改用 Adventure API(net.kyori.adventure.bossbar.BossBar),
  org.bukkit.boss.BossBar 在 26.x 已移除
2026-10-21 10:34:34 +08:00
youpaishidifu c7d89a972d 第35批修订2+3:机翻收紧(≥3字母/域名IP不翻/在线玩家名摘除不翻);清理IDE残留_seg_tx.txt
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m13s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-20 14:02:42 +08:00
youpaishidifu afcce6b3e0 第35批:机翻无条件执行;IPv4/IPv6未加白即拦;通用resources模板同步补缺
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-20 13:36:19 +08:00
youpaishidifu 0fdeb88559 第28批:英文聊天机翻双语展示 + 域名过滤按illegal_domains.txt后缀判定修误伤
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
任务1(新增)英文聊天机翻:
- 所有消息一律不拦截原样放行,仅「整条消息不含中文字符」时异步机翻
- 原始消息由MC正常广播后,追加[机翻]双语展示行
- 中文夹杂英文(如「用water免伤」)原样输出不机翻
- 复用领地系统YOUDAO→JIANXIN→GOOGLE降级链,MaterialTranslator新增
  translateChatText公开入口+独立缓存(上限500)
- 专用2线程池隔离HTTP阻塞,10秒/人冷却防刷,onDisable关闭线程池

任务2(修复)域名过滤误伤:
- extractUrls裸域名token必须命中illegal_domains.txt后缀(含中文后缀)
  才收录,显式协议URL(含://)无条件收录——修「今天.天气真好啊」整句误判
- shouldBlockUrl同步改后缀判定,不再对点分隔无条件拦截
- illegal_domains.txt补全TLD+中文后缀节(工作区遗留扩展一并入库)
2026-10-20 11:29:37 +08:00
youpaishidifu 67f4438f0d 33批二十七次修正:两翼都不空一律退回(删硬接现有木箱分支)+退回清方块走clearChestBlock,修地上残留半箱+空箱
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m25s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-19 11:29:48 +08:00
youpaishidifu 0d4e1e6f33 33批二十六次修正:放置接双箱只看左右两翼(空气翼优先/现有箱翼次之/全挡死退回+提醒),修环境紧张炸开
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m38s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-18 21:02:35 +08:00
youpaishidifu 644a8c4744 33批二十五次修正:拆大箱子清孤半方块前先清空真实库存(修onRemove吐双箱共享库存散落一地+打包箱双倍凋落)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-18 20:27:41 +08:00
youpaishidifu e67015f7a3 33批二十四次修正:restoreInventory从DB备份恢复背包前先拆超大打包箱(修登录自愈被DB备份炸弹覆盖照样被踢)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-18 19:48:52 +08:00
youpaishidifu 4cff8236b6 33批二十三次修正:PlayerLoginEvent登录自愈扫描背包超大PDC打包箱拆分替换(修存档卡旧超大物品每次登录被踢)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-18 19:21:10 +08:00
youpaishidifu 7b648d8729 33批二十二次修正:双半LEFT/RIGHT配对+拆分打包防PDC超64KB踢出(修双箱不连接/拆箱被踢两根因)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m18s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-18 19:04:31 +08:00
youpaishidifu 0c9543d63e 33批二十一次修正:空气位摆箱applyPhysics=true+未连成改27格回填剩余寄回(修大箱必炸/必须挨现有箱)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-18 18:31:08 +08:00
youpaishidifu 9c05d81047 33批二十次修正:接双箱双半LEFT+2tick实测+双箱已连单54格全量回填(修大箱必炸/必须挨现有箱才安全两条根因)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m16s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-18 17:52:35 +08:00
youpaishidifu 7eb5bdc093 第33批十九次修正:双箱54格全量回填+消灭双倍掉落+双连箱合并强制大箱
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
生产服 latest.log 实锤两个根因:
Bug1「物品≤27 永远给小箱」:双连箱54格物品被引擎分摊在左右两半,
  newMergedContents 读双半27格快照,物品≤27时全挤在一半边、
  合并 countSlots≤27 → buildPackedChest 判小箱给27格铜箱。
Bug2「物品≥27 永远双倍掉落」:放置侧 doBackfill(placedFinal) 读
  placedFinal.getState().getInventory(),双箱左半 BlockState 只覆盖
  27格,≥28件塞不下→触发重新打包寄玩家(一份),右半27格原版物品
  没被 clear() 清掉→拆箱时原版掉落一份=双倍。

修复:
① 新增 doBackfillDouble(placed,twin,...):双半各自读27格inventory,
   先 clear() 双半全54格杜绝原版残留,再按54格全量回填(左半0-26、
   右半0-26物理格)。塞不下的重新打包寄玩家(不残地)。
② 放置侧双箱已连分支 doBackfill 改 doBackfillDouble(placedFinal,twinFinal,...)
③ buildPackedChest 加 force54 参数:双连箱合并交付强制54格CHEST大箱,
   不再靠 countSlots>27 猜(物品少也按54格打包)。
④ deliver 双连箱合并分支(半名含「大箱子」)传 force54=true。
配平 0(173/173),编译 EXIT=0,jar sha 7a4e14f8
2026-10-18 16:27:33 +08:00
youpaishidifu 1a5487a25e 第33批十八次修正:双箱合并真根因——update applyPhysics 改 true 触发引擎双箱检测
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
生产服 latest.log 实锤:接双箱 connected=是 但主箱实测 placed=27格(需29)→
双箱未连回退寄玩家,连续 8 次全失败。

根因(已核实 MC 原版双箱合并机制):
- 两箱 XZ 紧邻、Y 相同、facing 一致 → 才构成双箱(代码 L493/L519 的 facing 对齐正确,勿改)
- 合并检测由引擎在方块物理更新时触发。代码全程 update(true,false)
  (applyPhysics=false) 跳过检测 → 主箱永远 27 格、永不 54 格
- 这是十三/十四/十五/十六次全失败的真正根因

修复(最小改动,仅 4 处 update true,false → true,true):
① 接已有箱分支 placed/c 两处
② 接空气位分支 placed/air 两处
facing 对齐逻辑保持不变;实测 <54 回退兜底保留(环境不安全仍清双箱+重打包)
配平 0(149/149),编译 EXIT=0
2026-10-18 15:47:18 +08:00
youpaishidifu 8abdb368a7 第33批十七次修正:小箱(≤27格)空箱根因修复——嵌套if死代码else提外层
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
根因实锤(生产服日志+代码分析):
- L468 if(slotCount>27) 外层块内 L570 又嵌套 if(slotCount>27)(条件恒true冗余)
- L612 else(单箱doBackfill) 对应 L570 的嵌套if → 死代码永不执行
- 小箱 slotCount≤27 走 L468 条件false → 整个 L469-617 块跳过 → L618 } 关闭
  → 没有 doBackfill 调用 → 箱子内容从未回填 → 空箱

修复:
- 移除 L570 冗余嵌套 if(slotCount>27)
- 双箱实测代码块缩进去一级(28→24空格)
- L612 else 提外层对应 L468 的 if/else,小箱立即 doBackfill 单箱回填
- 配平不变(open/close各减1)
2026-10-18 15:16:59 +08:00
youpaishidifu 1b02f16816 第33批十六次修正(诊断增强v2):放置入口+清CustomName后容器size+doBackfill入口三处日志,实锤小箱空箱根因
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-18 14:59:45 +08:00
youpaishidifu d3d6c7b67c 第33批十五次修正(诊断增强):给双箱/单箱回填路径加成功诊断日志(cap/filled/塞入数),定位小箱也发空箱根因
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m46s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-18 14:36:04 +08:00
youpaishidifu 24ecdf1ac9 第33批十四次修正:接双箱弃用setBlockData(LEFT)手动连接,引擎原生setType(CHEST)自动连双箱+立即实测真实size(同步读54/27格,修复空白箱/残疾版)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-18 14:18:22 +08:00
youpaishidifu a91b51f26d 第33批十三次修正:接双箱后2tick实测真实size判断54/27,未连成清双箱+全部物品重新打包寄玩家(不残地,修复残疾版)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m36s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-18 13:45:15 +08:00
youpaishidifu 46ee04a880 第33批十二次修正:接双箱失败/塞不下→重新打包寄玩家(不丢一地,防岩浆/垃圾箱环境物品丢失)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-18 13:25:25 +08:00
youpaishidifu aca8c31cc7 第33批十一次修正:接双箱固定顺序改4独立Location方位偏置+全被占回退27格单箱 + clearChestBlock加固防双箱回弹(清后2tick再清一次)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-18 13:04:48 +08:00
youpaishidifu 3635a0c3ae 第33批十次修正:合并分支清孤半方块本体(孤半物品丢失根因)+修复竞态损坏({}}裸括号/1plugin/getState(;)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-18 12:00:14 +08:00
youpaishidifu 806e2f6ed0 第33批九次修正:判定口径改非空格子数 + 接双箱update强制落盘 + 回填兜底零丢失
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
用户实测两图:①"小箱"239件实为15个非空格子堆叠,旧 count(amount)>27 误判大箱强接双箱
错位;②27件27格该接双箱但 setBlockData(LEFT) 连接态没建立→前后错位切断。
四条根因一次修死:
1. 判定口径错:countItems(amount累加) 把堆叠误判成"塞满"。改 countSlots(非空格子数)
   ——27个非空格子=小箱(27格塞满),>27个才接双箱。
2. 接双箱连接态没建立:setBlockData 只改 block data 缓存,方块实体库存没真连成54格。
   改:双块 setBlockData(LEFT)+facing对齐后 调 update(true,false) 强制落盘连接态。
3. 回填塞不下丢物品:废弃嵌套 lambda,改单次回填读真实库存(连接生效=54格/未生效=27格),
   塞不下的物品 dropItemNaturally 掉在箱子旁(零丢失)。
4. 加诊断日志「接双箱结果 connected=... 需塞入格子数=...」定位连接态是否真建立。
2026-10-18 11:19:04 +08:00
youpaishidifu 5816391b56 第33批八次修正:大箱子强制普通CHEST(不沿用陷阱箱来源) + 接双箱facing对齐
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
用户实测两问题:
1. 给的大箱子还是陷阱箱:buildPackedChest 用 sourceMat(来源材质),
   拆陷阱箱时打包箱沿用 TRAPPED_CHEST。修正:大箱子(>27件)强制用
   Material.CHEST(原版普通木箱),小箱子(≤27件)仍沿用来源材质保持
   铜箱观感。
2. 前后错位切断:接双箱只 setType(LEFT) 未对齐 facing(Chest 继承
   Directional,朝向不同则双箱不连接)。修正:接已有邻接木箱时双块
   都设 LEFT + bd.setFacing(bdN.getFacing()) 对齐;放第二个 CHEST 时
   bd2.setFacing(bd1.getFacing()) 对齐。
2026-10-18 10:25:18 +08:00
youpaishidifu b22c1342b1 第33批七次修正:孤立小箱误标大箱子+误接双箱→2个右半边 根因
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m25s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
用户实测图「已打包 小箱子(24件)」却是大箱子且放下来2个右半边。
四条根因与修正:
1. 接双箱判定口径错:onChestPlace 用 contentCount(物品总数量 amount 累加)>27
   判定接双箱,24件物品若多组堆叠 amount 总和可能>27 → 孤立小箱被强接双箱 →
   两块都设 LEFT 渲染成"两个独立右半"。改:用「非空格子数 slotCount」判定
   (一个双箱 54 格,>27 格才接双箱),amount 不再影响判定。
2. 标签误标:Paper 26 的 Chest.Type 对孤立半箱可能返回 LEFT/RIGHT 而非 SINGLE,
   直接按 Type 判定会把孤立小箱误标"大箱子"。改:按连接态判定——有水平邻接
   箱(twin)才算"大箱子·左/右半",无邻接箱一律"小箱子"。
3. 接双箱双块都设 LEFT:原接已有邻接木箱时只设新块 LEFT、相邻块保持原 type,
   双箱未真正共享 54 格库存。改:双块都 setBlockData(LEFT) 才合并成 54 格容器。
4. 取数保留孤半 54 格全量(双半 getSnapshotInventory 27+27)。

备份原则:jar 滚动备份 .old → .old.1 → .old.2(deploy_sftp_jar.py
next_old_name 自动递增,本地+生产双端各一套)。
2026-10-18 00:50:47 +08:00
youpaishidifu e40614282d 第33批六次修正:双连箱拆箱合二为一 + 不用陷阱箱 + 修翻倍
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1. 别用陷阱箱(用户明令):buildPackedChest>27件改回 CHEST/来源材质,
   onChestPlace 放下来自动在空气位再放一个 CHEST+双块setBlockData(LEFT)
   接成54格双箱,TRAPPED_CHEST 一律不用(避免红石电路误联控)。
2. 翻倍根因:onChestDropItem 旧版 suppressDrops.remove(loc) 一次性移除,
   双箱内容经 loot 表批量抛多个 BlockDropItemEvent 时第一个事件后 loc
   被删、后续漏出→翻倍。改为 contains 命中就拦不删 loc,靠 markSuppressDrop
   的 60 tick 定时任务自动清除,期间同位置所有掉落事件全拦。
3. 放下来丢另一半:onChestPlace 接双箱后重取 getState 回填,双箱连接后
   getInventory 返回 54 格全量,28~54件不再被 27 格截断。
2026-10-18 00:15:40 +08:00
youpaishidifu ca7d35b252 第33批五次修正:双连箱翻倍+散落根因——删clearChestBlock(setType触发二次事件)+幂等守卫;buildPackedChest>27件用TRAPPED_CHEST(54格)+onChestPlace自动接双箱
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m33s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-17 23:33:01 +08:00
youpaishidifu 7dd5fef5f9 第33批四次修正:双连箱0件根因=BlockBreakEvent时刻getInventory未同步→统一双半getSnapshotInventory(27+27),连箱=54全量/独立小箱=各27独立,弃getInventory与isSimilar
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-17 23:02:13 +08:00
youpaishidifu c947ef57ab 第33批三次修正:双连箱合并物品数错乱根因——newMergedContents连箱直接读54格全量(readContents(self))放弃isSimilar去重(误删同类型不同组+2x54翻倍),独立小箱selfSnap+twin各27不去重
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-17 22:50:58 +08:00
youpaishidifu 4d4fee6f2e 第33批修正:双连箱合并0件根因——newMergedContents改读selfSnap+readContents(twin)双源/连箱去重(按bigHalf),clearChestBlock前补markSuppressDrop(twin)拦孤半内容散落
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-17 22:37:28 +08:00
youpaishidifu 49190f0fc1 第33批:邻接箱合并口径改『紧挨着就算』(四向x±1z±1)+合并门槛去isBigChest(小箱也合)+全量诊断(invSize/双半twinInvSize/enter分支)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m41s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 22:14:52 +08:00
youpaishidifu f58e73ca9e 第32批补充:双连箱合并加全量诊断(数据类型/invSize/邻接twin/enter分支),定位snap=27孤半不合并根因
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 21:44:40 +08:00
youpaishidifu fef3f51203 第32批修正:双连木箱拆箱合二为一(setType清孤半+读54格全量,废弃breakBlock)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m33s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-17 21:06:01 +08:00
youpaishidifu 53af7bc23b 第32批补充:拆箱加诊断日志(snap格数/首破vs成组/间隔ms/mergeTwo),待实测后据此定合并路径
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m44s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-17 20:42:03 +08:00
youpaishidifu 9f155eecb0 第32批:铜双箱拆箱直接deliver 54格快照(不依赖第二刀)/who+banget非OP近似建议最小化披露 2026-10-17 20:35:08 +08:00
youpaishidifu 8d5fa288a4 第31批:title接管收窄仅JSON载荷/合箱cancel旧超时任务/json非OP走近似建议隐藏存在性
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 20:09:03 +08:00
youpaishidifu 08b2233fc3 第30批修正:chest/cfg.json 键值改回中文(与opwl.json项目惯例对齐,Gson解析中文键)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 19:06:34 +08:00
youpaishidifu 699d455bab 第30批:拆箱权限改看容器管理/合二为一/chest cfg热重载/actionbar接管/json调OP
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-17 18:53:06 +08:00
youpaishidifu 6c233939de 第29批验收修正:打包箱放置权限回退——放置侧不拦任何人交还原版回填,隔离改由下游链路实现(非授权用户拆箱交原版散落一地+领地deny_pickup禁拾取拿不走,开箱由deny_container拦)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 13:52:40 +08:00
youpaishidifu 12e68060fa 第29批:title/tellraw报错接管恢复压制原生报错并直接打开JSON构造器(附标准示例+/json超链接) + 打包箱放置仅限领地主/授权成员且放下清除自定义名 + 封禁对账收回回填created创建时间(修复拷贝生产封禁数据后全员播报乱叫)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m44s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-17 13:37:16 +08:00
youpaishidifu 2ee874eeda 28批验收修复:裸CHEST搬运判定、封禁播报起点回退180天、JSON构造器超链接唤醒+剪贴板复制+选择器剥离+subtitle同屏显示
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-17 11:39:59 +08:00
youpaishidifu d7d5ce7039 第二十七批:修线上验收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天就不过滤」守卫 —— 插件刚装上服时它退化成
   不过滤,正是「一个都没封却公示一堆人」的第二重根因。
2026-10-16 23:18:54 +08:00
youpaishidifu cbc3cb4db7 第二十六批:箱子搬运(精准采集斧拆箱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
2026-10-16 19:03:47 +08:00
youpaishidifu 6f831f065b 第二十五批:订正领地权限倒反天罡——denyItemFrame玩家路径回归getEffectiveDeny豁免链(领地主/授权成员/服务器管理员3组豁免),非玩家路径保留24批防爆硬保护
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-16 12:45:09 +08:00
youpaishidifu 3ecadd3228 第二十四批:展示框防爆——denyItemFrame开启即硬保护,拦截所有破坏路径(爆炸/闪电/非玩家投射物/授权玩家手动)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-16 12:01:30 +08:00
youpaishidifu d11987f97b 第二十三批:修复配置安全更新插入位置——表格行插表格末尾而非结束标记前,孤儿行自动归位
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m35s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-16 10:25:28 +08:00
youpaishidifu a175024766 二十二批:修复主线程SQLite同步读致watchdog卡服 + 错位配置目录检测
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- restoreInventory/hideInventoryDelayed 的 loadInventoryBackup 挪异步线程读库,背包写回仍在主线程;restoreInventory 增加 afterRestore 回调保持「先恢复背包、再发菜单雪球」原顺序(Main autoLogin + LoginManager 注册/登录三处调用点同步改)
- DatabaseManager 连接增加 PRAGMA busy_timeout=5000,争锁5秒快速失败而非无限等待
- BanBroadcastManager 启动时检测错位配置目录 plugins/ban/(插件只读 plugins/Sdf1_login/ban/),发现即警告指明正确路径(Linux宝塔服实测:旧版规则配置.md放错目录被误认为安全升级没生效)
2026-10-15 23:27:51 +08:00
youpaishidifu 23c1b9e468 第二十一批:已存在的ban/规则配置.md安全升级——saveResource只补缺失文件永不覆盖既有文件,模板新增配置行(播报起点)对生产服永远释放不出来;ensureConfigExists对已存在md走safeUpgradeDoc(原文件为基底逐字保留、只补新行、.bak校验失败取消、原子写失败还原、无新增幂等)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-15 18:34:14 +08:00
youpaishidifu 6d19e23f42 第二十批:修表格取值列错位(split丢尾空串致取到功能说明列、说明含「关闭」被容错命中→mode恒0不播) + 配置重载改增量日志(签名无变更静默、有变更打明细)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-15 18:00:24 +08:00
youpaishidifu ae69036b9b 第十九批:封禁公示按播报起点过滤(推送带created+ban_cutoff、零发送首轮/起点变化强制推) + 播报定时器生命周期(全员下线停机玩家上线恢复,关闭≠停机)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-15 14:06:45 +08:00
youpaishidifu 9bc8aaac37 第十八批遗留修复:播报模式填0仍播报——parseMode把0并入false映射成2(由远到近),拆为0=关闭/false=由远到近,加「关闭」容错
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m28s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-15 13:03:28 +08:00
youpaishidifu c83eaad23f 第十八批:cfg.json语法有效值空改继承不判损毁(回退表格/默认、真损毁才退md、每轮重查json、jar无模板日志友好化) + 新增/banget点名全量封禁查询(user/ip自动识别、遍历getEntries防NAME型API失效)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m28s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-15 12:36:55 +08:00
youpaishidifu c52d235972 第十七批补:播报懒重载语义补全——每次播报窗口先回出厂默认再解析配置,开关决定这次报/不报、最新间隔用于下次排程计时
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m48s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-14 21:02:19 +08:00
youpaishidifu 9a6a983ad7 第十七批:封禁播报一次发6条、随机间隔日志证据、无人在线完全静默、新增播报起点过滤插件安装前的历史封禁
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-14 20:33:05 +08:00
youpaishidifu 8d73722b6a 第十六批:雪球菜单SN校验+游戏内轮询封禁播报(BanBroadcastManager)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m30s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-14 18:04:48 +08:00
youpaishidifu 1c34c9618b 第十五批:execute as @s 拦截补刀、空指令统一压制、主线程卡顿看门狗
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-14 16:02:26 +08:00
youpaishidifu 2d9dd5b438 第十四批:控制台execute链@s拦截与单斜杠中文化、二手批量上架成功跳过计数、OP白名单定时器墙钟自对齐与ops.json先备份后撤销、撤销提示病句与最小化信息披露
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-14 13:59:16 +08:00
youpaishidifu 9cd85013d5 第十三批:二手管理员判定配置化+面板密码门+opwl轮询对齐+控制台@s中文化
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
问题1:二手管理员认定遵守配置文件「谁是管理员」三模式
- 补齐 applySecondHandCfgItem 未分派的「谁是管理员」「默认管理员面板密码」两行
- 新增 shAdminMode/shAdminPlayer/shAdminPassword 三字段
- isShAdmin 改 tag(默认)/op/player+名字 严格判定,tag 模式不再放行 OP

问题2:二手管理面板加密码验证(原为点击即进)
- 密码取 md「默认管理员面板密码」,未配置回退全局管理密码
- 新增 ChatInputManager.SH_ADMIN_AUTH 状态,聊天输入比对后才开面板
- 会话级记忆 shAdminAuthed,退出即失效;输入「取消」放弃

问题3:opwl 15秒轮询初始延迟对齐墙钟边界
- 原固定 300 tick 从启动时刻起算导致恒偏6秒(实际6/21/36/51)
- 改为对齐下一个 15秒边界,执行点回到 0/15/30/45 秒

问题4:控制台 @s 选择器报错中文化
- ServerCommandEvent HIGH 优先级拦下,取消后原版 No player was found 不再出现
- 用中文说明是第几个参数、@s 含义、三种改法
2026-10-13 23:19:33 +08:00
youpaishidifu 39d475a33b 修复二手交易规则.md的语法错误
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-13 21:07:29 +08:00
youpaishidifu 17c1b0c44f md混合配置文件安全交换:以原文件为基底(说明文本/用户值原样保留)补入模板多出的配置行与配置代码块,备份.bak失败即取消;二手规则与邀请规则接入
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-13 20:53:15 +08:00
youpaishidifu 26ab7b2435 配置安全交换机制:模板多字段时备份.bak(失败即取消)→重建模板→内存老值回填,消息/SMTP/插件设置三配置接入,取代旧append补全
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-13 19:55:10 +08:00
youpaishidifu cfe2eafc64 第十二批:二手市场管理员面板——左键下架(9格理由面板,8理由读md JSON块)/右键上架,下架物品暂存等卖家登录发放,重新上架离线不通知
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-13 19:22:52 +08:00
youpaishidifu 4352a35124 第十一批:修复秒/毫秒时间戳混存导致1970年显示bug
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- 根因:register_time/last_login_time/invited_at 两种单位混存——Java
  本地注册写毫秒(System.currentTimeMillis)、PHP同步拉回写秒(time()),
  而所有显示端都按毫秒解释 → 秒值~1.78e9显示为1970-01-21
- DurationParser 新增 toMs() 归一化辅助(<10^12视为秒,×1000,幂等)
- whois/Main /account/GUI多账号/邀请GUI绑定时间/临时密码邮件:读取端统一归一化
- InviteManager.reconcileInviteData:秒级存量invited_at原地×1000迁移,
  回填兜底链register_time/last_login_time先归一化;月度计数与明细比较前归一化
- ChatFilterManager.isNewPlayerUnder1Hour:旧代码无条件×1000,对毫秒行
  算出巨大负elapsed恒判新玩家 → 改用toMs
- AreaProtection/WebManager 领地转让5分钟防秒退检查:同样归一化
- 不动同步层(applyPulledRow保持原值),避免与PHP对账拉锯
2026-10-13 14:55:29 +08:00
youpaishidifu 4eec0c9a39 第十批:邀请存量数据自愈(回填绑定时间+重算月度计数)、本月邀请头颅明细GUI、修复Commission跨库查询失效(bond_transaction在bond.db)
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 17:44:37 +08:00
youpaishidifu 21c61e9e5c 第九批:邀请系统计数修复+绑定显示邀请人+双向奖励+配置懒更新
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m46s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
1. 计数修复:monthly_invite_count 改为绑定邀请码时由 onInviteeBind 计入
   (旧逻辑在被邀请人签到才计数,导致绑定成功但未签到时 GUI 恒显示已邀请0人);
   onInviteeCheckIn 清空计数逻辑避免重复累加。

2. B端显示:新增 invited_at 列(毫秒时间戳落库),邀请数据GUI slot16
   已绑定时改显示「邀请成功时间(yyyy-MM-dd HH:mm)+邀请人名字」,
   未绑定才显示输入邀请码按钮;点击已绑定则拦截。

3. 双向奖励:CommissionManager 适配新规则md 4键(邀请人单次奖励/
   被邀请人单次奖励/单日邀请奖励上限/单日邀请翻倍额度);
   payInviteBond 给邀请人(含每邀请X人翻Y倍)、payInviteeBond 给被邀请人。

4. 配置懒更新:玩家绑定邀请码时调用 loadRules() 实时重载规则md,
   不再依赖启动加载或定时热重载。
2026-10-12 17:00:14 +08:00
youpaishidifu df56e2c293 第八批:雪球菜单入口接入SN系统(换载体转移SN)、邀请码一次性持久落库并按新规则发债券、代码层删除提成规则.txt改由邀请好友赢债券/规则.md控制
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-12 15:51:38 +08:00
youpaishidifu c214abeafa 新bug的预热修复
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 15:43:33 +08:00
youpaishidifu 5e9282926c 二手商店第七批: 修复切换出售偏好误退还物品(物品复制)——refreshPublishGui 把物品克隆进新界面后 openInventory 换视图会触发 InventoryCloseEvent,onPublishGuiClose 扫旧界面仍见物品就退了背包,而新界面还有一份克隆=双份;改为换视图前先清空旧界面,CloseEvent 扫到空界面不退。下架/超时/取消退还路径不变
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m29s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-12 14:13:07 +08:00
youpaishidifu da89a98ef2 二手商店第六批: 配置热重载生效时序修复——①购买结算前先做增量对齐,本笔交易立即按新费率扣款(原购买触发点在结算之后,13:31改0.0038、13:33仍按2%结算) ②打开二手商店界面先对齐,lore手续费即时更新 ③定时扫描15分钟改15秒,对齐每分钟第0/15/30/45秒(原理解偏差:把整点写成了0/15/30/45分钟)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 13:50:01 +08:00
youpaishidifu 8a167d4b64 二手商店第五批: 批量上架状态机加固——①超时计时器改会话令牌制(旧计时器不再级联误杀新会话,根治批量莫名中止/反复超时像锁死) ②批量入口先清理残留会话并退还未定价暂存件 ③每件定价前与入库前双重复检上架上限,超限中止退还不超发 ④玩家退出退还暂存/队列物品防吞物 ⑤静默return补提示
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m45s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 13:21:38 +08:00
youpaishidifu 4a24e20bd6 更新resources的交易规则模板
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-12 10:51:13 +08:00
youpaishidifu ba63671cc3 二手商店第四批五修复: ①商店lore展示卖家描述 ②手续费仅奸商模式显示(正常模式隐藏) ③二手消息物品名改官方zh_cn.json翻译 ④配置增量热重载(每15分扫描diff播报+购买/上架触发对齐,平时静默) ⑤opwl注册校验改严格版(DB异常拒绝,堵SQL抖动放行漏洞)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m41s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 10:45:42 +08:00
youpaishidifu 4ba2947ef2 提交resources模板(二手交易规则.md)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-12 00:30:13 +08:00
youpaishidifu c95a0eee60 修复二手商店批量上架超时重复退还;手续费兼容5%与5‰写法
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-12 00:23:11 +08:00
youpaishidifu 572bd2fce2 二手商店手续费真实扣款并新增交易记录库;上架改批量GUI支持编辑下架与偏好切换;人机验证豁免改内存态
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-11 23:31:37 +08:00
youpaishidifu b0e1c5cf37 二手交易新增物品描述/手续费/上架数量限制/保底价格,用户组加对应字段
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m26s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
- 发布二手商品时可填写<=20字物品描述,6段存储格式向后兼容
- 从 二手交易规则.md 配置区读取手续费、上架上限、最低售价
- UserGroupManager 加 shop_max_listings/shop_min_price 列与查询方法
- WebManager 推送/拉取用户组时同步两字段(-1=跟随全局)
2026-10-11 21:23:54 +08:00
youpaishidifu 0bba2700c0 修复已授权领地成员被错误跟随全局权限限制的严重bug
- PermissionLevel 新增 MEMBER 态(DB role=member)
- getPermissionLevel 在白名单判定前精确查 admin/member 两态
- getEffectiveDeny 新增 2.5 步:MEMBER 豁免领地默认 deny
- info 命令与 waxAllowReason 显示已授权成员身份
2026-10-11 21:23:50 +08:00
youpaishidifu a1e59363d1 修复二手上架异步开GUI报错;物品名翻译改用官方zh_cn.json
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- onPublishChat(AsyncPlayerChatEvent)在异步线程直接openInventory触发
  InventoryOpenEvent同步校验异常,整体切回主线程处理(退还/入库/开界面)
- ItemNameResolver 删除手工中文映射表,改走 OfficialTranslations 反向索引
  (启动时自动加载的官方 zh_cn.json,无需再枚举维护)
- OfficialTranslations 新增 reverseMap() 中文→英文键懒构建索引;
  lookUp 去掉逐条调试日志
2026-10-11 18:59:02 +08:00
youpaishidifu 0837386623 违禁品面板与二手商店加固:物品名支持中文翻译、拦截SN/宝箱物品上架
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- 新增 ItemNameResolver 翻译中间人:中文物品名(钻石/黑曜石等)与英文ID双向解析,
  支持唯一前缀匹配与提示回填
- 违禁品添加入口(面板+CLI)改用 ItemNameResolver,输入中文自动转标准英文ID入库,
  列表与面板展示中文化(附英文ID)
- 二手商店三处加固:发布、确认上架、购买时拦截 SN 绑定物品与 sdf1_game 宝箱
  自定义物品(§0§k CUSTOM lore),命中即拒绝/退还/自动下架退款
2026-10-11 18:16:57 +08:00
youpaishidifu f6ad2ca3f9 领地门禁文案动态化 + 违禁品管理面板 + 二手交易商店/shopbuy
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m14s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
- 门禁权限(door)文案按启停状态动态显示允许/禁止,CLI与GUI同步
- 违禁品管理面板:访客禁带物品配置、没收计数、退出领地按设置归还(默认不退)
- 新增/shopbuy二手商店:玩家自由上架定价(封底0债券)、商店底部入口、冻结账户拦截
2026-10-11 17:27:53 +08:00
youpaishidifu 54d520ab3d 领地三修:CLI展示框文案按启停动态显示;封堵大小写变种ID越权(身份/白名单/豁免全链路精确匹配+遗留小写条目DB规范名迁移);新增村民交易权限denyVillagerTrade(右键拦截+交易窗兜底+CLI/GUI全链路)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m33s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-11 14:40:59 +08:00
youpaishidifu f844d5c618 恢复 WebManager.java 原始 CRLF 行尾(补丁脚本误转 LF,消除全文件 diff 噪音)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m40s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-11 10:40:05 +08:00
youpaishidifu baed9c8e43 商品库存推送加回执单核验:PHP 必须回显 push_id+内容指纹,无回执立即重推一次,两次失败不落基线等下轮
Check Source / check (push) Successful in 8s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-11 10:38:38 +08:00
youpaishidifu 9f3dffee1e 修复库存同步覆盖bug(双向对齐+分叉以游戏端为准):本地库存变更经快照比对推回PHP,pull应用时跳过本地未推送差异;流水补推批大小300起步每成功+300至3000、失败减半退避
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-10 21:50:28 +08:00
youpaishidifu b2aa317c75 更新readme.md,从登陆插件正式修改定位为多功能综合插件
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m18s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-09 18:42:39 +08:00
youpaishidifu 136cf9b8f8 Revert "版号切回3.0(正式版):3.0收尾,补AGPL-3.0协议后同步公开三仓"
This reverts commit d9f7f2de71.
2026-10-09 11:34:47 +08:00
youpaishidifu d9f7f2de71 版号切回3.0(正式版):3.0收尾,补AGPL-3.0协议后同步公开三仓
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-09 11:33:02 +08:00
youpaishidifu 84e6d3bbc0 添加 AGPL-3.0 开源协议:LICENSE 为 gnu.org 官方原文,README 补充协议说明与网络服务开源义务 2026-10-09 11:29:11 +08:00
youpaishidifu 34a344f39c 版号切回3.01(内测版):3.0正式版已圆满收官
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-09 00:10:51 +08:00
youpaishidifu 84d351b116 未登录白名单加白:正版验证mslogin/正版、找回密码、web/控制台(登录链路相关命令放行)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m3s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 23:27:59 +08:00
youpaishidifu ad11a2bcd3 收紧未登录命令白名单:仅放行 login/l/reg/sdf1_login reset 及中文登录注册
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 22:48:39 +08:00
youpaishidifu dea0370084 修复email未登录越权漏洞:从未登录放行名单移除email/绑定/绑定邮箱,三个绑定入口加登录硬校验,版号切回3.0
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 22:18:17 +08:00
youpaishidifu d6af2f69e0 版号切 3.01(内测版)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 20:40:47 +08:00
youpaishidifu 70f6112ad8 版号切 3.0(正式版)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 20:16:18 +08:00
51 changed files with 13698 additions and 680 deletions
+3 -1
View File
@@ -119,5 +119,7 @@ test.*
*.py
# 屏蔽其它文件
*.md
# 过滤readme
# 过滤readme和配置文件
!readme.md
!scr/main/resources
!src/main/resources/**/*.*
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+10 -1
View File
@@ -1,4 +1,13 @@
# Sdf1系列登录插件使用说明
# Sdf1系列多功能综合插件使用说明
## 开源协议
本项目(Sdf1_login 插件,含 Java 端与 php 端)采用 **GNU Affero General Public License v3.0(AGPL-3.0)** 授权,协议全文见 [LICENSE](LICENSE)。
- 你可以自由使用、修改、分发本项目。
- **若你把本项目(或其修改版)部署为网络服务对外开放,必须向使用该服务的用户提供完整的对应源代码**(AGPL 第 13 条)。
- 衍生作品必须同样以 AGPL-3.0 授权分发。
- 本项目按「原样」提供,不附带任何担保。
# 特别说明:本插件仅有简中语言版本,如需除简中以外的任何版本,请下载插件源码,自行修改!
+1 -1
View File
@@ -11,7 +11,7 @@
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<java.version>27</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
@@ -0,0 +1,698 @@
package Sdf1_login;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import io.papermc.paper.scoreboard.numbers.NumberFormat;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.event.ClickEvent;
import net.kyori.adventure.text.event.HoverEvent;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.scoreboard.Criteria;
import org.bukkit.scoreboard.DisplaySlot;
import org.bukkit.scoreboard.Objective;
import org.bukkit.scoreboard.Score;
import org.bukkit.scoreboard.Scoreboard;
import org.bukkit.scoreboard.ScoreboardManager;
import org.bukkit.scheduler.BukkitTask;
import java.io.File;
import java.io.InputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* ★ 第35批:公告计分板(侧边栏)。
*
* <p><b>数据源</b>:{@code plugins/Sdf1_login/公告/公告.md}
* 头部 {@code ## ----公告区域开始----} ~ {@code ## ----公告区域结束----}
* 之间的 ```json 代码块。块的键是<b>排序位</b>,值是公告条目。
*
* <p><b>规则</b>:
* <ul>
* <li>键 = 排序位(升序,越小越靠上);键冲突 → <b>自动进位</b>
* (往后找第一个空位)。用 JsonReader 流式解析,
* 重复键不会被 Gson 的 Map 覆盖掉</li>
* <li>msg 为空 → 整条跳过(空白键值自动跳过)</li>
* <li>msg 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)</li>
* <li>msg 是 http/https 链接 → 整条公告可点击(打开链接,悬停显示完整地址)</li>
* <li>color 支持 16 色英文名 / #RRGGBB / r=0 g=0 b=0;
* 未填或非法 → 回退 MC 原版白色</li>
* </ul>
*
* <p><b>渲染口径(paper-api 26.2 + 服务端 jar 字节码实测)</b>:
* <ul>
* <li>原版侧边栏<b>不解析 entry 里的 § 颜色码</b>(entry 只是纯文本标识),
* 所以颜色必须走 {@code Score.customName(Component)} 下发;
* entry 改用纯 ASCII 唯一串 {@code n0/n1/...},天然规避同名塌缩。</li>
* <li>侧边栏默认 <b>descending(高分在上)</b> → 分数<b>递减</b>赋值,
* 排序位小的排最上面。</li>
* <li>侧边栏<b>最多只显示 15 行</b> → 超出部分丢弃并告警。</li>
* <li>数字格式设 {@code NumberFormat.blank()} → 右侧不露分数。</li>
* </ul>
*
* <p><b>与 PVP 计分板共存</b>:PVP 区域 / 竞技场内由 PVP 模块接管计分板,
* 本类只在玩家空闲(不在 PVP 区域、不在竞技场)时把公告板挂上去。
*/
public class AnnouncementBoard implements Listener {
private final Main plugin;
/** 公告配置文件:plugins/Sdf1_login/公告/公告.md */
private File mdFile;
/** 上次解析时的文件修改时间(用于热重载检测) */
private long lastModified = 0L;
/** 公告区域标记 */
private static final String ZONE_START = "## ----公告区域开始----";
private static final String ZONE_END = "## ----公告区域结束----";
/** 计分板目标名(objective 名) */
private static final String OBJECTIVE = "sdf1_notice";
/** 单行最大字符数(超出截断换行) */
static final int MAX_LINE_CHARS = 15;
/** 原版侧边栏最多显示的行数(硬限制) */
static final int MAX_SIDEBAR_ROWS = 15;
/** 公告板标题 */
private static final String TITLE = "§6§l服务器公告";
/** 16 色英文名(与 JsonMessageGui 口径一致) */
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"));
/** 链接识别(整条 msg 是链接才算) */
private static final Pattern URL_PATTERN =
Pattern.compile("^(https?://\\S+)$", Pattern.CASE_INSENSITIVE);
/** ★ 第36批:公告板总开关(公告.md 顶层键 开关状态:true/1=开,false/0=关) */
private boolean enabled = true;
/** ★ 第36批:翻页秒数(公告.md 顶层键 翻页时间:纯数字=秒) */
private int pageSeconds = 15;
/** ★ 第36批:当前页码(0 起,全局统一页切) */
private int currentPage = 0;
/** ★ 第36批:翻页定时任务 */
private BukkitTask pagerTask = null;
/** ★ 第36批:翻页任务当前周期(秒),与配置不一致才重建 */
private int pagerSeconds = -1;
/** 解析出的公告条目(排序位 → 条目),按排序位升序 */
private final Map<Integer, Notice> notices = new LinkedHashMap<>();
/** 单条公告 */
public static final class Notice {
/** 排序位 */
public final int slot;
/** 原始文本 */
public final String msg;
/** 归一化后的颜色(合法色 / null=白色) */
public final TextColor color;
/** 链接地址(非链接时 null) */
public final String url;
Notice(int slot, String msg, TextColor color, String url) {
this.slot = slot;
this.msg = msg;
this.color = color;
this.url = url;
}
}
/** ★ 第36批:渲染用的一行(公告 + 该行文本),用于按页切片 */
private static final class Row {
final Notice n;
final String line;
Row(Notice n, String line) {
this.n = n;
this.line = line;
}
}
public AnnouncementBoard(Main plugin) {
this.plugin = plugin;
}
// ==================== 生命周期 ====================
/** 初始化:释放模板 → 解析 → 首次渲染 → 启动热重载监听 */
public void init() {
try {
File dir = new File(plugin.getDataFolder(), "公告");
if (!dir.exists()) dir.mkdirs();
mdFile = new File(dir, "公告.md");
if (!mdFile.exists()) {
try (InputStream in = plugin.getResource("公告/公告.md")) {
if (in != null) {
Files.copy(in, mdFile.toPath());
}
} catch (Exception e) {
plugin.getLogger().warning("[公告板] 模板释放失败: " + e.getMessage());
}
}
} catch (Exception e) {
plugin.getLogger().warning("[公告板] 初始化失败: " + e.getMessage());
return;
}
reload();
applyAll();
startAutoReload();
}
/** 启动文件热重载:每 30 秒比对一次修改时间 */
private void startAutoReload() {
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
if (mdFile == null || !mdFile.exists()) return;
long mt = mdFile.lastModified();
if (mt == lastModified) return;
lastModified = mt;
reloadAndApply();
plugin.getLogger().info("[公告板] 检测到公告文件变更,已自动重载");
}, 600L, 600L);
}
/** 重新解析公告.md */
public void reload() {
// ★ 第36批:配置默认值每次重载重置(md 里没写对应键就回默认)
enabled = true;
pageSeconds = 15;
currentPage = 0;
List<Notice> list = parse();
notices.clear();
for (int i = 0; i < list.size(); i++) {
Notice n = list.get(i);
notices.put(n.slot, n);
}
if (mdFile != null && mdFile.exists()) {
lastModified = mdFile.lastModified();
}
int rows = totalRows();
int pages = (rows + MAX_SIDEBAR_ROWS - 1) / MAX_SIDEBAR_ROWS;
plugin.getLogger().info("[公告板] 已加载 " + notices.size()
+ " 条公告,开关=" + (enabled ? "开" : "关")
+ ",翻页=" + pageSeconds + "秒,共 " + rows
+ " 行 / " + pages + " 页");
// ★ 第36批:按当前翻页秒数启动/重建翻页任务
restartPager();
}
/** 重载并立即刷新所有玩家 */
public void reloadAndApply() {
reload();
applyAll();
}
public File getMdFile() {
return mdFile;
}
public int getNoticeCount() {
return notices.size();
}
// ==================== 解析 ====================
/**
* 解析公告.md 里的 JSON 块。
*
* <p>用 {@link JsonReader} 流式解析而非 {@code JsonObject}:
* 后者会把重复键覆盖掉,「键冲突自动进位」就无从谈起。
*
* @return 按排序位升序的公告列表
*/
private List<Notice> parse() {
List<Notice> out = new ArrayList<>();
if (mdFile == null || !mdFile.exists()) return out;
String text;
try {
text = new String(Files.readAllBytes(mdFile.toPath()), StandardCharsets.UTF_8);
} catch (Exception e) {
plugin.getLogger().warning("[公告板] 读取失败: " + e.getMessage());
return out;
}
// ★ 变量名避开 e/ex(否则遮蔽 catch 块里的 Exception e)
int zStart = text.indexOf(ZONE_START);
int zEnd = text.indexOf(ZONE_END);
if (zStart < 0 || zEnd < 0 || zEnd <= zStart) {
plugin.getLogger().warning("[公告板] 未找到公告区域标记("
+ ZONE_START + " ~ " + ZONE_END + "),公告板为空");
return out;
}
String zone = text.substring(zStart + ZONE_START.length(), zEnd);
String json = extractJsonBlock(zone);
// ★ 第38批:剥掉尾逗号(实测 Gson 连 lenient 都不认尾逗号,用户手抖多打
// 一个逗号就会让整块 json 解析失败、公告板一条都加载不出来)
if (json != null) json = ConfigManager.stripTrailingCommas(json);
if (json == null || json.trim().isEmpty()) {
plugin.getLogger().warning("[公告板] 公告区域内没有 json 代码块");
return out;
}
// ---- 流式解析:保留重复键 ----
List<Notice> raw = new ArrayList<>();
int autoSlot = 1000000; // 非数字键的兜底排序位(排在数字键之后)
JsonReader reader = null;
try {
reader = new JsonReader(new StringReader(json));
reader.setLenient(true);
reader.beginObject();
while (reader.hasNext()) {
String key = reader.nextName();
// ★ 第36批:顶层配置键(开关状态/翻页时间)值是标量,
// 必须先识别 —— 否则对 true / 15 调 beginObject() 会抛
// IllegalStateException,整份解析失败、公告板全空。
if (isConfigKey(key)) {
readConfig(key, reader);
continue;
}
if (reader.peek() != JsonToken.BEGIN_OBJECT) {
// 非对象值(标量/数组/NULL)→ 不是公告条目,跳过
reader.skipValue();
continue;
}
String msg = null;
String colorRaw = null;
reader.beginObject();
while (reader.hasNext()) {
String vk = reader.nextName();
// ★ 第36批:键名兼容中文(默认 msg/color,兼容 消息/颜色)
if ("msg".equals(vk) || "消息".equals(vk)) {
if (reader.peek() == JsonToken.NULL) reader.skipValue();
else msg = reader.nextString();
} else if ("color".equals(vk) || "颜色".equals(vk)) {
if (reader.peek() == JsonToken.NULL) reader.skipValue();
else colorRaw = reader.nextString();
} else {
reader.skipValue();
}
}
reader.endObject();
if (msg == null) continue;
msg = msg.trim();
if (msg.isEmpty()) continue; // 空白键值自动跳过
if (colorRaw == null) colorRaw = "";
else colorRaw = colorRaw.trim();
int slot;
try {
slot = Integer.parseInt(key.trim());
} catch (Exception ex) {
slot = autoSlot++;
}
TextColor color = parseColor(colorRaw);
String url = null;
Matcher m = URL_PATTERN.matcher(msg);
if (m.matches()) url = m.group(1);
raw.add(new Notice(slot, msg, color, url));
}
reader.endObject();
} catch (Exception ex) {
plugin.getLogger().warning("[公告板] json 解析失败: " + ex.getMessage());
return out;
} finally {
if (reader != null) {
try {
reader.close();
} catch (Exception ignored) {
}
}
}
// ---- 排序(手动选择排序,不用 Comparator/lambda)----
sortBySlot(raw);
// ---- 排序位冲突 → 自动进位(往后找第一个空位)----
Set<Integer> used = new LinkedHashSet<>();
// ★ 第36批:color 继承(用户明令)—— 未填/非法 → 继承最近一条
// 合法 color;一条都没找到 → null(渲染回退 MC 原版白色)
TextColor inherit = null;
for (int i = 0; i < raw.size(); i++) {
Notice n = raw.get(i);
int s2 = n.slot;
if (s2 < 0) s2 = 0;
while (used.contains(s2)) s2++;
used.add(s2);
TextColor c = n.color;
if (c == null) c = inherit;
else inherit = c;
out.add(new Notice(s2, n.msg, c, n.url));
}
return out;
}
// ==================== 第36批:配置 / 翻页 ====================
/** ★ 第36批:顶层配置键识别(公告板开关 / 翻页秒数,中文键) */
private static boolean isConfigKey(String key) {
if (key == null) return false;
String k = key.trim();
return "开关状态".equals(k) || "开关".equals(k)
|| "翻页时间".equals(k) || "翻页".equals(k);
}
/**
* ★ 第36批:读标量配置值。
* JSON 的 true/false(BOOLEAN)、15(NUMBER)、"15"(STRING)
* 统一转成 String,交给 parseOnOff / parseInt 处理。
*/
private static String readScalar(JsonReader r) throws java.io.IOException {
JsonToken t = r.peek();
if (t == JsonToken.NULL) {
r.nextNull();
return null;
}
if (t == JsonToken.BOOLEAN) return String.valueOf(r.nextBoolean());
if (t == JsonToken.NUMBER || t == JsonToken.STRING) {
return r.nextString();
}
r.skipValue();
return null;
}
/** ★ 第36批:读取并应用一条顶层配置 */
private void readConfig(String key, JsonReader reader) {
String k = key.trim();
String v;
try {
v = readScalar(reader);
} catch (Exception ex) {
try { reader.skipValue(); } catch (Exception ignored) { }
return;
}
if ("开关状态".equals(k) || "开关".equals(k)) {
enabled = parseOnOff(v);
} else {
// 翻页时间:纯数字=秒(非法/<=0 → 保留默认15)
int sec = -1;
if (v != null) {
try { sec = Integer.parseInt(v.trim()); }
catch (Exception ignored) { }
}
if (sec > 0) pageSeconds = sec;
}
}
/**
* ★ 第36批:开关值解析(用户明令)——
* true / 1 → 开;false / 0 → 关;未识别 → 开(配置写错不至于把板关死)。
*/
static boolean parseOnOff(String v) {
if (v == null) return true;
String t = v.trim().toLowerCase(Locale.ROOT);
if (t.equals("0") || t.equals("false") || t.equals("off")
|| t.equals("no") || t.equals("关") || t.equals("否")) {
return false;
}
return true;
}
/** ★ 第36批:当前公告拆行后的总行数 */
private int totalRows() {
int rows = 0;
for (Notice n : notices.values()) {
rows += splitLine(n.msg).size();
}
return rows;
}
/**
* ★ 第36批:翻页任务 —— 总行数超过侧边栏 15 行时,
* 每「翻页时间」秒切下一页并刷新全员;只有一页时不动作。
*/
private void restartPager() {
int sec = Math.max(1, pageSeconds);
if (pagerTask != null && sec == pagerSeconds) return;
if (pagerTask != null) {
try { pagerTask.cancel(); } catch (Throwable ignored) { }
pagerTask = null;
}
pagerSeconds = sec;
pagerTask = Bukkit.getScheduler().runTaskTimer(plugin, () -> {
if (!enabled || notices.isEmpty()) return;
int rows = totalRows();
int pages = (rows + MAX_SIDEBAR_ROWS - 1) / MAX_SIDEBAR_ROWS;
if (pages <= 1) return; // 只有一页 → 无需翻页
currentPage = (currentPage + 1) % pages;
applyAll();
}, 20L * sec, 20L * sec);
}
/** 按排序位升序(稳定,手动冒泡,规避 JDK26 javac lambda 类型推断崩溃) */
private static void sortBySlot(List<Notice> list) {
for (int i = 0; i < list.size(); i++) {
for (int j = i + 1; j < list.size(); j++) {
if (list.get(j).slot < list.get(i).slot) {
Notice tmp = list.get(i);
list.set(i, list.get(j));
list.set(j, tmp);
}
}
}
}
/** 从文本中提取 ```json ... ``` 代码块内容 */
private static String extractJsonBlock(String zone) {
int fence = zone.indexOf("```");
if (fence < 0) return null;
int bodyStart = zone.indexOf('\n', fence);
if (bodyStart < 0) return null;
int fence2 = zone.indexOf("```", bodyStart);
if (fence2 < 0) return null;
return zone.substring(bodyStart + 1, fence2);
}
/**
* 颜色解析:16 色英文名 / #RRGGBB / r=0 g=0 b=0。
*
* @return 合法色;空或非法返回 null(调用方回退白色)
*/
static TextColor parseColor(String s) {
if (s == null) return null;
String t = s.trim().toLowerCase(Locale.ROOT);
if (t.isEmpty()) return null;
// 16 色英文名(NamedTextColor.NAMES 的键就是小写下划线名)
if (NATIVE_COLORS.contains(t)) {
NamedTextColor c = NamedTextColor.NAMES.value(t);
return c != null ? c : NamedTextColor.WHITE;
}
// #RRGGBB
if (t.matches("#?[0-9a-f]{6}")) {
try {
return TextColor.fromHexString(t.startsWith("#") ? t : "#" + t);
} catch (Exception ignored) {
return null;
}
}
// r=0 g=0 b=0
Matcher m = Pattern.compile(
"(?i)(\\d{1,3})[^0-9]*(\\d{1,3})[^0-9]*(\\d{1,3})")
.matcher(t);
if (m.find()) {
try {
int r = Integer.parseInt(m.group(1));
int g = Integer.parseInt(m.group(2));
int b = Integer.parseInt(m.group(3));
if (r >= 0 && r <= 255 && g >= 0 && g <= 255 && b >= 0 && b <= 255) {
return TextColor.color(r, g, b);
}
} catch (Exception ignored) {
return null;
}
}
return null;
}
// ==================== 渲染 ====================
/**
* 把公告板刷给单个玩家(PVP 侧边栏管辖范围内自动避让)。
*
* @param p 玩家
*/
public void applyTo(Player p) {
applyTo(p, false);
}
/**
* 把公告板刷给单个玩家。
*
* @param p 玩家
* @param force true=无视 PVP 区域/竞技场避让(PVP 模块交还侧边栏时用)
*/
public void applyTo(Player p, boolean force) {
if (p == null || !p.isOnline()) return;
if (!force && isInPVPSide(p)) return;
try {
ScoreboardManager mgr = Bukkit.getScoreboardManager();
if (mgr == null) return;
// ★ 第36批:开关关闭(开关状态=false/0)→ 归还原版主计分板
if (!enabled) {
p.setScoreboard(mgr.getMainScoreboard());
return;
}
// ★ 第36批:先把公告拆成「行」,再按当前页切片 ——
// 原版侧边栏最多 15 行,超过 15 行自动翻页(翻页时间 秒/页)
List<Row> rows = new ArrayList<>();
for (Notice n : notices.values()) {
List<String> lines = splitLine(n.msg);
for (int li = 0; li < lines.size(); li++) {
rows.add(new Row(n, lines.get(li)));
}
}
if (rows.isEmpty()) {
// 没有可显示的公告 → 归还原版主计分板
p.setScoreboard(mgr.getMainScoreboard());
return;
}
int pages = (rows.size() + MAX_SIDEBAR_ROWS - 1)
/ MAX_SIDEBAR_ROWS;
int page = currentPage;
if (page < 0) page = 0;
if (page >= pages) page = pages - 1;
int from = page * MAX_SIDEBAR_ROWS;
int to = Math.min(from + MAX_SIDEBAR_ROWS, rows.size());
Scoreboard sb = mgr.getNewScoreboard();
Objective obj = sb.registerNewObjective(
OBJECTIVE, Criteria.DUMMY, legacy(TITLE));
obj.setDisplaySlot(DisplaySlot.SIDEBAR);
// 右侧不显示分数数字(公告板只展示内容)
try {
obj.numberFormat(NumberFormat.blank());
} catch (Throwable ignored) {
}
int row = 0; // 本页已渲染行数
for (int i = from; i < to; i++) {
Row r = rows.get(i);
// entry 用纯 ASCII 唯一串(避免同名条目塌缩)
String entry = "n" + row;
Score sc = obj.getScore(entry);
// 侧边栏默认 descending(高分在上)→ 分数递减,本页首行排最上
sc.setScore(MAX_SIDEBAR_ROWS - row);
try {
sc.customName(buildComponent(r.n, r.line));
} catch (Throwable ignored) {
}
row++;
}
p.setScoreboard(sb);
} catch (Exception e) {
plugin.getLogger().warning("[公告板] 渲染失败: " + e.getMessage());
}
}
/**
* 构建条目显示组件。
*
* <p>原版侧边栏<b>不解析 entry 里的 § 码</b>,所以颜色/点击事件
* 全部通过 {@code customName} 下发。msg 内部允许写 § 颜色码
* (legacy 解析),其优先级高于 color 参数 —— 与原版文本语义一致。
*/
private static Component buildComponent(Notice n, String line) {
Component comp = legacy(line);
if (n.color != null) {
comp = comp.color(n.color);
}
if (n.url != null) {
comp = comp.clickEvent(ClickEvent.openUrl(n.url))
.hoverEvent(HoverEvent.showText(
Component.text(n.url)));
}
return comp;
}
/** legacy § 文本 → Component(解析失败退化为纯文本) */
private static Component legacy(String s) {
if (s == null || s.isEmpty()) return Component.empty();
try {
return LegacyComponentSerializer.legacySection().deserialize(s);
} catch (Throwable t) {
return Component.text(s);
}
}
/** 刷新所有在线玩家 */
public void applyAll() {
for (Player p : Bukkit.getOnlinePlayers()) {
applyTo(p);
}
}
/**
* 玩家是否在 PVP 侧边栏管辖范围内(PVP 区域 / PVP 竞技场)。
* 公告板据此避让 —— PVP 模块的侧边栏优先。
*/
private boolean isInPVPSide(Player p) {
if (p == null) return false;
try {
if (plugin.getPVPManager() != null
&& plugin.getPVPManager().isInPVPRegion(p.getName())) {
return true;
}
} catch (Throwable ignored) {
}
try {
if (plugin.getPVPArenaManager() != null
&& plugin.getPVPArenaManager().isInPVPArena(p.getName())) {
return true;
}
} catch (Throwable ignored) {
}
return false;
}
// ==================== 事件 ====================
/**
* 切换世界后重刷公告板(部分世界/插件会重置玩家的计分板)。
*/
@EventHandler
public void onWorldChange(PlayerChangedWorldEvent e) {
final Player p = e.getPlayer();
Bukkit.getScheduler().runTask(plugin, () -> applyTo(p));
}
/**
* 超过 15 个字符 → 截断换行(每行最多 15 字,超出的另起一行)。
*
* @return 该条公告占的行
*/
static List<String> splitLine(String msg) {
List<String> out = new ArrayList<>();
if (msg == null || msg.isEmpty()) return out;
int idx = 0;
while (idx < msg.length()) {
int end = Math.min(idx + MAX_LINE_CHARS, msg.length());
out.add(msg.substring(idx, end));
idx = end;
}
return out;
}
}
+190 -6
View File
@@ -70,6 +70,33 @@ public class AreaCLIManager {
return Component.text(text).color(color);
}
/**
* ★ 第30批:拉黑玩家禁止管理已有领地(降级访客后不再有任何管理权)。
* @return true=被拦截(调用方应直接 return)
*/
private boolean denyIfBlacklisted(Player p) {
if (p == null) return false;
BlacklistManager bl = plugin.getBlacklist();
if (bl == null) return false;
if (!bl.isBlacklisted(p.getName())) return false;
p.sendMessage("§c§l[罪人] §f你已被拉黑,禁止管理领地");
return true;
}
/**
* ★ 第30批:禁止把被拉黑的玩家加进领地授权名单。
* @return true=目标玩家在黑名单(调用方应直接 return)
*/
private boolean denyIfTargetBlacklisted(Player p, String targetPlayer) {
if (targetPlayer == null || targetPlayer.isEmpty()) return false;
BlacklistManager bl = plugin.getBlacklist();
if (bl == null) return false;
if (!bl.isBlacklisted(targetPlayer)) return false;
p.sendMessage("§c§l[罪人] §f玩家 §e" + targetPlayer
+ " §f已被拉黑,不能被授予领地权限");
return true;
}
public static Component header(String title) {
return Component.text("\n§6§l═══════ " + title + " ═══════\n");
}
@@ -316,6 +343,7 @@ public class AreaCLIManager {
// ==================== 领地管理 ====================
public void showLandManage(Player p, String landName, int permPage) {
if (denyIfBlacklisted(p)) return;
savePageInfo(p, "manage", landName, 1);
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
@@ -354,6 +382,9 @@ public class AreaCLIManager {
// ★ 新增:领地公告管理
p.sendMessage(clickableAction("📢", "领地公告(进出提示消息)",
"/protect cli announcement " + land.name + " 1"));
// ★ 新增:违禁品管理面板(2026-10-11)
p.sendMessage(clickableAction("🚫", "违禁品管理(禁带物品/没收计数/退出归还)",
"/protect cli confiscate " + land.name));
// ★ 新增:领地改名
long[] cooldown = areaProtect.getRenameCooldown(p);
String renameHint;
@@ -529,6 +560,7 @@ public class AreaCLIManager {
// ==================== 切换权限 ====================
public void togglePerm(Player p, String landName, String permKey, int page) {
if (denyIfBlacklisted(p)) return;
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
@@ -586,6 +618,7 @@ public class AreaCLIManager {
case "wool_shear": oldState = land.denyWoolShear; land.denyWoolShear = !land.denyWoolShear; break;
case "animal_feed": oldState = land.denyAnimalFeeding; land.denyAnimalFeeding = !land.denyAnimalFeeding; break;
case "mob_attack": oldState = land.denyMobAttack; land.denyMobAttack = !land.denyMobAttack; break;
case "villager_trade": oldState = land.denyVillagerTrade; land.denyVillagerTrade = !land.denyVillagerTrade; break;
case "glowing": oldState = land.denyGlowing; land.denyGlowing = !land.denyGlowing; break;
case "visitor_teleport": oldState = land.allowVisitorTeleport; land.allowVisitorTeleport = !land.allowVisitorTeleport; break;
case "peace_mode": oldState = land.peaceMode; land.peaceMode = !land.peaceMode; break;
@@ -647,6 +680,7 @@ public class AreaCLIManager {
case "wool_shear": return "denyWoolShear";
case "animal_feed": return "denyAnimalFeeding";
case "mob_attack": return "denyMobAttack";
case "villager_trade": return "denyVillagerTrade";
case "glowing": return "denyGlowing";
case "peace_mode": return "peaceMode";
case "public_building": return "isPublicBuilding";
@@ -699,9 +733,11 @@ public class AreaCLIManager {
perms.add(new PermItem("hunger", "饥饿", !land.denyHunger));
perms.add(new PermItem("all_damage", "所有伤害", !land.denyAllDamage));
perms.add(new PermItem("all_effects", "所有效果", !land.denyAllEffects));
perms.add(new PermItem("item_frame", "禁止展示框", !land.denyItemFrame));
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止交互展示框"
perms.add(new PermItem("item_frame", land.denyItemFrame ? "禁止交互展示框" : "允许交互展示框", !land.denyItemFrame));
perms.add(new PermItem("redstone", "红石电路(中继器/比较器)", !land.denyRedstoneInteraction));
perms.add(new PermItem("door", "禁止门禁(门/按钮/压力板)", !land.denyDoorInteraction));
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止门禁交互"
perms.add(new PermItem("door", land.denyDoorInteraction ? "禁止门禁(门/按钮/压力板)" : "允许门禁(门/按钮/压力板)", !land.denyDoorInteraction));
perms.add(new PermItem("sign_edit", land.denySignEdit ? "禁止编辑告示牌" : "允许编辑告示牌", !land.denySignEdit));
perms.add(new PermItem("audio", "音频(音符盒/唱片机)", !land.denyNoteblockJukebox));
perms.add(new PermItem("lead", "拴绳使用", !land.denyLead));
@@ -711,6 +747,7 @@ public class AreaCLIManager {
perms.add(new PermItem("wool_shear", "剪切羊毛/生物", !land.denyWoolShear));
perms.add(new PermItem("animal_feed", land.denyAnimalFeeding ? "禁止投喂" : "允许投喂动物", !land.denyAnimalFeeding));
perms.add(new PermItem("mob_attack", land.denyMobAttack ? "禁止攻击生物" : "允许攻击生物", !land.denyMobAttack));
perms.add(new PermItem("villager_trade", land.denyVillagerTrade ? "禁止村民交易" : "允许村民交易", !land.denyVillagerTrade));
perms.add(new PermItem("glowing", "玩家发光", !land.denyGlowing));
perms.add(new PermItem("visitor_teleport", "传送", land.allowVisitorTeleport));
perms.add(new PermItem("peace_mode", "和平模式", land.peaceMode));
@@ -742,9 +779,9 @@ public class AreaCLIManager {
case "hunger": return "饥饿";
case "all_damage": return "所有伤害";
case "all_effects": return "所有效果";
case "item_frame": return "禁止展示框";
case "item_frame": return "展示框交互";
case "redstone": return "红石电路";
case "door": return "禁止门禁";
case "door": return "门禁交互";
case "sign_edit": return "告示牌编辑";
case "audio": return "音频";
case "lead": return "拴绳使用";
@@ -754,6 +791,7 @@ public class AreaCLIManager {
case "wool_shear": return "剪切羊毛";
case "animal_feed": return "投喂动物";
case "mob_attack": return "攻击生物";
case "villager_trade": return "村民交易";
case "glowing": return "玩家发光";
case "peace_mode": return "和平模式";
case "public_building": return "公共建筑设施";
@@ -1020,6 +1058,8 @@ public class AreaCLIManager {
* ★ 切换玩家的管理员身份(仅领地主可操作)
*/
public void toggleAdmin(Player p, String landName, String targetPlayer, int page) {
if (denyIfBlacklisted(p)) return;
if (denyIfTargetBlacklisted(p, targetPlayer)) return;
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
@@ -1107,6 +1147,7 @@ public class AreaCLIManager {
* 清除某成员所有per-player权限覆盖
*/
public void clearPlayerPerm(Player p, String landName, String targetPlayer) {
if (denyIfBlacklisted(p)) return;
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
@@ -1162,9 +1203,9 @@ public class AreaCLIManager {
{"hunger", "饥饿", "denyHunger"},
{"all_damage", "所有伤害", "denyAllDamage"},
{"all_effects", "所有效果", "denyAllEffects"},
{"item_frame", "禁止展示框", "denyItemFrame"},
{"item_frame", "展示框交互", "denyItemFrame"},
{"redstone", "红石电路(中继器/比较器)", "denyRedstoneInteraction"},
{"door", "禁止门禁(门/按钮/压力板)", "denyDoorInteraction"},
{"door", "门禁交互", "denyDoorInteraction"},
{"sign_edit", "禁止编辑告示牌", "denySignEdit"},
{"audio", "音频(音符盒/唱片机)", "denyNoteblockJukebox"},
{"lead", "拴绳使用", "denyLead"},
@@ -1174,6 +1215,7 @@ public class AreaCLIManager {
{"wool_shear", "剪切羊毛/生物", "denyWoolShear"},
{"animal_feed", "投喂动物", "denyAnimalFeeding"},
{"mob_attack", "攻击生物", "denyMobAttack"},
{"villager_trade", "村民交易", "denyVillagerTrade"},
{"glowing", "玩家发光", "denyGlowing"},
{"peace_mode", "和平模式", "peaceMode"},
{"tp", "传送", "allowTeleport"}
@@ -1217,6 +1259,18 @@ public class AreaCLIManager {
if (field.equals("denyEnderTeleport")) {
name = landDefault ? "禁用末影人行为" : "启用末影人行为";
}
// ★ denyItemFrame显示名称(2026-10-10 按启停状态动态显示)
if (field.equals("denyItemFrame")) {
name = landDefault ? "禁止交互展示框" : "允许交互展示框";
}
// ★ denyVillagerTrade显示名称(2026-10-10 新增村民交易权限)
if (field.equals("denyVillagerTrade")) {
name = landDefault ? "禁止村民交易" : "允许村民交易";
}
// ★ denyDoorInteraction显示名称(2026-10-10 按启停状态动态显示)
if (field.equals("denyDoorInteraction")) {
name = landDefault ? "禁止门禁交互(门/按钮/压力板)" : "允许门禁交互(门/按钮/压力板)";
}
// 如果有per-player覆盖,使用覆盖值;否则使用领地默认
if (playerPerms.containsKey(field)) {
@@ -1270,6 +1324,7 @@ public class AreaCLIManager {
case "denyEnderTeleport": return land.denyEnderTeleport;
case "denyWoolShear": return land.denyWoolShear;
case "denyAnimalFeeding": return land.denyAnimalFeeding;
case "denyVillagerTrade": return land.denyVillagerTrade;
case "denyGlowing": return land.denyGlowing;
case "peaceMode": return land.peaceMode;
case "isPublicBuilding": return land.isPublicBuilding;
@@ -2022,4 +2077,133 @@ public class AreaCLIManager {
showAnnouncementManagement(p, landName, 1);
return true;
}
// ==================== 违禁品管理面板(2026-10-11) ====================
/**
* ★ 违禁品管理面板(CLI)。
*
* <p>展示:没收计数、当前访客禁带物品列表、退出是否归还开关。
* 领地主/管理员可在此配置「访客禁止携带的物品」,并控制退出领地时
* 是否退还已没收物品(默认不退)。
*/
public void showConfiscateManagement(Player p, String landName) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
return;
}
// 权限检查
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
boolean isAdmin = areaProtect.isAreaAdmin(p);
if (!isOwner && !isAdmin) {
p.sendMessage(Component.text("§c需要领地所有者或管理员权限"));
return;
}
p.sendMessage(header("违禁品管理: " + landName));
p.sendMessage(Component.text("§7配置访客进入领地时禁止携带的物品"));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
// 没收计数
p.sendMessage(Component.text("§a累计没收次数: §f" + land.confiscateCount + " §7次"));
// 当前禁带物品列表
p.sendMessage(Component.text(""));
if (land.confiscateItems == null || land.confiscateItems.isEmpty()) {
p.sendMessage(Component.text("§7当前访客禁带物品: §c(空)"));
} else {
p.sendMessage(Component.text("§7当前访客禁带物品(§f" + land.confiscateItems.size() + "§7种):"));
for (int i = 0; i < land.confiscateItems.size(); i++) {
final int idx = i;
String item = land.confiscateItems.get(i);
org.bukkit.Material mat = org.bukkit.Material.matchMaterial(item);
String cn = mat != null ? " §7" + ItemNameResolver.toChinese(mat) : "";
p.sendMessage(Component.empty()
.append(Component.text("§f " + (i + 1) + ". " + item + cn + " "))
.append(Component.text("§c[移除]")
.hoverEvent(HoverEvent.showText(Component.text("§e点击移除该禁带物品")))
.clickEvent(ClickEvent.runCommand(
"/protect cli confiscateremove " + landName + " " + (i + 1)))));
}
}
// 添加物品入口
p.sendMessage(Component.text(""));
p.sendMessage(clickableAction("➕", "添加禁带物品",
"/protect cli confiscateadd " + landName));
// 退出是否归还开关
p.sendMessage(Component.text(""));
String retState = land.returnOnLeave ? "§a开启" : "§c关闭";
p.sendMessage(Component.empty()
.append(Component.text("§a退出领地归还没收物: " + retState))
.append(Component.text(" §7(当前" + (land.returnOnLeave ? "开启,退出时退还" : "关闭,退出不退还") + ")")));
p.sendMessage(Component.empty()
.append(Component.text(land.returnOnLeave ? "§c[关闭归还] " : "§a[开启归还] "))
.hoverEvent(HoverEvent.showText(Component.text("§e切换:玩家离开领地时是否退还此前被没收的物品")))
.clickEvent(ClickEvent.runCommand(
"/protect cli confiscateret " + landName + " " + (land.returnOnLeave ? "off" : "on"))));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
p.sendMessage(clickableAction("◀", "返回领地管理", "/protect cli manage " + landName));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
/**
* 开始添加禁带物品(等待玩家聊天输入物品ID)
*/
public void startAddConfiscateItem(Player p, String landName) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) { p.sendMessage(Component.text("§c领地不存在: " + landName)); return; }
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
boolean isAdmin = areaProtect.isAreaAdmin(p);
if (!isOwner && !isAdmin) { p.sendMessage(Component.text("§c需要领地所有者或管理员权限")); return; }
areaProtect.setPendingEffectInput(p.getUniqueId(), landName, "confiscate_add", 3);
p.sendMessage(header("添加禁带物品: " + landName));
p.sendMessage(Component.text("§e请在聊天栏输入物品名 §7(支持中文,如 §f钻石、黑曜石§7)"));
p.sendMessage(Component.text("§7也可输入英文ID: §fdiamond、minecraft:gold_ingot§7;输入 §c取消 §7放弃添加"));
}
/**
* 移除指定序号的禁带物品
*/
public void removeConfiscateItem(Player p, String landName, String indexStr) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) { p.sendMessage(Component.text("§c领地不存在: " + landName)); return; }
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
boolean isAdmin = areaProtect.isAreaAdmin(p);
if (!isOwner && !isAdmin) { p.sendMessage(Component.text("§c需要领地所有者或管理员权限")); return; }
int idx;
try { idx = Integer.parseInt(indexStr); } catch (Exception e) { p.sendMessage(Component.text("§c序号无效")); return; }
if (idx < 1 || idx > land.confiscateItems.size()) { p.sendMessage(Component.text("§c序号超出范围")); return; }
String removed = land.confiscateItems.remove(idx - 1);
areaProtect.saveAreaToDb(land);
p.sendMessage(Component.text("§a已移除禁带物品: §f" + removed));
}
/**
* 切换「退出领地是否归还没收物」开关
*/
public void toggleReturnOnLeave(Player p, String landName, String newState) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
return;
}
boolean isOwner = AreaProtection.samePlayer(p.getName(), land.owner);
boolean isAdmin = areaProtect.isAreaAdmin(p);
if (!isOwner && !isAdmin) {
p.sendMessage(Component.text("§c需要领地所有者或管理员权限"));
return;
}
if (newState.equalsIgnoreCase("on")) {
land.returnOnLeave = true;
p.sendMessage(Component.text("§a已开启:玩家离开领地时将退还没收的物品"));
} else {
land.returnOnLeave = false;
p.sendMessage(Component.text("§a已关闭:玩家离开领地时不退还没收的物品"));
}
areaProtect.saveAreaToDb(land);
}
}
+146 -6
View File
@@ -42,6 +42,7 @@ public class AreaGUIManager implements Listener {
private static final String T_EFFECTS_CLEAR = "§b§l单清效果";
private static final String T_EFFECTS_GIVE = "§b§l增益效果";
private static final String T_LAND_TRANSFER = "§6§l过户领地";
private static final String T_CONFISCATE = "§c§l违禁品管理 - ";
// 每页显示数量
private static final int PAGE_SIZE = 45;
@@ -266,6 +267,15 @@ public class AreaGUIManager implements Listener {
inv.setItem(33, createItem(Material.NAME_TAG, "§e§l领地改名",
renameLore, "", "§e点击输入新名字"));
// 违禁品管理(位置34,2026-10-11)
inv.setItem(34, createItem(Material.BARRIER, "§c§l违禁品管理",
"§7配置访客禁止携带的物品",
"§7进入回收 · 退出按设置归还",
"§7累计没收: §f" + land.confiscateCount + " §7次",
"§7退出归还: " + (land.returnOnLeave ? "§a开启" : "§c关闭"),
"",
"§e点击管理"));
// 返回按钮(位置48)
inv.setItem(48, createItem(Material.ARROW, "§c§l返回领地列表", ""));
@@ -500,7 +510,8 @@ public class AreaGUIManager implements Listener {
{"攻击生物", "denyMobAttack"},
{"禁止编辑告示牌", "denySignEdit"},
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"},
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"}
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"},
{"村民交易", "denyVillagerTrade"}
};
for (String[] def : permDefs) {
@@ -537,6 +548,16 @@ public class AreaGUIManager implements Listener {
if (field.equals("denyEnderTeleport")) {
name = landDefault ? "禁用末影人行为" : "启用末影人行为";
}
if (field.equals("denyItemFrame")) {
name = landDefault ? "禁止交互展示框" : "允许交互展示框";
}
if (field.equals("denyVillagerTrade")) {
name = landDefault ? "禁止村民交易" : "允许村民交易";
}
// ★ denyDoorInteraction显示名称(2026-10-10 按启停状态动态显示)
if (field.equals("denyDoorInteraction")) {
name = landDefault ? "禁止门禁交互" : "允许门禁交互";
}
// ★ 对于 allowPickup/allowDrop,enabled=true 表示"允许操作"(即 allow=true)
// 其他权限,enabled = true 表示"允许操作"(即 deny=false)
@@ -619,6 +640,7 @@ public class AreaGUIManager implements Listener {
case "denyAnimalFeeding": return land.denyAnimalFeeding;
case "denyGlowing": return land.denyGlowing;
case "denySignEdit": return land.denySignEdit;
case "denyVillagerTrade": return land.denyVillagerTrade;
case "peaceMode": return land.peaceMode;
default: return false;
}
@@ -695,15 +717,18 @@ public class AreaGUIManager implements Listener {
perms.add(new PermEntry("饥饿", !land.denyHunger));
perms.add(new PermEntry("所有伤害", !land.denyAllDamage));
perms.add(new PermEntry("所有效果", !land.denyAllEffects));
perms.add(new PermEntry("禁止展示框", !land.denyItemFrame));
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止交互展示框"
perms.add(new PermEntry(land.denyItemFrame ? "禁止交互展示框" : "允许交互展示框", !land.denyItemFrame));
perms.add(new PermEntry("红石电路(按钮/压力板/中继器/比较器)", !land.denyRedstoneInteraction));
perms.add(new PermEntry("门禁交互(门/栅栏门)", !land.denyDoorInteraction));
// ★ 2026-10-10 文案动态化:按启停状态显示"允许/禁止门禁交互"
perms.add(new PermEntry(land.denyDoorInteraction ? "禁止门禁交互(门/栅栏门)" : "允许门禁交互(门/栅栏门)", !land.denyDoorInteraction));
perms.add(new PermEntry("音频(音符盒/唱片机)", !land.denyNoteblockJukebox));
perms.add(new PermEntry("拴绳使用", !land.denyLead));
perms.add(new PermEntry(land.denyCropHarvest ? "禁用农作物收获" : "启用农作物收获", !land.denyCropHarvest));
perms.add(new PermEntry("剪切羊毛/生物", !land.denyWoolShear));
perms.add(new PermEntry(land.denyAnimalFeeding ? "禁止投喂" : "允许投喂动物", !land.denyAnimalFeeding));
perms.add(new PermEntry(land.denyMobAttack ? "禁止攻击生物" : "允许攻击生物", !land.denyMobAttack));
perms.add(new PermEntry(land.denyVillagerTrade ? "禁止村民交易" : "允许村民交易", !land.denyVillagerTrade));
perms.add(new PermEntry(land.denySignEdit ? "禁止编辑告示牌" : "允许编辑告示牌", !land.denySignEdit));
perms.add(new PermEntry("玩家发光", !land.denyGlowing));
perms.add(new PermEntry(land.denyFarmlandTrample ? "禁用耕地破坏" : "启用耕地破坏", !land.denyFarmlandTrample));
@@ -758,9 +783,9 @@ public class AreaGUIManager implements Listener {
case "饥饿": land.denyHunger = !land.denyHunger; break;
case "所有伤害": land.denyAllDamage = !land.denyAllDamage; break;
case "所有效果": land.denyAllEffects = !land.denyAllEffects; break;
case "禁止展示框": land.denyItemFrame = !land.denyItemFrame; break;
case "禁止展示框": case "允许交互展示框": case "禁止交互展示框": land.denyItemFrame = !land.denyItemFrame; break;
case "红石电路(按钮/压力板/中继器/比较器)": land.denyRedstoneInteraction = !land.denyRedstoneInteraction; break;
case "门禁交互(门/栅栏门)": land.denyDoorInteraction = !land.denyDoorInteraction; break;
case "门禁交互(门/栅栏门)": case "禁止门禁交互(门/栅栏门)": case "允许门禁交互(门/栅栏门)": land.denyDoorInteraction = !land.denyDoorInteraction; break;
case "音频(音符盒/唱片机)": land.denyNoteblockJukebox = !land.denyNoteblockJukebox; break;
case "拴绳使用": land.denyLead = !land.denyLead; break;
case "农作物收获": land.denyCropHarvest = !land.denyCropHarvest; break;
@@ -769,6 +794,7 @@ public class AreaGUIManager implements Listener {
case "剪切羊毛/生物": land.denyWoolShear = !land.denyWoolShear; break;
case "禁止投喂": case "允许投喂动物": land.denyAnimalFeeding = !land.denyAnimalFeeding; break;
case "禁止攻击生物": case "允许攻击生物": land.denyMobAttack = !land.denyMobAttack; break;
case "禁止村民交易": case "允许村民交易": land.denyVillagerTrade = !land.denyVillagerTrade; break;
case "禁止编辑告示牌": case "允许编辑告示牌": land.denySignEdit = !land.denySignEdit; break;
case "玩家发光": land.denyGlowing = !land.denyGlowing; break;
case "禁用耕地破坏": case "启用耕地破坏": land.denyFarmlandTrample = !land.denyFarmlandTrample; break;
@@ -991,6 +1017,9 @@ public class AreaGUIManager implements Listener {
openLandSettings(p, landName);
} else if (raw == 16) {
openEffectsManagement(p, landName, 1);
} else if (raw == 34) {
// ★ 违禁品管理面板(2026-10-11)
openConfiscatePanel(p, landName);
} else if (raw == 32) {
// 过户领地
openTransferLandPanel(p, landName);
@@ -1048,6 +1077,52 @@ public class AreaGUIManager implements Listener {
return;
}
// ★ 违禁品管理面板(2026-10-11)
if (title.startsWith(T_CONFISCATE)) {
event.setCancelled(true);
String landName = title.substring(T_CONFISCATE.length()).trim();
if (!event.isLeftClick()) return;
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) { p.closeInventory(); return; }
int[] slots = {10,11,12,13,14,15,16, 19,20,21,22,23,24,25, 28,29,30,31,32,33,34};
// 判断点击是否命中某个禁带物品
int hitIdx = -1;
for (int i = 0; i < slots.length && i < land.confiscateItems.size(); i++) {
if (slots[i] == raw) { hitIdx = i; break; }
}
if (hitIdx >= 0) {
String removed = land.confiscateItems.remove(hitIdx);
areaProtect.saveAreaToDb(land);
p.sendMessage("§a§l[违禁品] §f已移除禁带物品: §e" + removed);
openConfiscatePanel(p, landName);
return;
}
if (raw == 22) {
// 切换退出归还开关
land.returnOnLeave = !land.returnOnLeave;
areaProtect.saveAreaToDb(land);
p.sendMessage(land.returnOnLeave
? "§a§l[违禁品] §f已开启:玩家离开领地时退还没收物"
: "§c§l[违禁品] §f已关闭:玩家离开领地时不退还没收物");
openConfiscatePanel(p, landName);
return;
}
if (raw == 40) {
// 添加禁带物品 → 关闭GUI转聊天输入
p.closeInventory();
areaProtect.setPendingEffectInput(p.getUniqueId(), landName, "confiscate_add", 1);
p.sendMessage("§e§l[违禁品] §f请在聊天栏输入物品名(支持中文,如 §e钻石、黑曜石§f),输入 §c取消 §7放弃");
return;
}
if (raw == 48) {
openLandManage(p, landName);
return;
}
return;
}
// 成员列表
if (title.startsWith(T_MEMBER_LIST)) {
event.setCancelled(true);
@@ -1176,7 +1251,8 @@ public class AreaGUIManager implements Listener {
{"攻击生物", "denyMobAttack"},
{"禁止编辑告示牌", "denySignEdit"},
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"},
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"}
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"},
{"村民交易", "denyVillagerTrade"}
};
if (raw < permDefs.length) {
@@ -2297,6 +2373,70 @@ public class AreaGUIManager implements Listener {
p.openInventory(inv);
}
/**
* ★ 违禁品管理面板(2026-10-11)。
*
* <p>展示没收计数、当前访客禁带物品列表(点击移除)、添加入口、
* 以及「退出领地是否归还没收物」开关(默认关闭=不退)。
*/
public void openConfiscatePanel(Player p, String landName) {
managingLand.put(p.getUniqueId(), landName);
Inventory inv = Bukkit.createInventory(null, 54, T_CONFISCATE + landName);
fillBg(inv);
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage("§c领地不存在");
p.closeInventory();
return;
}
// 没收计数(位置4)
inv.setItem(4, createItem(Material.HOPPER, "§e§l没收计数",
"§7该领地累计没收违禁品次数",
"§7累计: §f" + land.confiscateCount + " §7次"));
// 退出归还开关(位置22)
inv.setItem(22, createItem(land.returnOnLeave ? Material.LIME_DYE : Material.GRAY_DYE,
land.returnOnLeave ? "§a§l退出归还: 开启" : "§c§l退出归还: 关闭",
"§7玩家离开领地时是否退还没收物",
"§7当前: " + (land.returnOnLeave ? "§a开启,退出时退还" : "§c关闭,退出不退还"),
"",
"§e点击切换"));
// 禁带物品列表(位置 10-16, 19-25, 28-34)
int[] slots = {10,11,12,13,14,15,16, 19,20,21,22,23,24,25, 28,29,30,31,32,33,34};
java.util.List<String> items = land.confiscateItems;
for (int i = 0; i < items.size() && i < slots.length; i++) {
final int idx = i;
String item = items.get(i);
org.bukkit.Material mat = org.bukkit.Material.matchMaterial(item);
inv.setItem(slots[i], createItem(
mat != null ? mat : Material.BARRIER,
"§c" + ItemNameResolver.toChinese(mat),
"§7英文ID: §f" + item,
"§7访客禁止携带此物品",
"",
"§e点击移除"));
}
// 添加物品按钮(位置40)
inv.setItem(40, createItem(Material.EMERALD, "§a§l添加禁带物品",
"§7在聊天栏输入物品名(支持中文)",
"§7示例: 钻石、黑曜石、diamond",
"",
"§e点击添加"));
// 当前数量提示(位置49)
inv.setItem(49, createItem(Material.PAPER, "§7§l共 " + items.size() + " 种禁带物品",
"§7列表已满时请先移除"));
// 返回按钮(位置48)
inv.setItem(48, createItem(Material.ARROW, "§c§l返回管理领地", ""));
p.openInventory(inv);
}
/**
* 处理过户领地GUI点击事件
*/
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,890 @@
package Sdf1_login;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
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.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 自愈);
* 已存在的 md → 安全升级自动补齐模板新增行(safeUpgradeDoc,第二十一批)。
*
* ★ 第二十七批:数据源改为原版 banned-players.json(用户明令「这是 MC 唯一的封禁数据来源」)。
* 线上事故:旧实现读 Bukkit.getBanList(NAME) 的内存名单,而内存名单在重启/被
Web 后台批量改写后与磁盘 banned-players.json 严重不一致(文件里 [] 空,内存里
却有几十条)→ 玩家看到「一个都没封却公示一堆人」。
* 现改为:每轮播报都重新读 <worldContainer>/banned-players.json 到内存,
* 解析每条的 created,过滤掉插件数据目录创建时间之前的全部历史封禁;
* 文件缺失 / 语法坏 / 过滤后为空 → 一律闭嘴不播,绝不拿兜底假数据说话。
*
* 键值:轮播最低间隔(秒) / 轮播最大间隔(秒) / 播报模式 / 播报起点(插件安装之前的封禁不播)
* 播报模式:true=由近到远、false=由远到近、0=关闭;
* 表格值:由近到远 / 由远到近 / 0。
* 间隔或模式为 0 → 关闭播报。
*/
public class BanBroadcastManager implements Listener {
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 = 6; // 每轮播报条数(耻辱台一页,一次发6个)
private int lastDelaySec = 0; // 本轮实际排定的随机间隔秒数(播报日志用)
private long startCutoffMs = 0; // 播报起点(毫秒):只播该时刻之后创建的封禁
private boolean startCutoffAuto = true; // 起点未配置 → 自动取插件数据目录创建时间(=插件安装时间)
private long autoCutoffMs = -1; // 自动起点缓存(-1=未取)
private boolean jsonSyntaxOk = false; // json 文本语法是否有效(区分「语法坏」与「语法好但值空」)
private String lastCfgSig = null; // 上轮配置签名(增量重载:无变更不打日志)
private volatile boolean timerRunning = false; // 已有待触发的排程(防重复拉起)
private volatile boolean stoppedByEmpty = false; // 全员下线导致的停机(等玩家上线恢复)
private static BanBroadcastManager instance; // 单插件单实例,供 WebManager 取播报起点
public BanBroadcastManager(Main plugin) {
this.plugin = plugin;
instance = this;
}
/** 单例访问(WebManager 推送封禁时取播报起点) */
public static BanBroadcastManager get() { return instance; }
/**
* 播报起点(毫秒),0=不过滤。与游戏内播报同一套语义(配置的播报起点 / 自动=插件数据目录创建时间),
* 供 PHP 端封禁公示过滤「插件上服务器之前的历史封禁」——两端同源,不各配一套。
*/
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() {
banDir = new File(plugin.getDataFolder(), "ban");
mdFile = new File(banDir, "规则配置.md");
jsonFile = findJsonConfig();
ensureConfigExists();
reloadConfig();
scheduleNext();
plugin.getServer().getPluginManager().registerEvents(this, plugin);
// ★ 二十二批:错位配置检测 —— 曾有人把 规则配置.md/json 放到
// plugins/ban/(插件只读 plugins/Sdf1_login/ban/),表现为
// 「安全升级没生效」。发现即警告并指明正确路径。
try {
File wrongDir = new File(
plugin.getDataFolder().getParentFile(),
"ban");
if (wrongDir.isDirectory()) {
String[] wrongs = wrongDir.list((d, n) ->
n.equals("规则配置.md")
|| n.toLowerCase(Locale.ROOT)
.endsWith(".json"));
if (wrongs != null && wrongs.length > 0) {
warn("检测到错位配置目录 plugins/ban/(含 "
+ String.join(", ", wrongs)
+ ")——本插件只读取 "
+ banDir.getPath()
+ ",该目录内的配置不会生效!");
}
}
} 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)) : "不限")
+ " 数据源=" + (bf0 == null ? "未定位" : bf0.getPath()));
List<JsonBan> boot = collectBans();
log("启动首筛:banned-players.json 有效封禁 " + boot.size() + " 条");
}
public void shutdown() { /* BukkitRunnable 随插件卸载自动取消 */ }
/**
* ★ 第十九批:定时器生命周期 —— 停止条件只有两个:全员下线、服务器关机/插件卸载。
* 配置为「关闭」不停定时器:只静默本轮,下一轮醒来重读配置,
* 改回开启(或把间隔从 0 恢复)即自动续播,无需重启。
* 全员下线时 scheduleNext 的空服分支停掉排程,第一个玩家上线由此重新拉起。
* (start/runTaskLater/PlayerJoinEvent 都在主线程,状态无并发问题)
*/
@EventHandler
public void onPlayerJoin(PlayerJoinEvent e) {
if (!stoppedByEmpty || timerRunning) return;
stoppedByEmpty = false;
scheduleNext();
}
// ==================== 配置:自愈 + 双模式解析 ====================
/** 找 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;
}
/** 配置文件丢失就补一份(自愈);已存在的 md 走安全升级补齐模板新增行 */
private void ensureConfigExists() {
jsonFile = findJsonConfig(); // ★ 每轮重查:运行中新增/删除的 json 都要跟上
try {
if (jsonFile != null) {
if (!banDir.exists()) banDir.mkdirs();
if (!jsonFile.exists()) {
plugin.saveResource("ban/" + jsonFile.getName(), false);
log("配置文件丢失,已自愈补发: " + jsonFile.getName());
}
} else if (!mdFile.exists()) {
banDir.mkdirs();
plugin.saveResource("ban/规则配置.md", false);
log("配置文件丢失,已自愈补发: 规则配置.md");
}
// ★ 第二十一批:已存在的 md 安全升级 —— saveResource(...,false) 对已存在文件
// 永不覆盖,jar 模板新增的配置行(如「播报起点」)借此补进原文件;
// 说明文字与用户改过的值原样保留(safeUpgradeDoc:先 .bak 逐字节校验,
// 备份失败整个取消不动原文件;原子写失败自动还原;无新增行幂等不写文件)。
// 有变更由 safeUpgradeDoc 打一条明细,无变更安静 —— 对齐增量日志要求。
if (mdFile != null && mdFile.exists()) {
java.util.List<String> tpl = ConfigManager.readJarResourceLines(
plugin.getResource("ban/规则配置.md"));
if (tpl != null) {
plugin.getConfig2().safeUpgradeDoc(mdFile, tpl, "封禁播报规则");
}
}
} catch (Exception e) {
warn("自愈补发失败: " + e.getMessage());
}
}
/** 重新加载配置(json 优先 → md json块 → md 表格 → 备份重置)。
* ★ 第二十批:增量重载 —— 生效配置与上一轮逐项对比:
* 无变更一个字不打(安静执行随机计时,到点该干嘛干嘛);
* 有变更才打一条明细(模式/间隔/起点/来源 哪里从什么变成什么)。 */
public void reloadConfig() {
// 进门快照(对比基线):上一轮生效值
final int oMin = minInterval, oMax = maxInterval, oMode = mode;
final long oCut = startCutoffMs;
final boolean oAuto = startCutoffAuto;
final String oldSig = lastCfgSig;
String src = null; // 本轮配置源(变更明细用)
try {
// ★ 懒重载(每次播报窗口执行一次):先回出厂默认再解析文件 ——
// 文件里新改的值、被删掉/清空的项都能如实读到(读到的就是文件里最新的);
// 开关(播报模式)决定这次报/不报,最新间隔用于播报完后的下次排程计时。
minInterval = 60;
maxInterval = 180;
mode = 1;
startCutoffAuto = true;
startCutoffMs = 0;
jsonFile = findJsonConfig(); // ★ 每轮重查(不再只在 null 时查一次)
if (jsonFile != null && jsonFile.exists()) {
String txt = readFile(jsonFile);
if (parseJsonText(txt)) {
src = "json:" + jsonFile.getName();
} else if (jsonSyntaxOk) {
// ★ 第十八批:语法有效但间隔值为空(文档语义「表格/json 二者选一填」)
// → 继承 json 里已解析的播报模式/播报起点,间隔回退 md 表格
// (只取间隔,绝不覆盖已继承的模式/起点)或出厂默认,不判损毁。
boolean iv = false;
if (mdFile != null && mdFile.exists()) {
String md0 = readFile(mdFile);
if (md0 != null) iv = parseIntervalTable(md0);
}
normalize();
src = "json继承" + (iv ? "+md间隔" : "+默认间隔") + ":" + jsonFile.getName();
} else {
// 语法真损坏 → 备份+移除,继续回退 md 分支(不再就此 return)
backupAndReset(jsonFile, jsonFile.getName());
jsonFile = null;
}
}
if (src == null) {
if (!mdFile.exists()) { ensureConfigExists(); }
String md = readFile(mdFile);
if (md != null) {
String jsonBlock = extractJsonBlock(md);
if (jsonBlock != null && parseJsonText(jsonBlock)) {
src = "md内嵌json";
} else if (parseTableText(md)) {
src = "md表格";
}
}
if (src == null) {
// 全部解码失败 → 备份原文件 + 释放一份新的(低频真事件,仍打日志)
backupAndReset(mdFile, "规则配置.md");
src = "出厂重置";
}
}
} catch (Exception e) {
warn("reloadConfig 异常: " + e.getMessage());
src = "异常";
}
// ★ 增量日志:签名 = 配置源 + 全部生效值;与上一轮完全一致 → 安静
String sig = src + "|" + mode + "|" + minInterval + "|" + maxInterval + "|"
+ (startCutoffAuto ? "auto" : startCutoffMs);
if (sig.equals(oldSig)) { return; }
lastCfgSig = sig;
if (oldSig == null) { return; } // 首轮只记基线(start()「已启动」总览已覆盖,不重复刷屏)
StringBuilder sb = new StringBuilder("配置变更(").append(src).append("):");
if (oMode != mode) {
sb.append(" 模式 ").append(modeNameOf(oMode)).append("→").append(modeName());
}
if (oMin != minInterval || oMax != maxInterval) {
sb.append(" 间隔 ").append(oMin).append("~").append(oMax).append("s→")
.append(minInterval).append("~").append(maxInterval).append("s");
}
if (oAuto != startCutoffAuto || oCut != startCutoffMs) {
sb.append(" 起点 ").append(cutText(oAuto, oCut))
.append("→").append(cutText(startCutoffAuto, startCutoffMs));
}
String oldSrc = oldSig.substring(0, oldSig.indexOf('|'));
if (!oldSrc.equals(src)) {
sb.append(" 来源 ").append(oldSrc).append("→").append(src);
}
log(sb.toString());
}
/** 模式码→文字(增量日志对比旧值用;modeName() 取当前字段) */
private String modeNameOf(int m) {
return m == 0 ? "关闭" : (m == 1 ? "由近到远" : "由远到近");
}
/** 起点状态→文字(增量日志用):auto=自动 / 时间戳=日期 / 0=不限 */
private String cutText(boolean auto, long ms) {
if (auto) return "自动";
return ms > 0 ? new SimpleDateFormat("yyyy-MM-dd").format(new Date(ms)) : "不限";
}
/** 从 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) {
jsonSyntaxOk = false;
try {
JsonObject o = JsonParser.parseString(txt).getAsJsonObject();
jsonSyntaxOk = true; // 语法解析成功(值是否为空另行判定)
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 (o.has("播报起点") && !o.get("播报起点").isJsonNull()) {
parseStartCutoff(o.get("播报起点").getAsString());
}
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 - 1].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; }
else if (key.equals("播报起点")) { parseStartCutoff(val); any = true; }
}
if (any) normalize();
return any;
}
/** 只从 md 表格取间隔值(不碰播报模式/播报起点 —— 继承 json 时仅回退间隔用) */
private boolean parseIntervalTable(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 - 1].trim();
if (key.equals("轮播最低间隔")) { minInterval = toSec(val); any = true; }
else if (key.equals("轮播最大间隔")) { maxInterval = toSec(val); any = true; }
}
if (any) {
if (minInterval < 0) minInterval = 0;
if (maxInterval < 0) maxInterval = 0;
if (maxInterval > 0 && minInterval > maxInterval) maxInterval = minInterval;
}
return any;
}
/** 播报模式文本/布尔/数字 → 0/1/2 */
private int parseMode(String s) {
if (s == null) return 1;
String v = s.trim();
// ★ 第十八批遗留修复:0 = 关闭(表格文档语义)。
// 原实现把 "0" 和 "false" 合并映射成 2(由远到近),
// 导致表格填 0 永远关不掉、一直播报 —— 现拆开各归各位。
if (v.equals("0")) return 0; // 0=关闭
if (v.equalsIgnoreCase("false")) return 2; // false=由远到近
if (v.equals("1") || v.equalsIgnoreCase("true")) return 1; // true=由近到远
if (v.contains("关闭")) return 0; // 容错:直接写「关闭」
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;
}
/**
* 解析「播报起点」:插件安装之前的封禁(历史封禁)不播报。
* 支持 yyyy-MM-dd[ HH:mm[:ss]] 日期、10/13 位时间戳;0/留空 = 自动
* (自动模式取插件数据目录创建时间,即插件安装上服务器的时间)
*/
private void parseStartCutoff(String s) {
String v = s == null ? "" : s.trim();
if (v.isEmpty() || v.equals("0")) {
startCutoffAuto = true;
startCutoffMs = 0;
return;
}
if (v.matches("\\d{10,13}")) {
long n = Long.parseLong(v);
startCutoffMs = v.length() <= 10 ? n * 1000L : n;
startCutoffAuto = false;
return;
}
String[] pats = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd HH:mm", "yyyy-MM-dd"};
for (String p : pats) {
try {
java.text.ParsePosition pos = new java.text.ParsePosition(0);
Date d = new SimpleDateFormat(p, Locale.ROOT).parse(v, pos);
if (d != null && pos.getIndex() == v.length()) {
startCutoffMs = d.getTime();
startCutoffAuto = false;
return;
}
} catch (Exception ignored) { }
}
warn("播报起点无法解析,回退自动: " + v);
startCutoffAuto = true;
startCutoffMs = 0;
}
/**
* 取播报起点(毫秒):>0 表示只播该时刻之后创建的封禁。
*
* <p>★ 第二十七批:自动模式取插件数据目录(plugins/Sdf1_login)的创建时间
* = 插件安装上服务器的时间,插件上服之前的全部历史封禁一律不播。
*
* <p>★ 第二十八批:数据目录可能被重建/迁移/重装——creationTime 变成当天,
* 把之后创建的全部生效封禁一并滤光(实测起点=2026-10-16 → 2026-09-30
* 的封禁被滤 → 播报误报「当前无生效封禁」)。故自动起点加下限保护:
* 绝不晚于「距今 180 天」——既挡住古早历史封禁(半年前的一律不播),
* 又保证目录被重建时近半年内的生效封禁不被误杀。
* 手动配置的播报起点(startCutoffAuto=false)不受此保护,完全按配置走。
*
* <p>旧实现有「距今不足 7 天就放弃过滤」的守卫(当年担心 mtime 兜底不可信),
* 结果插件刚装上服务器时自动退化成「不过滤」,把上线前几年的历史封禁全播了出来
* —— 这正是用户看到的「一个都没封却公示一堆人」。
* 现改为无条件采用 creationTime:只有文件系统根本给不出创建时间(返回 0 /
* 抛异常)才退化为不过滤,宁可少播也不误播历史数据。
*/
private long resolveStartCutoff() {
if (!startCutoffAuto) return startCutoffMs;
if (autoCutoffMs < 0) {
autoCutoffMs = 0;
long folderCreated = 0;
try {
File df = plugin.getDataFolder();
if (df != null && df.exists()) {
long t = java.nio.file.Files.readAttributes(df.toPath(),
java.nio.file.attribute.BasicFileAttributes.class)
.creationTime().toMillis();
if (t > 0) folderCreated = t;
}
} catch (Exception e) {
warn("读取插件数据目录创建时间失败,本次不过滤历史封禁: "
+ e.getMessage());
}
if (folderCreated > 0) {
long cap = System.currentTimeMillis() - MAX_AUTO_CUTOFF_WINDOW_MS;
autoCutoffMs = Math.min(folderCreated, cap);
if (folderCreated > cap) {
warn("插件数据目录创建时间 " + fmtCutoff(folderCreated)
+ " 晚于180天前(目录可能被重建/迁移),"
+ "自动起点回退为 " + fmtCutoff(cap)
+ ",近半年内的生效封禁照常播报"
+ "(可在 ban/规则配置.md 手动填「播报起点」精确控制)");
}
}
if (autoCutoffMs <= 0) {
warn("取不到插件数据目录创建时间,历史封禁过滤已关闭"
+ "(可在 ban/规则配置.md 里手动填「播报起点」锁定时间)");
}
}
return autoCutoffMs;
}
/** 28批:自动播报起点最多回溯 180 天(约半年)——数据目录重建时起点不得过新 */
private static final long MAX_AUTO_CUTOFF_WINDOW_MS = 180L * 24 * 3600 * 1000;
private static String fmtCutoff(long ms) {
return new SimpleDateFormat("yyyy-MM-dd").format(new Date(ms));
}
// ==================== 调度 ====================
/** 排下一次窗口期:随机 [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);
lastDelaySec = delaySec;
}
long delayTicks = Math.max(20L, delaySec * 20L);
timerRunning = true;
new BukkitRunnable() {
@Override
public void run() {
timerRunning = false;
if (!plugin.isEnabled()) return;
// ★ 服务器没人就停机:全员下线是定时器的两个停止条件之一(另一个是关服),
// 不播报、不重载、不自愈、不打任何日志,直接停排程 ——
// 第一个玩家上线时 onPlayerJoin 重新拉起(配置为关闭同样只静默本轮)
if (Bukkit.getOnlinePlayers().isEmpty()) {
stoppedByEmpty = true;
return;
}
// ★ 懒重载:每次播报窗口重读配置 —— 开关决定这次报/不报,
// 最新间隔用于播报完后的下次排程计时;顺带自愈补发丢失的配置文件
ensureConfigExists();
reloadConfig();
if (mode != 0) {
try { broadcastOnce(); }
catch (Exception e) { warn("播报异常: " + e.getMessage()); }
}
scheduleNext();
}
}.runTaskLater(plugin, delayTicks);
}
// ==================== 播报 ====================
/**
* ★ 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 {
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("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;
}
/**
* ★ 第30批:黑名单数据源(群控黑名单 = 软折磨,与封禁公示并列)。
* 复用 JsonBan 结构,source 标 "黑名单",理由取黑名单条目的理由。
*/
private List<JsonBan> collectBlacklistBans() {
List<JsonBan> out = new ArrayList<>();
BlacklistManager bl = plugin.getBlacklist();
if (bl == null) return out;
try {
for (BlacklistManager.BlackEntry e : bl.getActiveEntries()) {
if (e == null || e.name == null || e.name.isEmpty()) continue;
out.add(new JsonBan(e.name,
e.reason != null && !e.reason.isEmpty()
? e.reason : "违反服务器规则",
"黑名单",
e.createdMs > 0 ? e.createdMs : System.currentTimeMillis(),
e.expiresMs));
}
} catch (Exception ex) {
warn("读取黑名单失败: " + ex.getMessage());
}
return out;
}
/** 轮播当前页并广播给全服 */
private void broadcastOnce() {
List<JsonBan> bans = collectBans();
// ★ 第30批:并入黑名单玩家(独立数据源,不受 banned-players.json 是否存在影响)
int blackCount = 0;
try {
List<JsonBan> bl = collectBlacklistBans();
blackCount = bl.size();
bans.addAll(bl);
} catch (Exception ignored) {}
if (bans.isEmpty()) { log("当前无生效封禁,跳过本轮"); return; }
// 由近到远:最新封禁在前;由远到近:最早封禁在前
// ★ 第33批(#213):黑名单条目一律置顶(source="黑名单"),
// 防止其被分页轮播淹没 —— 短时拉黑等不到自己那一页。
bans.sort((a, b) -> {
boolean ab = "黑名单".equals(a.source);
boolean bb = "黑名单".equals(b.source);
if (ab != bb) return ab ? -1 : 1;
return 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<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 (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 + " 条封禁"
+ "(含黑名单 " + blackCount + " 条)"
+ "(本次随机间隔 " + lastDelaySec + "s,区间 "
+ minInterval + "~" + maxInterval + "s;banned-players.json 原始 "
+ lastFileRawCount + " 条,过滤后 " + lastFileKeptCount + " 条)");
}
/** 轮播游标(下一页起点) */
private int cursor = 0;
// ==================== 管理员命令 ====================
/** 手动重载配置并汇报当前生效值 */
public void report(CommandSender to) {
reloadConfig();
long cut = resolveStartCutoff();
to.sendMessage("§a[封禁播报] §7模式=§f" + modeName()
+ " §7间隔=§f" + minInterval + "~" + maxInterval + "s"
+ " §7每页=§f" + pageSize + "条"
+ " §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() {
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 (IllegalArgumentException e) {
// jar 里没有同名模板(如用户自建 cfg.json):原文件已备份并移除,
// 不算失败 —— 调用方会继续回退 md / 出厂默认
warn("jar 内无模板资源 ban/" + 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); }
}
@@ -0,0 +1,769 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.ParsePosition;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* ★ 第30批:群控黑名单(软折磨)。
*
* <p>数据源:{@code plugins/Sdf1_login/black/黑名单.md} 的「配置区域」表格。
*
* <p>拉黑时长口径:空 / 0 / 终身 / 永久 = 永久;
* {@code yyyy-MM-dd HH:mm} = 到期即该时刻;{@code yyyy-MM-dd} = 当天 23:59:59。
*
* <p>生效规则:每 5 秒比对文件 mtime+size 做增量重载。
* 新增条目 → 立即生效(备份并清空背包 + 冻结债券账户 + 撤销领地管理员身份);
* 删除条目 → 立即恢复(解冻债券 + 从最新自动备份恢复背包)。
*
* <p>指令:{@code /addblack <玩家> [时长] [理由]}、{@code /removeblack <玩家>}。
*/
public class BlacklistManager {
private final Main plugin;
/** 黑名单配置文件:plugins/Sdf1_login/black/黑名单.md */
private File mdFile;
/** 配置区域起止标记 */
private static final String ZONE_START = "### ----配置区域开始----";
private static final String ZONE_END = "### ----配置区域结束";
/** 内存黑名单:玩家名 → 条目 */
private final Map<String, BlackEntry> entries = new LinkedHashMap<>();
/** 已施加处罚的玩家 —— 防重复应用(备份/清空只做一次) */
private final Set<String> applied = ConcurrentHashMap.newKeySet();
/** 上次读取的文件签名(mtime + size),用于增量重载判定 */
private long lastMtime = -1L;
private long lastSize = -1L;
/** 写回 md 时使用的行尾风格(true=CRLF) */
private boolean crlfStyle = true;
public BlacklistManager(Main plugin) {
this.plugin = plugin;
}
// ==================== 条目模型 ====================
/** 黑名单条目 */
public static final class BlackEntry {
/** 玩家名(原始拼写,写回 md 用) */
public final String name;
/** 拉黑时长原文 */
public final String timeText;
/** 理由 */
public final String reason;
/** 到期毫秒;0 = 永久 */
public final long expiresMs;
/** 记录时间毫秒 */
public final long createdMs;
BlackEntry(String name, String timeText, String reason,
long expiresMs, long createdMs) {
this.name = name;
this.timeText = timeText;
this.reason = reason;
this.expiresMs = expiresMs;
this.createdMs = createdMs;
}
public boolean isExpired(long nowMs) {
return expiresMs > 0L && expiresMs <= nowMs;
}
}
// ==================== 生命周期 ====================
/** 初始化:定位/释放配置文件 → 首次加载 → 启动 5 秒增量重载 */
public void init() {
try {
File dir = new File(plugin.getDataFolder(), "black");
if (!dir.exists()) dir.mkdirs();
mdFile = new File(dir, "黑名单.md");
if (!mdFile.exists()) {
try (InputStream in = plugin.getResource("black/黑名单.md")) {
if (in != null) {
Files.copy(in, mdFile.toPath());
}
} catch (IOException e) {
plugin.getLogger().warning("[黑名单] 模板释放失败: " + e.getMessage());
}
}
} catch (Exception e) {
plugin.getLogger().warning("[黑名单] 初始化失败: " + e.getMessage());
return;
}
reload();
// ★ 每 5 秒增量重载(mtime+size 变化才重新解析)
Bukkit.getScheduler().runTaskTimer(plugin, () -> {
try {
if (mdFile == null || !mdFile.exists()) return;
long mt = mdFile.lastModified();
long sz = mdFile.length();
if (mt != lastMtime || sz != lastSize) {
reload();
}
// ★ 第33批(#213):文件没动也要扫 wall-clock 到期
// (原实现只比 mtime+size,时长拉黑永远不自动解除)
sweepExpired();
} catch (Exception ignored) {}
}, 100L, 100L);
}
public File getMdFile() {
return mdFile;
}
// ==================== 查询 ====================
/**
* 该玩家是否在生效中的黑名单里。
* <p>★ 精确匹配(本服离线模式,大小写不同 = 不同玩家)。
*/
public boolean isBlacklisted(String playerName) {
if (playerName == null || playerName.isEmpty()) return false;
return findEntry(playerName) != null;
}
/** 精确查条目(过期返回 null) */
public BlackEntry findEntry(String playerName) {
if (playerName == null || playerName.isEmpty()) return null;
BlackEntry e = entries.get(playerName);
if (e == null) return null;
if (e.isExpired(System.currentTimeMillis())) {
entries.remove(playerName);
return null;
}
return e;
}
/** 归一化查询(大小写不敏感兜底,仅用于 md 写回定位) */
public BlackEntry findEntryLoose(String playerName) {
if (playerName == null) return null;
BlackEntry exact = findEntry(playerName);
if (exact != null) return exact;
String want = AreaProtection.normName(playerName);
for (BlackEntry e : new ArrayList<>(entries.values())) {
if (AreaProtection.normName(e.name).equals(want)) return e;
}
return null;
}
/** 当前生效中的黑名单条目快照(供耻辱台播报) */
public List<BlackEntry> getActiveEntries() {
long now = System.currentTimeMillis();
List<BlackEntry> out = new ArrayList<>();
for (BlackEntry e : new ArrayList<>(entries.values())) {
if (e.isExpired(now)) {
entries.remove(e.name);
continue;
}
out.add(e);
}
return out;
}
public int size() {
return getActiveEntries().size();
}
// ==================== 增删(写回 md) ====================
/**
* 添加黑名单条目并写回 md。
*
* @param timeText 拉黑时长(空/0/终身=永久,日期或日期+时间=到期)
* @param reason 理由(可空)
* @return null=成功,否则为错误原因
*/
public String addBlacklist(String playerName, String timeText, String reason) {
if (playerName == null || playerName.trim().isEmpty()) {
return "玩家名不能为空";
}
String name = playerName.trim();
if (mdFile == null) return "黑名单文件未初始化";
List<String> lines = readLines();
if (lines == null) return "读取黑名单文件失败";
// 定位配置区域 + 表头
int[] zone = locateZone(lines);
if (zone == null) return "黑名单文件缺少配置区域标记";
int zoneStart = zone[0], zoneEnd = zone[1];
int headerIdx = locateHeader(lines, zoneStart, zoneEnd);
if (headerIdx < 0) return "黑名单文件缺少表头(| 玩家名字 | 拉黑时长 | 理由 |)";
// 去掉同名旧行(大小写不敏感)
String want = AreaProtection.normName(name);
List<String> cleaned = new ArrayList<>();
for (int i = 0; i < lines.size(); i++) {
if (i > zoneStart && i < zoneEnd && isRowOf(lines.get(i), want)) {
continue;
}
cleaned.add(lines.get(i));
}
lines = cleaned;
// 行数变了 → 重新定位
zone = locateZone(lines);
if (zone == null) return "黑名单文件缺少配置区域标记";
zoneStart = zone[0];
zoneEnd = zone[1];
headerIdx = locateHeader(lines, zoneStart, zoneEnd);
if (headerIdx < 0) return "黑名单文件缺少表头";
// 插入点 = 表头 + 分隔行之后
int insertAt = headerIdx + 1;
if (insertAt < zoneEnd && isSeparatorRow(lines.get(insertAt))) {
insertAt++;
}
String safeTime = (timeText == null || timeText.trim().isEmpty()) ? "终身" : timeText.trim();
String safeReason = (reason == null || reason.trim().isEmpty()) ? "违反服务器规则" : reason.trim();
// 理由里的竖线会破坏表格 → 替换
safeReason = safeReason.replace("|", "/").replace("\r", " ").replace("\n", " ");
lines.add(insertAt, "| " + name + " | " + safeTime + " | " + safeReason + " |");
if (!writeLines(lines)) return "写回黑名单文件失败";
reload();
return null;
}
/**
* 移除黑名单条目并写回 md。
*
* @return null=成功(或本来就没有),否则为错误原因
*/
public String removeBlacklist(String playerName) {
if (playerName == null || playerName.trim().isEmpty()) {
return "玩家名不能为空";
}
if (mdFile == null) return "黑名单文件未初始化";
List<String> lines = readLines();
if (lines == null) return "读取黑名单文件失败";
int[] zone = locateZone(lines);
if (zone == null) return "黑名单文件缺少配置区域标记";
int zoneStart = zone[0], zoneEnd = zone[1];
String want = AreaProtection.normName(playerName.trim());
List<String> out = new ArrayList<>();
boolean removed = false;
for (int i = 0; i < lines.size(); i++) {
if (i > zoneStart && i < zoneEnd && isRowOf(lines.get(i), want)) {
removed = true;
continue;
}
out.add(lines.get(i));
}
if (!removed) {
// 文件里没有 → 仍要解冻(可能只冻了账户没进表)
restoreBlacklist(playerName.trim());
return null;
}
if (!writeLines(out)) return "写回黑名单文件失败";
reload();
return null;
}
// ==================== 解析 ====================
/** 重新解析 md 并应用差异(新增→处罚,移除→恢复) */
public synchronized void reload() {
if (mdFile == null) return;
Map<String, BlackEntry> parsed = new LinkedHashMap<>();
long now = System.currentTimeMillis();
List<String> lines = readLines();
if (lines != null) {
int[] zone = locateZone(lines);
if (zone != null) {
int zoneStart = zone[0], zoneEnd = zone[1];
for (int i = zoneStart + 1; i < zoneEnd; i++) {
String line = lines.get(i).trim();
if (!line.startsWith("|")) continue;
if (isSeparatorRow(line)) continue;
String[] cells = splitRow(line);
if (cells.length < 1) continue;
String name = cells[0].trim();
if (name.isEmpty() || name.contains("玩家名字")) continue;
String timeText = cells.length > 1 ? cells[1].trim() : "";
String reason = cells.length > 2 ? cells[2].trim() : "";
long expires = parseExpireMs(timeText, now);
if (expires > 0 && expires <= now) continue; // 已过期
BlackEntry old = parsed.get(name);
long created = old != null ? old.createdMs : now;
parsed.put(name, new BlackEntry(name, timeText, reason, expires, created));
}
}
}
// ===== 差异应用 =====
boolean changed = parsed.size() != entries.size();
// 1) 新增 → 施加处罚
for (BlackEntry e : parsed.values()) {
if (!entries.containsKey(e.name)) {
applyBlacklist(e);
changed = true;
} else {
BlackEntry old = entries.get(e.name);
if (old == null || old.expiresMs != e.expiresMs
|| !old.timeText.equals(e.timeText)
|| !old.reason.equals(e.reason)) {
changed = true; // 条目内容改了(理由/时长)
}
}
}
// 2) 移除 → 恢复
for (BlackEntry e : new ArrayList<>(entries.values())) {
if (!parsed.containsKey(e.name)) {
restoreBlacklist(e.name);
changed = true;
}
}
entries.clear();
entries.putAll(parsed);
if (mdFile.exists()) {
lastMtime = mdFile.lastModified();
lastSize = mdFile.length();
}
plugin.getLogger().info("[黑名单] 已重载,当前生效 " + parsed.size() + " 人");
// ★ 第32批(#208):名单变化 → 立即回响PHP(异步;syncBlacklist 内部 hash 门控兜底)
if (changed) {
try {
WebManager wm = plugin.webManager;
if (wm != null) wm.forceSyncBlacklist();
} catch (Exception ignored) {}
}
}
/**
* ★ 第33批(#213):墙钟到期扫描 —— 时长拉黑自动解除。
* <p>根因:5 秒定时器只比对 mtime+size,md 文件不动就永不 reload,
* 而 reload 里只在「解析时」过滤已过期行 —— 于是绝对日期/相对时长
* 到期后,处罚(applied、债券冻结、md 行)永远挂着 = 用户反馈的
* 「配置时长不生效」。本方法独立于文件变化,每 5 秒按当前时间扫。
* <p>到期条目:内存移除 → 恢复处罚 → 删 md 对应行 → 回响 PHP。
*/
private synchronized void sweepExpired() {
if (entries.isEmpty()) return;
long now = System.currentTimeMillis();
List<BlackEntry> expired = new ArrayList<>();
for (BlackEntry e : new ArrayList<>(entries.values())) {
if (e.isExpired(now)) expired.add(e);
}
if (expired.isEmpty()) return;
List<String> names = new ArrayList<>();
for (BlackEntry e : expired) {
entries.remove(e.name);
names.add(e.name);
plugin.getLogger().info("[黑名单] 拉黑到期自动解除: " + e.name
+ "(" + e.timeText + ")");
restoreBlacklist(e.name);
}
// 同步删 md 里对应行(防止文件签名不变、行永远留着)
try {
List<String> lines = readLines();
if (lines != null) {
int[] zone = locateZone(lines);
if (zone != null) {
List<String> out = new ArrayList<>();
for (int i = 0; i < lines.size(); i++) {
String line = lines.get(i);
boolean drop = false;
if (i > zone[0] && i < zone[1] && line.trim().startsWith("|")
&& !isSeparatorRow(line.trim())) {
for (String n : names) {
if (isRowOf(line, AreaProtection.normName(n))) {
drop = true;
break;
}
}
}
if (!drop) out.add(line);
}
if (out.size() != lines.size() && writeLines(out)) {
// 写回成功 → 立即刷新签名,免得下个 tick 又整表 reload 一遍
if (mdFile.exists()) {
lastMtime = mdFile.lastModified();
lastSize = mdFile.length();
}
}
}
}
} catch (Exception ex) {
plugin.getLogger().warning("[黑名单] 删除到期 md 行失败: " + ex.getMessage());
}
// 名单变了 → 回响 PHP(web_player_blacklist 同步移除)
try {
WebManager wm = plugin.webManager;
if (wm != null) wm.forceSyncBlacklist();
} catch (Exception ignored) {}
}
/**
* 拉黑时长 → 到期毫秒;0 = 永久。
* <p>空 / 0 / 终身 / 永久 / forever → 永久;
* {@code yyyy-MM-dd HH:mm[:ss]} → 该时刻;
* {@code yyyy-MM-dd} → 当天 23:59:59。
*/
public static long parseExpireMs(String timeText, long now) {
if (timeText == null) return 0L;
String t = timeText.trim();
if (t.isEmpty()) return 0L;
if ("0".equals(t) || "终身".equals(t) || "永久".equals(t)
|| "forever".equalsIgnoreCase(t) || "never".equalsIgnoreCase(t)) {
return 0L;
}
String[] pats = {
"yyyy-MM-dd HH:mm:ss",
"yyyy-MM-dd HH:mm",
"yyyy/MM/dd HH:mm:ss",
"yyyy/MM/dd HH:mm"};
for (String p : pats) {
long v = parseStrict(p, t);
if (v > 0L) return v;
}
// 仅日期 → 当天 23:59:59
String[] datePats = {"yyyy-MM-dd", "yyyy/MM/dd"};
for (String p : datePats) {
long v = parseStrict(p, t);
if (v > 0L) {
Calendar c = Calendar.getInstance();
c.setTimeInMillis(v);
c.set(Calendar.HOUR_OF_DAY, 23);
c.set(Calendar.MINUTE, 59);
c.set(Calendar.SECOND, 59);
c.set(Calendar.MILLISECOND, 999);
return c.getTimeInMillis();
}
}
// ★ 第33批(#213):相对时长(3天 / 7d / 2小时 / 30分 …)——
// 「配置了时长 = 临时拉黑,到期自动解除」。交 DurationParser 统一解析。
// 两个护栏:
// ① 只认「数字+单位」形态 —— DurationParser 的纯数字分支=Unix秒时间戳(1970),
// 裸写 "100" 会从原语义「永久」变成「立即过期」,比不识别更危险;
// ② 排除含「年」的中文日期(2026年10月20日)—— 那属绝对日期语义,
// DurationParser 会取当天 00:00 = 配完即过期。
if (t.matches(".*\\d+\\s*(周|天|日|时|分|秒|小时|分钟|星期|礼拜|w|d|h|m|s|W|D|H|M|S).*")
&& !t.contains("年")) {
try {
long v = DurationParser.parseToExpireMs(t);
if (v > 0L) return v;
} catch (Throwable ignored) {
}
}
// 解析不了 → 永久(宁可拉黑也不放过)
return 0L;
}
private static long parseStrict(String pattern, String value) {
try {
SimpleDateFormat sdf = new SimpleDateFormat(pattern, Locale.ROOT);
sdf.setLenient(false);
ParsePosition pos = new ParsePosition(0);
Date d = sdf.parse(value, pos);
if (d != null && pos.getIndex() == value.length()) return d.getTime();
} catch (Exception ignored) {}
return 0L;
}
// ==================== 处罚 / 恢复 ====================
/**
* 施加拉黑处罚:备份并清空背包 + 冻结债券账户 + 撤销领地管理员身份(降级访客)。
*/
public void applyBlacklist(BlackEntry e) {
if (e == null) return;
final String name = e.name;
if (!applied.add(name)) {
return; // 已施加过,不重复备份/清空
}
plugin.getLogger().info("[黑名单] 拉黑生效: " + name
+ "(" + e.timeText + " / " + e.reason + ")");
// ★ 第33批(#213):拉黑当下即时插播一条「拉黑公示」。
// 根因:耻辱台轮播 6 页 × 110~400s 随机间隔,短时拉黑
// (如测试用 30 秒/13 秒)永远轮不到那一页 = 用户反馈
// 「没能进入耻辱台」。插播保证拉黑瞬间全服可见。
try {
SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm");
String when = df.format(new Date(e.createdMs > 0 ? e.createdMs
: System.currentTimeMillis()));
String expStr = e.expiresMs > 0 ? ("§7至§f" + df.format(new Date(e.expiresMs)))
: "§c永久";
Bukkit.broadcastMessage("§c§l⚠ §6拉黑公示 §c☠ §f" + name
+ " §7- " + (e.reason == null || e.reason.isEmpty()
? "违反服务器规则" : e.reason)
+ " §8| §7拉黑于§f" + when + " §8| " + expStr);
} catch (Exception ignored) {}
// 1) 冻结债券账户(按拉黑到期时间;永久=0)
try {
BondManager bm = plugin.getBonds();
if (bm != null) bm.freezeAccount(name, e.expiresMs);
} catch (Exception ex) {
plugin.getLogger().warning("[黑名单] 冻结债券失败 (" + name + "): " + ex.getMessage());
}
// 2) 撤销其在所有领地的管理员身份(降级访客)
try {
AreaProtection ap = plugin.getAreaProtection();
if (ap != null) {
int n = ap.demoteBlacklistAdmins(name);
if (n > 0) {
plugin.getLogger().info("[黑名单] 已撤销 " + name + " 的 " + n + " 个领地管理员身份");
}
}
} catch (Exception ex) {
plugin.getLogger().warning("[黑名单] 撤销领地权限失败 (" + name + "): " + ex.getMessage());
}
// 3) 在线玩家:备份并清空背包
Player p = Bukkit.getPlayerExact(name);
if (p == null || !p.isOnline()) return;
backupAndClear(p, name);
}
/**
* 解除拉黑:解冻债券 + 从最新自动备份恢复背包。
*/
public void restoreBlacklist(String playerName) {
if (playerName == null || playerName.isEmpty()) return;
if (!applied.remove(playerName)) {
// 未必处罚过,但仍确保解冻
try {
BondManager bm = plugin.getBonds();
if (bm != null) bm.unfreezeAccount(playerName);
} catch (Exception ignored) {}
return;
}
plugin.getLogger().info("[黑名单] 拉黑解除: " + playerName);
try {
BondManager bm = plugin.getBonds();
if (bm != null) bm.unfreezeAccount(playerName);
} catch (Exception ex) {
plugin.getLogger().warning("[黑名单] 解冻债券失败 (" + playerName + "): " + ex.getMessage());
}
Player p = Bukkit.getPlayerExact(playerName);
if (p == null || !p.isOnline()) return;
restoreInventory(p, playerName);
}
/**
* 备份并清空背包。
* <p>已有备份时不覆盖(保留拉黑时刻那一份),只清空。
*/
private void backupAndClear(final Player p, final String name) {
boolean hasBackup = false;
try {
hasBackup = plugin.getDb() != null
&& plugin.getDb().getLatestAutoBackup(name) != null;
} catch (Exception ignored) {}
if (!hasBackup) {
final ItemStack[] contents = p.getInventory().getContents();
final ItemStack[] armor = p.getInventory().getArmorContents();
final ItemStack[] extra = p.getInventory().getExtraContents();
final int level = p.getLevel();
final double exp = p.getExp();
final String c64, a64, e64;
try {
c64 = InventorySerializer.toBase64(contents);
a64 = InventorySerializer.toBase64(armor);
e64 = InventorySerializer.toBase64(extra);
} catch (IOException ex) {
plugin.getLogger().warning("[黑名单] 背包序列化失败 (" + name + "): " + ex.getMessage());
return;
}
// ★ 落库是阻塞 DB 写 → 异步;写完再回主线程清空(保证备份的是拉黑前快照)
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDb().saveAutoBackup(name, c64, a64, e64, level, exp);
} catch (Throwable t) {
plugin.getLogger().warning("[黑名单] 背包备份落库失败 (" + name + "): " + t.getMessage());
}
Bukkit.getScheduler().runTask(plugin, () -> {
Player cur = Bukkit.getPlayerExact(name);
if (cur == null || !cur.isOnline()) return;
clearInventory(cur);
cur.sendMessage("§c§l[罪人] §f你已被拉黑,背包已清空(已备份)");
cur.sendMessage("§7背包物品将在解除拉黑后自动返还");
});
});
return;
}
// 已有备份 → 只清空,不覆盖
Bukkit.getScheduler().runTask(plugin, () -> {
Player cur = Bukkit.getPlayerExact(name);
if (cur == null || !cur.isOnline()) return;
clearInventory(cur);
cur.sendMessage("§c§l[罪人] §f你已被拉黑,背包已清空");
});
}
/** 从最新自动备份恢复背包 */
private void restoreInventory(Player p, String name) {
String[] data;
try {
data = plugin.getDb() == null ? null : plugin.getDb().getLatestAutoBackup(name);
} catch (Exception ex) {
plugin.getLogger().warning("[黑名单] 读取背包备份失败 (" + name + "): " + ex.getMessage());
return;
}
if (data == null) {
p.sendMessage("§e§l[罪人] §f拉黑已解除,但没有找到可返还的背包备份");
return;
}
try {
ItemStack[] contents = InventorySerializer.fromBase64(data[0]);
ItemStack[] armor = InventorySerializer.fromBase64(data[1]);
ItemStack[] extra = InventorySerializer.fromBase64(data[2]);
p.getInventory().setContents(contents);
p.getInventory().setArmorContents(armor);
p.getInventory().setExtraContents(extra);
if (data.length > 3) {
try { p.setLevel(Integer.parseInt(data[3])); } catch (Exception ignored) {}
}
if (data.length > 4) {
try { p.setExp((float) Double.parseDouble(data[4])); } catch (Exception ignored) {}
}
p.sendMessage("§a§l[罪人] §f拉黑已解除,背包已返还");
} catch (IOException ex) {
plugin.getLogger().warning("[黑名单] 背包反序列化失败 (" + name + "): " + ex.getMessage());
p.sendMessage("§e§l[罪人] §f拉黑已解除,但背包返还失败,请联系管理员");
}
}
private void clearInventory(Player p) {
p.getInventory().clear();
p.getInventory().setArmorContents(null);
p.getInventory().setExtraContents(null);
p.setLevel(0);
p.setExp(0);
}
// ==================== md 读写工具 ====================
/** 读全部行(去掉行尾 \r) */
private List<String> readLines() {
if (mdFile == null || !mdFile.exists()) return null;
List<String> out = new ArrayList<>();
try {
byte[] raw = Files.readAllBytes(mdFile.toPath());
crlfStyle = new String(raw, StandardCharsets.UTF_8).contains("\r\n");
try (BufferedReader br = new BufferedReader(new InputStreamReader(
new ByteArrayInputStream(raw), StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
out.add(line);
}
}
} catch (IOException e) {
plugin.getLogger().warning("[黑名单] 读取失败: " + e.getMessage());
return null;
}
return out;
}
/** 写回全部行(保持原行尾风格) */
private boolean writeLines(List<String> lines) {
if (mdFile == null) return false;
String sep = crlfStyle ? "\r\n" : "\n";
StringBuilder sb = new StringBuilder();
for (String l : lines) {
sb.append(l).append(sep);
}
try {
File parent = mdFile.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
Files.write(mdFile.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8));
return true;
} catch (IOException e) {
plugin.getLogger().warning("[黑名单] 写入失败: " + e.getMessage());
return false;
}
}
/** 定位配置区域,返回 {起始行, 结束行};找不到返回 null */
private int[] locateZone(List<String> lines) {
int zoneStart = -1, zoneEnd = -1;
for (int i = 0; i < lines.size(); i++) {
String t = lines.get(i).trim();
if (t.startsWith(ZONE_START)) {
zoneStart = i;
} else if (t.startsWith(ZONE_END) && zoneStart >= 0) {
zoneEnd = i;
break;
}
}
if (zoneStart < 0 || zoneEnd < 0) return null;
return new int[]{zoneStart, zoneEnd};
}
/** 定位表头行索引;找不到返回 -1 */
private int locateHeader(List<String> lines, int zoneStart, int zoneEnd) {
for (int i = zoneStart + 1; i < zoneEnd; i++) {
String t = lines.get(i).trim();
if (t.startsWith("|") && t.contains("玩家名字")) {
return i;
}
}
return -1;
}
/** 表格行是否属于该玩家(第一列匹配,大小写不敏感) */
private boolean isRowOf(String line, String wantNorm) {
String[] cells = splitRow(line.trim());
if (cells.length < 1) return false;
String c0 = cells[0].trim();
if (c0.isEmpty() || c0.contains("玩家名字")) return false;
return AreaProtection.normName(c0).equals(wantNorm);
}
/** 分隔行判断(| --- | --- |) */
private boolean isSeparatorRow(String line) {
String t = line.trim();
if (!t.startsWith("|")) return false;
String[] cells = splitRow(t);
for (String c : cells) {
String v = c.trim();
if (!v.isEmpty() && !v.startsWith("---") && !v.matches("[-:]+")) return false;
}
return true;
}
/** 拆表格行(去掉首尾竖线,按 | 切分) */
private String[] splitRow(String line) {
String t = line.trim();
if (t.startsWith("|")) t = t.substring(1);
if (t.endsWith("|")) t = t.substring(0, t.length() - 1);
return t.split("\\|", -1);
}
}
+10
View File
@@ -53,6 +53,16 @@ public class BondManager {
initDB();
}
/**
* ★ 第十批(2026-10-12):暴露 bond.db 连接。
* bond_transaction 在 bond.db(财务债券/),不在 login.db——
* 跨库查询必须走这里(CommissionManager 日上限/翻倍统计、
* InviteManager 存量绑定时间回填均依赖)。
*/
public Connection getConnection() {
return db;
}
// ======================== 数据库初始化 ========================
private void initDB() {
+6
View File
@@ -57,6 +57,12 @@ public class CDKManager {
*/
public String redeem(String code, String player) {
try {
// ★ 第32批:拉黑检查提到查码之前 —— 被拉黑玩家一律拒绝,
// 不管码在本地是否存在,绝不放行进远程验证链
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(player))
return "fail:blacklisted";
PreparedStatement ps = db.prepareStatement(
"SELECT amount,type,used FROM cdk "
+ "WHERE code=?");
+154 -29
View File
@@ -205,14 +205,13 @@ public class ChatFilterManager {
return VerificationResult.PENDING;
}
// 检查玩家加入时间是否≤1小时
if (isNewPlayerUnder1Hour(p)) {
return VerificationResult.NEED_VERIFICATION;
}
// 老玩家:直接永久验证通过
verifiedPlayers.add(name);
return VerificationResult.VERIFIED;
// ★ 2026-10-11 用户裁定(任务6 严重bug修复):
// 人机验证豁免必须是【内存态】——服务器重启后,所有人都要重新过一次人机验证。
// 旧实现按 register_time(DB 持久化字段)判定「老玩家直接永久验证通过」,
// 等于把豁免做成了持久化存储,重启也形同虚设,已废除。
// 唯一免验证通道:正版 OAuth 验证通过(上方分支),仅本次运行期内有效,
// 重启后随内存一并清空;再次放行请重新走 /mslogin 一键通过。
return VerificationResult.NEED_VERIFICATION;
}
/** 全局共享的Random实例,避免频繁创建产生相同序列 */
@@ -632,14 +631,17 @@ public class ChatFilterManager {
DatabaseManager db = mainPlugin.getDb();
if (db == null) return false; // 查不到就不拦截,直接放行
// register_time 在数据库存的是秒级时间戳
// ★ 第十一批:register_time 秒/毫秒混存(PHP 同步行为秒、
// 本地注册为毫秒)→ 统一归一化为毫秒。
// 旧代码无条件 ×1000:对已是毫秒的行会算出巨大负数
// elapsed,恒判「新玩家」。
Object regTimeObj = db.getField(p.getName(), "register_time");
if (regTimeObj == null) return false; // 没注册记录 → 放行
long regTimeSeconds = ((Number) regTimeObj).longValue();
if (regTimeSeconds == 0) return false; // 注册时间为0 → 放行
long regTimeMs = regTimeSeconds * 1000;
long regTimeMs = DurationParser.toMs(
((Number) regTimeObj).longValue());
if (regTimeMs == 0) return false; // 注册时间为0 → 放行
long elapsed = System.currentTimeMillis() - regTimeMs;
boolean isNew = elapsed < 3600000;
@@ -679,18 +681,20 @@ public class ChatFilterManager {
+ "[_.][\\p{L}]{2,}");
private static final Pattern DOT_VARIANTS =
Pattern.compile(
"[\uff0e\u3002\u2025\u2026\u00b7\uff65\ufe52]");
"[.。‥…·・﹒]");
/** 零宽/不可见字符:变形URL归一前剔除(如 baidu.<零宽空格>com) */
private static final Pattern INVISIBLE =
Pattern.compile(
"[\u200b\u200c\u200d\u2060\ufeff\u00ad]");
"[" + UnicodeChars.INVISIBLE_CHARS + "]");
/** 点周带空白的变形域名:baidu . com(2026-10-08 加强拦截) */
private static final Pattern SPACED_DOT_URL =
Pattern.compile(
"(?i)(?:[\\p{L}0-9](?:[\\p{L}0-9\\-]*[\\p{L}0-9])?[\\s\\u3000]*\\.)+[\\s\\u3000]*[\\p{L}]{2,}");
"(?i)(?:[\\p{L}0-9](?:[\\p{L}0-9\\-]*[\\p{L}0-9])?[\\s"
+ UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]*\\.)+[\\s"
+ UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]*[\\p{L}]{2,}");
private static final Pattern COLOR_CODES =
Pattern.compile(
"\u00a7[0-9a-fk-orA-FK-OR]");
"§[0-9a-fk-orA-FK-OR]");
private static final Set<String> FILE_EXT;
static {
@@ -745,7 +749,7 @@ public class ChatFilterManager {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
if (line.startsWith(UnicodeChars.BOM_TEXT))
line = line.substring(1);
String t = line.trim();
@@ -1036,7 +1040,19 @@ public class ChatFilterManager {
"[\\s.,;!?,。;!?、)》」』】"
+ "\\]\\[\\((]+$",
"");
if (!url.isEmpty()) urls.add(url);
if (url.isEmpty()) continue;
// ★ 28批修复(用户口径「接入illegal_domains.txt,命中的中英文后缀一律拦截」):
// 显式协议URL(含 ://,如 http://1.2.3.4/x、https://a.com)无条件收录;
// 裸域名/下划线 token 必须命中非法域名后缀(含中文后缀:中国/网络/公司…
// 与英文后缀 com/cn/net…,均来自 illegal_domains.txt)才收录。
// —— 修「今天.天气真好啊」被 URL_PATTERN 点分隔支整句匹配为域名而误伤。
if (!url.contains("://")) {
String probe = url;
int slash = probe.indexOf('/');
if (slash > 0) probe = probe.substring(0, slash);
if (!containsIllegalDomain(probe)) continue;
}
if (!urls.contains(url)) urls.add(url);
}
// ★ 变形URL加强(2026-10-08):点周带空白(baidu . com)压缩空白后再判定;
// 仅当顶级域命中「非法域名后缀」才收录,避免误伤 "OK. Good" 之类正常英文句子
@@ -1044,8 +1060,8 @@ public class ChatFilterManager {
while (m2.find()) {
String token = m2.group();
if (token.indexOf(' ') < 0 && token.indexOf('\t') < 0
&& token.indexOf('\u3000') < 0) continue; // 无空白 = 常规域名,上面已提取
String compact = token.replaceAll("[\\s\u3000]+", "").toLowerCase();
&& token.indexOf(UnicodeChars.IDEOGRAPHIC_SPACE) < 0) continue; // 无空白 = 常规域名,上面已提取
String compact = token.replaceAll("[\\s" + UnicodeChars.IDEOGRAPHIC_SPACE + "]+", "").toLowerCase();
int dot = compact.lastIndexOf('.');
if (dot < 0) continue;
if (!illegalDomainSuffixes.contains(compact.substring(dot + 1))) continue;
@@ -1088,17 +1104,126 @@ public class ChatFilterManager {
/**
* 判断URL是否应被拦截
* 下划线分隔的字符串(如 player_name),必须命中非法域名后缀才拦截
* 点分隔或标准URL正常拦截
* ★ 28批修复(用户口径):接入 illegal_domains.txt,命中的中英文后缀一律拦截,其余放行。
* - 白名单优先放行;
* - 显式协议URL(含 ://,如 http://a.com、http://1.2.3.4/x)非白名单一律拦截
* (IP直链无后缀可查,保持反广告强度);
* - 裸域名/下划线 token(如 xxx.com、xxx_com、今天.天气真好啊):
* 必须命中非法域名后缀(英文 com/cn/net… 或中文 中国/网络/公司…)才拦截 —— 修误伤。
*/
public boolean shouldBlockUrl(String url) {
if (isWhitelisted(url)) return false;
// 下划线分隔 → 检查是否非法域名,不在列表则跳过(合法玩家名下划线误匹配)
if (url.contains("_")) {
return containsIllegalDomain(url);
// 显式协议URL → 非白名单一律拦截(原行为保持)
if (url.contains("://")) {
return true;
}
// 点分隔或标准URL正常拦截
return true;
// 裸域名/下划线token → 必须命中非法域名后缀才拦截
String probe = url;
int slash = probe.indexOf('/');
if (slash > 0) probe = probe.substring(0, slash);
return containsIllegalDomain(probe);
}
// ==================== 35批修订:IPv4/IPv6 地址检测(加白豁免,其余无条件拦) ====================
/** IPv4:四段 0-255,前后用非数字边界包住("1.21.4" 三段版本号不匹配,"1.2.3.4.5" 不匹配) */
private static final Pattern IPV4_PATTERN = Pattern.compile(
"(?<![0-9.])(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}(?![0-9]|\\.\\d)");
/** IPv6 候选:>=1 个冒号的十六进制/冒号/点串(粗取后 isRealIpv6 严格校验,防 12:34:56 时间误伤) */
private static final Pattern IPV6_CAND = Pattern.compile(
"(?i)(?:[0-9a-f]{0,4}:){1,}[0-9a-f:.]*");
/**
* 提取消息里全部 IPv4/IPv6 地址(严格校验、按出现顺序去重)。
* IPv4 按 0-255 校验;IPv6 先粗取候选再去尾点后校验组数/::唯一性/组格式,
* 时间 "12:34:56"、三段冒号串不会误伤;句末句号("…cbd5.")不吞。
*/
public java.util.List<String> extractIpAddresses(String msg) {
java.util.List<String> out = new ArrayList<>();
if (msg == null || msg.isEmpty()) return out;
Matcher m4 = IPV4_PATTERN.matcher(msg);
while (m4.find()) {
String ip = m4.group();
if (!out.contains(ip)) out.add(ip);
}
Matcher m6 = IPV6_CAND.matcher(msg);
while (m6.find()) {
String cand = m6.group();
// 去尾部句点(英文句号紧跟 IP 时不能因此漏判)
int end = cand.length();
while (end > 0 && cand.charAt(end - 1) == '.') end--;
cand = cand.substring(0, end);
if (cand.isEmpty() || !isRealIpv6(cand)) continue;
if (!out.contains(cand)) out.add(cand);
}
return out;
}
/**
* 消息是否包含「未加白」的 IPv4/IPv6(35批修订口径,用户原话:
* 「对加白的ip和玩家不受影响,其它玩家ipv6、ipv4全部无条件拦下来」)。
* 加白 IP = chat.txt「白名单」节条目(与域名共用同一列表,支持完整 IP 与 *.x.x.x 后缀);
* 消息里的 IP 全部加白才放行,出现任何一个未加白 IP 即返回 true。
* 加白玩家在 onChat 更早的「白名单玩家」早退里豁免,走不到 IP 检测。
*/
public boolean containsNonWhitelistedIp(String msg) {
for (String ip : extractIpAddresses(msg)) {
if (!isWhitelistedIp(ip)) return true;
}
return false;
}
/**
* 加白 IP 判定。不复用 isWhitelisted:它按第一个冒号剥「端口」,
* 对 IPv6(2408:8352::1)会截断成 "2408" 误判未加白 —— 这里不剥端口,
* 提取出来的 IP 本就不含端口;条目支持完整等值与 *.段 后缀通配。
*/
private boolean isWhitelistedIp(String ip) {
if (ip == null || ip.isEmpty()) return false;
String clean = ip.trim().toLowerCase(java.util.Locale.ROOT);
for (String entry : whitelistUrls) {
String e = entry.trim().toLowerCase(java.util.Locale.ROOT);
if (e.isEmpty()) continue;
if (e.startsWith("*.")) {
if (clean.endsWith(e.substring(1))) return true;
} else if (clean.equals(e)) {
return true;
}
}
return false;
}
/** 候选串是否为合法 IPv6(:: 压缩、末段 IPv4-mapped、%scope 尾巴) */
private static boolean isRealIpv6(String t) {
if (t == null || t.isEmpty()) return false;
String s = t;
int pct = s.indexOf('%');
if (pct >= 0) s = s.substring(0, pct); // fe80::1%eth0
int slash = s.indexOf('/');
if (slash >= 0) s = s.substring(0, slash); // CIDR 尾巴
if (s.indexOf(':') < 0) return false;
int dc = s.indexOf("::");
if (dc >= 0 && s.indexOf("::", dc + 1) >= 0) return false; // 两个 :: 非法
String body = dc >= 0
? s.substring(0, dc) + ":" + s.substring(dc + 2) : s;
int groups = 0;
for (String part : body.split(":", -1)) {
if (part.isEmpty()) continue;
if (part.indexOf('.') >= 0) {
// 末段 IPv4 形态(::ffff:192.168.1.1)→ 按 IPv4 校验,计 2 组
if (!part.matches("(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)"
+ "(?:\\.(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}")) return false;
groups += 2;
} else {
if (!part.matches("[0-9a-fA-F]{1,4}")) return false;
groups++;
}
if (groups > 8) return false;
}
if (dc >= 0) return groups < 8; // :: 至少压缩 1 组 -> 显式组 <=7
return groups == 8; // 无 :: 必须恰好 8 组
}
public boolean isLikelyDomain(String url) {
+55 -7
View File
@@ -15,6 +15,7 @@ public class ChatInputManager {
SET_EMAIL,
SET_EMAIL_VERIFY,
ADMIN_AUTH,
SH_ADMIN_AUTH, // ★ 问题2:二手管理面板密码(ShopManager.onPublishChat 消费)
ADMIN_SET_POINTS,
ADMIN_SET_PWD,
ADMIN_DELETE_CONFIRM,
@@ -639,6 +640,18 @@ public class ChatInputManager {
reset(p);
return;
}
// ★ 第八批(2026-10-12):邀请码一次性——已绑定过邀请人的
// 禁止重复使用(invited_by 持久落库,重启后依然拦截)
Object bound = db.getField(
myName, "invited_by");
if (bound != null
&& !bound.toString().isEmpty()) {
p.sendMessage(
"§c你已绑定过邀请人(" + bound
+ "),邀请码仅能使用一次");
reset(p);
return;
}
boolean found = false;
for (Map<String, Object> u :
db.getAllUsers()) {
@@ -650,17 +663,52 @@ public class ChatInputManager {
u.get("player_name");
db.setField(myName,
"invited_by", inv);
db.addPoints(inv, 50);
p.sendMessage(
"§a邀请成功!邀请人: "
+ inv);
// ★ 第九批:绑定时间落库(供邀请GUI显示
// 「邀请成功时间+邀请人」)
db.setField(myName, "invited_at",
System.currentTimeMillis());
// ★ 第九批(配置懒更新):玩家绑定邀请码时
// 自动重载 规则.md,实时刷新奖励信息
// (不再依赖启动加载或定时热重载)
mainPlugin.getCommission().loadRules();
// ★ 第九批:绑定即计入邀请人「本月邀请」
// (修复旧逻辑在签到才计数导致恒 0 人)
mainPlugin.getInvite().onInviteeBind(
inv, myName);
// ★ 第八批(2026-10-12):绑定奖励改按
// 邀请好友赢债券/规则.md 发债券;
// 旧 addPoints(inv,50) 积分随提成规则体系废弃
// ★ 第九批:奖励单向改双向——邀请人 + 被邀请人
int bondGive = mainPlugin
.getCommission()
.payInviteBond(inv, myName);
int bondGet = mainPlugin
.getCommission()
.payInviteeBond(myName, inv);
String bMsg =
"§a邀请成功!邀请人: " + inv;
if (bondGet > 0) {
bMsg += "§7,你获得 §e" + bondGet
+ " §a债券";
}
p.sendMessage(bMsg);
Player ip =
Bukkit.getPlayer(inv);
Bukkit.getPlayerExact(inv);
if (ip != null
&& ip.isOnline())
&& ip.isOnline()) {
String extra = "";
if (bondGive > 0) {
extra = "§7,你获得 §e"
+ bondGive
+ " §a债券";
} else if (bondGive == 0) {
extra = "§7(今日邀请债券已达上限)";
}
ip.sendMessage("§a"
+ myName
+ " 使用了您的邀请码");
+ " 使用了您的邀请码"
+ extra);
}
found = true;
break;
}
File diff suppressed because it is too large Load Diff
+251 -156
View File
@@ -1,18 +1,44 @@
package Sdf1_login;
import org.bukkit.entity.Player;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 邀请好友赢债券奖励(配置:邀请好友赢债券/规则.md)。
*
* <p>★ 2026-10-12 第八批(用户决策):旧「提成规则.txt」体系整体废弃,
* 奖励改由新 md 规则控制,绑定邀请码成功时立即结算债券。</p>
*
* <p>★ 2026-10-12 第九批(用户需求)三点改造:
* ① 解析键改用新 md 4 键(旧「单次邀请奖励上限」→「邀请人单次奖励」),
* 新增「被邀请人单次奖励」「单日邀请翻倍额度」;
* ② 奖励单向改<b>双向</b>——payInviteBond 给邀请人、payInviteeBond 给被邀请人;
* 翻倍额度作用于邀请人(每邀请 X 人,奖金翻 Y 倍);
* ③ 支持<b>懒更新</b>——玩家绑定邀请码时调用 loadRules() 实时重载规则,
* 不再依赖启动时一次性加载或定时热重载。</p>
*/
public class CommissionManager {
private final Main main;
private final Logger logger;
private final List<double[]> rules =
new ArrayList<>();
/** 邀请人单次奖励(债券数,绑定成功立即结算) */
private volatile int perInviteReward = 10;
/** 被邀请人单次奖励(债券数,绑定成功立即结算) */
private volatile int perInviteeReward = 5;
/** 单日邀请奖励上限(债券数,自然日重置,仅针对邀请人) */
private volatile int dailyInviteCap = 100;
/** 单日邀请翻倍额度:每邀请 X 人(>0 生效,0=关闭翻倍) */
private volatile int doubleEveryX = 0;
/** 单日邀请翻倍额度:奖金翻 Y 倍(翻 Y 倍 = 每满 X 人基数加 Y 倍) */
private volatile int doubleByY = 0;
public CommissionManager(Main main) {
this.main = main;
@@ -20,190 +46,259 @@ public class CommissionManager {
loadRules();
}
/**
* 重载规则(★ 第九批:绑定邀请码时懒调用,实时刷新奖励信息)。
*/
public void loadRules() {
rules.clear();
File f = new File(
main.getDataFolder(),
// ★ 代码层面删除旧提成规则.txt(手删会被再次释放,故写文件
// 逻辑已移除,这里负责清理存量文件)
File legacy = new File(main.getDataFolder(),
"提成规则.txt");
if (!f.exists()) {
writeDefault(f);
if (legacy.exists() && legacy.delete()) {
logger.info("[Commission] 已删除旧版 提成规则.txt"
+ "(改由 邀请好友赢债券/规则.md 控制)");
}
try (BufferedReader br =
new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets
.UTF_8))) {
File f = new File(main.getDataFolder(),
"邀请好友赢债券/规则.md");
if (!f.exists()) {
try {
main.saveResource(
"邀请好友赢债券/规则.md", false);
} catch (Exception e) {
logger.warning("[Commission] 规则文件释放失败: "
+ e.getMessage());
}
}
if (!f.exists()) {
logger.warning("[Commission] 规则文件缺失,使用默认值"
+ " 邀请人=" + perInviteReward
+ " 被邀请人=" + perInviteeReward
+ " 单日=" + dailyInviteCap);
return;
}
// ★ 安全交换(2026-10-13):jar 模板多出的配置行补进原文件,
// 说明文本与用户改过的值原样保留(旧菜新菜一起端上桌)
java.util.List<String> inviteTpl =
ConfigManager.readJarResourceLines(
main.getResource("邀请好友赢债券/规则.md"));
if (inviteTpl != null) {
main.getConfig2().safeUpgradeDoc(f, inviteTpl, "邀请债券规则");
}
boolean inCfg = false;
try (BufferedReader br = new BufferedReader(
new InputStreamReader(
new FileInputStream(f),
StandardCharsets.UTF_8))) {
String line;
while ((line = br.readLine()) != null) {
line = line.trim();
if (line.isEmpty()
|| line.startsWith("#"))
String t = line.trim();
if (t.startsWith("##") && t.contains("配置区")
&& t.contains("开始")) {
inCfg = true;
continue;
// 格式: 邀请人数:提成百分比
// 如 "5:15" = 邀请5人提成15%
String[] parts =
line.split(":");
if (parts.length >= 2) {
try {
int count = Integer.parseInt(
parts[0].trim());
double rate = Double
.parseDouble(
parts[1]
.trim()) / 100.0;
rules.add(new double[]{
count, rate});
} catch (Exception ignored) {
}
}
if (t.startsWith("##") && t.contains("配置区")
&& t.contains("结束")) {
break;
}
if (!inCfg || !t.startsWith("|")) continue;
String[] cols = t.split("\\|", -1);
if (cols.length < 4) continue; // | 键 | 解释 | 值 |
String key = cols[1].trim();
String val = cols[cols.length - 2].trim()
.replace("`", "");
// ★ 第九批:适配新 md 4 键
if ("邀请人单次奖励".equals(key)) {
perInviteReward = parseIntSafe(val,
perInviteReward);
} else if ("被邀请人单次奖励".equals(key)) {
perInviteeReward = parseIntSafe(val,
perInviteeReward);
} else if ("单日邀请奖励上限".equals(key)) {
dailyInviteCap = parseIntSafe(val,
dailyInviteCap);
} else if ("单日邀请翻倍额度".equals(key)) {
parseDoubleRule(val);
}
}
// 按人数排序(从大到小)
rules.sort((a, b) ->
Double.compare(b[0], a[0]));
logger.info(
"[Commission] 加载 "
+ rules.size() + " 条提成规则");
logger.info("[Commission] 邀请债券规则: 邀请人="
+ perInviteReward
+ " 被邀请人=" + perInviteeReward
+ " 单日=" + dailyInviteCap
+ " 翻倍=" + (doubleEveryX > 0
? ("每" + doubleEveryX + "人翻"
+ doubleByY + "倍")
: "关闭"));
} catch (Exception e) {
logger.warning(
"[Commission] 加载失败: "
+ e.getMessage());
logger.warning("[Commission] 规则解析失败: "
+ e.getMessage());
}
}
private void writeDefault(File f) {
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
StandardCharsets.UTF_8))) {
pw.println("# 邀请提成规则");
pw.println("# 格式: 邀请人数:提成百分比");
pw.println("# 邀请10人提成20%");
pw.println("10:20");
pw.println("# 邀请5人提成15%");
pw.println("5:15");
pw.println("# 邀请1人提成10%");
pw.println("1:10");
} catch (IOException ignored) {
/** 解析「每邀请X人,奖金翻Y倍」格式 */
private void parseDoubleRule(String v) {
// 默认关闭
doubleEveryX = 0;
doubleByY = 0;
if (v == null) return;
Matcher m = Pattern.compile(
"每邀请\\s*(\\d+)\\s*人\\s*[,,]\\s*奖金翻\\s*(\\d+)\\s*倍")
.matcher(v);
if (m.find()) {
doubleEveryX = parseIntSafe(m.group(1), 0);
doubleByY = parseIntSafe(m.group(2), 0);
} else {
logger.warning("[Commission] 翻倍额度格式无法解析: "
+ v + "(示例:每邀请2人,奖金翻1倍)");
}
}
/**
* 获取邀请人的提成比例
* 根据已邀请人数匹配最高规则
*/
public double getCommissionRate(
String inviter) {
int inviteCount = getInviteCount(
inviter);
for (double[] rule : rules) {
if (inviteCount >= (int) rule[0]) {
return rule[1];
}
}
return 0;
}
/**
* 获取邀请人数
*/
public int getInviteCount(
String inviter) {
private static int parseIntSafe(String s, int def) {
try {
Object val =
main.getDb().getField(
inviter,
"monthly_invite_count");
if (val instanceof Number) {
return ((Number) val).intValue();
}
Object total =
main.getDb().getField(
inviter,
"total_invite_count");
if (total instanceof Number) {
return ((Number) total).intValue();
}
} catch (Exception ignored) {
return (int) Double.parseDouble(s.trim());
} catch (Exception e) {
return def;
}
return 0;
}
/** 邀请人单次奖励(债券) */
public int getPerInviteReward() {
return perInviteReward;
}
/** 被邀请人单次奖励(债券) */
public int getPerInviteeReward() {
return perInviteeReward;
}
/** 单日邀请奖励上限(债券) */
public int getDailyInviteCap() {
return dailyInviteCap;
}
/**
* 发放提成
* 被邀请人获得奖励时,邀请人获得提成
* 邀请绑定成功 → 给<b>邀请人</b>发债券(★ 第九批:含翻倍额度)。
* 规则见 邀请好友赢债券/规则.md:每次按单次上限结算(每满 X 人翻 Y 倍),
* 当日累计不超单日上限。
*
* @param inviter 邀请人(收款方)
* @param invitee 被邀请人(绑定者)
* @return &gt;0 实发债券数;0 = 今日已达上限或规则为0;
* -1 = 异常/冻结未发
*/
public void payCommission(
String invitee,
double originalAmount) {
// 查找邀请人
String inviter = getInviter(invitee);
if (inviter == null
|| inviter.isEmpty()) return;
double rate = getCommissionRate(
inviter);
if (rate <= 0) return;
double commission =
originalAmount * rate;
if (commission <= 0) return;
// Economy已移除,佣金提成功能不可用
// 邀请人可通过债券系统获取奖励
// 通知邀请人
Player inviterP =
main.getServer()
.getPlayer(inviter);
if (inviterP != null
&& inviterP.isOnline()) {
inviterP.sendMessage(
"§a§l[邀请提成] §f"
+ invitee + " 完成任务,"
+ "你获得 §e"
+ String.format("%.2f",
commission)
+ " §f游戏币提成 ("
+ (int)(rate * 100) + "%)");
public int payInviteBond(String inviter, String invitee) {
if (inviter == null || inviter.isEmpty()) return -1;
if (perInviteReward <= 0) return 0;
int used = inviteBondToday(inviter);
int room = dailyInviteCap - used;
if (room <= 0) {
logger.info("[Commission] " + inviter
+ " 今日邀请债券已达上限(" + dailyInviteCap + ")");
return 0;
}
logger.info(
"[Commission] 提成: "
+ inviter + " ← "
+ invitee + " | "
+ String.format("%.2f",
originalAmount)
+ " × "
+ (int)(rate * 100) + "% = "
+ String.format("%.2f",
commission));
// ★ 第九批:单日邀请翻倍额度——每邀请 X 人,奖金翻 Y 倍
// nToday = 今日已发放的邀请奖励笔数(≈已成功邀请人数)
// mult = 1 + (nToday / X) * Y(整数除法,每满 X 人加 Y 倍)
int nToday = inviteBondCountToday(inviter);
int mult = 1;
if (doubleEveryX > 0) {
mult = 1 + (nToday / doubleEveryX) * doubleByY;
if (mult < 1) mult = 1;
}
int base = perInviteReward * mult;
int give = Math.min(base, room);
int after = main.getBonds().addBonds(inviter, give,
"invite_reward", invitee, "system",
"邀请好友绑定奖励(" + invitee + ")");
if (after < 0) return -1; // 冻结期未入账
logger.info("[Commission] 邀请债券: " + inviter + " ← "
+ invitee + " | +" + give + "(基数"
+ perInviteReward + "×" + mult + ",今日累计 "
+ (used + give) + "/" + dailyInviteCap + ")");
return give;
}
/**
* 获取邀请人
* 邀请绑定成功 → 给<b>被邀请人</b>发债券(★ 第九批:双向奖励)。
* 被邀请人奖励为固定单次数额,不受单日上限约束(上限仅针对邀请人)。
*
* @param invitee 被邀请人(绑定者,收款方)
* @param inviter 邀请人(被邀请人的邀请来源)
* @return &gt;0 实发债券数;0 = 规则为0;-1 = 异常/冻结未发
*/
private String getInviter(String player) {
public int payInviteeBond(String invitee, String inviter) {
if (invitee == null || invitee.isEmpty()) return -1;
if (perInviteeReward <= 0) return 0;
// 用独立 type='invitee_reward',避免污染邀请人日上限统计
int after = main.getBonds().addBonds(invitee,
perInviteeReward, "invitee_reward", inviter,
"system",
"被邀请人绑定奖励(邀请人:" + inviter + ")");
if (after < 0) return -1; // 冻结期未入账
logger.info("[Commission] 被邀请人债券: " + invitee
+ " ← " + inviter + " | +" + perInviteeReward);
return perInviteeReward;
}
/** 今日 0 点起该玩家已发放的邀请债券总数(金额) */
private int inviteBondToday(String inviter) {
long dayStart = LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toInstant().toEpochMilli();
try {
Object val =
main.getDb().getField(
player,
"invited_by");
if (val != null
&& !val.toString()
.isEmpty()) {
return val.toString();
}
} catch (Exception ignored) {
PreparedStatement ps = main.getBonds()
.getConnection().prepareStatement(
"SELECT COALESCE(SUM(amount),0) "
+ "FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND type='invite_reward' "
+ "AND time>=?");
ps.setString(1, inviter);
ps.setLong(2, dayStart);
ResultSet rs = ps.executeQuery();
int v = rs.next() ? rs.getInt(1) : 0;
rs.close();
ps.close();
return v;
} catch (Exception e) {
logger.warning("[Commission] 查询当日邀请债券失败: "
+ e.getMessage());
return 0;
}
}
/** 今日 0 点起该玩家已成功邀请人数(发奖笔数,用于翻倍档位) */
private int inviteBondCountToday(String inviter) {
long dayStart = LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toInstant().toEpochMilli();
try {
PreparedStatement ps = main.getBonds()
.getConnection().prepareStatement(
"SELECT COUNT(*) "
+ "FROM bond_transaction "
+ "WHERE player_name=? "
+ "AND type='invite_reward' "
+ "AND time>=?");
ps.setString(1, inviter);
ps.setLong(2, dayStart);
ResultSet rs = ps.executeQuery();
int v = rs.next() ? rs.getInt(1) : 0;
rs.close();
ps.close();
return v;
} catch (Exception e) {
logger.warning("[Commission] 查询当日邀请人数失败: "
+ e.getMessage());
return 0;
}
return null;
}
/**
* 判断金额是否包含经济内容
*/
public boolean hasEconomyContent(
String text) {
public boolean hasEconomyContent(String text) {
String t = text.toLowerCase();
return t.contains("游戏币")
|| t.contains("金币")
File diff suppressed because it is too large Load Diff
+73 -2
View File
@@ -32,6 +32,11 @@ public class DatabaseManager {
+ dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
// ★ 二十二批:busy_timeout —— WAL 下读写仍会与
// db-worker/长事务争锁,无超时会让主线程
// sqlite3_step 无限等待(watchdog 卡点之一),
// 5秒等锁后报 SQLITE_BUSY 快速失败。
st.execute("PRAGMA busy_timeout=5000");
// 用户表
st.execute("CREATE TABLE IF NOT EXISTS "
@@ -51,6 +56,7 @@ public class DatabaseManager {
+ "gift_claimed TEXT DEFAULT '',"
+ "invite_code TEXT DEFAULT '',"
+ "invited_by TEXT DEFAULT '',"
+ "invited_at INTEGER DEFAULT 0,"
+ "monthly_invite_count INTEGER DEFAULT 0,"
+ "invite_month TEXT DEFAULT '',"
+ "points INTEGER DEFAULT 0,"
@@ -92,6 +98,10 @@ public class DatabaseManager {
"TEXT DEFAULT ''");
safeAdd(st, "last_login_ip",
"TEXT DEFAULT ''");
// ★ 第九批:邀请绑定时间(毫秒时间戳),
// 供「我的邀请码」GUI 显示邀请成功时间
safeAdd(st, "invited_at",
"INTEGER DEFAULT 0");
// UI偏好:1=CLI(默认), 0=GUI
safeAdd(st, "ui_mode",
"INTEGER DEFAULT 1");
@@ -391,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,"
@@ -711,6 +721,29 @@ public class DatabaseManager {
}
}
/**
* ★ OP白名单授权校验用(2026-10-11):与 userExistsIgnoreCase 相反的失败语义——
* SQL 异常一律返回 false(保守拒绝)。opwl add/addtime 宁可让运维改 json 兜底,
* 也绝不因 DB 抖动把临时 OP 发给未注册玩家。
*/
public boolean userExistsStrict(String name) {
if (name == null || name.isEmpty()) return false;
try {
PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM users "
+ "WHERE player_name=? "
+ "COLLATE NOCASE");
ps.setString(1, name);
boolean r = ps.executeQuery().next();
ps.close();
return r;
} catch (SQLException e) {
logger.warning("[OP白名单] 注册校验查询失败(拒绝): "
+ e.getMessage());
return false;
}
}
public void createUser(String name, String hash,
String salt) {
try {
@@ -2138,6 +2171,44 @@ public class DatabaseManager {
}
/**
* 获取玩家最新一份自动背包备份(黑名单解除时返还用)。
* @return String[]{contents_b64, armor_b64, extra_b64, level, experience},无备份返回 null
*/
public String[] getLatestAutoBackup(String playerName) {
try {
PreparedStatement ps =
db.prepareStatement(
"SELECT contents_b64, "
+ "armor_b64, "
+ "extra_b64, "
+ "level, "
+ "experience "
+ "FROM inv_backup_auto "
+ "WHERE player_name=? "
+ "ORDER BY save_time DESC "
+ "LIMIT 1");
ps.setString(1, playerName);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String[] result = new String[5];
result[0] = rs.getString("contents_b64");
result[1] = rs.getString("armor_b64");
result[2] = rs.getString("extra_b64");
result[3] = String.valueOf(rs.getInt("level"));
result[4] = String.valueOf(rs.getDouble("experience"));
rs.close();
ps.close();
return result;
}
rs.close();
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return null;
}
/**
* 获取玩家最新背包备份
*/
@@ -2981,7 +3052,7 @@ public class DatabaseManager {
return deleted;
}
// ==================== 背包自动滚动备份(15分钟定时)====================
// ==================== 背包自动滚动备份(每小时 0/15/30/45 分准点定时)====================
/**
* 保存背包自动备份 —— 追加写入,每位玩家仅保留最近 3 份。
+11 -2
View File
@@ -181,7 +181,7 @@ public class DurationParser {
/** 中文日期+时间:2026年10月8日 14:43(“日”已界定日期边界,时间可紧贴) */
private static final Pattern DT_CN = Pattern.compile(
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T\\u3000]+)?"
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T" + UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]+)?"
+ "(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
/** 分隔日期+时间:26-10-08 14:43 / 2026-10-08 14:43 / 2026.10.08T14:43:00
@@ -189,7 +189,7 @@ public class DurationParser {
* 防止 2026-1-8 14:43 被粘连误读成「日=81、时=4」 */
private static final Pattern DT_SEP = Pattern.compile(
"(?<!\\d)(\\d{4}|\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})"
+ "[ T\\u3000]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
+ "[ T" + UnicodeChars.IDEOGRAPHIC_SPACE_TEXT + "]+(\\d{1,2}):(\\d{2})(?::(\\d{2}))?(?!\\d|[A-Za-z])");
/** 两位年份无时间:26-10-08
* lookbehind 防止吃掉 4 位年份的后半段(2026-07-09 不能被读成 07-09) */
@@ -315,6 +315,15 @@ public class DurationParser {
return section + (hasNum ? num : 0);
}
/** ★ 第十一批(2026-10-12):秒/毫秒混合时间戳归一化为毫秒。
* login.db 的 register_time/last_login_time/invited_at 两种单位
* 混存(Java 本地注册写毫秒、PHP 同步拉回写秒),凡显示、比较、
* 回填前一律先过这里。幂等:已是毫秒的原样返回。
* 阈值 10^12:秒级时间戳到 33658 年才到 10^12,安全。 */
public static long toMs(long ts) {
return (ts > 0 && ts < 1000000000000L) ? ts * 1000L : ts;
}
/** 格式化绝对到期时间戳为「yyyy年M月d日 HH:mm」 */
public static String formatExpire(long ms) {
Calendar c = Calendar.getInstance();
+202 -22
View File
@@ -9,6 +9,7 @@ import org.bukkit.event.inventory.*;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.inventory.meta.SkullMeta;
import org.bukkit.scheduler.BukkitRunnable;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.persistence.PersistentDataType;
@@ -43,6 +44,8 @@ public class GUIManager implements Listener {
"§6§l管理员面板";
public static final String T_INVITE =
"§6§l邀请数据";
public static final String T_MONTHLY_INVITE =
"§6§l本月邀请";
public static final String T_USER_MGMT =
"§6§l用户管理";
public static final String T_USER_GROUP =
@@ -396,7 +399,7 @@ public class GUIManager implements Listener {
public void openSubMenu(
Player p, String fileName) {
// ★ 剥离MC颜色代码(§f、§7等),防止污染文件名
fileName = fileName.replaceAll("\u00A7[0-9a-fk-or]", "").trim();
fileName = fileName.replaceAll("§[0-9a-fk-or]", "").trim();
String menuTitle =
"§6§l" + fileName
.replace(".txt", "");
@@ -629,7 +632,7 @@ public class GUIManager implements Listener {
for (String line : block.split("\n")) {
line = line.trim();
String v = "";
int idx = line.indexOf('\uff1a');
int idx = line.indexOf(':');
if (idx < 0)
idx = line.indexOf(':');
if (idx >= 0) {
@@ -807,6 +810,8 @@ public class GUIManager implements Listener {
if ("§a§l购物车".equals(title)
|| "§c§l退款中心".equals(title)
|| "§6§l商店".equals(title)
|| "§6§l二手商店".equals(title)
|| "§6§l我的二手上架".equals(title)
|| title.startsWith("§e§l流水记录")
|| title.startsWith("§e§l转账记录")
|| title.startsWith("§d§l余额操作")) {
@@ -814,6 +819,11 @@ public class GUIManager implements Listener {
return;
}
// ★ 批量上架 GUI(2026-10-11 任务2):允许拖入物品,由 ShopManager 自行按槽位判定
if ("§6§l二手上架".equals(title)) {
return;
}
// 商店分类页:禁止拖拽
if (title.startsWith("§6§l") && !title.equals(T_MAIN)
&& !title.equals(T_MY_INFO)
@@ -970,6 +980,34 @@ public class GUIManager implements Listener {
return;
}
// ===== 二手商店(2026-10-11 /shopbuy)=====
if ("§6§l二手商店".equals(title)) {
event.setCancelled(true);
plugin.getShopManager().handleSecondHandClick(
p, raw, event.isLeftClick());
return;
}
if ("§6§l我的二手上架".equals(title)) {
event.setCancelled(true);
// ★ 2026-10-11 任务1:左键编辑(售价&描述),右键下架(退还物品)
plugin.getShopManager().handleSecondHandMineClick(
p, raw, event.isLeftClick(), event.isRightClick());
return;
}
// ★ 第十二批(2026-10-13):管理员面板(在售左键下架/已下架右键上架)
if ("§6§l二手管理".equals(title)) {
event.setCancelled(true);
plugin.getShopManager().handleSecondHandAdminClick(
p, raw, event.isLeftClick(), event.isRightClick());
return;
}
// ★ 第十二批(2026-10-13):下架理由 9 格面板
if ("§6§l下架理由".equals(title)) {
event.setCancelled(true);
plugin.getShopManager().handleDelistReasonClick(p, raw);
return;
}
// ===== 退款中心 → 重定向订单中心 =====
if ("§c§l退款中心".equals(title)) {
event.setCancelled(true);
@@ -1648,6 +1686,34 @@ public class GUIManager implements Listener {
return;
}
// ★ 第八批(2026-10-12):图标槽接入 SN 系统(此前完全没拦)——
// ① 背包已有自定义菜单物品 → 拒绝重复载体;
// ② 读旧雪球 SN 作换载体复用(登记不动、不触发冷静期);
// ③ 无旧 SN 则先过申领管控(冷静期/1人1个),拦在动背包之前。
SnManager snm = plugin.getSnManager();
for (ItemStack pre : p.getInventory().getContents()) {
if (isCustomMenuTrigger(pre)) {
p.sendMessage("§c[菜单] 你已拥有菜单物品,请先清除当前图标再更换");
return;
}
}
String reuseSn = null;
if (snm != null) {
for (ItemStack pre : p.getInventory().getContents()) {
if (pre != null
&& pre.getType() == Material.SNOWBALL
&& isMenuTriggerItem(pre)) {
reuseSn = snm.readSn(pre);
break;
}
}
}
if (reuseSn == null && snm != null
&& snm.isBlocked(p, SnManager.TYPE_MENU)) {
p.sendMessage("§c[SN] 菜单物品管控中(冷静期或已有有效登记),暂不能更换图标");
return;
}
int amount = item.getAmount();
removeSnowballFromInventory(p); //没收雪球菜单
// 1. 从来源移除
@@ -1700,13 +1766,13 @@ public class GUIManager implements Listener {
oldName = "自定义物品";
}
mm.setDisplayName(
"\u00a7e\u00a7l[菜单] \u00a7f"
"§e§l[菜单] §f"
+ oldName);
List<String> lore = mm.hasLore()
? new ArrayList<>(mm.getLore())
: new ArrayList<>();
lore.add(0,
"\u00a77右键打开主菜单");
"§7右键打开主菜单");
mm.setLore(lore);
mm.getPersistentDataContainer().set(
new org.bukkit.NamespacedKey(
@@ -1718,6 +1784,23 @@ public class GUIManager implements Listener {
menu.setItemMeta(mm);
}
// 4.5 ★ 第八批:自定义菜单物品打 SN 副本——
// 有旧雪球 SN 则转移复用;没有则新申领(入口已过管控,
// 此处失败按 DB 异常放行不打标,与 Main.snTag 同款容错)
String menuSn = reuseSn;
if (menuSn == null && snm != null) {
menuSn = snm.applySnQuiet(p,
SnManager.TYPE_MENU, "自定义菜单图标");
}
if (menuSn != null && snm != null) {
snm.writeSn(menu, menuSn, SnManager.TYPE_MENU);
if (reuseSn != null) {
snm.logSn(menuSn, SnManager.TYPE_MENU, "xfer",
p.getName(),
"菜单载体转换:雪球->自定义图标");
}
}
// 5. 发放菜单物品
int slot = p.getInventory()
.firstEmpty();
@@ -1744,6 +1827,18 @@ public class GUIManager implements Listener {
String dbIcon = plugin.getDb()
.getMenuIcon(p.getName());
// ★ 第八批:没收前先读自定义物品的 SN,发雪球时转移复用
// (登记不动,避免旧登记把新雪球卡在「1人1个」/冷静期)
String reuseSn = null;
if (plugin.getSnManager() != null) {
for (ItemStack it : p.getInventory().getContents()) {
if (isCustomMenuTrigger(it)) {
reuseSn = plugin.getSnManager().readSn(it);
break;
}
}
}
// 1. 没收自定义菜单物品
removeCustomMenuFromInventory(p);
@@ -1754,8 +1849,8 @@ public class GUIManager implements Listener {
// 3. 强制清空slot 31
topInv.setItem(31, null);
// 4. 发雪球
plugin.giveMenuSnowball(p);
// 4. 发雪球(复用旧 SN 转移登记)
plugin.giveMenuSnowball(p, reuseSn);
// 5. 还原件
if (dbIcon != null && !dbIcon.isEmpty()) {
@@ -1782,7 +1877,7 @@ public class GUIManager implements Listener {
List<String> lore = meta.getLore();
if (lore == null) return false;
for (String line : lore) {
if (line.contains("\u00a70\u00a7k")
if (line.contains("§0§k")
|| line.contains("CUSTOM")) {
return true;
}
@@ -1891,7 +1986,7 @@ public class GUIManager implements Listener {
&& im.getLore() != null) {
for (String ln : im.getLore()) {
if (ln.contains(
"\u00a78")) {
"§8")) {
p.getInventory()
.setItem(i,
null);
@@ -1915,7 +2010,7 @@ public class GUIManager implements Listener {
return true;
ItemMeta im = item.getItemMeta();
if (im == null) return true;
if ("\u00a77默认菜单(雪球)"
if ("§7默认菜单(雪球)"
.equals(im.getDisplayName()))
return true;
if (im.hasLore() && im.getLore() != null
@@ -1945,7 +2040,7 @@ public class GUIManager implements Listener {
&& im.hasLore()
&& im.getLore() != null) {
for (String ln : im.getLore()) {
if (ln.contains("\u00a77右键打开主菜单"))
if (ln.contains("§7右键打开主菜单"))
return true;
}
}
@@ -1958,7 +2053,7 @@ public class GUIManager implements Listener {
return true;
ItemMeta im = item.getItemMeta();
if (im == null) return true;
if ("\u00a77默认菜单(雪球)"
if ("§7默认菜单(雪球)"
.equals(im.getDisplayName())) {
return true;
}
@@ -2137,8 +2232,9 @@ public class GUIManager implements Listener {
if (slot >= 45) break;
String accName = (String) acc
.get("player_name");
long regTime = ((Number) acc
.get("register_time")).longValue();
// ★ 第十一批:秒/毫秒混存归一化(PHP 同步行为秒)
long regTime = DurationParser.toMs(((Number) acc
.get("register_time")).longValue());
String dateStr = regTime > 0
? sdf.format(
new java.util.Date(regTime))
@@ -2340,10 +2436,9 @@ public class GUIManager implements Listener {
if (code == null || code.isEmpty()) {
code = plugin.getInvite().generateCode(p);
}
int monthly = ((Number) plugin.getDb()
.getField(p.getName(),
"monthly_invite_count"))
.intValue();
// ★ 第十批:计数改派生口径(invited_at 回填后与头颅明细同源、跨月自愈)
int monthly = plugin.getInvite()
.getMonthlyInviteCount(p.getName());
g.setItem(10, mkItem(Material.PAPER,
"§b§l我的邀请码",
"§7" + code,
@@ -2351,12 +2446,36 @@ public class GUIManager implements Listener {
"§e点击刷新邀请码"));
g.setItem(12, mkItem(Material.PLAYER_HEAD,
"§a本月邀请",
"§7已邀请 §e" + monthly + " §7人"));
g.setItem(16, mkItem(Material.BOOK,
"§e输入邀请码",
"§7绑定他人的邀请码",
"§7已邀请 §e" + monthly + " §7人",
"",
"§e点击输入"));
"§e点击查看邀请明细"));
// ★ 第九批:已绑定邀请人则显示「邀请成功时间+邀请人」,
// 否则显示「输入邀请码」按钮
String invitedBy = (String) plugin.getDb()
.getField(p.getName(), "invited_by");
if (invitedBy != null && !invitedBy.isEmpty()) {
Object tsObj = plugin.getDb().getField(
p.getName(), "invited_at");
long ts = (tsObj instanceof Number)
? ((Number) tsObj).longValue() : 0L;
// ★ 第十一批:秒/毫秒混存归一化(回填来源 register_time 可能为秒)
ts = DurationParser.toMs(ts);
String timeStr = ts > 0
? new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm")
.format(new java.util.Date(ts))
: "未知";
g.setItem(16, mkItem(Material.WRITTEN_BOOK,
"§a邀请已绑定",
"§7邀请人: §e" + invitedBy,
"§7绑定时间: §e" + timeStr));
} else {
g.setItem(16, mkItem(Material.BOOK,
"§e输入邀请码",
"§7绑定他人的邀请码",
"",
"§e点击输入"));
}
String ugLabel = "§7(玩家)";
UserGroupManager.UserGroupConfig ugCfg =
plugin.getUserGroup().getHighestGroup(p.getName());
@@ -2371,6 +2490,67 @@ public class GUIManager implements Listener {
p.openInventory(g);
}
/**
* ★ 第十批(2026-10-12):「本月邀请」明细 GUI——
* 本月绑定我邀请码的玩家,一人一个真实玩家头颅。
* 标题 T_MONTHLY_INVITE 已加入 Main.onInvClick 排除列表,
* 点击全部由该分支自行 cancel(防拿走头颅)。
*/
public void openMonthlyInvite(Player p) {
Inventory g = Bukkit.createInventory(
null, 54, T_MONTHLY_INVITE);
fillBg(g);
List<Map<String, Object>> list = plugin.getInvite()
.getMonthlyInvitedPlayers(p.getName());
if (list.isEmpty()) {
g.setItem(22, mkItem(Material.BARRIER,
"§c本月暂无邀请记录",
"§7邀请好友绑定你的邀请码后",
"§7这里会展示他们的头颅"));
} else {
// 内容区 0-44 共 45 格;超出 45 人时留 1 格提示
int limit = Math.min(list.size(),
list.size() > 45 ? 44 : 45);
for (int i = 0; i < limit; i++) {
Map<String, Object> row = list.get(i);
String name = String.valueOf(row.get("name"));
long at = ((Number) row.get("at")).longValue();
ItemStack head = new ItemStack(
Material.PLAYER_HEAD);
SkullMeta sm = (SkullMeta)
head.getItemMeta();
if (sm != null) {
// 在线优先用精确档案;离线按玩家名解析真实皮肤
Player target =
Bukkit.getPlayerExact(name);
if (target != null) {
sm.setOwningPlayer(target);
} else {
sm.setOwner(name);
}
sm.setDisplayName("§e§l" + name);
sm.setLore(Arrays.asList(
"§7绑定时间: §e"
+ new java.text.SimpleDateFormat(
"yyyy-MM-dd HH:mm")
.format(new java.util.Date(at)),
"",
"§7已计入你的本月邀请"));
head.setItemMeta(sm);
}
g.setItem(i, head);
}
if (list.size() > 45) {
g.setItem(44, mkItem(Material.PAPER,
"§e还有 " + (list.size() - 44)
+ " 人未显示"));
}
}
g.setItem(49, mkItem(Material.ARROW,
"§7返回", "§7返回「邀请数据」"));
p.openInventory(g);
}
private String getInviteRate(int count) {
if (count > 15) return "60%";
+476 -26
View File
@@ -9,6 +9,14 @@ import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.scheduler.BukkitRunnable;
import net.kyori.adventure.bossbar.BossBar;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.io.*;
import java.nio.charset.StandardCharsets;
@@ -17,7 +25,7 @@ import java.sql.Date;
import java.text.SimpleDateFormat;
import java.util.*;
public class GarbageManager {
public class GarbageManager implements Listener {
private final Main plugin;
private Connection db;
@@ -25,9 +33,53 @@ public class GarbageManager {
private int maxRounds = 1;
private boolean enabled = true;
private int currentRound = 0;
/**
* ★ 第42批:自动清理任务引用(BukkitRunnable)。
* 热停用时 cancel 该任务;applyEnabledState() 用它判断是否已在跑。
*/
private BukkitRunnable cleanupTask = null;
/**
* ★ 第36批:上一轮「清扫」进回收站的掉落物数量(血条展示)。
* -1 = 还没跑过任何一轮(血条不显示该字段)。
*/
private int lastCleaned = -1;
/**
* ★ 第36批:上一轮「永久倒掉」的过期物品数量(血条展示)。
* -1 = 还没跑过任何一轮;非倒垃圾轮固定为 0。
*/
private int lastDumped = -1;
private final Map<String, Integer>
recyclePages = new HashMap<>();
/**
* ★ 第31批:本轮开始的墙钟时间戳(毫秒)。
* 血条色段按真实时间判定,与 tick 计数解耦,
* 避免“第3分30秒还是绿色”的漂移。0 = 本轮未开始。
*/
private long roundStartMs = 0L;
/**
* ★ 第34批:已广播的倒计时节点位图。
* 墙钟口径下 remain 可能跨值(如 301→299),原来的 `remain == 300`
* 式判断会漏播;改用「<= 命中一次 + 位图去重」,卡顿也不漏。
* bit0=5分钟 bit1=3分钟 bit2=1分钟 bit3=30秒 bit4=10秒
*/
private int announcedMask = 0;
/**
* ★ 第30批:顶部血条(玩家 UUID → BossBar)
*/
private final Map<UUID, BossBar> bossBars = new HashMap<>();
/**
* ★ 第30批:双击 Q 确认丢弃的首次按键时间(玩家 UUID → 毫秒)
*/
private final Map<UUID, Long> pendingDrops = new HashMap<>();
public GarbageManager(Main plugin) {
this.plugin = plugin;
@@ -132,65 +184,435 @@ public class GarbageManager {
public void startAutoCleanup() {
if (!enabled) return;
new BukkitRunnable() {
int countdown = 0;
// ★ 第42批:已在跑则不再重复调度(applyEnabledState 可能被多路热重载同时触发)
if (cleanupTask != null) return;
cleanupTask = new BukkitRunnable() {
@Override
public void run() {
// ★ 第42批:启停状态热更兜底 —— ConfigManager.garbageEnabled 变 false 后
// (插件设置.txt 热重载),任务自己收摊:撤血条+清状态+取消自身。
// 反向 false→true 时任务已不存在,靠 applyEnabledState() 重新拉起。
if (!plugin.getConfig2().garbageEnabled) {
enabled = false;
hideAllBossBars();
roundStartMs = 0L;
announcedMask = 0;
pendingDrops.clear();
cleanupTask = null;
plugin.getLogger().info(
"[Garbage] 垃圾站_启用 热更关闭:自动清理任务已自停、血条已撤");
this.cancel();
return;
}
enabled = true;
// 在线人数≤0时暂停
if (Bukkit.getOnlinePlayers()
.isEmpty()) {
countdown = 0;
// ★ 第34批:墙钟口径 —— 全员离线则本轮暂停,
// 玩家回来时重新记录起点(重新计时)
roundStartMs = 0L;
announcedMask = 0;
return;
}
// ★ 第31批:本轮墙钟起点(0=新轮开始,色段按真实时间判定)
if (roundStartMs == 0L)
roundStartMs = System.currentTimeMillis();
// 重新读取配置(支持热更)
cleanupInterval =
plugin.getConfig2()
.garbageInterval;
// ★ 第37批:保留轮数同样纳入热更(节点重载 插件设置.txt 后
// 垃圾站_保留轮数 即时生效,无需重启)
maxRounds =
plugin.getConfig2()
.garbageMaxRounds;
int remain =
cleanupInterval - countdown;
if (remain == 300)
// ★ 第34批:唯一时间源(墙钟)—— 血条 / 广播 / 抢救打印 /
// 丢弃拦截 全部读 remainSeconds(),误差 ±0 秒。
int remain = remainSeconds();
// ★ 第34批:节点广播改「<= 命中一次 + 位图去重」
// (墙钟口径 remain 可能跨值,== 判断会漏播)
boolean nodeHit = false;
if (cleanupInterval >= 300
&& remain <= 300
&& (announcedMask & 1) == 0) {
announcedMask |= 1;
nodeHit = true;
broadcast("§e§l[垃圾站] §f距离清理还有 §e5分钟");
else if (remain == 180)
}
if (cleanupInterval >= 180
&& remain <= 180
&& (announcedMask & 2) == 0) {
announcedMask |= 2;
nodeHit = true;
broadcast("§e§l[垃圾站] §f距离清理还有 §e3分钟");
else if (remain == 60)
}
if (cleanupInterval >= 60
&& remain <= 60
&& (announcedMask & 4) == 0) {
announcedMask |= 4;
nodeHit = true;
broadcast("§e§l[垃圾站] §f距离清理还有 §e1分钟");
else if (remain == 30)
}
if (cleanupInterval >= 30
&& remain <= 30
&& (announcedMask & 8) == 0) {
announcedMask |= 8;
nodeHit = true;
broadcast("§e§l[垃圾站] §f距离清理还有 §e30秒");
else if (remain == 10)
}
if (cleanupInterval >= 10
&& remain <= 10
&& (announcedMask & 16) == 0) {
announcedMask |= 16;
nodeHit = true;
broadcast("§e§l[垃圾站] §f距离清理还有 §e10秒");
else if (remain <= 5 && remain > 0)
}
if (nodeHit) {
// ★ 第31批(#204/#205):垃圾箱进入下一节点 → 查一次 插件设置.txt,
// 有变化整份重载(ConfigManager 内部比对,无变化静默、有变化打日志)
// ★ 第42批:重载成功后把「垃圾站_启用」应用到运行态 ——
// 此前只更新 ConfigManager 字段,GarbageManager.enabled 没人回写,
// 导致日志喊了「true→false」但任务照跑、血条照显示(用户截图根因)。
if (plugin.getConfig2()
.reloadSettingsIfChanged()) {
applyEnabledState();
if (!enabled) return; // 本轮已被热停 → 不再往下跑
}
}
if (remain <= 5 && remain > 0)
broadcast("§e§l[垃圾站] §c" + remain + "§f...");
else if (remain == 0) {
if (remain == 0) {
doCleanup();
countdown = 0;
// ★ 第34批:新一轮开始 —— 清墙钟起点与节点位图
roundStartMs = 0L;
announcedMask = 0;
return;
}
countdown++;
// ★ 第30批:顶部血条常驻倒计时(软提醒)
// ★ 第32批:血条只受 垃圾站_启用 控制,与启发式开关解耦
// ★ 第34批:remain 取自墙钟唯一时间源,与广播/抢救打印同源
updateBossBars(remain);
}
}.runTaskTimer(plugin, 20L, 20L);
};
cleanupTask.runTaskTimer(plugin, 20L, 20L);
}
/**
* ★ 第42批:停止自动清理任务并复位运行态(热停用时用)。
* 撤血条 + 清按键状态 + 复位墙钟起点与节点位图,下次启用从满格重新计时。
*/
public void stopAutoCleanup() {
if (cleanupTask != null) {
try {
cleanupTask.cancel();
} catch (IllegalStateException ignored) {
}
cleanupTask = null;
}
enabled = false;
hideAllBossBars();
roundStartMs = 0L;
announcedMask = 0;
pendingDrops.clear();
}
/**
* ★ 第42批:把 ConfigManager.garbageEnabled(插件设置.txt 的「垃圾站_启用」)
* 应用到 GarbageManager 运行态(双向:false→true 重新拉起任务 / true→false 停任务撤血条)。
*
* <p>根因(第42批用户截图):reloadSettingsIfChanged() 整份重载后只更新了
* ConfigManager 的字段并打了「已整份重载」日志,但 GarbageManager.enabled
* 是启动时 loadConfig() 拷贝的运行态快照,没人回写 → 日志喊了 true→false,
* 垃圾站照常运行。反向(false→true)更糟:启动时任务就没创建,
* 之后改 true 也没人调 startAutoCleanup(),垃圾站永远起不来。
*
* <p>调用点:Web 热重载 / 垃圾站节点热重载 / /sdf1_login reload 命令。
* 非主线程调用会自动切回主线程(BossBar 撤除必须主线程)。
*/
public void applyEnabledState() {
if (!Bukkit.isPrimaryThread()) {
Bukkit.getScheduler().runTask(plugin, this::applyEnabledState);
return;
}
boolean want = plugin.getConfig2().garbageEnabled;
if (want == enabled) return; // 状态一致 → no-op(防多路触发重复打日志)
if (want) {
enabled = true;
startAutoCleanup();
plugin.getLogger().info(
"[Garbage] 垃圾站_启用 热更生效:自动清理任务已启动");
} else {
stopAutoCleanup();
plugin.getLogger().info(
"[Garbage] 垃圾站_启用 热更生效:自动清理任务已停止、血条已撤");
}
}
/**
* ★ 第30批:顶部血条常驻倒计时 + 色段软提醒。
*
* <p>★ 第33批(#211):色段改按「剩余时间」纯数值分段(用户明令):
* <ul>
* <li>剩余 ≥ 180 秒 → 绿 = 安全期</li>
* <li>30 秒 < 剩余 < 180 秒 → 黄 = 警告(只提示快动作了,
* 丢的时候注意安全控好量,不误导「别丢」)</li>
* <li>剩余 ≤ 30 秒 → 红 = 危险</li>
* </ul>
* 第31批的「墙钟绝对时刻」方案废弃;百分比方案更早废弃(间隔一改就漂移)。
*
* <p>★ 第32批:血条与启发式开关解耦 —— 只要垃圾站启用,血条常驻显示;
* 启发式开关(垃圾站_启发式拦截)关闭时只关掉「黄/红区title警告 + 末5秒双击Q拦截」,
* 不再撤血条(否则玩家对清理状态一无所知)。
*/
private void updateBossBars(int remain) {
int interval = Math.max(1, cleanupInterval);
double progress = (double) remain / (double) interval;
if (progress < 0.0D) progress = 0.0D;
if (progress > 1.0D) progress = 1.0D;
BossBar.Color color;
String label;
if (remain <= 30) {
color = BossBar.Color.RED;
label = "§c§l危险 §7| §c马上清理,抓紧捡";
} else if (remain < 180) {
color = BossBar.Color.YELLOW;
label = "§e§l注意 §7| §e快动作了,注意安全控好量";
} else {
color = BossBar.Color.GREEN;
label = "§a§l安全期 §7| §a还早,放心丢";
}
String title = "§6§l垃圾站 §8| " + label
+ " §8| §f剩余 " + fmtRemain(remain);
// ★ 第36批:血条插一个字段 —— 上一轮清扫数 / 上一轮倒垃圾数
// (还没跑过任何一轮时不显示,避免出现"上轮扫-1个")
if (lastCleaned >= 0 && lastDumped >= 0) {
title += " §8| §7上轮扫§e" + lastCleaned
+ "§7个、倒§e" + lastDumped + "§7个";
}
Component comp = LegacyComponentSerializer
.legacySection().deserialize(title);
for (Player p : Bukkit.getOnlinePlayers()) {
UUID uuid = p.getUniqueId();
BossBar bar = bossBars.get(uuid);
if (bar == null) {
// Adventure 的 BossBar 按观众分发,必须每人一份
bar = BossBar.bossBar(comp, (float) progress,
color, BossBar.Overlay.NOTCHED_10);
bossBars.put(uuid, bar);
p.showBossBar(bar);
} else {
bar.name(comp);
bar.color(color);
if (Math.abs(bar.progress() - progress) > 0.001D) {
bar.progress((float) progress);
}
}
}
}
/** 撤掉所有玩家的顶部血条(插件关闭/停服时用;启发式开关不再触发) */
private void hideAllBossBars() {
for (Player p : Bukkit.getOnlinePlayers()) {
BossBar bar = bossBars.get(p.getUniqueId());
if (bar != null) {
try {
p.hideBossBar(bar);
} catch (Throwable ignored) {
}
}
}
bossBars.clear();
}
/** 剩余秒数格式化为 mm:ss */
private String fmtRemain(int remain) {
if (remain < 0) remain = 0;
int m = remain / 60;
int s = remain % 60;
return (m < 10 ? "0" + m : String.valueOf(m))
+ ":" + (s < 10 ? "0" + s : String.valueOf(s));
}
/**
* ★ 第34批:剩余秒数「唯一时间源」(墙钟推算)。
*
* <p>根因:旧实现里「血条 / 广播」读的是 tick 计数(increment 前的值),
* 而「抢救打印 / 指令显示 / 丢弃拦截」读的是 increment 后的值,
* 两者恒差 1 秒(用户截图:血条 02:47 vs 打印 02:46)。
*
* <p>现统一按墙钟推算:距本轮结束 = roundStartMs + 清理间隔 - now,
* 向上取整到秒。血条标题、抢救提示、指令显示、丢弃拦截全部读本方法,
* 保证 ±0 秒误差。
*
* @return 剩余秒数(0 = 本轮已到点 / 未开始)
*/
private int remainSeconds() {
if (roundStartMs <= 0L) {
// 本轮尚未开始(刚启动 / 抢救后 / 全员离线)→ 视为满格
return Math.max(0, cleanupInterval);
}
long endMs = roundStartMs + (long) cleanupInterval * 1000L;
long leftMs = endMs - System.currentTimeMillis();
if (leftMs <= 0L) return 0;
// 向上取整到秒:剩余 2.001s 显示 3 秒,2.000s 显示 2 秒
return (int) ((leftMs + 999L) / 1000L);
}
/**
* ★ 第30批:抢救模式 —— 跳过本轮清理并重新开始计时。
* 玩家物品被错误投入垃圾箱时可急停抢救。
*
* @return true=抢救成功(本轮清理已取消)
*/
public boolean rescueRound(Player p) {
if (!enabled) {
if (p != null)
p.sendMessage("§c§l[垃圾站] §f垃圾站未启用,无法抢救");
return false;
}
// ★ 第34批:改用墙钟唯一时间源(与头顶血条显示值完全一致)
if (remainSeconds() <= 0) {
if (p != null)
p.sendMessage("§c§l[垃圾站] §f本轮清理已经结束了,来不及抢救");
return false;
}
// ★ 第31批(#203):抢救窗口 = 本轮最后 1 分钟;未到时间不可触发
// ★ 第34批:与血条同源(墙钟)—— 打印值 = 头顶血条值(±0 秒)
int remainSec = remainSeconds();
if (remainSec > 60) {
if (p != null)
p.sendMessage("§c§l[垃圾站] §f未到抢救窗口:本轮清理还剩 §e"
+ fmtRemain(remainSec)
+ "§f,最后 1 分钟内才能发起抢救");
else
plugin.getLogger().info(
"[垃圾站] 抢救被拒:还剩 " + remainSec
+ " 秒,未进入最后1分钟窗口");
return false;
}
// ★ 第34批:取消本轮 —— 清墙钟起点与节点位图,计时重新开始
roundStartMs = 0L;
announcedMask = 0;
pendingDrops.clear();
String who = p != null ? p.getName() : "控制台";
broadcast("§a§l[垃圾站] §f玩家 §e" + who
+ " §f发起抢救,本轮清理已取消,计时重新开始");
if (p != null)
p.sendMessage("§a§l[垃圾站] §f抢救成功!本轮清理已取消,"
+ "倒计时重新开始");
return true;
}
/**
* 当前剩余秒数(指令/UI 显示用)。
* ★ 第34批:与头顶血条、抢救打印完全同源(墙钟),不再有 ±1 秒差。
*/
public int getRemainSeconds() {
return remainSeconds();
}
/**
* ★ 第30批:垃圾箱丢弃拦截。
*
* <p>★ 第33批(#211)三处改造:
* <ul>
* <li>警告期(黄区 30~180 秒)不加任何 title 干预 —— 该期的提示
* 只走头顶血条(updateBossBars 文案已改「注意安全控好量」);</li>
* <li>拦截窗口从末 10 秒收窄到末 5 秒,且仅启发式开关启用时生效;</li>
* <li>确认窗口从 2 秒收窄到 1 秒:拦下第一次 Q,1 秒内连按
* 第二次 = 确认丢弃放行;超过 1 秒视为过期跳过(重新计时)。</li>
* </ul>
*/
@EventHandler
public void onPlayerDropItem(PlayerDropItemEvent e) {
if (!enabled) return;
if (!plugin.getConfig2().garbageHeuristic) return;
// ★ 第34批:与血条同源(墙钟)
int remain = remainSeconds();
if (remain > 30) return; // 安全区+警告期(>30秒)→ 不干涉
Player p = e.getPlayer();
// 危险期(红区 ≤30秒)小标题:按用户口径「注意安全控好量」,不写「别丢」
p.sendTitle("", "§c§l垃圾站警告 §f马上清理了,注意安全控好量!",
3, 30, 5);
if (remain > 5) return; // ★ 拦截仅在最后 5 秒窗口
long now = System.currentTimeMillis();
UUID uuid = p.getUniqueId();
Long first = pendingDrops.get(uuid);
if (first != null && now - first <= 1000L) {
pendingDrops.remove(uuid);
return; // ★ 1 秒窗口内连按 2 次 Q = 确认丢弃 → 放行
}
// 首次按键(或上次按键已超 1 秒窗口 → 视为过期,重新计时)
pendingDrops.put(uuid, now);
e.setCancelled(true);
p.sendMessage("§e§l[垃圾站] §e马上清理了!1秒内再按一次 §eQ§f 确认丢弃,"
+ "或用 §e/垃圾抢救§f 取消本轮清理");
}
@EventHandler
public void onPlayerJoin(PlayerJoinEvent e) {
// 下一轮 tick 会自动建血条(第32批:不再受启发式开关守卫),
// 这里只清掉可能残留的按键状态
pendingDrops.remove(e.getPlayer().getUniqueId());
}
@EventHandler
public void onPlayerQuit(PlayerQuitEvent e) {
UUID uuid = e.getPlayer().getUniqueId();
pendingDrops.remove(uuid);
BossBar bar = bossBars.remove(uuid);
if (bar != null) {
try {
e.getPlayer().hideBossBar(bar);
} catch (Throwable ignored) {
}
}
}
private void doCleanup() {
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
final int deleteRound =
currentRound - maxRounds;
// ★ 第34批(#217):保留轮次语义按用户思维导图重写 ——
// N = 目标保留轮次(配置 垃圾站_保留轮数)
// k = 当前轮次(本方法入口时 currentRound = k-1)
// 公式余数 = N - k;余数为 0(k % N == 0)的那一轮才「永久倒垃圾」,
// 其余轮次只把地面掉落物收进回收站,跳过永久删除。
// N=3 → k=1(余2)跳过 / k=2(余1)跳过 / k=3(余0)倒垃圾。
// 到期批次 = 存活满 N 轮的批次(批次 j 存于第 j 轮,
// 到第 j+N 轮才满 N 轮)→ j <= k - N。
// 验证 N=2:k=1 跳过;k=2 倒垃圾(第1批尚未满2轮,删0件);
// k=3 跳过;k=4 倒垃圾(第1、2批满2轮,删除)。
// 验证 N=3:k=1/2 跳过;k=3 倒垃圾(无到期);k=6 倒垃圾(删第1、2、3批)。
// 性质:每个倒垃圾轮次结束后,回收站恰好保留 N 个批次的垃圾。
final int k = currentRound + 1; // 当前轮次(1 起)
final int n = Math.max(1, maxRounds); // 目标保留轮次
final boolean purgeThisRound = (k % n == 0);
final int deleteRound = k - n; // <= 该批次的都存活满 n 轮
Bukkit.getScheduler().runTaskAsynchronously(
plugin, () -> {
final int deleted =
deleteRound >= 0
(purgeThisRound && deleteRound >= 0)
? deleteOldItems(deleteRound)
: 0;
Bukkit.getScheduler().runTask(
plugin, () -> {
if (deleted > 0) {
// ★ 第39批:倒垃圾轮的「永久清理」播报保留,
// 轮次提示改为**恒发**(原来只写在 else 分支
// → 倒垃圾轮整轮静默,用户看不到任何轮次信息)。
if (purgeThisRound && deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
// 递增轮次
currentRound++;
final int roundNow =
@@ -205,10 +627,35 @@ public class GarbageManager {
// 收集当前掉落物
// (实体移除必须主线程,落库已在内部转异步)
int count = collectItems();
// ★ 第36批:记录上一轮统计(血条展示)——
// 清扫 = 本轮收进回收站的掉落物数;
// 倒垃圾 = 本轮永久删除的过期物品数(非倒垃圾轮为0)
lastCleaned = count;
lastDumped = deleted;
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// ★ 第39批:轮次提示**恒发**,且口径改「刚开启的新一轮」。
// 原实现算的是*刚跑完*的轮次 k(r=((k-1)%n)+1),
// 而玩家屏幕上正在倒计时的其实是**刚开启的新一轮 k+1**
// → 播报与实际轮次差一轮(用户截图那轮:k=25402 跑完,
// 开启 25403 = 周期内第1轮 → 应播「当前是第1轮,
// 第2轮倒垃圾,还剩1轮倾倒垃圾」)。
// 周期内轮次 r = ((nextRound-1) % n) + 1 ∈ [1, n];
// r == n 即「本轮结束时倾倒垃圾」(下一轮才真正倒)。
final int nextRound = roundNow + 1; // 清理后开启的新一轮
final int r = ((nextRound - 1) % n) + 1;
if (r < n) {
broadcast("§7[垃圾站] §f当前是第 §e" + r
+ " §f轮,第 §e" + n
+ " §f轮倒垃圾,还剩 §e" + (n - r)
+ " §f轮倾倒垃圾");
} else {
broadcast("§7[垃圾站] §f当前是第 §e" + n
+ " §f轮,本轮结束时倾倒垃圾");
}
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
@@ -252,7 +699,6 @@ public class GarbageManager {
}
return count;
}
public void saveItem(ItemStack stack) {
try {
// ★ SN 物品进垃圾箱 = 脱离玩家本人管控,起算 12 小时找回期。
@@ -623,9 +1069,13 @@ public class GarbageManager {
public void close() {
try {
if (db != null && !db.isClosed())
db.close();
hideAllBossBars();
pendingDrops.clear();
} catch (Exception ignored) {
}
try {
if (db != null && !db.isClosed())
db.close();
} catch (Exception ignored) {}
}
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ public class GiftManager {
|| line.contains("任务内容")) {
int colon = line.indexOf(':');
if (colon < 0)
colon = line.indexOf('\uff1a');
colon = line.indexOf(':');
if (colon >= 0) {
rewards.add(
line.substring(colon + 1)
@@ -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) {
@@ -45,6 +45,12 @@ public class InventoryAutoBackup {
private void backupPlayer(Player p) {
String name = p.getName();
// ★ 第30批:黑名单玩家跳过自动备份
// (拉黑时的背包快照由 BlacklistManager 单独落库,不能被自动备份覆盖)
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(name)) {
return;
}
// ★ 跳过 PVP 竞技场:竞技场内不备份,避免 PVP 装备覆盖真实背包
if (plugin.getPVPArenaManager().isInPVPArena(name)) {
return;
+232 -33
View File
@@ -21,55 +21,51 @@ public class InviteManager {
return code;
}
public void onInviteeCheckIn(String inviteeName) {
String invitedBy = (String) plugin.getDb()
.getField(inviteeName, "invited_by");
if (invitedBy == null || invitedBy.isEmpty())
return;
/**
* ★ 第九批(2026-10-12):绑定邀请码成功时立即计入邀请人
* 「本月邀请」计数(带月度重置)。
*
* <p>旧逻辑把计数放在 onInviteeCheckIn(被邀请人签到)里,
* 导致「绑定成功但未签到」时 GUI 恒显示「已邀请 0 人」(用户反馈
* 的计数 bug 根因)。现改为绑定即计数——绑定才是「邀请成功」
* 的真正时点,与债券发放同一时机,语义一致。</p>
*
* @param inviter 邀请人(被计数方)
* @param invitee 被邀请人(绑定者)
*/
public void onInviteeBind(String inviter, String invitee) {
if (inviter == null || inviter.isEmpty()) return;
String month = new SimpleDateFormat("yyyy-MM")
.format(new Date());
String lastMonth = (String) plugin.getDb()
.getField(invitedBy, "invite_month");
.getField(inviter, "invite_month");
if (!month.equals(lastMonth)) {
plugin.getDb().setField(
invitedBy, "invite_month", month);
inviter, "invite_month", month);
plugin.getDb().setField(
invitedBy, "monthly_invite_count", 0);
inviter, "monthly_invite_count", 0);
}
int count = ((Number) plugin.getDb()
.getField(invitedBy,
.getField(inviter,
"monthly_invite_count"))
.intValue();
plugin.getDb().setField(
invitedBy, "monthly_invite_count",
inviter, "monthly_invite_count",
count + 1);
}
double rate = 0.10;
if (count + 1 > 15) {
rate = 0.60;
} else if (count + 1 > 10) {
rate = 0.35;
} else if (count + 1 > 5) {
rate = 0.15;
}
int bonus = (int) (1 * rate);
if (bonus > 0) {
plugin.getDb().addPoints(invitedBy, bonus);
Player inviterPlayer = Bukkit
.getPlayerExact(invitedBy);
if (inviterPlayer != null
&& inviterPlayer.isOnline()) {
inviterPlayer.sendMessage(
plugin.getConfig2()
.msg("invite_referral_bonus")
.replace("{points}",
String.valueOf(bonus)));
}
}
/**
* ★ 第九批:签到链路不再重复计数——「本月邀请」计数已在
* 绑定邀请码时由 onInviteeBind 完成(见上)。若此处再 +1,
* 同一被邀请人每签到一次都会重复累加,造成计数虚高。
* 方法保留以兼容既有三处签到调用点(Main.dailySign 等),
* 但已清空计数逻辑。
*/
public void onInviteeCheckIn(String inviteeName) {
// 计数已迁移至 onInviteeBind(绑定时),签到不再计数
}
public List<Map<String, Object>> getAllInviteData() {
@@ -93,4 +89,207 @@ public class InviteManager {
}
return result;
}
// ==================== 第十批(2026-10-12)存量自愈 + 派生口径 ====================
/** 本月 1 号 00:00(本地时区)毫秒时间戳 */
private long monthStartTime() {
Calendar c = Calendar.getInstance();
c.set(Calendar.DAY_OF_MONTH, 1);
c.set(Calendar.HOUR_OF_DAY, 0);
c.set(Calendar.MINUTE, 0);
c.set(Calendar.SECOND, 0);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long lng(Object o) {
return (o instanceof Number)
? ((Number) o).longValue() : 0L;
}
/**
* ★ 第十批(2026-10-12):存量邀请数据自愈,
* onEnable 启动时调用一次(幂等)。
*
* <p>① 回填 invited_at=0 的存量绑定时间——绑定功能自项目
* 初始化(2026-05)就存在但没记时间,用户 GUI 看到
* 「绑定时间: 未知」。回填优先级:</p>
* <ol>
* <li>bond_transaction type='invitee_reward'
* (第九批起被邀请人收款,time=绑定时刻,最精确)</li>
* <li>bond_transaction type='invite_reward' 且
* target_player=被邀请人(第八批起邀请人收款)</li>
* <li>注册时间(早期绑定大多发生在注册前后,最佳代理)</li>
* <li>最后登录时间 → 当前时间</li>
* </ol>
*
* <p>② 按回填后的口径重算 monthly_invite_count / invite_month
* ——修复存量绑定不计入导致的「已邀请 0 人」。</p>
*/
public void reconcileInviteData() {
try {
List<Map<String, Object>> users =
plugin.getDb().getAllUsers();
long now = System.currentTimeMillis();
int fixedAt = 0;
// ---- ① 回填存量绑定时间 ----
for (Map<String, Object> u : users) {
String name = str(u.get("player_name"));
if (name.isEmpty()) continue;
if (str(u.get("invited_by")).isEmpty()) continue;
long invAt = lng(u.get("invited_at"));
if (invAt > 0 && invAt < 1000000000000L) {
// ★ 第十一批:秒级存量(回填时继承了秒级
// register_time)→ 原地转毫秒
plugin.getDb().setField(name, "invited_at",
invAt * 1000L);
u.put("invited_at", invAt * 1000L);
fixedAt++;
}
if (lng(u.get("invited_at")) > 0) continue;
long ts = lookupBondTx(
"player_name", name,
"invitee_reward");
if (ts <= 0) ts = lookupBondTx(
"target_player", name,
"invite_reward");
if (ts <= 0) ts = DurationParser.toMs(
lng(u.get("register_time")));
if (ts <= 0) ts = DurationParser.toMs(
lng(u.get("last_login_time")));
if (ts <= 0) ts = now;
plugin.getDb().setField(
name, "invited_at", ts);
u.put("invited_at", ts);
fixedAt++;
}
// ---- ② 重算月度计数(仅维护邀请人,与 GUI 派生口径同源) ----
long monthStart = monthStartTime();
Map<String, Integer> counter = new HashMap<>();
for (Map<String, Object> u : users) {
String inviter = str(u.get("invited_by"));
if (inviter.isEmpty()) continue;
if (DurationParser.toMs(
lng(u.get("invited_at"))) >= monthStart) {
counter.merge(inviter, 1, Integer::sum);
}
}
String curMonth = new SimpleDateFormat("yyyy-MM")
.format(new Date());
int fixedCnt = 0;
for (Map<String, Object> u : users) {
String name = str(u.get("player_name"));
if (name.isEmpty()) continue;
// 只维护邀请人(有邀请码的玩家)
if (str(u.get("invite_code")).isEmpty()) {
continue;
}
int want = counter.getOrDefault(name, 0);
if (lng(u.get("monthly_invite_count")) != want) {
plugin.getDb().setField(
name, "monthly_invite_count",
want);
fixedCnt++;
}
if (!curMonth.equals(
str(u.get("invite_month")))) {
plugin.getDb().setField(
name, "invite_month", curMonth);
}
}
if (fixedAt > 0 || fixedCnt > 0) {
plugin.getLogger().info(
"[Invite] 存量自愈: 回填绑定时间 "
+ fixedAt + " 条, 重算月度计数 "
+ fixedCnt + " 人");
}
} catch (Exception e) {
plugin.getLogger().warning(
"[Invite] 存量邀请数据自愈失败: "
+ e.getMessage());
}
}
/**
* 在 bond.db 的 bond_transaction 里查该玩家某类流水的最早时间
* (跨库:必须走 BondManager.getConnection(),
* bond_transaction 不在 login.db)
*/
private long lookupBondTx(String col, String player,
String type) {
try {
java.sql.PreparedStatement ps = plugin.getBonds()
.getConnection().prepareStatement(
"SELECT MIN(time) "
+ "FROM bond_transaction "
+ "WHERE type=? AND "
+ col + "=?");
ps.setString(1, type);
ps.setString(2, player);
java.sql.ResultSet rs = ps.executeQuery();
long t = rs.next() ? rs.getLong(1) : 0L;
rs.close();
ps.close();
// 兼容以秒为单位的历史行
if (t > 0 && t < 1000000000000L) t *= 1000L;
return t;
} catch (Exception e) {
return 0L;
}
}
/**
* ★ 第十批:派生口径的「本月邀请人数」——
* 按 invited_at >= 本月 1 号实时统计(GUI 显示用,
* 跨月自愈,不依赖增量列 monthly_invite_count)。
*/
public int getMonthlyInviteCount(String inviter) {
if (inviter == null || inviter.isEmpty()) return 0;
long monthStart = monthStartTime();
int n = 0;
for (Map<String, Object> u
: plugin.getDb().getAllUsers()) {
if (!inviter.equals(
str(u.get("invited_by")))) continue;
// ★ 第十一批:秒/毫秒归一化后再比较
if (DurationParser.toMs(
lng(u.get("invited_at"))) >= monthStart) n++;
}
return n;
}
/**
* ★ 第十批:本月绑定该玩家邀请码的玩家列表(头颅明细 GUI)。
*
* @return 每项含 name(被邀请人)与 at(绑定时间戳),
* 按绑定时间倒序
*/
public List<Map<String, Object>> getMonthlyInvitedPlayers(
String inviter) {
List<Map<String, Object>> result =
new ArrayList<>();
if (inviter == null || inviter.isEmpty()) return result;
long monthStart = monthStartTime();
for (Map<String, Object> u
: plugin.getDb().getAllUsers()) {
if (!inviter.equals(
str(u.get("invited_by")))) continue;
// ★ 第十一批:秒/毫秒归一化后再比较与展示
long ts = DurationParser.toMs(
lng(u.get("invited_at")));
if (ts < monthStart) continue;
Map<String, Object> row = new LinkedHashMap<>();
row.put("name", str(u.get("player_name")));
row.put("at", ts);
result.add(row);
}
result.sort((a, b) -> Long.compare(
lng(b.get("at")), lng(a.get("at"))));
return result;
}
}
@@ -0,0 +1,118 @@
package Sdf1_login;
import org.bukkit.Material;
import java.util.List;
import java.util.Map;
/**
* 物品名翻译中间人(2026-10-11,v2 改用官方 zh_cn.json)。
* <p>
* 玩家在聊天栏输入物品 ID 时,不是人人都能背下 MC 每个物品的英文 ID。
* 本类把中文名(如「钻石」)翻译为标准英文 Material 名(如 diamond),
* 入库统一用英文标准名,回收/匹配时按英文名比对。
* <p>
* 数据来源:{@link OfficialTranslations#reverseMap()} —— 官方原版 zh_cn.json
* (plugins/Sdf1_login/zh_cn.json,启动时自动加载/下载,领地容器查询等已在用)。
* 不再维护手工中文映射表。
* <p>
* 解析顺序:
* 1. 英文直通(Material.matchMaterial,支持 minecraft: 前缀与 legacy 名)
* 2. 官方中文精确(zh_cn.json 反向索引)
* 3. 归一化后精确(去空格、去 minecraft: 前缀)
* 4. 唯一前缀匹配(输入「钻」时,若全表只有一个候选以它开头则放行并回填提示)
*/
public final class ItemNameResolver {
private ItemNameResolver() {}
/**
* 解析玩家输入为 Material。
*
* @param input 玩家输入(英文 ID / minecraft: 前缀 / 官方中文名)
* @return Material;无法解析返回 null
*/
public static Material resolve(String input) {
return resolveWithHint(input, null);
}
/**
* 解析并附带「实际匹配到的中文名」用于提示(如输入「钻」且唯一命中「钻石剑」时回填)。
*/
public static Material resolveWithHint(String input, StringBuilder hintOut) {
if (input == null) return null;
String raw = input.trim();
if (raw.isEmpty()) return null;
// 1. 英文直通(含 minecraft: 前缀与 legacy 名)
Material m = Material.matchMaterial(raw);
if (m != null) return m;
Map<String, List<String>> rev = OfficialTranslations.reverseMap();
if (rev.isEmpty()) return null; // zh_cn.json 未加载,仅支持英文
// 2. 官方中文精确
Material hit = byChinese(rev, raw);
if (hit != null) return hit;
// 3. 归一化后再查(去空格、去 minecraft: 前缀、小写)
String norm = raw.replace(" ", "");
if (!norm.equals(raw)) {
m = Material.matchMaterial(norm);
if (m != null) return m;
hit = byChinese(rev, norm);
if (hit != null) return hit;
} else {
norm = raw;
}
// 4. 唯一前缀匹配(全表只有一个候选以它开头才放行)
String firstKey = null;
int hits = 0;
for (String zh : rev.keySet()) {
if (zh.startsWith(norm) && !zh.equals(norm)) {
hits++;
if (hits == 1) firstKey = zh;
if (hits > 1) break;
}
}
if (hits == 1 && firstKey != null) {
Material cand = byChinese(rev, firstKey);
if (cand != null) {
if (hintOut != null) hintOut.append(firstKey);
return cand;
}
}
return null;
}
/** 用中文名查反向索引并转 Material;未命中或非法返回 null。 */
private static Material byChinese(Map<String, List<String>> rev, String zh) {
List<String> names = rev.get(zh);
if (names == null) return null;
for (String en : names) {
Material m = Material.matchMaterial(en);
if (m != null) return m;
}
return null;
}
/**
* 英文标准名 → 中文显示名。
* 优先官方 zh_cn.json(OfficialTranslations),未命中回退 Title Case。
*/
public static String toChinese(Material mat) {
if (mat == null) return "";
String official = OfficialTranslations.lookUp(mat.name());
if (official != null && !official.isEmpty()) return official;
// 回退:下划线转空格的 Title Case
String[] parts = mat.name().split("_");
StringBuilder sb = new StringBuilder();
for (String part : parts) {
if (sb.length() > 0) sb.append(" ");
sb.append(part.substring(0, 1).toUpperCase())
.append(part.substring(1).toLowerCase());
}
return sb.toString();
}
}
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -83,9 +83,10 @@ public class LoginManager {
plugin.getDb().setField(name,
"register_time",
System.currentTimeMillis());
plugin.restoreInventory(p);
// ★ 二十二批:DB 读挪异步线程,菜单雪球延后到恢复完成回调
plugin.restoreInventory(p,
() -> plugin.giveMenuSnowball(p));
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("reg_success"));
@@ -286,9 +287,10 @@ public class LoginManager {
p.setAllowFlight(false);
p.setFlying(false);
// 只在有有效备份时恢复
plugin.restoreInventory(p);
// ★ 二十二批:DB 读挪异步线程,菜单雪球延后到恢复完成回调
plugin.restoreInventory(p,
() -> plugin.giveMenuSnowball(p));
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
if (tempPassword) {
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.plugin.java.JavaPlugin;
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
/**
* 主线程卡顿看门狗(第十五批,2026-10-14)。
*
* <p>背景:生产服 2026-10-14 14:52:08~14:52:29 主线程静默 21 秒
* (玩家在「我的信息」GUI 做自定义菜单图标交换期间),全程无 ERROR、
* 只有 SSL 断路器 WARN —— 日志里没有任何栈信息能定位卡在哪一帧。
* 本看门狗用于下次复现时直接抓出现场。
*
* <p>机制:
* <ol>
* <li>主线程每 tick 心跳(同步定时任务,主线程一卡心跳就停走);</li>
* <li>独立 daemon 线程每 1 秒检查心跳年龄,≥5 秒 → 立刻把主线程栈
* (深度 120 帧)打进日志,标签 {@code [主线程看门狗]};</li>
* <li>持续卡顿每 10 秒补一次栈(观察卡点是否在变);</li>
* <li>恢复后补一条「恢复」日志,带总卡顿时长;</li>
* <li>若看门狗自己 sleep 都睡了 3 倍以上时长 → JVM/主机级停顿,
* 单独记一条(此时单线程栈已无意义)。</li>
* </ol>
*
* <p>生命周期:daemon 线程 + {@code while (plugin.isEnabled())} 自退出,
* onDisable 后心跳任务被调度器取消 → 1 秒内自然结束,无需手动 stop。
*/
public final class MainThreadWatchdog implements Runnable {
/** 心跳超过这个年龄视为主线程卡顿 */
private static final long STALL_MS = 5000L;
/** 检查间隔 */
private static final long CHECK_MS = 1000L;
/** 持续卡顿时补栈间隔 */
private static final long REDUMP_MS = 10000L;
/** 抓栈深度 */
private static final int DEPTH = 120;
private final JavaPlugin plugin;
private volatile long heartbeat;
private volatile boolean stalled;
private volatile long stallStartAt;
private volatile long lastDumpAt;
public MainThreadWatchdog(JavaPlugin plugin) {
this.plugin = plugin;
}
/** 在 onEnable 里调用一次即可。 */
public void start() {
heartbeat = System.currentTimeMillis();
// 主线程心跳:每 tick 刷一次;主线程被卡住时它自然停走
Bukkit.getScheduler().runTaskTimer(plugin, new Runnable() {
@Override
public void run() {
heartbeat = System.currentTimeMillis();
}
}, 1L, 1L);
Thread t = new Thread(this, "sdf1-main-watchdog");
t.setDaemon(true);
t.start();
plugin.getLogger().info("[主线程看门狗] 已启动(主线程卡顿≥"
+ (STALL_MS / 1000) + " 秒自动抓栈打日志)");
}
@Override
public void run() {
while (plugin.isEnabled()) {
long sleepBegin = System.nanoTime();
try {
Thread.sleep(CHECK_MS);
} catch (InterruptedException ie) {
return;
}
long sleptMs = (System.nanoTime() - sleepBegin) / 1000000L;
if (sleptMs > CHECK_MS * 3) {
// 连看门狗自己都被冻住 → JVM/GC/主机级停顿,栈已无意义
plugin.getLogger().warning("[主线程看门狗] ★ 全线程停顿约 "
+ sleptMs + "ms(JVM/主机级,非单点死锁)");
}
if (!plugin.isEnabled()) return;
long now = System.currentTimeMillis();
long age = now - heartbeat;
if (age >= STALL_MS) {
if (!stalled) {
stalled = true;
stallStartAt = now;
lastDumpAt = now;
dumpMainThread(age, true);
} else if (now - lastDumpAt >= REDUMP_MS) {
lastDumpAt = now;
dumpMainThread(age, false);
}
} else if (stalled) {
stalled = false;
plugin.getLogger().warning("[主线程看门狗] ✓ 主线程已恢复,本次卡顿共 "
+ (now - stallStartAt) + "ms");
}
}
}
private void dumpMainThread(long ageMs, boolean first) {
plugin.getLogger().warning("[主线程看门狗] ★ 主线程已 " + ageMs
+ "ms 未推进!"
+ (first ? "首次抓栈(卡点看最上面几帧):" : "补栈:"));
try {
Thread main = null;
for (Thread t : Thread.getAllStackTraces().keySet()) {
if ("Server thread".equals(t.getName())) {
main = t;
break;
}
}
if (main == null) {
plugin.getLogger().warning(" (没找到 Server thread)");
return;
}
ThreadMXBean mx = ManagementFactory.getThreadMXBean();
ThreadInfo ti = mx.getThreadInfo(main.getId(), DEPTH);
if (ti == null) {
plugin.getLogger().warning(" (拿不到主线程 ThreadInfo)");
return;
}
plugin.getLogger().warning(" 主线程状态: " + ti.getThreadState());
for (StackTraceElement el : ti.getStackTrace()) {
plugin.getLogger().warning(" at " + el);
}
} catch (Throwable t) {
plugin.getLogger().warning(" 抓栈失败: " + t);
}
}
}
@@ -69,7 +69,8 @@ public class MaintenanceManager implements Listener {
"&c&l[服务器维护] &7服务器正在维护中,请稍后再试&7(结束时间: &e{结束时间}&7)";
private static final Pattern NUM_PAT = Pattern.compile("\\d+");
private static final Pattern DUR_PAT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z\\u4e00-\\u9fa5]*)");
/** 时长单位正则:单位部分匹配「字母 + 汉字区(一~龥)」,例:10秒 / 5分 / 2小时 */
private static final Pattern DUR_PAT = Pattern.compile("(\\d+(?:\\.\\d+)?)\\s*([a-zA-Z一-龥]*)");
private static final Pattern AMP_COLOR_PAT = Pattern.compile("&([0-9a-fk-orA-FK-OR])");
private final Main plugin;
@@ -701,8 +702,7 @@ public class MaintenanceManager implements Listener {
String body = s.replaceAll("^[=\\-_::·*#\\s]+|[=\\-_::·*#\\s]+$", "");
if (body.indexOf('=') >= 0) return false;
// 剥离各类括号与装饰符号(含全角【】〔〕「」『』()),只留标题正文
String stripped = s.replaceAll("[\\[\\]\\u3010\\u3011\\u3014\\u3015\\u300c\\u300d\\u300e\\u300f"
+ "\\uff08\\uff09()\\-=_::·*#\\s]+", "");
String stripped = s.replaceAll("[\\[\\]【】〔〕「」『』()()\\-=_::·*#\\s]+", "");
String low = stripped.toLowerCase(Locale.ROOT);
// 精确匹配常见写法;再兜底 endsWith 兼容「玩家白名单」「ALLOWLIST」这类变体标题
//(有了上面的 = 门槛,endsWith 已经不会再吞掉控制段键值对)
@@ -781,7 +781,7 @@ public class MaintenanceManager implements Listener {
s = s.substring(0, c).trim();
}
// 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
String noDecor = s.replaceFirst("^[-*>·•]\\s+", "");
if (!noDecor.equals(s)) {
warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
s = noDecor;
@@ -790,9 +790,9 @@ public class MaintenanceManager implements Listener {
if (s.isEmpty()) return;
if (note != null) warn(log, note + ": " + raw);
// 仍坚持「一行一个玩家名」:整行收录,但格式不规范必须能被看见
if (s.indexOf(',') >= 0 || s.indexOf('\uFF0C') >= 0 || s.indexOf('\u3001') >= 0
|| s.indexOf(';') >= 0 || s.indexOf('\uFF1B') >= 0 || s.indexOf(' ') >= 0
|| s.indexOf('\u3000') >= 0 || s.indexOf('\t') >= 0) {
if (s.indexOf(',') >= 0 || s.indexOf(',') >= 0 || s.indexOf('、') >= 0
|| s.indexOf(';') >= 0 || s.indexOf(';') >= 0 || s.indexOf(' ') >= 0
|| s.indexOf(UnicodeChars.IDEOGRAPHIC_SPACE) >= 0 || s.indexOf('\t') >= 0) {
warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
}
wl.add(s);
@@ -801,13 +801,13 @@ public class MaintenanceManager implements Listener {
/** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
private static String stripInvisible(String s) {
if (s == null) return null;
return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
return s.replaceAll(UnicodeChars.INVISIBLE_STRIP_CLASS, "");
}
/** 行内注释起点(# / # / //),返回 -1 表示没有 */
private static int indexOfInlineComment(String s) {
int h = s.indexOf('#');
if (h < 0) h = s.indexOf('\uFF03');
if (h < 0) h = s.indexOf('#');
if (h < 0) h = s.indexOf("//");
return h <= 0 ? -1 : h;
}
@@ -309,6 +309,35 @@ public class MaterialTranslator {
*/
public static void clearCache() {
cache.clear();
chatCache.clear();
}
// ==================== 聊天整句机翻(28批新增) ====================
/** 聊天机翻独立缓存(与材料名缓存隔离),上限保护防内存膨胀 */
private static final ConcurrentHashMap<String, String> chatCache = new ConcurrentHashMap<>();
private static final int CHAT_CACHE_MAX = 500;
/**
* 聊天整句机翻(英→中)。复用 translateWithFallback 的
* YOUDAO→JIANXIN→GOOGLE 降级链(与领地系统同一套 API 流程)。
* 结果独立缓存(上限500条,满则整体清空重来),不污染材料名缓存。
* 同步阻塞 HTTP(每源 3s 超时),调用方必须放在异步线程执行。
* 返回 null 表示翻译失败或译文无意义(如与原文相同)。
*/
public static String translateChatText(String text) {
if (text == null) return null;
String key = text.trim();
if (key.isEmpty()) return null;
String hit = chatCache.get(key);
if (hit != null) return hit;
String zh = translateWithFallback(key);
if (zh != null && !zh.isEmpty() && !zh.equals(key)) {
if (chatCache.size() >= CHAT_CACHE_MAX) chatCache.clear();
chatCache.put(key, zh);
return zh;
}
return null;
}
/**
@@ -36,6 +36,8 @@ public class OfficialTranslations {
private static final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
private static final Gson gson = new Gson();
/** 反向索引(中文→英文键),懒构建;loadFromJson 后置 null 触发重建 */
private static volatile Map<String, java.util.List<String>> reverseIndex;
// ---------- 对外 API ----------
@@ -86,33 +88,46 @@ public class OfficialTranslations {
if (materialName == null || materialName.isEmpty()) return null;
String lower = materialName.toLowerCase(Locale.ROOT);
// 日志:当前 map 状态
Bukkit.getLogger().info("[翻译] officialLookUp: " + materialName
+ " | map已加载=" + isLoaded() + " 条目数=" + map.size());
// 1. 精确匹配(部分 key 可直接用)
String val = map.get(lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(精确): " + lower + " → " + val);
return val;
}
if (val != null) return val;
// 2. block.minecraft.<name>
val = map.get("block.minecraft." + lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(block): " + lower + " → " + val);
return val;
}
if (val != null) return val;
// 3. item.minecraft.<name>
val = map.get("item.minecraft." + lower);
if (val != null) {
Bukkit.getLogger().info("[翻译] officialLookUp 命中(item): " + lower + " → " + val);
return val;
}
if (val != null) return val;
Bukkit.getLogger().info("[翻译] officialLookUp 未命中: " + lower + ",将走机翻兜底");
return null; // 未命中 -> 调用者走机翻
return null; // 未命中 -> 调用者走机翻兜底
}
/**
* 反向索引:中文名 → 英文键列表(如 「钻石剑」→ ["diamond_sword"])。<br>
* 仅收录 item.minecraft.* / block.minecraft.* 条目;同名时 item 优先(先扫 item 再扫 block)。<br>
* 懒构建 + volatile,zh_cn.json 重新 load 后失效重建。
*/
public static Map<String, java.util.List<String>> reverseMap() {
Map<String, java.util.List<String>> idx = reverseIndex;
if (idx != null) return idx;
synchronized (OfficialTranslations.class) {
if (reverseIndex != null) return reverseIndex;
Map<String, java.util.List<String>> built = new java.util.HashMap<>();
for (String prefix : new String[]{"item.minecraft.", "block.minecraft."}) {
for (Map.Entry<String, String> e : map.entrySet()) {
if (!e.getKey().startsWith(prefix)) continue;
String zh = e.getValue();
if (zh == null || zh.isEmpty()) continue;
String mat = e.getKey().substring(prefix.length());
java.util.List<String> list = built.computeIfAbsent(zh,
k -> new java.util.ArrayList<>(2));
if (!list.contains(mat)) list.add(mat);
}
}
reverseIndex = built;
return built;
}
}
/** 当前加载的条目数(调试用) */
@@ -135,6 +150,7 @@ public class OfficialTranslations {
for (Map.Entry<String, com.google.gson.JsonElement> entry : obj.entrySet()) {
map.put(entry.getKey(), entry.getValue().getAsString());
}
reverseIndex = null; // 失效反向索引,下次访问重建
}
/** 从 Mojang API 拉取 zh_cn.json(完整链:manifest → version → asset index → object) */
+108 -16
View File
@@ -146,10 +146,50 @@ public class OpWhiteListManager implements Listener {
// a) 15s tick 只做 checkAndReload()(指纹变化才重载+打日志,无变化静默 apply)
// b) 整点扫描做 checkAndReload() + apply()(撤销非法持有 + 补授权)
// 且【无更新则不打印】心跳行——见 onQuarterPoint()
Bukkit.getScheduler().runTaskTimer(plugin, this::checkAndReload, 300L, 300L);
long delayMs = millisToNextQuarterPoint();
Bukkit.getScheduler().runTaskTimer(plugin, this::onQuarterPoint,
(long) delayMs / 50L, 18000L);
// ★ 2026-10-13 问题3:初始延迟原来写死 300 tick=15s,从插件启动时刻起算,
// 于是轮询点整体漂移固定偏移(实际 6/21/36/51 秒,期望 0/15/30/45 秒)。
// 改为对齐到下一个 15 秒墙钟边界后,再按 15s 周期跑(同 startShCfgReloadTask 范式)。
// ★ 2026-10-14 问题4(第十三批复发根因):上一版只对齐了【初始延迟】,
// 固定 period(300/18000 tick)在 TPS≠20 时逐轮累积漂移,运行几小时后
// 轮询点又飘离 0/15/30/45 秒整点。改为墙钟自对齐、每轮执行完重排的
// runTaskLater 调度(见 scheduleAligned),无论 TPS 多少都精确落点。
scheduleAligned(this::checkAndReload, 15000L, "15s热重载");
scheduleAligned(this::onQuarterPoint, 900000L, "整点扫描");
}
/**
* ★ 2026-10-14 问题4:墙钟自对齐、每轮重排的调度器(替代固定 period 的 runTaskTimer)。
* 每轮任务跑完后按【执行时刻的墙钟】重新计算下一个 periodMs 边界再 runTaskLater,
* 漂移不累积 —— TPS 高低、卡顿都影响不了落点;任务异常也不打断重排(finally)。
*
* @param task 每轮要跑的任务
* @param periodMs 墙钟周期毫秒(15000=对齐 :00/:15/:30/:45 秒;900000=对齐 :00/:15/:30/:45 分)
* @param tag 日志标签(异常时打印用)
*/
private void scheduleAligned(Runnable task, long periodMs, String tag) {
Runnable self = new Runnable() {
@Override
public void run() {
if (!plugin.isEnabled()) {
return; // 插件已禁用,不再重排
}
try {
task.run();
} catch (Throwable t) {
plugin.getLogger().warning("[OP白名单] " + tag + "任务异常(下轮照常): "
+ t.getMessage());
} finally {
long now = System.currentTimeMillis();
long next = (now / periodMs + 1) * periodMs;
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
Bukkit.getScheduler().runTaskLater(plugin, this, delayTicks);
}
}
};
long now = System.currentTimeMillis();
long next = (now / periodMs + 1) * periodMs;
long delayTicks = Math.max(1L, (next - now + 49L) / 50L);
Bukkit.getScheduler().runTaskLater(plugin, self, delayTicks);
}
// ================= 配置读取 =================
@@ -378,8 +418,15 @@ public class OpWhiteListManager implements Listener {
if (!dir.exists()) {
dir.mkdirs();
}
// 原版 ops.json 不存在或为空 = 没有内容可丢;已有历史备份则原样保留(绝不用空文件覆盖)
if (!src.exists() || src.length() == 0) {
// 原版 ops.json 不存在 / 空白 / 只剩 "[]" 空壳 = 没有内容可丢;
// 一律保留已有历史备份(★ 2026-10-14 问题4:fail() 清空后 ops.json 是
// 2 字节的 "[]",旧判空 length()==0 挡不住 → REPLACE_EXISTING 会用空壳
// 覆盖掉唯一一份好备份,管理员就只能手动逐个恢复 OP)
if (!src.exists() || isDegenerateOps(src)) {
if (dst.exists() && dst.length() > 0) {
plugin.getLogger().info("[OP白名单] 原版 ops.json 实质为空(空壳),"
+ "已有历史备份原样保留: OP白名单/" + NATIVE_OPS_BACKUP);
}
return true;
}
Files.copy(src.toPath(), dst.toPath(),
@@ -399,6 +446,19 @@ public class OpWhiteListManager implements Listener {
}
}
/** ops.json 是否实质为空(不存在 / 空白 / 只有 "[]" 空壳)——空壳绝不允许覆盖历史备份 */
private static boolean isDegenerateOps(File f) {
try {
if (!f.exists() || f.length() == 0) {
return true;
}
String s = new String(Files.readAllBytes(f.toPath()), StandardCharsets.UTF_8).trim();
return s.isEmpty() || s.equals("[]");
} catch (Exception e) {
return false; // 读不了 → 当作有内容,走正常备份流程(失败则按铁律不卸载)
}
}
/**
* ★ 从留底备份复原原版 OP(返回复原到的玩家名,供 adoptToWhitelist 写回白名单)。
*
@@ -733,6 +793,12 @@ public class OpWhiteListManager implements Listener {
+ String.join(", ", justRestored));
}
// 1) 非法持有扫描:持 OP 但不在白名单 → 撤销 + 报警
// ★ 问题4 铁律(2026-10-14 用户原话):「先备份原生 ops.json 到插件自己的
// json 文件夹下面来,再卸载删除」。本循环是 fail() 之外唯一的自动撤销入口
// (配置自愈后的全量接管也走这里)—— 此前该路径一次备份都没做过,
// 用户没提前备份就只能手动逐个恢复。备份失败 → 数据第一优先级,
// 本轮中止 apply(不撤也不授),下轮自动重试。
boolean opsBackedUp = false;
for (OfflinePlayer op : new ArrayList<>(Bukkit.getOperators())) {
String n = op.getName();
if (n == null || n.isEmpty() || !op.isOp()) {
@@ -744,10 +810,19 @@ public class OpWhiteListManager implements Listener {
if (justRestored.contains(n.toLowerCase(Locale.ROOT))) {
continue;
}
if (!opsBackedUp) {
if (!backupNativeOps()) {
plugin.getLogger().warning("[OP白名单] 撤销前备份原版 ops.json 失败 → "
+ "本轮【跳过全部撤销】(数据第一优先级),下轮自动重试");
return;
}
opsBackedUp = true;
}
setOpByName(n, false);
warnIllegal(n, now, describeIllegal(n));
// 卸任提示当事人(在线直发,离线暂存)
notifyOpChange(n, false, "自动撤销(不在白名单:" + describeIllegal(n) + ")");
// ★ 问题5(2026-10-14):玩家侧最小化信息披露 —— 只说结果,
// 不点名白名单/判定细节(细节只进控制台 warnIllegal 日志)
notifyAutoRevoked(n);
}
// 2) 白名单内玩家补授(已是 OP 的跳过)
// ★ 2026-10-08 现场修复:原实现只遍历【在线】玩家,导致离线授权人
@@ -1170,7 +1245,8 @@ public class OpWhiteListManager implements Listener {
saveConfig();
if (isOpNow(name)) {
setOpByName(name, false);
notifyOpChange(name, false, "临时OP被扣减至到期,已撤销");
// ★ 问题6:旧文案与模板拼成病句「你的 临时OP被扣减至到期,已撤销 已撤销」
notifyAutoRevoked(name);
}
sender.sendMessage("§a扣减后 " + name + " 的临时OP已到期,条目已移除,OP 已撤销");
} else {
@@ -1194,7 +1270,9 @@ public class OpWhiteListManager implements Listener {
boolean wasOp = isOpNow(name);
setOpByName(name, false);
if (wasOp) {
notifyOpChange(name, false, inTimed ? "临时OP被移除" : "长期OP被移除");
// ★ 问题6(2026-10-14):旧文案「你的 长期OP被移除 已撤销」是病句,
// 改走固定文案「管理员已撤销你的OP」(最小化信息披露)
notifyAutoRevoked(name);
}
lastWarn.remove(name.toLowerCase(Locale.ROOT));
if (!inPerm && !inTimed && !wasOp) {
@@ -1275,10 +1353,22 @@ public class OpWhiteListManager implements Listener {
/** 提示当事人 OP 上任/卸任。在线直发;离线暂存 pendingMsg,上线时补发。 */
public void notifyOpChange(String name, boolean isGrant, String what) {
if (name == null || name.isEmpty()) return;
Player p = Bukkit.getPlayerExact(name);
String msg = isGrant
? "§a[OP白名单] 你已获得 §f" + what + "§a。"
: "§c[OP白名单] 你的 §f" + what + " §c已撤销,OP 权限已收回。";
notifyMsg(name, msg);
}
/** ★ 问题5(2026-10-14):自动撤销的玩家侧固定文案 —— 最小化信息披露,
* 只说结果「管理员已撤销你的OP」;白名单/判定细节只留控制台 warnIllegal 日志。 */
public void notifyAutoRevoked(String name) {
if (name == null || name.isEmpty()) return;
notifyMsg(name, "§c[OP白名单] §f管理员已撤销你的OP");
}
/** 提示投递:在线直发,离线暂存(每人最多 2 条,上线由 flushPendingMsgs 投递) */
private void notifyMsg(String name, String msg) {
Player p = Bukkit.getPlayerExact(name);
if (p != null) {
p.sendMessage(msg);
} else {
@@ -1303,16 +1393,18 @@ public class OpWhiteListManager implements Listener {
// ================= 注册检查 =================
/** 查被授权人是不是本服务器注册用户(DatabaseManager.userExistsIgnoreCase)。
* 查不到 / db 未就绪 一律返回 false(保守拒绝,让运维改 json 兜底)。 */
/** 查被授权人是不是本服务器注册用户(DatabaseManager.userExistsStrict 严格版)。
* 查不到 / db 未就绪 / SQL 异常一律返回 false(保守拒绝,让运维改 json 兜底)。
* 2026-10-11:userExistsIgnoreCase 在 SQL 异常时会放行(为封禁防护设计),
* 对 opwl 授权是漏洞——DB 抖动时未注册玩家也能拿到临时 OP,故换严格版。 */
private boolean isRegistered(String name) {
if (name == null || name.isEmpty()) return false;
DatabaseManager db = plugin.getDb();
if (db == null) return false;
try {
return db.userExistsIgnoreCase(name);
return db.userExistsStrict(name);
} catch (Throwable t) {
plugin.getLogger().warning("[OP白名单] 注册检查异常: " + t.getMessage());
plugin.getLogger().warning("[OP白名单] 注册检查异常(拒绝): " + t.getMessage());
return false;
}
}
@@ -1425,6 +1517,6 @@ public class OpWhiteListManager implements Listener {
/** 日志里去掉颜色符号,避免控制台刷屏乱码 */
private static String stripColor(String s) {
return s == null ? "" : s.replaceAll("(?i)&[0-9a-k]", "").replaceAll("\u00a7[0-9a-k]", "");
return s == null ? "" : s.replaceAll("(?i)&[0-9a-k]", "").replaceAll("§[0-9a-k]", "");
}
}
@@ -512,8 +512,13 @@ public class PVPArenaManager implements Listener {
if (p == null) return;
String name = p.getName();
// 仅将离场玩家个人记分板切回主世界(其战绩行仍保留在PVP记分板供其余玩家查看)
// ★ 第35批:优先归还公告板,避免玩家看到空白侧边栏
if (p.isOnline()) {
p.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
if (plugin.getAnnouncementBoard() != null) {
plugin.getAnnouncementBoard().applyTo(p, true);
} else {
p.setScoreboard(Bukkit.getScoreboardManager().getMainScoreboard());
}
}
// 世界未被销毁 → 保留所有参与者战绩,仅刷新榜面(含已离开玩家)
if (pvpScoreboard != null) {
@@ -1800,7 +1805,7 @@ public class PVPArenaManager implements Listener {
killerSession);
}
((Player) killer).sendMessage("\u00a7a\u00a7l击杀 " + vName + "\u00a7a 当前击杀: " + pvpKills.get(kName));
((Player) killer).sendMessage("§a§l击杀 " + vName + "§a 当前击杀: " + pvpKills.get(kName));
}
pvpDeaths.put(player.getName(), pvpDeaths.getOrDefault(player.getName(), 0) + 1);
// ★ 非击杀死亡也持久化(如摔死/溺水)
+18
View File
@@ -417,6 +417,16 @@ public class PVPManager implements Listener {
return regions.keySet();
}
/**
* ★ 第35批:玩家当前是否在某个 PVP 区域内。
* 公告计分板据此避让 —— PVP 区域/竞技场内的侧边栏归 PVP 模块管,
* 玩家离开 PVP 区域后由公告板接管回去。
*/
public boolean isInPVPRegion(String playerName) {
if (playerName == null) return false;
return inRegion.get(playerName) != null;
}
// ===== 加载 =====
private void loadRegions() {
@@ -1017,6 +1027,14 @@ public class PVPManager implements Listener {
private void removeScoreboard(
Player p) {
// ★ 第35批:PVP 区域结束后把侧边栏还给公告板
// (原实现塞一个空计分板,玩家看到空白侧边栏)
// force=true:此时 inRegion 已移除,强制归还公告板
if (plugin.getAnnouncementBoard() != null) {
plugin.getAnnouncementBoard()
.applyTo(p, true);
return;
}
ScoreboardManager mgr =
Bukkit.getScoreboardManager();
p.setScoreboard(
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -135,7 +135,7 @@ public class SnManager implements Listener {
/** 作废实物拦截提示限流(玩家名 -> 上次提示时间) */
private final Map<String, Long> deadMsgAt = new ConcurrentHashMap<>();
/** PHP 命令执行结果缓冲(id + \u0001 + result),由 tickSync 回传 */
/** PHP 命令执行结果缓冲(id + U+0001 分隔符 + result),由 tickSync 回传 */
private final List<String> ackBuf = new ArrayList<String>();
/** 目录快照节流计数(每 3 轮 tickSync 推一次全量) */
@@ -1999,7 +1999,7 @@ public class SnManager implements Listener {
if (acks != null) {
List<Map<String, Object>> rows = new ArrayList<Map<String, Object>>();
for (String a : acks) {
int p = a.indexOf('\u0001');
int p = a.indexOf(UnicodeChars.SEPARATOR);
if (p <= 0) continue;
Map<String, Object> m = new LinkedHashMap<String, Object>();
m.put("id", Long.parseLong(a.substring(0, p)));
@@ -2187,7 +2187,7 @@ public class SnManager implements Listener {
}
if (ackId >= 0 && result != null) {
synchronized (ackBuf) {
ackBuf.add(ackId + "\u0001" + result);
ackBuf.add(ackId + UnicodeChars.SEPARATOR_TEXT + result);
}
}
return result == null ? "" : result;
@@ -0,0 +1,186 @@
package Sdf1_login;
import java.io.File;
import java.sql.*;
/**
* 交易记录数据库(2026-10-11 新增)。
*
* <p>独立 db(交易记录/shop_trades.db),专门存放三类数据:
* <ul>
* <li>{@code normal_shop_orders} —— 正常商店的交易订单(直购 + 购物车结账)</li>
* <li>{@code secondhand_orders} —— 二手商店的交易订单(含手续费与实际到手)</li>
* <li>{@code player_selling_prefs}—— 玩家的出售偏好(奸商模式=买方出 / 正常模式=卖方出)</li>
* </ul>
*
* <p>铁律:db 文件创建即指向子目录,绝不人工移动/改名(WAL 三件套同进同退)。
*/
public class TradeLogManager {
private final Main plugin;
private Connection db;
private boolean ready = false;
public TradeLogManager(Main plugin) {
this.plugin = plugin;
init();
}
private synchronized void init() {
try {
Class.forName("org.sqlite.JDBC");
// ★ 创建即归位到子目录(2026-10-11 db 铁律:路径直接指向目标目录)
File dir = new File(plugin.getDataFolder(), "交易记录");
dir.mkdirs();
File dbFile = new File(dir, "shop_trades.db");
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
try (Statement st = db.createStatement()) {
st.execute("PRAGMA journal_mode=WAL");
st.execute("CREATE TABLE IF NOT EXISTS normal_shop_orders ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "buyer TEXT NOT NULL,"
+ "item_name TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "unit_price INTEGER NOT NULL,"
+ "total_bonds INTEGER NOT NULL,"
+ "discount_pct INTEGER DEFAULT 0,"
+ "source TEXT DEFAULT 'direct',"
+ "created_at INTEGER NOT NULL)");
st.execute("CREATE TABLE IF NOT EXISTS secondhand_orders ("
+ "id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ "listing_id INTEGER NOT NULL,"
+ "buyer TEXT NOT NULL,"
+ "seller TEXT NOT NULL,"
+ "item_name TEXT NOT NULL,"
+ "amount INTEGER NOT NULL,"
+ "price INTEGER NOT NULL,"
+ "fee INTEGER DEFAULT 0,"
+ "fee_mode TEXT DEFAULT 'SELLER',"
+ "buyer_paid INTEGER NOT NULL,"
+ "seller_received INTEGER NOT NULL,"
+ "created_at INTEGER NOT NULL)");
st.execute("CREATE TABLE IF NOT EXISTS player_selling_prefs ("
+ "player_name TEXT PRIMARY KEY,"
+ "fee_mode TEXT NOT NULL,"
+ "updated_at INTEGER NOT NULL)");
}
ready = true;
plugin.getLogger().info("[交易记录] shop_trades.db 初始化完成: "
+ dbFile.getAbsolutePath());
} catch (Exception e) {
ready = false;
plugin.getLogger().warning("[交易记录] 初始化失败: " + e.getMessage());
}
}
// ==================== 正常商店订单 ====================
/**
* 记录一笔正常商店交易订单。
*
* @param source direct=直购 / cart=购物车结账
*/
public void recordNormalOrder(String buyer, String itemName, int amount,
int unitPrice, int totalBonds,
int discountPct, String source) {
if (!ready) return;
try (PreparedStatement ps = db.prepareStatement(
"INSERT INTO normal_shop_orders "
+ "(buyer, item_name, amount, unit_price, total_bonds, "
+ "discount_pct, source, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)")) {
ps.setString(1, nvl(buyer));
ps.setString(2, nvl(itemName));
ps.setInt(3, amount);
ps.setInt(4, unitPrice);
ps.setInt(5, totalBonds);
ps.setInt(6, discountPct);
ps.setString(7, nvl(source));
ps.setLong(8, System.currentTimeMillis());
ps.executeUpdate();
} catch (Exception e) {
plugin.getLogger().warning("[交易记录] 正常订单写入失败: " + e.getMessage());
}
}
// ==================== 二手商店订单 ====================
/**
* 记录一笔二手交易订单(含手续费与双方实际金额)。
*
* @param feeMode BUYER=奸商模式(买方承担手续费)/ SELLER=正常模式(卖方承担)
*/
public void recordSecondHandOrder(long listingId, String buyer, String seller,
String itemName, int amount, int price,
int fee, String feeMode,
int buyerPaid, int sellerReceived) {
if (!ready) return;
try (PreparedStatement ps = db.prepareStatement(
"INSERT INTO secondhand_orders "
+ "(listing_id, buyer, seller, item_name, amount, price, "
+ "fee, fee_mode, buyer_paid, seller_received, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")) {
ps.setLong(1, listingId);
ps.setString(2, nvl(buyer));
ps.setString(3, nvl(seller));
ps.setString(4, nvl(itemName));
ps.setInt(5, amount);
ps.setInt(6, price);
ps.setInt(7, fee);
ps.setString(8, nvl(feeMode));
ps.setInt(9, buyerPaid);
ps.setInt(10, sellerReceived);
ps.setLong(11, System.currentTimeMillis());
ps.executeUpdate();
} catch (Exception e) {
plugin.getLogger().warning("[交易记录] 二手订单写入失败: " + e.getMessage());
}
}
// ==================== 玩家出售偏好 ====================
/** 取玩家出售偏好;无记录返回 null(由调用方回退全局配置)。 */
public String getSellingPref(String player) {
if (!ready || player == null) return null;
try (PreparedStatement ps = db.prepareStatement(
"SELECT fee_mode FROM player_selling_prefs WHERE player_name = ?")) {
ps.setString(1, player);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) return rs.getString(1);
}
} catch (Exception e) {
plugin.getLogger().warning("[交易记录] 读取偏好失败: " + e.getMessage());
}
return null;
}
/** 写入/覆盖玩家出售偏好(BUYER=奸商模式 / SELLER=正常模式)。 */
public void setSellingPref(String player, String feeMode) {
if (!ready || player == null || feeMode == null) return;
try (PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO player_selling_prefs "
+ "(player_name, fee_mode, updated_at) VALUES (?, ?, ?)")) {
ps.setString(1, player);
ps.setString(2, feeMode);
ps.setLong(3, System.currentTimeMillis());
ps.executeUpdate();
} catch (Exception e) {
plugin.getLogger().warning("[交易记录] 写入偏好失败: " + e.getMessage());
}
}
private static String nvl(String s) {
return s == null ? "" : s;
}
public synchronized void close() {
try {
if (db != null) db.close();
} catch (Exception ignored) {
}
}
}
+240
View File
@@ -0,0 +1,240 @@
package Sdf1_login;
/**
* Unicode 特殊字符常量表。
*
* <p>背景:项目里原先大量使用 Unicode 转义字面量(写成 U+XXXX 形式),
* 读代码的人必须逐个心算码位才能看懂(例如 U+00A7 其实是 MC 颜色码 §)。
* 尤其不可见字符(零宽空格、BOM、全角空格…)直接写成字面量后
* 在编辑器里根本"看不见",反而更难排查。</p>
*
* <p>因此本类把两类字符分开处理:</p>
* <ul>
* <li><b>可见字符</b>(§、:、全角数字、汉字等)→ 直接在业务代码里写字面量,
* 一眼可读;</li>
* <li><b>不可见字符</b>(零宽空格、BOM、全角空格、U+0001 分隔符等)→
* 统一引用本类的具名常量,既可读又不会因"看不见"而误判。</li>
* </ul>
*
* <p>注意:正则表达式字符串里的 Unicode 转义属于<b>正则层</b>转义
* (Java 字符串里存的是字面反斜杠+u,由正则引擎在运行期解释),
* 改写会改变正则语义。本项目已把正则层里<b>可见</b>的转义全部改写成
* 真实字符或本类的具名常量;<b>不可见</b>的转义则通过本类常量参与拼接。</p>
*/
public final class UnicodeChars {
private UnicodeChars() {
}
// ==================== 不可见字符(必须用具名常量) ====================
/** U+0001 SOH —— SnManager 与 PHP 命令执行结果之间的分隔符 */
public static final char SEPARATOR = '\u0001';
/** U+00A0 不间断空格(NO-BREAK SPACE) */
public static final char NBSP = '\u00A0';
/** U+00AD 软连字符(SOFT HYPHEN) */
public static final char SOFT_HYPHEN = '\u00AD';
/** U+2000 EN QUAD —— Unicode 各种空格区起点 */
public static final char SPACE_U2000 = '\u2000';
/** U+200A HAIR SPACE —— Unicode 各种空格区终点 */
public static final char SPACE_U200A = '\u200A';
/** U+200B 零宽空格(ZERO WIDTH SPACE) */
public static final char ZERO_WIDTH_SPACE = '\u200B';
/** U+200C 零宽非连接符(ZERO WIDTH NON-JOINER) */
public static final char ZERO_WIDTH_NON_JOINER = '\u200C';
/** U+200D 零宽连接符(ZERO WIDTH JOINER) */
public static final char ZERO_WIDTH_JOINER = '\u200D';
/** U+200E 从左至右标记(LTR MARK) */
public static final char LTR_MARK = '\u200E';
/** U+200F 从右至左标记(RTL MARK) */
public static final char RTL_MARK = '\u200F';
/** U+2028 行分隔符(LINE SEPARATOR) */
public static final char LINE_SEPARATOR = '\u2028';
/** U+2029 段分隔符(PARAGRAPH SEPARATOR) */
public static final char PARAGRAPH_SEPARATOR = '\u2029';
/** U+2060 字连接符(WORD JOINER) */
public static final char WORD_JOINER = '\u2060';
/** U+3000 表意空格(IDEOGRAPHIC SPACE,即"全角空格") */
public static final char IDEOGRAPHIC_SPACE = '\u3000';
/** U+FEFF 零宽不换行空格 / BOM(ZERO WIDTH NO-BREAK SPACE) */
public static final char BOM = '\uFEFF';
// ==================== 区间常量(不可见/边界判定用) ====================
/** CJK 基本汉字区起点 U+4E00(一) */
public static final char CJK_START = '一';
/** CJK 基本汉字区终点 U+9FFF */
public static final char CJK_END = '鿿';
/** CJK 兼容汉字常用终点 U+9FA5(龥) */
public static final char CJK_COMPAT_END = '龥';
/** CJK 扩展 A 区起点 U+3400(㐀) */
public static final char CJK_EXT_A_START = '㐀';
/** CJK 扩展 A 区终点 U+4DBF(䶿) */
public static final char CJK_EXT_A_END = '䶿';
/** 全角 ASCII 起点 U+FF01(!) */
public static final char FULLWIDTH_START = '!';
/** 全角 ASCII 终点 U+FF5E(~) */
public static final char FULLWIDTH_END = '~';
/** 全角数字 0 U+FF10 */
public static final char FULLWIDTH_ZERO = '0';
/** 全角数字 9 U+FF19 */
public static final char FULLWIDTH_NINE = '9';
/** 全角大写 A U+FF21 */
public static final char FULLWIDTH_A_UPPER = 'A';
/** 全角大写 Z U+FF3A */
public static final char FULLWIDTH_Z_UPPER = 'Z';
/** 全角小写 a U+FF41 */
public static final char FULLWIDTH_A_LOWER = 'a';
/** 全角小写 z U+FF5A */
public static final char FULLWIDTH_Z_LOWER = 'z';
/** 全角左括号 U+FF08 */
public static final char FULLWIDTH_LPAREN = '(';
/** 全角右括号 U+FF09 */
public static final char FULLWIDTH_RPAREN = ')';
/** 全角井号 U+FF03 */
public static final char FULLWIDTH_HASH = '#';
/** 全角逗号 U+FF0C */
public static final char FULLWIDTH_COMMA = ',';
/** 全角分号 U+FF1B */
public static final char FULLWIDTH_SEMICOLON = ';';
/** 全角冒号 U+FF1A */
public static final char FULLWIDTH_COLON = ':';
/** 全角句点 U+FF0E */
public static final char FULLWIDTH_STOP = '.';
/** 全角波浪号 U+FF5E */
public static final char FULLWIDTH_TILDE = '~';
/** 半角片假名中点 U+FF65 */
public static final char HALFWIDTH_KATAKANA_MIDDLE_DOT = '・';
/** 小型句点 U+FE52 */
public static final char SMALL_FULL_STOP = '﹒';
/** 二点引导符 U+2025 */
public static final char TWO_DOT_LEADER = '‥';
/** 水平省略号 U+2026 */
public static final char HORIZONTAL_ELLIPSIS = '…';
/** 中点 U+00B7 */
public static final char MIDDLE_DOT = '·';
/** 项目符号 U+2022 */
public static final char BULLET = '•';
/** 表意句点 U+3002 */
public static final char IDEOGRAPHIC_STOP = '。';
/** 表意逗号 U+3001 */
public static final char IDEOGRAPHIC_COMMA = '、';
/** 表意数字零 U+3007(〇) */
public static final char IDEOGRAPHIC_ZERO = '〇';
/** 左黑 lenticular 方括号 U+3010(【) */
public static final char LEFT_BLACK_LENTICULAR = '【';
/** 右黑 lenticular 方括号 U+3011(】) */
public static final char RIGHT_BLACK_LENTICULAR = '】';
/** 左 tortoise shell 括号 U+3014(〔) */
public static final char LEFT_TORTOISE_SHELL = '〔';
/** 右 tortoise shell 括号 U+3015(〕) */
public static final char RIGHT_TORTOISE_SHELL = '〕';
/** 左直角引号 U+300C(「) */
public static final char LEFT_CORNER_BRACKET = '「';
/** 右直角引号 U+300D」(」) */
public static final char RIGHT_CORNER_BRACKET = '」';
/** 左白直角引号 U+300E(『) */
public static final char LEFT_WHITE_CORNER_BRACKET = '『';
/** 右白直角引号 U+300F(』) */
public static final char RIGHT_WHITE_CORNER_BRACKET = '』';
// ==================== 常用字符串常量 ====================
/** MC 颜色码前缀 § */
public static final String COLOR_SIGN = "§";
/** 全角冒号 ":" */
public static final String WIDE_COLON = ":";
// ==================== 字符串版(用于拼接场景) ====================
// 注意:char 常量参与字符串拼接时若两侧都是 char 会被当成数值加法,
// 因此需要拼接的不可见字符一律用这里的 String 版常量。
/** U+FEFF BOM 的字符串版 */
public static final String BOM_TEXT = "\uFEFF";
/** U+0001 分隔符的字符串版 */
public static final String SEPARATOR_TEXT = "\u0001";
/** U+3000 全角空格的字符串版 */
public static final String IDEOGRAPHIC_SPACE_TEXT = "\u3000";
/**
* 零宽/不可见字符集合(聊天变形 URL 归一前剔除用)。
* 含:零宽空格、零宽非连接、零宽连接、字连接符、BOM、软连字符。
*/
public static final String INVISIBLE_CHARS = "\u200B\u200C\u200D\u2060\uFEFF\u00AD";
/** U+200B 零宽空格的字符串版 */
public static final String ZERO_WIDTH_SPACE_TEXT = "\u200B";
/** U+200D 零宽连接符的字符串版 */
public static final String ZERO_WIDTH_JOINER_TEXT = "\u200D";
/** U+2060 字连接符的字符串版 */
public static final String WORD_JOINER_TEXT = "\u2060";
/** U+00A0 不间断空格的字符串版 */
public static final String NBSP_TEXT = "\u00A0";
/**
* 需要剔除的不可见字符(正则字符类形式,净化玩家输入用)。
* 含:零宽空格~零宽连接(区间)、字连接符、BOM、不间断空格、全角空格。
* <p>这里用具名常量拼接,而不是转义写法——这样源码里能直接看出
* 每个字符的用途,读代码的人不必去心算码位。</p>
*/
public static final String INVISIBLE_STRIP_CLASS =
"[" + ZERO_WIDTH_SPACE_TEXT + "-" + ZERO_WIDTH_JOINER_TEXT
+ WORD_JOINER_TEXT + BOM_TEXT + NBSP_TEXT + IDEOGRAPHIC_SPACE_TEXT + "]";
}
+39 -3
View File
@@ -56,7 +56,10 @@ public class UserGroupManager {
+ "default_perms TEXT DEFAULT '{}'," // JSON: 默认deny_*标志
+ "is_permanent INTEGER DEFAULT 1," // 保留兼容
+ "duration_minutes INTEGER DEFAULT 0," // 保留兼容
+ "max_effect_level INTEGER DEFAULT 256" // 药效等级上限,<=0=按原版256
+ "max_effect_level INTEGER DEFAULT 256," // 药效等级上限,<=0=按原版256
// === 二手商店相关(2026-10-11)===
+ "shop_max_listings INTEGER DEFAULT -1," // -1 = 使用全局值
+ "shop_min_price INTEGER DEFAULT -1" // -1 = 使用全局值
+ ")");
// 玩家↔用户组关联
@@ -84,6 +87,9 @@ public class UserGroupManager {
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN max_effects INTEGER DEFAULT 5"); } catch (Exception ignored) {}
// ★ 药效等级上限(2026-10-04 任务4):默认256=原版最大药效强度,<=0=按原版
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN max_effect_level INTEGER DEFAULT 256"); } catch (Exception ignored) {}
// ★ 二手商店(2026-10-11):上架数量/最低售价,-1=跟随全局
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN shop_max_listings INTEGER DEFAULT -1"); } catch (Exception ignored) {}
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN shop_min_price INTEGER DEFAULT -1"); } catch (Exception ignored) {}
// duration_minutes已有(旧列),复用
st.close();
@@ -118,6 +124,10 @@ public class UserGroupManager {
if (rs.wasNull()) cfg.maxEffects = 5;
cfg.maxEffectLevel = rs.getInt("max_effect_level");
if (rs.wasNull()) cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL;
cfg.shopMaxListings = rs.getInt("shop_max_listings");
if (rs.wasNull()) cfg.shopMaxListings = -1;
cfg.shopMinPrice = rs.getInt("shop_min_price");
if (rs.wasNull()) cfg.shopMinPrice = -1;
if (cfg.name != null && !cfg.name.isEmpty()) {
groupConfigs.put(cfg.name, cfg);
}
@@ -139,8 +149,8 @@ public class UserGroupManager {
+ "(group_name, display_name, display_color,"
+ " priority, land_price_per_sqm, max_lands, default_perms,"
+ " home_limit, join_price, auto_renew, renew_price, duration_minutes,"
+ " max_effects, max_effect_level)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
+ " max_effects, max_effect_level, shop_max_listings, shop_min_price)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
ps.setString(1, cfg.name);
ps.setString(2, cfg.displayName);
ps.setString(3, cfg.displayColor);
@@ -155,6 +165,8 @@ public class UserGroupManager {
ps.setLong(12, cfg.durationMinutes);
ps.setInt(13, cfg.maxEffects);
ps.setInt(14, cfg.maxEffectLevel);
ps.setInt(15, cfg.shopMaxListings);
ps.setInt(16, cfg.shopMinPrice);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
@@ -975,6 +987,28 @@ public class UserGroupManager {
return cfg.maxLands >= 0 ? cfg.maxLands : globalDefault;
}
/**
* ★ 二手商店:获取玩家的可上架数量上限(2026-10-11)
* 组配置 >=0 用组值(0=禁止上架),-1=跟随全局
*/
public int getPlayerShopMaxListings(String player, int globalDefault) {
if (player == null || player.isEmpty()) return globalDefault;
UserGroupConfig cfg = getHighestGroup(player);
if (cfg == null) return globalDefault;
return cfg.shopMaxListings >= 0 ? cfg.shopMaxListings : globalDefault;
}
/**
* ★ 二手商店:获取玩家的最低售价(2026-10-11)
* 组配置 >=0 用组值,-1=跟随全局
*/
public int getPlayerShopMinPrice(String player, int globalDefault) {
if (player == null || player.isEmpty()) return globalDefault;
UserGroupConfig cfg = getHighestGroup(player);
if (cfg == null) return globalDefault;
return cfg.shopMinPrice >= 0 ? cfg.shopMinPrice : globalDefault;
}
/**
* ★ 领地增益效果数量上限(2026-10-04 任务3)
* 普通玩家(无用户组)走 globalDefault=5;用户组按组配置;
@@ -1202,6 +1236,8 @@ public class UserGroupManager {
public int maxLands = -1; // -1 = 使用全局
public int maxEffects = 5; // 领地增益效果上限,<=0=不限
public int maxEffectLevel = DEFAULT_MAX_EFFECT_LEVEL; // 药效等级上限,<=0=按原版256
public int shopMaxListings = -1; // 二手商店上架数量上限,-1=跟随全局
public int shopMinPrice = -1; // 二手商店最低售价,-1=跟随全局
public String defaultPerms = "{}"; // JSON
// Home相关
public int homeLimit = 0; // 0=跟随全局, >0=独立限制, -1=无限
+406 -33
View File
@@ -159,6 +159,16 @@ public class WebManager {
settingsFileStamp = stamp;
// ★ 第31批(#204):web 感知到 插件设置.txt 变化 → 拉起整份文件重载
// (垃圾站等其他模块的配置字段;ConfigManager 内部比对,无变化静默)
// ★ 第42批:重载成功后把垃圾站启停应用到运行态(否则只改字段不生效)
if (plugin.getConfig2() != null
&& plugin.getConfig2().reloadSettingsIfChanged()) {
if (plugin.getGarbage() != null) {
plugin.getGarbage().applyEnabledState();
}
}
if (!urlChanged && !secretChanged && !enableChanged && !othersChanged) {
return; // 文件动了但 Web 段没变(如改了别的模块)→ 安静走人
}
@@ -321,8 +331,11 @@ public class WebManager {
private String lastBondBalanceHash = "";
private String lastLandDataHash = "";
private String lastBansHash = "";
private boolean bansCreatedPushed = false; // ★ 第十九批:进程内是否已把 created/起点推给PHP过一次
private long lastPushedCutoff = -1; // 上次推送时的播报起点(变了就再强制推一轮)
private String lastAdminsHash = "";
private String lastUserRegistrationHash = "";
private String lastBlacklistHash = ""; // ★ 第32批:拉黑名单同步快照
// 嵌入式HTTP服务器(接收PHP回调)
private java.net.ServerSocket callbackServer;
@@ -3081,6 +3094,12 @@ public class WebManager {
private static final int SHOP_STOCK_POLL_FAIL_THRESHOLD = 10;
/** 防重入锁:pullShopStock执行期间不重复拉取 */
private volatile boolean shopStockPulling = false;
/** ★ 库存双向同步(2026-10-10):本地库存快照 id->stock(盘上持久化 shop_stock_snapshot.properties)。
* 与 md 当前值比对得出本地未推送差异 -> push 回 PHP;pull 应用 PHP 值时若本地有未推送差异则跳过覆盖
* (哪边有改动就对齐哪边,数据分叉以 Java 为主)。 */
private final Map<String, Integer> shopStockSnapshot = new java.util.concurrent.ConcurrentHashMap<>();
private volatile boolean shopStockSnapshotLoaded = false;
private volatile long lastShopStockPushLog = 0;
/**
* 库存高频轮询:每15秒(±5)请求Web端库存变更检测接口
@@ -3094,6 +3113,8 @@ public class WebManager {
if (!initialSyncComplete) return;
try {
if (shopStockPulling) return;
// ★ 双向同步:每轮 poll 先把本地库存差异推回 PHP(哪边有改动就对齐哪边)
pushShopStockDiffs();
if (shopStockPollFailCount >= SHOP_STOCK_POLL_FAIL_THRESHOLD) {
if (shopStockPollFailCount % 12 != 0) {
shopStockPollFailCount++;
@@ -3174,6 +3195,8 @@ public class WebManager {
*/
private boolean pullShopStockSync() {
try {
// ★ 即时/兜底拉取路径也先推本地差异,保证 push 不依赖 poll 节奏
pushShopStockDiffs();
String urlStr = webBaseUrl + "/api/sync.php?action=pull_shop_stock&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8");
@@ -3320,7 +3343,14 @@ public class WebManager {
// ★ 直接检查sdf1计分板CDK(bond.db只是钱包,不存CDK)
String status = "not_found";
int amount = 0;
try {
// ★ 第32批:被拉黑玩家的兑换请求 → 直接回 blacklisted,
// 不进验证链、不加债券(PHP cdk.php 据此明确提示「你已被拉黑」)
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(playerName)) {
status = "blacklisted";
plugin.getLogger().info("[CDK远程验证] " + cdkCode
+ " 拉黑拦截 player=" + playerName);
} else try {
Object sdf1Plugin = Bukkit.getPluginManager().getPlugin("sdf1");
if (sdf1Plugin != null) {
plugin.getLogger().info("[CDK远程验证] 调用sdf1检查计分板: " + cdkCode);
@@ -3425,7 +3455,13 @@ public class WebManager {
String status = "not_found";
int amount = 0;
boolean transientFail = false; // 瞬时故障 -> 可重试
try {
// ★ 第32批:被拉黑玩家 → 直接回 blacklisted,不发起远程验证
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(playerName)) {
status = "blacklisted";
plugin.getLogger().info("[CDK-Web验证] " + cdkCode
+ " 拉黑拦截 player=" + playerName);
} else try {
String validateUrl = webBaseUrl + "/api/sync.php?action=check_cdk_exists&secret="
+ java.net.URLEncoder.encode(secretKey, "UTF-8")
+ "&code=" + java.net.URLEncoder.encode(cdkCode, "UTF-8");
@@ -3958,6 +3994,8 @@ public class WebManager {
* 解码JSON中的 Unicode转义序列
*/
private static String decodeUnicodeEscapes(String str) {
// 这里的 "\\u" 是「反斜杠 + u」两个字符(PHP 端 JSON 的转义标记),
// 不是 Java 的 Unicode 转义——探测原始字符串里是否带转义标记。
if (str == null || !str.contains("\\u")) return str;
try {
StringBuilder sb = new StringBuilder(str.length());
@@ -4282,11 +4320,15 @@ public class WebManager {
new String[]{},
alignMap(), false, false));
// —— 封禁名单(Web 后台加的封禁要拉回游戏内生效)——
// ★ 29批:map 增加 created_time→created —— PHP check_alignment 的 cols 已带上
// created_time,收回行才能携带真实封禁创建时间;hashcols 故意不含 created,
// 指纹不变(防两端 created 精度/缺失差异恒 changed 死循环)。
ALIGN_CFGS.put("bans", new AlignCfg("bans",
new String[]{"target", "ban_type"},
new String[]{"target", "ban_type", "reason", "source", "expire_time"},
new String[]{"expire_time"},
alignMap("ban_type", "type", "expire_time", "expire"), true, false));
alignMap("ban_type", "type", "expire_time", "expire",
"created_time", "created"), true, false));
// —— 领地数据(PHP web_area_lands;游戏内 area_lands 为唯一权威)——
// pull=false:Web 后台的领地改动不回流 Java(与改造前一致)。
// pushOnExtra=true:PHP 多出的行(游戏里已删 / 后台自建)→ 触发一次整包推,
@@ -4751,8 +4793,16 @@ public class WebManager {
}
// 真实签名:addBan(target, reason, expires, source)
// Date 必须全限定——本文件同时 import 了 java.sql.* 与 java.util.*
banList.addBan(target, reason,
// ★ 29批:收回行现在携带 PHP 侧创建时间(check_alignment cols 已含
// created_time→created)。addBan 默认 created=now,若不回填,
// 生产封禁数据拷到测试服后「收回」一轮会把所有封禁的创建时间刷成
// 当天 → 封禁播报全员乱叫(2026-10-22 用户实测)。
org.bukkit.BanEntry<?> pulled = banList.addBan(target, reason,
expire > 0 ? new java.util.Date(expire) : null, source);
long created = longOf(row.get("created"));
if (pulled != null && created > 0) {
pulled.setCreated(new java.util.Date(created));
}
plugin.getLogger().info("[对账] 已收回 Web 端封禁: " + (isIp ? "IP " : "") + target);
return true;
}
@@ -5262,6 +5312,8 @@ public class WebManager {
// ★ 封禁名单和管理员列表独立于领地hash变化,必须每次都同步
syncBans();
syncAdmins();
// ★ 第32批:拉黑名单同样每轮兜底(PHP重启丢数据时自愈)
syncBlacklist();
// ★ 双向对账(多退少补):PHP 数据齐全就不推 —— 消除「重启必整包推」
// (lastLandDataHash 是内存字段,重启清零,旧逻辑每次开服必整包一次)。
@@ -5412,6 +5464,8 @@ public class WebManager {
gs.append("\"max_lands\":").append(cfg.maxLands).append(",");
gs.append("\"max_effects\":").append(cfg.maxEffects).append(",");
gs.append("\"max_effect_level\":").append(cfg.maxEffectLevel).append(",");
gs.append("\"shop_max_listings\":").append(cfg.shopMaxListings).append(",");
gs.append("\"shop_min_price\":").append(cfg.shopMinPrice).append(",");
gs.append("\"home_limit\":").append(cfg.homeLimit).append(",");
gs.append("\"join_price\":").append(cfg.joinPrice).append(",");
gs.append("\"auto_renew\":").append(cfg.autoRenew ? 1 : 0).append(",");
@@ -5529,6 +5583,62 @@ public class WebManager {
syncAdmins();
}
// ★ 第32批(#208):拉黑名单同步到PHP(web_player_blacklist),
// 供 PHP 后台识别被拉黑玩家并给出明确提示(登录/兑换码双链路)。
/**
* 同步拉黑名单到PHP。参照 syncBans 模板:
* hash 门控(无变化静默)→ POST → 回执 success 才提交 hash(防 404 写脏)。
* PHP 侧 sync_blacklist 做全量覆盖,无需对账。
*/
private void syncBlacklist() {
if (!enabled) return;
try {
BlacklistManager blm = plugin.getBlacklist();
java.util.List<BlacklistManager.BlackEntry> list =
blm != null ? blm.getActiveEntries() : null;
StringBuilder sb = new StringBuilder("[");
StringBuilder hb = new StringBuilder();
boolean first = true;
if (list != null) {
for (BlacklistManager.BlackEntry e : list) {
if (!first) sb.append(",");
first = false;
sb.append("{\"name\":\"").append(escapeJson(e.name))
.append("\",\"time\":\"").append(escapeJson(e.timeText))
.append("\",\"reason\":\"").append(escapeJson(e.reason))
.append("\",\"expire\":").append(e.expiresMs)
.append(",\"created\":").append(e.createdMs).append("}");
hb.append(e.name).append(":").append(e.expiresMs)
.append(":").append(e.reason).append("|");
}
}
sb.append("]");
String currentHash = hb.toString();
if (currentHash.equals(lastBlacklistHash)) return; // 无变化静默
Map<String, Object> body = new LinkedHashMap<>();
body.put("secret", secretKey);
body.put("blacks", sb.toString());
String resp = httpPost("api/sync.php?action=sync_blacklist", mapToJson(body));
if (resp != null && resp.contains("\"success\":true")) {
lastBlacklistHash = currentHash;
plugin.getLogger().info("[防护-sync] 拉黑名单同步: "
+ (list == null ? 0 : list.size()) + "人 → " + resp);
} else {
plugin.getLogger().warning("[防护-sync] 拉黑名单同步未确认成功,下轮重试(hash 不提交)");
}
} catch (Exception e) {
plugin.getLogger().warning("[防护-sync] 拉黑名单同步异常: " + e.getMessage());
}
}
/** ★ 第32批:拉黑/解除时立即异步同步(不等定时轮询;内部 hash 门控兜底) */
public void forceSyncBlacklist() {
if (!enabled) return;
Bukkit.getScheduler().runTaskAsynchronously(plugin, this::syncBlacklist);
}
/**
* ★ 同步封禁名单到PHP(供用户管理页面显示封禁状态)
*/
@@ -5552,10 +5662,13 @@ public class WebManager {
String reason = entry.getReason() != null ? entry.getReason() : "";
String source = entry.getSource() != null ? entry.getSource() : "";
long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0;
// ★ 第十九批:封禁创建时间(毫秒)一并下发 —— PHP 公示按它过滤插件上架前的历史封禁
long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0;
sb.append("{\"type\":\"name\",\"target\":\"").append(escapeJson(target))
.append("\",\"reason\":\"").append(escapeJson(reason))
.append("\",\"source\":\"").append(escapeJson(source))
.append("\",\"expire\":").append(expireDate).append("}");
.append("\",\"expire\":").append(expireDate)
.append(",\"created\":").append(createdDate).append("}");
hashBuilder.append("n:").append(target).append(":").append(reason).append(":").append(expireDate).append("|");
}
@@ -5569,10 +5682,12 @@ public class WebManager {
String reason = entry.getReason() != null ? entry.getReason() : "";
String source = entry.getSource() != null ? entry.getSource() : "";
long expireDate = entry.getExpiration() != null ? entry.getExpiration().getTime() : 0;
long createdDate = entry.getCreated() != null ? entry.getCreated().getTime() : 0;
sb.append("{\"type\":\"ip\",\"target\":\"").append(escapeJson(target))
.append("\",\"reason\":\"").append(escapeJson(reason))
.append("\",\"source\":\"").append(escapeJson(source))
.append("\",\"expire\":").append(expireDate).append("}");
.append("\",\"expire\":").append(expireDate)
.append(",\"created\":").append(createdDate).append("}");
hashBuilder.append("i:").append(target).append(":").append(reason).append(":").append(expireDate).append("|");
}
@@ -5586,14 +5701,20 @@ public class WebManager {
Set<org.bukkit.BanEntry<?>> ib0 = (Set<org.bukkit.BanEntry<?>>) (Set<?>) ipBanList.getEntries();
for (org.bukkit.BanEntry<?> e0 : ib0) banRows.add(banRowOf(e0, "ip"));
AlignResult arAlign = alignGate("bans", banRows);
if (arAlign != null && !arAlign.needPush()) return; // 零发送
// ★ 第十九批:created/ban_cutoff(PHP公示过滤历史封禁用)不进对账列,两端行一致时
// alignGate 判「零发送」会把它们永远拦住 —— 进程内首轮 & 起点配置变化时强制整包推一轮;
// 只绕过「零发送」,「本轮只收不发」保持原样(pull 后立即整包推会先把 PHP 多的行抹掉)。
BanBroadcastManager bbCut = BanBroadcastManager.get();
long cutoffNow = bbCut != null ? bbCut.getStartCutoff() : 0L;
boolean forceCreatedPush = !bansCreatedPushed || cutoffNow != lastPushedCutoff;
if (arAlign != null && !arAlign.needPush() && !forceCreatedPush) return; // 零发送
if (arAlign != null && arAlign.pulled > 0) return; // 本轮只收不发
sb.append("]");
String currentHash = hashBuilder.toString();
// ★ 对账明说要推时本地 hash 不得拦截(同 admins,2026-10-06 灾备修复)
boolean alignNeedsPushBans = arAlign != null && arAlign.needPush();
if (!alignNeedsPushBans && currentHash.equals(lastBansHash)) {
if (!alignNeedsPushBans && !forceCreatedPush && currentHash.equals(lastBansHash)) {
plugin.getLogger().fine("[防护-sync] 封禁名单无变化,跳过");
return;
}
@@ -5620,12 +5741,16 @@ public class WebManager {
body.put("secret", secretKey);
body.put("bans", sb.toString());
body.put("ips", ipsb.toString());
// ★ 第十九批:连同「播报起点」一起推给PHP(get_ban_board 用它过滤插件安装前的历史封禁)
body.put("ban_cutoff", String.valueOf(cutoffNow));
String resp = httpPost("api/sync.php?action=sync_bans", mapToJson(body));
plugin.getLogger().info("[防护-sync] 封禁名单同步: " + (nameEntries.size() + ipEntries.size())
+ "条(IP黑名单 " + ipOnlyEntries.size() + "个) → " + resp);
// ★ 关键修复:hash 成功后才提交(防 404 时把 hash 写脏 → 永久漏推,2026-10-06)
if (resp != null && resp.contains("\"success\":true")) {
lastBansHash = currentHash;
bansCreatedPushed = true;
lastPushedCutoff = cutoffNow;
} else {
plugin.getLogger().warning("[防护-sync] 封禁名单同步未确认成功,下轮重试(hash 不提交)");
}
@@ -5702,6 +5827,8 @@ public class WebManager {
|| existing.autoRenew != cfg.autoRenew
|| existing.renewPrice != cfg.renewPrice
|| existing.durationMinutes != cfg.durationMinutes
|| existing.shopMaxListings != cfg.shopMaxListings
|| existing.shopMinPrice != cfg.shopMinPrice
|| !safeEq(existing.defaultPerms, cfg.defaultPerms);
if (isDifferent) {
ugm.saveGroupConfigToDB(cfg);
@@ -5857,6 +5984,11 @@ public class WebManager {
catch (Exception ignored) { cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL; }
}
if (cfg.maxEffectLevel <= 0) cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL;
// ★ 二手商店(2026-10-11):上架数量/最低售价,-1=跟随全局
String shMaxStr = extractJsonField(obj, "shop_max_listings");
if (!shMaxStr.isEmpty()) try { cfg.shopMaxListings = Integer.parseInt(shMaxStr); } catch (Exception ignored) {}
String shMinStr = extractJsonField(obj, "shop_min_price");
if (!shMinStr.isEmpty()) try { cfg.shopMinPrice = Integer.parseInt(shMinStr); } catch (Exception ignored) {}
String homeLimStr = extractJsonField(obj, "home_limit");
if (!homeLimStr.isEmpty()) try { cfg.homeLimit = Integer.parseInt(homeLimStr); } catch (Exception ignored) {}
String joinStr = extractJsonField(obj, "join_price");
@@ -5874,9 +6006,10 @@ public class WebManager {
}
}
/** 解码JSON中的 \\uXXXX Unicode转义(如 \\u00a7 → §) */
/** 解码JSON中的 Unicode 转义(U+XXXX 形式,如 U+00A7 即 §) */
private String decodeJsonUnicode(String s) {
if (s == null || s.isEmpty()) return s;
// 同上:这里探测的是 JSON 里的反斜杠+u 转义标记,不是 Java Unicode 转义。
if (!s.contains("\\u")) return s;
StringBuilder sb = new StringBuilder(s.length());
for (int i = 0; i < s.length(); i++) {
@@ -6081,7 +6214,9 @@ public class WebManager {
try {
Object regTimeObj = plugin.getDb().getField(newOwner, "register_time");
if (regTimeObj instanceof Number) {
registerTime = ((Number) regTimeObj).longValue();
// ★ 第十一批:秒/毫秒混存归一化
registerTime = DurationParser.toMs(
((Number) regTimeObj).longValue());
}
} catch (Exception e) {
// 忽略异常
@@ -6470,7 +6605,9 @@ public class WebManager {
try {
Object regTimeObj = dbMgr.getField(newOwner, "register_time");
if (regTimeObj instanceof Number) {
registerTime = ((Number) regTimeObj).longValue();
// ★ 第十一批:秒/毫秒混存归一化
registerTime = DurationParser.toMs(
((Number) regTimeObj).longValue());
}
} catch (Exception e) {
// 忽略异常
@@ -6862,12 +6999,14 @@ public class WebManager {
private void startTxBatchRun(List<Map<String, Object>> txs) {
final List<Map<String, Object>> queue = new ArrayList<>(txs);
final int total = queue.size();
final int batchCount = (total + TX_SYNC_BATCH_SIZE - 1) / TX_SYNC_BATCH_SIZE;
// ★ 自适应批大小:本轮从下限起探,成功一批加一档、失败当场减半(见 TX_SYNC_BATCH_MIN 注释)
final int atLeastBatches = (total + TX_SYNC_BATCH_MIN - 1) / TX_SYNC_BATCH_MIN;
final int myRunId = txBatchRunSeq.incrementAndGet();
txBatchRunId = myRunId;
txBatchLastProgress = System.currentTimeMillis();
plugin.getLogger().info("[Web交易同步] ▶ 启动本轮推进: 共 " + total + " 笔 / "
+ batchCount + " 批(先点验后补推,多退少补)");
plugin.getLogger().info("[Web交易同步] ▶ 启动本轮推进: 共 " + total + " 笔(先点验后补推,多退少补)"
+ " | 批大小自适应 " + TX_SYNC_BATCH_MIN + "→" + TX_SYNC_BATCH_MAX
+ "(≥" + atLeastBatches + " 批;每批成功 +" + TX_SYNC_BATCH_STEP + ",无回执减半)");
new BukkitRunnable() {
int index = 0;
@@ -6875,6 +7014,10 @@ public class WebManager {
int pushedCount = 0;
/** 当前批的连续失败次数(拿到回执推进后清零) */
int batchRetries = 0;
/** 自适应批大小:每轮从下限起探(见 TX_SYNC_BATCH_MIN 注释) */
int curSize = TX_SYNC_BATCH_MIN;
/** 批号独立计数:批大小自适应后不能由 index 推导;重试同一批时不递增 */
int batchNo = 0;
@Override
public void run() {
@@ -6898,19 +7041,20 @@ public class WebManager {
return;
}
// ★ 批号由 index 推导:重试同一批时批号保持不变,日志不虚增
final int batchNo = index / TX_SYNC_BATCH_SIZE + 1;
List<Map<String, Object>> batch =
queue.subList(index, Math.min(index + TX_SYNC_BATCH_SIZE, queue.size()));
// ★ 批号独立计数:批大小自适应后不能由 index 推导;重试同一批时不递增
batchNo++;
final int size = Math.max(1, Math.min(curSize, queue.size() - index));
List<Map<String, Object>> batch = queue.subList(index, index + size);
plugin.getLogger().info("[Web交易同步] ● 第" + batchNo + "/" + batchCount
plugin.getLogger().info("[Web交易同步] ● 第" + batchNo
+ "批开始(" + batch.size() + " 笔,索引 " + index + "/" + total
+ ",自适应批大小当前=" + size
+ ",本批已重试 " + batchRetries + "/" + TX_SYNC_MAX_BATCH_RETRIES + ")");
boolean ok;
int pushed;
try {
int[] r = syncTxBatch(batch, total, batchNo, batchCount);
int[] r = syncTxBatch(batch, total, batchNo);
ok = (r[0] == 1);
pushed = r[1];
} catch (Exception e) {
@@ -6930,6 +7074,14 @@ public class WebManager {
finishTxBatchRun();
return;
}
// ★ 自适应退避:没拿到回执 → 批大小当场减半(不低于下限),
// 用更小的批重试,顺势探出 PHP 的真实承受力
int beforeDown = curSize;
curSize = Math.max(TX_SYNC_BATCH_MIN, curSize / 2);
if (curSize != beforeDown) {
plugin.getLogger().warning("[Web交易同步] 自适应批大小退避: " + beforeDown
+ " → " + curSize + "(本批没拿到回执,换更小的批重试)");
}
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "批没拿到回执,"
+ (TX_SYNC_RECEIPT_RETRY_MS / 1000) + " 秒后重试同一批(第 "
+ batchRetries + "/" + TX_SYNC_MAX_BATCH_RETRIES + " 次)");
@@ -6940,13 +7092,20 @@ public class WebManager {
batchRetries = 0;
okCount += batch.size();
pushedCount += pushed;
index += TX_SYNC_BATCH_SIZE;
index += size;
// ★ 自适应加压:这批顺利拿到回执 → 批大小加一档(封顶 MAX),一点点试探 PHP 的胃口
int beforeUp = curSize;
curSize = Math.min(TX_SYNC_BATCH_MAX, curSize + TX_SYNC_BATCH_STEP);
if (curSize != beforeUp) {
plugin.getLogger().info("[Web交易同步] 自适应批大小加压: " + beforeUp + " → " + curSize);
}
// ★ 回执节奏:HTTP success 即回执,收到才推下一批;
// 真推了 PHP 的批次再冷却 15 秒让 PHP 消化,纯对账跳过的批次走短间隔
long stepMs = pushed > 0 ? TX_SYNC_RECEIPT_STEP_MS : TX_SYNC_VERIFY_STEP_MS;
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "批回执已收到(补推 "
+ pushed + " 笔)," + (stepMs / 1000) + " 秒后继续下一批");
+ pushed + " 笔,下批大小=" + curSize + "),"
+ (stepMs / 1000) + " 秒后继续下一批");
scheduleTxStep(this, stepMs, myRunId);
}
}.runTaskLaterAsynchronously(plugin, 1L);
@@ -7007,7 +7166,7 @@ public class WebManager {
* 处理一批交易:先与 PHP 点验这批序列号,只补推缺失部分,成功后推进水位线
* 返回 int[]{ 成功?(1:0), 实际补推笔数 }
*/
private int[] syncTxBatch(List<Map<String, Object>> batch, int total, int batchNo, int batchCount) {
private int[] syncTxBatch(List<Map<String, Object>> batch, int total, int batchNo) {
List<Long> ids = new ArrayList<>(batch.size());
for (Map<String, Object> tx : batch) {
Object iv = tx.get("id");
@@ -7026,7 +7185,7 @@ public class WebManager {
if (missing.isEmpty()) {
// PHP 已经全都有 → 一笔都不推,只推进水位线(本批只花 1 次轻量对账请求)
advanceTxWatermark(batch);
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount
plugin.getLogger().info("[Web交易同步] 第" + batchNo
+ "批 PHP 已齐全(" + batch.size() + " 笔),跳过推送");
return new int[]{1, 0};
}
@@ -7038,11 +7197,11 @@ public class WebManager {
toPush.add(tx);
}
}
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount
plugin.getLogger().info("[Web交易同步] 第" + batchNo
+ "批 仅缺 " + toPush.size() + "/" + batch.size() + " 笔,只补推缺失部分");
}
if (!pushTxBatch(toPush, total, batchNo, batchCount)) {
if (!pushTxBatch(toPush, total, batchNo)) {
return new int[]{0, 0};
}
advanceTxWatermark(batch);
@@ -7152,8 +7311,17 @@ public class WebManager {
}
/** 单批交易同步的最大笔数 */
private static final int TX_SYNC_BATCH_SIZE = 300;
/**
* 交易补推批大小:自适应探测(2026-10-10)。
* 用户要求:「300 笔是下限,每次一点点加,试探 PHP 一次能吃多少笔,且不会引起其它调度器崩溃」。
* · 每轮从 TX_SYNC_BATCH_MIN 起推,每顺利拿到一批回执 +TX_SYNC_BATCH_STEP,直到 TX_SYNC_BATCH_MAX;
* · 任一批没拿到回执(15 秒超时 / PHP 明确失败)→ 当场减半(不低于下限)后重试同一批,
* 既探到 PHP 实际承受上限,又不会把大单持续怼向 PHP 拖垮其它调度器;
* · 每轮重新从下限开始探,不跨轮记忆(避免把一次网络波动误学成固定上限)。
*/
private static final int TX_SYNC_BATCH_MIN = 300;
private static final int TX_SYNC_BATCH_STEP = 300;
private static final int TX_SYNC_BATCH_MAX = 3000;
/** 首次全量同步:每提交一项打一行进度,便于用户实时观察 */
private void fullSyncStep(String what) {
@@ -7163,7 +7331,7 @@ public class WebManager {
/**
* 推送一批交易到 PHP,成功则推进水位线,失败返回 false(调用方负责中止并保留水位线)
*/
private boolean pushTxBatch(List<Map<String, Object>> batch, int totalAll, int batchNo, int batchCount) {
private boolean pushTxBatch(List<Map<String, Object>> batch, int totalAll, int batchNo) {
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("transactions", batch);
@@ -7171,19 +7339,19 @@ public class WebManager {
// ★ 单次请求、15 秒超时:PHP 必须在窗口内给答复
String response = httpPostWithTokenOnce("api/sync.php?action=sync_transactions", body);
if (response == null) {
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "/" + batchCount
plugin.getLogger().warning("[Web交易同步] 第" + batchNo
+ "批 ★ 15 秒内没有答复(超时/网络),不算成功也不算失败 → 等 15 秒重试同一批");
return false;
}
Map<String, Object> result = parseJson(response);
if (!Boolean.TRUE.equals(result.get("success"))) {
plugin.getLogger().warning("[Web交易同步] 第" + batchNo + "/" + batchCount
plugin.getLogger().warning("[Web交易同步] 第" + batchNo
+ "批 ★ PHP 明确答复失败: " + result.get("message"));
return false;
}
// ★ 水位线由调用方 advanceTxWatermark 统一推进(对账跳过时也要推进)
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "/" + batchCount + "批 已推送 "
plugin.getLogger().info("[Web交易同步] 第" + batchNo + "批 已推送 "
+ batch.size() + " 笔(本轮共 " + totalAll + " 笔),回执=" + result.get("message"));
return true;
} catch (Exception e) {
@@ -8500,6 +8668,12 @@ public class WebManager {
sender.sendMessage("§c[Web] Web通信未启用");
return;
}
// ★ 第30批:拉黑玩家禁止访问 PHP 后端
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(sender.getName())) {
sender.sendMessage("§c§l[罪人] §f你已被拉黑,禁止使用网页端功能");
return;
}
String playerName = sender.getName();
long now = System.currentTimeMillis();
@@ -8595,6 +8769,11 @@ public class WebManager {
*/
public void autoGenerateWebLoginToken(Player player) {
if (!enabled) return;
// ★ 第30批:拉黑玩家禁止访问 PHP 后端(不签发 token)
if (plugin.getBlacklist() != null
&& plugin.getBlacklist().isBlacklisted(player.getName())) {
return;
}
String playerName = player.getName();
// 生成weblogin专用Token
@@ -10707,6 +10886,7 @@ public class WebManager {
*/
private boolean updateLocalShopStock(String itemsJson) {
try {
loadShopStockSnapshot();
File shopDir = new File(plugin.getDataFolder(), "shop");
if (!shopDir.exists()) return false;
@@ -10787,10 +10967,22 @@ public class WebManager {
// ★ 管理员手动修改的库存:跳过保护逻辑,直接应用
// 管理员设stock=0表示"售罄",需要同步到游戏
if (!adminOverride.contains(itemId)) {
if (adminOverride.contains(itemId)) {
// 管理员显式改动:直接应用,并刷新同步基线
shopStockSnapshot.put(itemId, newStock);
} else {
// stock<-1 为未定义值(-2以下),不生效,跳过同步
// -1=无限库存, 0=售罄, >=1=有库存,都正常同步
if (newStock < -1) continue;
// ★ 数据分叉以 Java 为主:本地相对同步基线有未推送改动(snapStock != oldStockNum)
// 且 PHP 值与本地不同(oldStockNum != newStock)-> 跳过覆盖,保留本地 md 值,
// 随后由 pushShopStockDiffs() 推回 PHP 对齐。
Integer snapStock = shopStockSnapshot.get(itemId);
if (snapStock != null && snapStock != oldStockNum && oldStockNum != newStock) {
continue;
}
// 应用 PHP 值并刷新同步基线
shopStockSnapshot.put(itemId, newStock);
}
if (oldStockNum != newStock) {
@@ -10821,8 +11013,10 @@ public class WebManager {
plugin.getLogger().info("[Web通信] 已同步Web端商品库存,更新了 " + updatedCount + " 个商品");
lastPullShopStockLog = now;
}
saveShopStockSnapshot();
return true;
}
saveShopStockSnapshot();
return false;
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 更新商品库存失败: " + e.getMessage());
@@ -10830,6 +11024,185 @@ public class WebManager {
}
}
/** 库存同步基线文件(插件 data 目录,盘上持久化,不依赖数据库) */
private File shopStockSnapshotFile() {
return new File(plugin.getDataFolder(), "shop_stock_snapshot.properties");
}
/**
* 懒加载库存同步基线(id->stock)。文件不存在 = 首装/基线丢失,
* 按空基线处理 -> 首轮全量对齐推送(以 Java 本地为准)。
*/
private void loadShopStockSnapshot() {
if (shopStockSnapshotLoaded) return;
shopStockSnapshotLoaded = true;
try {
File f = shopStockSnapshotFile();
if (!f.isFile()) return;
java.util.Properties props = new java.util.Properties();
java.io.InputStream in = new java.io.FileInputStream(f);
try {
props.load(in);
} finally {
in.close();
}
for (String key : props.stringPropertyNames()) {
try {
shopStockSnapshot.put(key, Integer.parseInt(props.getProperty(key).trim()));
} catch (NumberFormatException ignored) {
}
}
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 读取库存同步基线失败(按空基线全量对齐): " + e.getMessage());
}
}
/** 库存同步基线落盘(推送/应用成功后才调用,防假成功写脏基线) */
private void saveShopStockSnapshot() {
try {
java.util.Properties props = new java.util.Properties();
for (Map.Entry<String, Integer> e : shopStockSnapshot.entrySet()) {
props.setProperty(e.getKey(), String.valueOf(e.getValue()));
}
File f = shopStockSnapshotFile();
File parent = f.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
java.io.OutputStream out = new java.io.FileOutputStream(f);
try {
props.store(out, "shop stock sync baseline id=stock");
} finally {
out.close();
}
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 保存库存同步基线失败: " + e.getMessage());
}
}
/**
* ★ 库存双向同步(2026-10-10):把本地(md)相对同步基线的差异推回 PHP。
* 用户原则:哪边有改动就对齐哪边;数据分叉以 Java 为主。
* 基线只在推送成功后才更新(防假成功,与对账 hash 铁律同构)。
*
* @return true=无差异或已全部推送成功
*/
private boolean pushShopStockDiffs() {
try {
File shopDir = new File(plugin.getDataFolder(), "shop");
if (!shopDir.exists()) return true;
File[] mdFiles = shopDir.listFiles((d, n) -> n.endsWith(".md"));
if (mdFiles == null || mdFiles.length == 0) return true;
loadShopStockSnapshot();
Map<String, Integer> current = new HashMap<>();
for (File mdFile : mdFiles) {
List<Map<String, Object>> catItems = parseMdShopFile(mdFile, mdFile.getName().replace(".md", ""));
for (Map<String, Object> it : catItems) {
Object idv = it.get("id");
Object sv = it.get("stock");
if (idv == null || sv == null) continue;
current.put(String.valueOf(idv), ((Number) sv).intValue());
}
}
if (current.isEmpty()) return true;
List<Map<String, Object>> diff = new ArrayList<>();
for (Map.Entry<String, Integer> e : current.entrySet()) {
Integer snap = shopStockSnapshot.get(e.getKey());
if (snap == null || !snap.equals(e.getValue())) {
Map<String, Object> one = new LinkedHashMap<>();
one.put("id", e.getKey());
one.put("stock", e.getValue());
diff.add(one);
}
}
if (diff.isEmpty()) return true;
String token = generateAndSyncToken("system", "sync");
// ★ 回执单校验(2026-10-11 用户要求):Java 不再无条件相信 PHP。
// 推送带 push_id + 内容指纹 fp;PHP 必须在回执里原样回显两者(且条数一致)才算「确实收到」。
// 无回执/回执不匹配 → 立即重推一次;两次都无有效回执 → 不落基线,下轮 poll 再推。
List<String> fpEntries = new ArrayList<>();
for (Map<String, Object> one : diff) {
fpEntries.add(String.valueOf(one.get("id")) + "|" + ((Number) one.get("stock")).intValue());
}
Collections.sort(fpEntries);
java.util.zip.CRC32 crc = new java.util.zip.CRC32();
crc.update(String.join("\n", fpEntries).getBytes(StandardCharsets.UTF_8));
String fp = String.valueOf(crc.getValue());
String pushId = System.currentTimeMillis() + "-"
+ java.util.concurrent.ThreadLocalRandom.current().nextInt(100000, 999999);
Map<String, Object> body = new LinkedHashMap<>();
body.put("items", diff);
body.put("push_id", pushId);
body.put("fp", fp);
String response = null;
String badReason = "无响应";
boolean gotReceipt = false;
for (int attempt = 1; attempt <= 2 && !gotReceipt; attempt++) {
if (attempt > 1) {
plugin.getLogger().warning("[Web通信] 库存推送无有效回执(" + badReason
+ ")→ 立即重推一次 push_id=" + pushId);
}
response = httpPostWithToken("api/sync.php?action=push_shop_stock", token, body);
badReason = checkShopStockReceipt(response, pushId, fp, diff.size());
gotReceipt = (badReason == null);
}
if (gotReceipt) {
for (Map<String, Object> one : diff) {
shopStockSnapshot.put(String.valueOf(one.get("id")), ((Number) one.get("stock")).intValue());
}
saveShopStockSnapshot();
long now = System.currentTimeMillis();
if (now - lastShopStockPushLog > LOG_INTERVAL) {
plugin.getLogger().info("[Web通信] 本地库存变更已推送 " + diff.size() + " 个商品(双向同步,分叉以游戏端为准,回执已核验 push_id=" + pushId + ")");
lastShopStockPushLog = now;
}
return true;
}
plugin.getLogger().warning("[Web通信] 库存推送失败(回执未通过[" + badReason + "],下轮重试): "
+ (response == null ? "无响应" : response.substring(0, Math.min(160, response.length()))));
return false;
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 库存推送异常: " + e.getMessage());
return false;
}
}
/**
* ★ 库存推送回执单核验(2026-10-11):PHP 必须原样回显 push_id + 内容指纹 fp + 条数,
* Java 才承认「PHP 确实收到」。仅匹配 success:true 不再算数(防中间层假 200 / 旧版 PHP)。
* @return null=回执有效;非 null=失效原因(用于日志与重推判断)
*/
private String checkShopStockReceipt(String response, String pushId, String fp, int count) {
if (response == null || response.isEmpty()) return "无响应";
try {
Gson gson = new Gson();
JsonObject root = gson.fromJson(response, JsonObject.class);
if (root == null || !root.has("success") || !root.get("success").getAsBoolean()) {
String msg = (root != null && root.has("message") && !root.get("message").isJsonNull())
? root.get("message").getAsString() : "success非true";
return "PHP报错:" + msg;
}
if (!root.has("data") || !root.get("data").isJsonObject()) return "缺回执单(data)";
JsonObject data = root.getAsJsonObject("data");
if (!data.has("receipt") || !data.get("receipt").isJsonObject()) return "缺回执单(receipt)";
JsonObject r = data.getAsJsonObject("receipt");
String rid = (r.has("push_id") && !r.get("push_id").isJsonNull()) ? r.get("push_id").getAsString() : "";
String rfp = (r.has("fp") && !r.get("fp").isJsonNull()) ? r.get("fp").getAsString() : "";
int rcount = (r.has("count") && !r.get("count").isJsonNull()) ? r.get("count").getAsInt() : -1;
if (!pushId.equals(rid)) return "push_id不匹配(回=" + rid + ")";
if (!fp.equals(rfp)) return "指纹不匹配(期望" + fp + "回" + rfp + ")";
if (rcount != count) return "条数不匹配(期望" + count + "回" + rcount + ")";
return null;
} catch (Exception e) {
return "回执解析异常:" + e.getMessage();
}
}
/**
* 检查同步通知文件
*/
+2 -2
View File
@@ -41,7 +41,7 @@ public class WelcomeManager {
String section = "";
String line;
while ((line = r.readLine()) != null) {
if (line.startsWith("\uFEFF"))
if (line.startsWith(UnicodeChars.BOM_TEXT))
line = line.substring(1);
String t = line.trim();
if (t.isEmpty()
@@ -135,7 +135,7 @@ public class WelcomeManager {
}
private String colorize(String msg) {
return msg.replace("&", "\u00a7");
return msg.replace("&", "§");
}
private void writeDefault(File f) {
+29
View File
@@ -0,0 +1,29 @@
## ----配置区开始----
| 名字 | 功能说明 | 参数值 |
| --- | --- | --- |
| 轮播最低间隔 | 最短是多少秒一次(建议阈值60,太低很吵) | 60 |
| 轮播最大间隔 | 最长多少秒一次(建议阈值300以内,太长起不到效果) | 180 |
| 播报模式 | 是由远到近(即从第一个封禁的玩家开始报到最后一个),还是又近到远(从最新一个被封禁的玩家播报到第一个被封禁的玩家)。此项填0或最大最小间隔都为0表示关闭播报 | 由近到远 |
| 播报起点 | 插件安装之前的封禁(历史封禁)不播报。填插件安装日期如2026-08-01,0或留空=自动(取插件数据目录创建时间) | 0 |
```json
{
"最小间隔":"",
"最大间隔":"",
"播报模式":true,
"播报起点":"",
"解释说明":{
"播报模式":"true表示开启播报又近到远,false表示开启播报由远到近。0为关闭",
"最小间隔":"最短是多少秒一次(建议阈值60,太低很吵)",
"最大间隔":"最长多少秒一次(建议阈值300以内,太长起不到效果)",
"播报起点":"插件安装之前的封禁不播报,填如2026-08-01;0或留空=自动"
}
}
```
## ----配置区域结束----
## 功能说明
- 此功能为服务器打造一个不可拆解的耻辱台,展示所有被封禁的玩家。json代码块的参数值和md表格是2选1,你可以删了表格,留json则表示用json作为配置值,也可以删了json用表格。小白用户推荐使用md,因为最简单,专业人员推荐使用json,因为最清晰,没有注释(注释是另起一行,不在配置块)。<br>
- 同时兼容.json直接作为配置文件
+30
View File
@@ -0,0 +1,30 @@
### ----配置区域开始----
| 玩家名字 | 拉黑时长 | 理由 |
| --- | --- | --- |
### ----配置区域结束
## 软折磨黑名单配置
功能说明:<br>
此功能为软折磨,用于折磨违反规则的玩家。通过`/addblack user time(可选) msg(可选)`添加违规玩家或直接编辑本文件即可。被拉黑的玩家不能消费债券、不能登录PHP后端、不能创建领地,不能成为他人领地的授权玩家。不能破坏、放置方块,不能打开任何容器、不能拾取任何物品、不能与村民交易,没有背包(清空前备份,自动备份跳过该玩家)。能说话但,会被系统加上"---我是罪人user"的签名
## 示例配置:
| 玩家名字 | 拉黑时长 | 理由 |
| --- | --- | --- |
| steve | 终身 | 违反服务器规则 |
| Dream | 2026-10-20 14:30 | 随意殴打他人 |
## 指令集
| 前缀 | 参数1 | 参数2 | 参数3 |
| --- | --- | ---| --- |
| /addblack | user | time | msg |
| /removeblack | user | 空 | 空 |
| /addblack | steve | 2026-10-20 10:00 | 违反服务器规则 |
| /addblack | Dream | 0 | 空 |
| /removeblack | Notch | 空 | 空 |
- 备注:0表示永久拉黑,填入日期+时间则表示遵循到期时间。填入日期则表示以当天23:59:59为界限。填相对时长(30分 / 2小时 / 3天 / 1周)= 从拉黑那一刻起算,到期自动解除并自动恢复背包与权限<br>
- 生效规则: 定时,每分钟的第0秒、5秒、10秒...以此类推、55秒定时增量重载,删了5秒后立即恢复背包和各项权限,加了5秒后立即生效
+10
View File
@@ -0,0 +1,10 @@
{
"开关": true,
"是否合并两个箱子": 1,
"解释说明": {
"开关": "开启状态下,将自动接管手持精准采集斧头的、被授权「容器管理」权限的领地在拆箱动作;关闭则拆箱完全交还原版散落",
"是否合并两个箱子": "即2秒内连续拆箱时,是否将两个箱子的物品合二为一个打包箱(不看塞满,只看时间窗口达标即合并)",
"热重载": "玩家每次拆箱时增量热重载:文件 mtime+size 没变就不重新解析,变化才按新值生效",
"支持参数": "同时支持填 true 为开 / false 为关;数字 0 为关 / 1 为开"
}
}
+423
View File
@@ -33,3 +33,426 @@ party
fun
zone
space
name
pro
aero
coop
museum
jobs
travel
asia
cat
edu
gov
int
mil
ac
ad
ae
af
ag
ai
al
am
ao
aq
ar
as
at
au
aw
ax
az
ba
bb
bd
be
bf
bg
bh
bi
bj
bm
bn
bo
br
bs
bt
bw
by
bz
ca
cd
cg
ch
ci
ck
cl
cm
cr
cu
cv
cy
cz
de
dj
dk
dm
do
dz
ec
ee
eg
er
es
et
eu
fi
fj
fk
fm
fo
fr
gd
ge
gf
gg
gh
gi
gl
gm
gn
gp
gq
gr
gs
gt
gu
gw
gy
hk
hn
hr
ht
hu
id
ie
il
im
in
iq
ir
is
it
je
jm
jo
jp
ke
kg
kh
ki
km
kn
kp
kr
kw
ky
kz
la
lb
lc
li
lk
lr
ls
lt
lu
lv
ly
ma
mc
md
me
mg
mh
mk
mm
mn
mo
mp
mq
mr
ms
mt
mu
mv
mw
mx
my
mz
na
nc
ne
nf
ng
ni
nl
no
np
nr
nu
nz
om
pa
pe
pf
pg
ph
pk
pl
pm
pn
pr
ps
pt
pw
py
qa
re
ro
rs
rw
sa
sb
sc
sd
se
sg
sh
si
sk
sl
sm
sn
so
sr
ss
st
sv
sx
sy
sz
tc
td
tf
tg
th
tj
tl
tm
tn
to
tt
tw
tz
ug
uk
um
us
uy
uz
va
vc
ve
vg
vi
vn
vu
wf
ye
yt
za
zm
zw
ac.uk
co.uk
org.uk
gov.uk
edu.au
ac.nz
co.nz
org.nz
gov.au
co.jp
ne.jp
or.jp
ed.jp
ac.jp
go.jp
co.kr
or.kr
re.kr
go.kr
ac.kr
ne.kr
com.cn
net.cn
org.cn
gov.cn
edu.cn
ac.cn
mil.cn
com.hk
net.hk
org.hk
idv.hk
com.tw
net.tw
org.tw
gov.tw
edu.tw
com.sg
net.sg
org.sg
gov.sg
edu.sg
per.sg
com.my
net.my
org.my
gov.my
edu.my
per.my
com.vn
net.vn
org.vn
gov.vn
edu.vn
ac.vn
com.ph
net.ph
org.ph
gov.ph
edu.ph
com.pk
net.pk
org.pk
gov.pk
edu.pk
com.bd
net.bd
org.bd
gov.bd
edu.bd
com.lk
net.lk
gov.lk
ac.lk
com.np
net.np
org.np
gov.np
com.mm
com.kh
com.kg
com.kz
com.ge
com.am
com.az
com.by
com.km
com.sc
co.id
web.id
co.th
or.th
ac.th
go.th
net.th
com.mu
org.mu
co.za
org.za
web.za
com.eg
net.eg
com.ma
net.ma
org.ma
co.ke
or.ke
ac.ke
co.tz
or.tz
ac.tz
co.ug
or.ug
com.sn
net.sn
org.sn
co.rw
ac.rw
com.cm
co.bi
co.dz
com.dj
co.so
co.tg
co.np
ac.cy
com.cy
co.il
org.il
net.il
ac.il
gov.il
co.ma
com.kw
net.kw
org.kw
com.om
net.om
com.ps
com.sy
com.jo
net.jo
com.lb
net.lb
com.tn
com.tj
com.tf
app
dev
page
shop
work
website
wiki
blog
live
best
cool
red
icu
one
ltd
# ===== 中文域名(真实可注册,按最后一段匹配)=====
# ICANN 官方中文 TLD
中国
网络
公司
我爱你
# .cn 注册局中文通用域名
集团
商城
网店
在线
游戏
网址
网站
商店
企业
娱乐
移动
中文网
@@ -0,0 +1,16 @@
## ----配置区域开始----
| 颜色代码 |
| --- |
| red |
| aqua |
| |
## ----配置区域结束----
## json解码规则说明
### 一、怎么触发?
使用`json`指令触发,或在原版`title、tellraw`产生报错时,插件介入。
### 二、颜色代码怎么配置?
支持填入`16进制`颜色代码(比如#000000)、或者颜色`英文名`(比如red)、也可以是rgb。比如(R0 G0 B0)
+44 -23
View File
@@ -1,5 +1,5 @@
name: Sdf1_login
version: 2.92
version: 3.01
main: Sdf1_login.Main
api-version: '1.21'
commands:
@@ -26,16 +26,16 @@ commands:
usage: /玩家信息
改密码:
description: 修改密码(中文)
usage: /改密码 <旧密码> <新密码> <确认新密码>
usage: /改密码 <旧密码> <新密码>
设置邮箱:
description: 设置邮箱(中文)
usage: /设置邮箱 <邮箱地址>
撤销:
description: 撤销操作(中文)
usage: /撤销
删除:
description: 删除玩家(中文)
usage: /删除 <玩家> <密码>
# 撤销:
# description: 撤销操作(中文)
# usage: /撤销
# 删除:
# description: 删除玩家(中文)
# usage: /删除 <玩家> <密码>
找回密码:
description: 找回密码(中文)
usage: /找回密码
@@ -45,21 +45,21 @@ commands:
#oa:
# description: 申请删除账号
# usage: /oa [玩家名]
删除账号:
description: 申请删除账号(中文)
usage: /删除账号 [玩家名]
通过:
description: 批准OA请求(中文)
usage: /通过 <编号>
拒绝:
description: 拒绝OA请求(中文)
usage: /拒绝 <编号> [理由]
accept:
description: 批准OA请求
usage: /accept <编号>
deny:
description: 拒绝OA请求
usage: /deny <编号> [理由]
# 删除账号:
# description: 申请删除账号(中文)
# usage: /删除账号 [玩家名]
# 通过:
# description: 批准OA请求(中文)
# usage: /通过 <编号>
# 拒绝:
# description: 拒绝OA请求(中文)
# usage: /拒绝 <编号> [理由]
# accept:
# description: 批准OA请求
# usage: /accept <编号>
# deny:
# description: 拒绝OA请求
# usage: /deny <编号> [理由]
email:
description: 绑定邮箱
usage: /email <邮箱>
@@ -78,9 +78,21 @@ commands:
recycle:
description: 打开垃圾回收站
usage: /recycle
公告板:
description: 刷新/查看服务器公告计分板
usage: /公告板
垃圾清理:
description: 手动清理掉落物
usage: /垃圾清理
垃圾抢救:
description: 紧急取消本轮垃圾站清理(物品误投时抢救)
usage: /垃圾抢救
addblack:
description: 拉黑玩家(禁止建领地/破坏/兑换码/网页端,债券冻结,背包备份清空)
usage: /addblack <玩家名> [时长] [理由]
removeblack:
description: 解除拉黑
usage: /removeblack <玩家名>
# menu:
# description: 雪球菜单开关控制
# usage: /menu <开/关>
@@ -96,6 +108,9 @@ commands:
shop :
description: 游戏商人
usage: /shop
shopbuy :
description: 二手交易商店
usage: /shopbuy
商店:
description: 打开商店
usage: /商店
@@ -181,9 +196,15 @@ commands:
who:
description: 查看在线玩家列表
usage: /who
banget:
description: 点名查询封禁(全量,含插件安装前的历史封禁)
usage: /banget [user|ip] <玩家名或IP>
清空队列:
description: 清空DB队列中未回应的web同步任务(管理员)
usage: /清空队列
cleartake:
description: 清空DB队列中未回应的web同步任务(管理员)
usage: /cleartake
json:
description: JSON消息构造器(tellraw/大标题/小标题/actionbar+颜色,需OP以上权限)
usage: /json
@@ -0,0 +1,45 @@
## ----配置区域开始----
| 项目 | 解释 | 值 |
| :---: | :---: | :---: |
| 可上架的商品数量 | 每位普通玩家能上架多少个物品。数值填写为纯数字。用户组另算 | 3 |
| 最低售价 | 普通玩家上架物品时,最低能买多少钱,数值填写为纯数字。用户组另算 | 0 |
| 手续费 | 二手交易物品的抽成比例,上限10。支持三种写法:小数 `0.05`=5%、`0.005`=5‰;带单位 `5%`、`5‰`。 | 0.05 |
| 手续费支付方 | 谁出手续费,买方出即订单加费(奸商模式)或卖方出(到手后的价格已包含完税) | 卖方出 |
| 默认管理员面板密码 | 面板后台的验证凭据 | qweasd |
| 谁是管理员 | 插件全局tag管理员就填tag,OP则填写OP,指定玩家则填写player+名字 | tag |
```json
{
"默认下架理由":"一行一个",
"理由列表":[
"商品包含违禁品",
"商品售价非法",
"商品描述与实物不符",
"疑似诈骗或诱导交易",
"商品利用漏洞复制",
"恶意炒作价格",
"占位刷屏恶意上架",
"违反二手交易规则其他条款"
]
}
```
## ----配置区域结束---
## 二手交易商店说明书
- 什么是二手商店: 二手商店的定位就类似于闲鱼这类二手交易平台。货品挂在平台内,平台平台进行担保<br>
- 什么不能卖: sdf1_game宝箱的物品不能卖,本插件的SN物品不能卖<br>
- 怎么配置: 只需要改对应的值,比如可上架的商品数量,只要改数字,比如100,1000就行<br>
- 手续费怎么算:
| 交易金额 | 谁出 | 手续费多少钱 | 卖家到手多少钱 | 买家支付多少钱 | 计算公式 |
| :---: | :---: | :---: | :---: | :---: | :---: |
| 100债券 | 卖家出 | 5% | 95 | 100 | 100(原价)×5%(0.05)=5(手续费金额),100(原价)-5(手续费)=95(卖家到手) |
| 100债券 | 买家出 | 5% | 100 | 105 | 100(原价)×5%(0.05)=5(手续费金额),100(原价)+5(手续费)=105(买家实付金额) |
| 100债券 | 卖家出 | 5‰(=0.005) | 99 | 100 | 100(原价)×5‰=0.5,手续费四舍五入=1,100-1=99(卖家到手);千分位适合低抽成场景 |
| 100债券 | 买家出 | 5‰ | 100 | 101 | 100(原价)×5‰(手续费)=0.5(4舍5入),100(原价)+1(手续费取整)=101 |
- 改完多久生效: 自动增量重载,平时安安静静,有人下单了,发布新商品了自动拉起增量重载,有变更就对齐。每个整点(每分钟的第0秒、15秒、30秒、45秒)自动拉起增量热重载,有变更就对齐,没有则跳过<br>
- 默认理由最大支持8个,第9号位为返回。<br>
- 谁是管理员这一栏填单格式:`player+example`→指定example为管理员,`op`→指定全服OP为管理员,`tag`→指定插件的tag玩家是管理员
+65
View File
@@ -0,0 +1,65 @@
## ----公告区域开始----
```json
{
"开关状态":true,
"翻页时间":15,
"1": {
"msg": "草原探险服务器欢迎您",
"color": "green"
},
"2": {
"msg": "服务器官方群:981954292",
"color": "aqua"
},
"3":{
"msg":"最低支持版本:1.21.11",
"color":"gold"
},
"4":{
"msg":"最大支持版本:26.x",
"color":"#FF55FF"
},
"5":{
"msg":"服务器ip: mc2.ypshidifu.cn",
"color":"blue"
},
"6":{
"msg":"端口:30679,Java免输入~"
}
}
```
## ----公告区域结束----
## 公告板使用手册
- 插件主导计分板式公告栏,用于记录和公示服务器的基本信息。<br>
- 支持填入任意纯文本。<br>
- 空白键值自动跳过
## 公告区参数选项
| 键 | 值 | 必填 | 解释 |
| --- | --- | --- |--------------------------------------------|
| 数字编号 | 花括号 | 必填 | 一条公告的开始 |
| msg | 纯文本消息内容 | 必填 | 消息正文 |
| color | 颜色英文名或16进制颜色代码,或rgb | 非必填 | 指定颜色,未填写回退MC原版逻辑(继承最近一个color参数,如果都没有就回退白色) |
## 示例模板
```json
{
"1": {
"msg": "这是第一条公告",
"color": ""
},
"2": {
"msg": "这是第二条公告",
"color": "r=0 g=0 b=0"
},
"3": {
"msg": "这是第三条公告",
"color": "#000000"
},
"4": {
}
}
```
@@ -0,0 +1,23 @@
# 邀好友赢债券活动配置
## ----配置区开始----
| 项目 | 解释 | 值 |
| :---: | :---: | :---: |
| 邀请人单次奖励 | 邀请人每次成功邀请一位好友后,立即结算获得的债券数量。数值填写为纯数字,单位默认为"个"。适用于"马上邀、马上结账"的场景。 | 10 |
| 被邀请人单次奖励 | 被邀请人在注册并首次上线后,立即结算获得的债券数量。数值填写为纯数字,用于吸引新玩家加入。 | 5 |
| 单日邀请奖励上限 | 单个邀请人在**一天内**通过邀请好友累计可获得的债券总数上限。达到上限后当日不再发放奖励,次日自动重置。适合控制薅羊毛行为。 | 100 |
| 单日邀请翻倍额度 | 拉了多少个人以后,给邀请人的奖励。参数值为:"每邀请X人,奖金翻Y倍" | 每邀请2人,奖金翻1倍 |
## ----配置区域结束----
## 邀好友赢债券说明书
- 什么是邀好友赢债券:玩家邀请新玩家加入服务器,双方完成任务后分别获得债券奖励,激励玩家拉新。<br>
- 三种配置的区别:
- **邀请人单次奖励**:每次成功邀请后,邀请人立即获得的债券数,不累积
- **被邀请人单次奖励**:被邀请人注册并首次上线后,自己立即获得的债券数
- **单日邀请奖励上限**:邀请人一天内累计获得的总奖励上限(仅针对邀请人),达到后当日停止发放,次日重置
- 计算公式示例:
- 邀请人单次奖励 = 10,单人当天邀请 3 人 → 累计获得 10 × 3 = **30 债券**(未超单日上限)
- 邀请人单次奖励 = 10,单人当天邀请 11 人 → 理论应得 10 × 11 = 110,但被单日上限卡住,实际获得 **100 债券**,当日不再发放
- 被邀请人单次奖励 = 5,新玩家注册首次上线 → 立即获得 **5 债券**