Compare commits

...
138 Commits
Author SHA1 Message Date
youpaishidifu cbfdfb016b 三库归位登录凭证/用户组/Php,处罚预告按规则档位修正,解禁与重置不再动人机验证
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
1) login.db 创建即入 登录凭证/、usergroup.db 入 用户组/、web_sync.db 入 Php/(WebManager 5处统一走 webSyncDbFile()),根目录存量由 migrateRootDataFiles 启动最早期整组迁移(.db+-shm+-wal 同进同退,失败整组回滚);2) nextPunishHint 改为按 applyPunish同款判据(violation>=阈值取最高档)计算下一次违规真正命中的档位与配置时长,修复旧实现取 k>违规数 导致第3次违规就预告第6档15分钟、预告与实际处罚长期对不上的问题;3) unmutePlayer/resetPlayer 移除 markReverify 调用,解禁与重置发言违规不再重置人机验证(resetPlayer 顺带清 DB muted_until,避免重置后重启禁言复活)
2026-10-08 18:57:51 +08:00
youpaishidifu 47301edaed 归位迁移改为sqlite三件套原子搬移,失败整组回滚
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
quest_data.db/bond.db/garbage.db/pvp.db 都是 WAL 模式 sqlite,运行时
-shm/-wal 与主库必须同进同退:单搬主库会丢未checkpoint事务导致数据回退,
单搬wal缺主库直接损坏。原实现逐文件rename,崩在中间会留下破损三件套。

改为每个 db 一组,主库/-shm/-wal 整体搬,任一失败立即把已搬的搬回原位;
目标端已存在则整组跳过不合并。创建时路径直接指向子目录(QuestStorage/
PlayerStorage 已改),迁移只兜存量。
2026-10-08 17:49:02 +08:00
youpaishidifu 0ab7c7052c 修复违规计数文案歧义、验证码重验回归、quest_data.db归位
1. 违规计数订正:实际计数一直是1→2→3(incrementViolation是+1),截图显示的
   1/3/6三角数是文案歧义——nextPunishHint返回的第N次违规预告与命中提示的第N次
   违规两行并列,被读成跳号。现改为预告只报处罚档位不报次数,命中提示改为
   「本次为第N次违规」,次数全局只出现一次。

2. 验证码回归修复:isMuted()禁言到期自动解禁时调用markReverify(),把已通过
   人机验证的玩家踢出verifiedPlayers,导致「验证码通过→被禁言→到期解禁→又要
   重验」的死循环。按用户裁定改为:到期自动解禁不再吊销验证状态,仅显式
   /chat unmute 与 /chat reset 才吊销。

3. quest_data.db 归位任务面板/:QuestStorage与PlayerStorage两处硬编码路径
   改为子目录并自动建目录,migrateRootDataFiles() 增加 db/-shm/-wal 三件套
   幂等迁移(启动最早期执行)。
2026-10-08 17:44:15 +08:00
youpaishidifu 19e03c5fb4 修复三bug并归位根目录5类文件:①禁言解除后强制重新人机验证(解禁≠解除人机验证,到期/命令解禁/重置均打reverify标记,答对清除) ②OP白名单过期条目不再补授(修复26-10-08到期后每分钟撤销↔授予无限横跳) ③敏感词命中改为明确反馈(控制台日志+玩家提示+下一档处罚预告,修复启用过滤配置解析失效) ④bond.db/chat.txt/garbage.db/maxsell_config.txt/pvp.db启动最早期自动迁入对应子文件夹
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m31s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 17:02:02 +08:00
youpaishidifu 58f9a756e9 调整.gitignore拉黑规则
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 16:20:58 +08:00
youpaishidifu bdd55cc9bf 将opwl.json移入resources目录,随jar一起释放。移除根目录的opwl.json暂存
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 16:15:06 +08:00
youpaishidifu 3217e53cda 补充opwl.json使用说明;注释停用/menu命令注册
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 15:41:49 +08:00
youpaishidifu 881fb85408 修复三处bug:时间解析支持26-10-08 14:43短横线格式、禁言期间屏蔽一切私信收发、URL拦截baidu . com空格变形绕过 2026-10-08 15:41:46 +08:00
youpaishidifu 581475e760 opwl事故修复三连: ①ops.json真实字段是uuid(旧代码只认id致复原全靠猜名字) ②复原的OP自动写回白名单(否则同一轮巡检把刚复原的11人又当非法持有全撤+报警) ③配置丢失/解码失败自动隔离坏文件(改名不删)+从jar释放默认配置+复原OP备份(热重载与/opwl reload同生效) ④补授从仅在线扩展到在线+离线(白名单11人但ops.json为空的根因)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-08 14:01:34 +08:00
youpaishidifu 32fe81f908 补单提速第二轮+控台指令最小化信息透露
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m12s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
问题1 补单10秒才抓到单(根因两条):
- 线程错位: TimerA-周期批处理跑在sdf1-db-worker上, triggerFastPoller在该线程同步doGet,
  撞sendHard的10秒硬超时 -> 每次补单固定10秒且返回null, 还吃光DB队列整轮预算;
- 节奏不符设计: 定时器是固定5秒而非0~5秒随机, 4秒限流又把随机节奏里<4秒的轮次整轮吞掉。
修法: 定时器改0~100tick随机(nextPollerDelayTicks); 限流4000ms->500ms;
HTTP挪到补单专用单线程pollerExecutor(fire-and-forget, 队列容量1+丢弃);
补单改8秒短超时POLLER_TIMEOUT_S; 本轮processed>0立刻pullPendingTransactions入账;
shutdown补关pollerExecutor。

问题2 conmsg/opwl对玩家泄露"仅控制台可用/需要控台权限":
从plugin.yml摘掉注册 -> 玩家敲它必然落进UnknownCommandEvent, 与"不存在的指令"
走完全相同路径(压制原生unknown报错+相似建议候选里永远没有这俩);
控制台由onUnknownCommand里的dispatchConsoleOnlyCommand就地接管执行,
onCommand里的opwl/conmsg分支改为兜底且玩家侧一律静默。
2026-10-08 12:45:36 +08:00
youpaishidifu 31f2a60096 opwl扫描点订正: 心跳日志改为无更新就闭嘴(仅重载发生或统计快照变化才打印) + 修正扫描周期9000tick(实为7.5分钟)→18000tick真正15分钟,消除非整点多余冒泡
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m27s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 11:16:16 +08:00
youpaishidifu cd0a45d284 opwl数据安全+补单提速: 解析失败卸载OP前先备份原版ops.json(备份失败则不卸载,恢复时复原,pickJson排除ops.json*) + 补单触发限流15s→4s并新增独立5秒定时器(不受锁库退避/队列积压影响)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-08 11:04:53 +08:00
youpaishidifu e2447f0b95 opwl第四轮: 修临时OP自动续期(存exp:绝对到期时间)+上下任提示当事人(在线直发/离线补发)+/op按启用状态分支(true全拦/false仅注册校验)+扫描点心跳日志+新增opwl check子命令
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m59s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 00:45:30 +08:00
youpaishidifu ca1fe6f252 opwl第二轮: 整点扫描(:15/:30/:45)替代纯15秒应用 + 15秒文件热重载保留 + 控制台/op拦截引导opwl add + add/addtime注册检查(非注册引导改json强制授权)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-08 00:18:21 +08:00
youpaishidifu 84285e5009 opwl改回独立目录OP白名单/(目录隔离,任意json都当配置;模板resources/OP白名单/opwl.json)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-07 23:58:12 +08:00
youpaishidifu a39e26caf7 opwl接管原生OP名单:加载时导入原生OP,非法持有一律撤销+报警(新增非法持有报警信息模板,支持user/time变量中英文),改为单文件opwl.json
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 23:05:29 +08:00
youpaishidifu d19ecc5ce0 新增OP白名单功能(opwl):读OP白名单目录json授OP,读失败一次性卸载全部OP,15秒增量热重载,命令仅控台
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-07 22:09:01 +08:00
youpaishidifu e745010220 修复pushShopCatalog对账后回退本地hash翻案:对账needPush=false直接回,不再整包推975项
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 18:02:31 +08:00
youpaishidifu a580fbbcb7 领地/商城目录接入alignGate差集对账,重启不再整包推,多退少补
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m18s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 17:32:15 +08:00
youpaishidifu dfdb951c8a 修复bans收回死循环:已过期临时封禁不再拉回Bukkit,避免每轮extra=1无限收回1条
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m8s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 16:21:40 +08:00
youpaishidifu f3a987dde9 修复setField对null写成字面量null导致credentials每轮恒定补推1:改setNull并启动自愈清理存量脏数据
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 2s
2026-10-07 13:47:56 +08:00
youpaishidifu ed558517ba 修复credentials对账恒changed全量:凭证行补指纹键password_salt/temp_password,推送改对账差集不再整包硬推611条
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m56s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-07 12:07:25 +08:00
youpaishidifu 9f8a881d62 IP同步改为只推对账差集,删除全量硬推;补上users/bonds/shop三处补推守卫
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- syncAllPlayerIps 去掉 forceFullIpPush:以前只要对账发现缺口就跳过 ipCache
  过滤、把本机全部IP整包推给PHP(生产实测 missing=0/changed=110 却推了609条),
  现在只推 alignGate 裁决出的 pushKeys(missing + 本机改过的 changed);
  对账没做成时本轮不推不收,绝不回退全量硬推。
- 日志改为「对账补推 N/M 个玩家IP(PHP 侧共 X 条)」,与实际发送量一致。
- shop/bonds/users 三处补上 alignNeedsPush 守卫:PHP 库被清空但本地数据没变时,
  本地 hash 会永久短路补推(与 10-06 修过的 admins/providers/bans/credentials 同款 bug)。
2026-10-07 11:17:09 +08:00
youpaishidifu d0d3b48000 临时屏蔽一下没做完的功能和配置文件
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-07 00:21:41 +08:00
youpaishidifu d8d8c18d53 修复配置类对账推送在404后永久卡死:5处hash改为推送成功后才提交
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
灾备演练(删PHP库+404+空白库重建)实测发现:
- admins/providers/credentials/bans/lands 5处 lastXxxHash 在HTTP推送前就提交
- 404期间唯一一次推送失败,hash却已写脏
- 之后 alignGate 每轮判「PHP缺N条要补推」,全被本地hash短路挡回
- 表现为日志反复打「补推N」却永远发不出去,PHP端表永久0行
  (web_plugin_admins/web_service_providers/weblogin_credentials/web_player_bans/web_area_lands)

修复:
1. 对账明确 needPush 时本地hash不得拦截(alignNeedsPush 绕过短路)
2. hash 一律改为 PHP 明确返回 success=true 后才提交
   与 syncUserRegistrations(2026-10-04)/syncShopData/syncBonds 的既有规矩对齐

交易流水多退少补在本次演练中表现正常:空白库→443/443全量补推成功
2026-10-06 23:00:27 +08:00
youpaishidifu c118e8e708 周期对账通过时也打印两边笔数,让对齐结论随时可验证
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-06 21:50:01 +08:00
youpaishidifu 07d0a5a192 交易流水补运行期周期对账,修复PHP库被重载清空后的假对齐
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m34s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 21:25:13 +08:00
youpaishidifu 9c2d5db41e 充值回执对账排进首次全量同步队列:无人在线时启动即点差集,不再只依赖玩家上线的周期批次
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 19:58:08 +08:00
youpaishidifu 64cb501321 充值回执一式两份对账:Java持web_tx_receipts镜像,随全量同步多退少补热回补PHP缺失流水
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m17s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 18:18:00 +08:00
youpaishidifu e5e460ce20 WebManager:超时日志节流降噪、连接4秒判死换连接重试、对账无答复一律不推不收、system/sync token软硬双到期异步续期
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-06 15:14:29 +08:00
youpaishidifu 19aecebc90 WebManager:修正DB队列硬超时线程标记错位,对账硬超时不回退全量硬推,加队列节奏与商品目录hash静默
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 13:12:15 +08:00
youpaishidifu 53cadf2282 WebManager:DB队列HTTP改10秒硬超时并移交旁路线程放行,任务加10秒预算防堵门
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-06 12:06:31 +08:00
youpaishidifu ec4bd415d2 双向对账改造:全量/周期同步与PHP指纹对账多退少补,null整数列归一防每轮changed
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 22:29:50 +08:00
youpaishidifu 6a2bfaf1ff 修复开关关闭时插件设置.txt热重载永不触发:start()不再因未启用而跳过启动合并定时器
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 18:58:23 +08:00
youpaishidifu 5d89ff54c9 修复交易推流死等:批调度毫秒误当tick(15秒被排成12.5分钟);改单次15秒必答复三态日志;轮次卡死自愈不需手动reload;双向对账多退少补(PHP缺的补推、PHP多的收回导入本地)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 17:46:34 +08:00
youpaishidifu ebb17d69bf 交易推流加15秒回执窗口:收到PHP回执才推下一批,未收到则等15秒重试同一批(带20次上限),批调度纳入锁库退避
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
2026-10-05 17:00:25 +08:00
youpaishidifu 478aa97ca8 修复两处同步缺陷:parseJson漏嵌套对象分支致流水对齐恒判PHP无响应;热重载/reload后排全量同步
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m36s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-05 15:44:02 +08:00
youpaishidifu 88db479ede 交易流水同步改为节流+两边对账:开服先点验,一致则跳过补推;逐批只补PHP缺失序列号;推进异步化不阻塞DB队列
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m13s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 23:52:17 +08:00
youpaishidifu 2822ba9c9a 负面效果从增益里拆出独立子菜单 + 药水成就防作弊
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
用户要求:增益只归纳增益,负面另开子菜单;移除指定效果那套不动。
插件给的效果必须打标记,靠插件凑齐原版药水成就的立刻撤销。

效果拆分
- AreaConfig 新增 badEffects 字段,area_lands 加 bad_effects 列(73 列)
- GUI:效果管理主菜单 11~15 连排五格(清负面/禁效果/单清/增益/负面),
  新增 subPage 4 负面效果列表;选择页按 clear/give/bad 分别只列对应档位
- CLI:新增 effectsmgmt 6/7 子页与 effectsbadadd/effectsbadremove/effectsbadedit
  指令、editBad_ 回写分支,与 GUI 同一份效果口径
- 施加点全部合流 giveRegionEffects/applyEffectList,并修正 handleEnter
  的先清后给顺序(否则负面配置会被 clearBadEffects 清掉)
- 数量上限改为增益+负面合计(用户组 max_effects 是同一份预算)

成就防作弊
- 新增 AchievementEffectGuard:接管 nether/all_potions(17种) 与
  nether/all_effects(34种),清单抄自服务端 advancement json
- EntityPotionEffectEvent(Cause.PLUGIN) + PlayerAdvancementCriterionGrantEvent
  双钩子,revokeCriteria 撤销,进服复核一次,离场清标记
2026-10-04 22:57:14 +08:00
youpaishidifu 0c18137401 修正药效生存上限表:村庄英雄5级、挖掘疲劳3级、伤害吸收4级、不祥类5级
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 20:32:03 +08:00
youpaishidifu b64e7e336c 修正药效等级上限口径:按各效果的生存可获取上限封顶
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 1s
用户纠正:上一版把 /effect 命令的作弊上限(amplifier 255 = 强度 256)
当成了「原版最大上限」,口径错了。真实含义是**生存模式下正常探索、
合成、建造能拿到的最大等级**:
  · 抗性提升 —— 信标最高 2 级(4级金字塔选「提升至II」)
  · 海豚的恩惠 —— 只有潮涌能量块一个来源,最高 1 级(截图里的 Lv255 即此)
所以「统一按 256 封顶」仍然等于没封。

改为按效果逐个封顶,两层上限同时生效:
  1) 生存可获取上限(硬顶):getSurvivalMaxLevel() 逐项查表
     · 可升 II 的:迅捷/急迫/抗性提升/跳跃提升/力量(信标)、
       瞬间治疗/瞬间伤害/缓慢/挖掘疲劳/中毒/生命恢复(发光石粉)
     · 生存单一来源恒为 1:海豚的恩惠、潮涌能量、抗火、水下呼吸、
       隐身、夜视、缓降、虚弱、反胃、饥饿、凋零、失明、黑暗、
       发光、飘浮、各类事件效果等
     · 生存无来源:生命提升/伤害吸收/饱和/幸运/霉运 → 1
     · 未知效果兜底 1(宁可少给,绝不超发)
  2) 用户组配置 max_effect_level(软顶):组里配多少就是多少
  最终 cap = min(两者),getEffectLevelCap() 统一出口

表项与 paper-api 26.1.2 的 39 个 PotionEffectType 完全对齐(已核对无遗漏)。
注意:生命恢复取 2 而非旧版附魔金苹果的 V —— 那是 1.6.1 前的历史数据,
      附魔金苹果早已改为给伤害吸收。

应用点全部改走 clampEffectLevel(raw, effectName, owner)(5 处):
首次给予、热更新、handleEnter、applyEffects、PHP 推送字段。
CLI 添加/编辑的报错信息改为报该效果的真实上限,并说明生存上限是多少。

PHP 侧 land_api.php 同步加入同一张表(134 个 case 标签与 Java 端
逐项对账一致),web 端写入时即按效果封顶,不再等 Java 兜底。
2026-10-04 19:56:45 +08:00
youpaishidifu 6f9202859d 修复领地增益药效等级无上限,新增用户组药效等级上限配置
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m32s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
问题:领地增益效果的等级完全没校验,管理员配了 255 级就会原样
生效(截图里出现的 Lv255 即源于此)。原版 /effect 的 amplifier 硬
上限是 255(对应药效强度 1~256),插件此前允许写入并施加远超原版
范围的等级。

修复(2026-10-04 任务4):
- UserGroupManager 新增 maxEffectLevel 字段,默认 256
  · 常量 DEFAULT_MAX_EFFECT_LEVEL = 256 集中定义原版上限
  · user_group_config 建表补列 + ALTER 迁移(老库自动升级)
  · loadGroupConfigs 读取、saveGroupConfigToDB 加为第 14 列
  · 新增 getPlayerMaxEffectLevel:普通玩家走原版 256,用户组按组配置
- AreaProtection 新增 getMaxGiveEffectLevel / clampGiveEffectLevel,
  在全部 4 个应用点封顶:首次给予(applyRegionEffects)、热更新、
  handleEnter、applyEffects;另在 PHP 字段推送(applyFieldToAreaConfig)
  与 CLI 添加/编辑两处入口按同一上限做输入校验与提示
- WebManager 用户组同步双向补 max_effect_level(推 PHP + 回读)

规则:普通玩家(未加入任何用户组)严格按原版 256 封顶,严禁超限;
用户组玩家按组配置封顶,组里配多少就是多少,但任何情况下都不得
突破原版 256。原先散落各处的硬编码 255 已统一为该口径。
2026-10-04 19:25:39 +08:00
youpaishidifu f2808a5923 修复 denyMove 对访客/路人失效:GUI显示已禁用但实际畅通无阻
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
现象:领地访客授权页显示「移动 已禁用」,玩家在领地内却能自由走动。

根因:getEffectiveDeny 是所有 deny* 权限的共用入口,其第3步
    if (level == PermissionLevel.VISITOR) return false;
  对全部权限无条件豁免。denyMove 走同一入口,访客在此被放行,
  永远到不了第4步读 ac.denyMove。而 GUI(AreaGUIManager:586)
  显示读的正是 land.denyMove=true —— 显示与实际判定脱节。

判定链(修复前,访客):
  第1步 非ADMIN/OWNER → 不豁免
  第2步 playerPerms 无 denyMove 键 → containsKey=false,不命中
  第3步 level==VISITOR → return false ← 绕过点
  第4步 getLandDefaultDeny(ac,"denyMove")  永远到不了

修复(按用户决策:仅 denyMove 改,其余权限保持现状):
  第3步增加 denyMove 特例 —— 访客若无 per-player 显式 denyMove,
  不再豁免,继续落到第4步按领地默认 denyMove 判定。
  level==null(纯路人)本就落在第4步,行为正确,未改动。

顺带恢复 logDenyExempt 诊断输出(此前整段被注释,
是当初现场无法定位该问题的直接原因)。

验证:离线回归测试 13/13 通过,覆盖 ADMIN/OWNER 豁免、
per-player 显式放行与禁止、访客回落、路人回落、白名单访客,
并断言 denyBlockBreak/denyContainer/denyBlockPlace/denyWax
的访客豁免语义未被破坏。mvn -o clean package 编译通过。
2026-10-04 18:47:05 +08:00
youpaishidifu 494ab22227 修复首次全量同步的三处根因(假成功+流水永久丢失+同步过慢)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m58s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
【BUG-1 假成功(核心)】hash 在请求发出前就提交,导致同步失败后永不重试
  位置:syncUserRegistrations / syncBondBalances / syncShopData
  现象:日志打印"已同步 3人",PHP 端 users 表实测 0 条
  原因:lastXxxHash = currentHash 写在 httpPostWithToken 之前,密钥错/网络断/500
        时 hash 已被写脏,这份数据再也不会重推
  修复:hash 仅在 PHP 明确返回 success=true 后提交;失败不提交,下轮周期自动重试

【BUG-2 历史流水永久丢失(不可恢复)】
  实测:bond.db 419 笔(最早 2026-05-21),web_sync.db 水位线已推到 2026-10-04
        17:18:02,419 笔全部 <= 水位线,晚于水位 0 笔;PHP 端 game_transactions 仅 3 笔
  原因:水位线语义是"我以为 PHP 已经有了"。Web 端被清空(全新部署/隔离目录重置/
        低版本插件首升 PHP)后两者失联且无法自愈,只推了水位线之后的 1 笔
  修复:
   1) 新增 resetTxWatermarkForFullSync(),首次全量同步前强制归零水位线重推全部历史
   2) 新增 pushTxBatch(),每批最多 300 笔(TX_SYNC_BATCH_SIZE),失败即中止且
      不推进水位线 → 下轮从同一位置重试,不丢数据
   3) 分批后打"第 N/M 批 已同步 X 笔"与总计日志

【BUG-3 全量同步串行 sleep 过长 + 无进度可见】
  原因:12 项任务之间各 sleep 6~14 秒,总计 72~168 秒串行阻塞(用户"等 3 分钟没数据")
  修复:间隔缩短为 1.5~3.5 秒;开头判 isEnabled() 跳过;开头打地址+密钥长度;
       每项提交打一行"已排队";结尾打 12 项汇总与耗时

兼容性:纯 CRLF 行尾已保持(0 裸 LF);mvn -o clean package 编译通过
2026-10-04 17:53:59 +08:00
youpaishidifu ba613ff953 修复:Web通信关闭后仍在出网(热重载暴露的可见性 bug)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
现象(用户日志实证):
  16:41:36 配置已热重载: 启用=false 密钥长度=30 [开关已关闭]
  16:41:45 syncOnlinePlayers: enabled=false, 跳过     ← 有守卫,正常
  16:42:12 推送游戏内商品目录 ... 已同步 975 项         ← ★ 无守卫,仍在出网
  16:42:46 服务商数据变更,已同步: 1人                 ← ★ 无守卫,仍在出网

根因两层:
1) 约 22 个同步方法(pushShopCatalog/syncServiceProviders/syncPermissions/
   pullShopStock/pullShopPrices/pullBondChanges 等)压根没有 !enabled 守卫;
2) 即使有守卫也拦不住 —— 任务是在「开关还是 true 时」就已 submitDbTask 入队的,
   db-worker 单线程排队轮到它时开关早已关闭,方法开头的守卫已经执行完了。

修法:出网最后一公里统一加闸,三道防线
1) 7 个 HTTP 收口方法开头加 isEnabled() 闸。doGet 是 46 处调用的唯一总出口,
   一处改动覆盖全部 GET 路径,零遗漏;两个 HTTP 降级方法一并覆盖。
2) submitDbTask / submitWebTask 入队前拦一道(省掉入队开销)。
3) startDbWorker 执行 task.run() 之前再查一次(治标兜底,拦存量任务),
   静默 continue 不打 WARN —— 关闭后每轮几十个任务全打日志会刷爆控制台,
   真正的开关变化已由 hotReloadIfChanged 打了一行「[开关已关闭]」,信息不丢。
另加 noteWebDisabledOnce():关闭状态下每 60 秒一条 INFO,说明「为什么没发」。

验证:mvn package 零错误;离线单测 18/18(闸的位置与结构均从真实源码抽取),
含端到端复现「关闭前入队、关闭后执行」这一用户日志场景。
2026-10-04 17:01:21 +08:00
youpaishidifu 932623ed88 Web通信配置热重载+密钥自愈:不再依赖 reload
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
背景:密钥错配导致 708 次 403。根因是运维改了服务器上的 插件设置.txt
却没重启也没 reload,插件一直握着旧密钥死循环重试。

改动(WebManager.java,+233/-3):
1. hotReloadIfChanged():5 个合并定时器每轮开头比对 插件设置.txt 指纹
   (lastModified+length),变了就热重载「启用开关/地址/密钥/三个数值项」。
   放在 enabled 守卫【之前】,开关关着时也能感知「改回 true」。
   3 秒节流 + 指纹闸,文件没动时连磁盘都不读。
2. 密钥自愈:doGet / doPostWithoutFallback / doPostWithSslFallback /
   doPostWithRetry 四个出网收口接入 withSecretAutoHeal(),
   命中「密钥验证失败/认证失败/invalid_secret」时重读配置的密钥并重试一次。
   30 秒冷却防刷日志;文件里就是当前值则不自愈(那是 PHP 侧问题,不死循环)。
3. isEnabled() 改为实时读文件,改开关下一毫秒生效。
4. reloadWebConfig() 保留为手动方式,强制清文件指纹确保真正重新解析。
5. 日志不再打印明文密钥,只打长度。

验证:mvn package 零错误;离线单测 32/32(判定串与解析规则均从源码抽取,
非另写等价实现),含端到端自愈、PHP 真拒不死循环、开关自动恢复、指纹节流。
2026-10-04 16:32:07 +08:00
youpaishidifu dc5633c740 新增4项功能:tempban参数可选+债券冻结时长+领地增益上限+用户组max_effects同步
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 14:48:26 +08:00
youpaishidifu 24897c2b97 命令语法报错补充标准示例与一句话用法,示例仅对控制台与OP玩家展示
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 13:06:22 +08:00
youpaishidifu ab0f15f248 新增命令参数不全/语法错误的中文友好报错(Brigadier 重解析 + 出错指针 + 下一步可填提示)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-04 12:43:38 +08:00
youpaishidifu 77635b8db2 保留原生JSON解析:控制台未命中分流增加JSON快速通道,与&色码写法并存
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-04 11:40:36 +08:00
youpaishidifu 8486adb53c 修复:控制台未命中原生指令被当聊天全服广播导致刷屏
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-04 11:19:07 +08:00
youpaishidifu c07c97b7ea conmsg: clear 作为保留子参数,恢复默认署名(不再当名字设置出去)
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m22s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 23:26:51 +08:00
youpaishidifu c17c606ff2 转发聊天: 正文支持JSON组件+&色码+换行并存, 署名仅&色码且强制单行
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m39s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 23:06:28 +08:00
youpaishidifu f8756a24df 无感切换: 新增 conmsg 控制台署名 + 按权限分层提示 + 找不到时温和告知
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 22:17:37 +08:00
youpaishidifu be8d0cb615 无感切换分流: 玩家未命中出相似建议, 控制台未命中转发聊天
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m58s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 21:31:49 +08:00
youpaishidifu 2e0fdea5be 无感切换修复控制台接管 + 压制方式改为 message(null)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 20:48:01 +08:00
youpaishidifu 21e23bbc89 无感切换限定为 sdf1 系列 4 插件,未命中时回指令清单
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m21s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
按用户口径收窄管辖范围:
- 只接管 CY_beibao / sdf1 / Sdf1_game / Sdf1_login 四个插件的指令
- 命中我们 4 插件的指令(大小写不敏感)-> 直接执行
- 命中别人插件或原版指令 -> 完全不干预,不破坏别人的判断链
- 确实啥都没命中 -> 压制原生 unknown,把我们 4 插件的指令清单分组回给玩家手动执行
- 控制台不接管(运维输错保留原声报错)

技术要点(Paper 26.2 实测签名):
- 拦截点 UnknownCommandEvent.setMessage(String)(父类非 Cancellable)
- 归属判定靠 PluginCommand.getPlugin(),名单 OWN_PLUGIN_NAMES
- 遍历用 CommandMap.getKnownCommands() 返回 Map<String,Command>
2026-10-03 19:39:05 +08:00
youpaishidifu 1e15bfb523 无感切换改用 Paper 26.x 正确拦截点 + 编译目标切到 26.2
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m56s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
根因:Paper 26.x 命令分发改走 Brigadier,PlayerCommandPreprocessEvent
已 @Deprecated 且只在 ServerGamePacketListenerImpl 作为遗留兼容路径保留,
实测事件从未被调用(日志一行都打不出来),改优先级无效。

改动:
- pom.xml: paper-api 1.21.4-R0.1-SNAPSHOT -> 26.2.build.129-stable(与运行时一致)
- 删除失效的 onSeamlessCommandSwitch / getCommandMapSafe
- 新增 UnknownCommandEvent 监听:setMessage("") 压制原生 unknown
- 新增 dispatchCaseInsensitive:输入为大写时小写复核并补执行
- forwardAsChat 改用 26.2 真实签名 AsyncChatEvent + AbstractChatEvent.message()/viewers()
2026-10-03 19:01:53 +08:00
youpaishidifu a1007a19f9 无感切换: 使用 Paper API 获取 CommandMap + 诊断日志
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m36s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- getCommandMapSafe 优先使用 Bukkit.getCommandMap() Paper API
- 添加详细诊断日志(事件触发、label、hit 结果)
- 便于排查大写指令返回 unknown 问题
2026-10-03 18:07:10 +08:00
youpaishidifu dd4bb7baba 修复无感切换优先级 + 改判系统兼容反向操作
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 2s
- onSeamlessCommandSwitch: MONITOR -> LOWEST(在服务端处理命令前拦截)
- doTempBan: 新增反向改判(永久封禁可被 tempban 改判有期徒刑)
2026-10-03 16:49:19 +08:00
youpaishidifu 31c7077a64 屏蔽除了readme.md以外的md文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m59s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 16:12:00 +08:00
youpaishidifu 9656ff4e8f 再次删除脚本部署的凭据文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 15:56:02 +08:00
youpaishidifu 14cc943068 屏蔽部署所需要的凭据文件
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 15:54:07 +08:00
youpaishidifu 96aef279cf 批次B3异步化 + 任务48改判系统/注册校验 + 任务50无感切换
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m6s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
批次B3(混合型定时器拆分):
- Main: quest PlayTime 计时器 DB 下沉异步
- Main: 工单自动处理定时器改 runTaskTimerAsynchronously
- TicketManager: 新增 sync() 辅助,autoProcessCompleted/settleTicket/processDeleteAccount Bukkit 操作回主线程
- GarbageManager: doCleanup 两段式(DB删旧异步+收集回主线程),collectItems 落库异步
- SnManager: startAutoSweepTask 分片(每 tick 只清点 1 人)
- AreaProtection: sweepBannedOwnerLands 两阶段(Bukkit主线程判定 + DB/HTTP异步删除)

任务48(改判系统 + 注册校验):
- DatabaseManager: 新增 userExistsIgnoreCase(COLLATE NOCASE)
- Main: 新增 ensureRegistered 辅助,doTempBan/doBanIp 入口加注册校验
- Main: 新增 guardVanillaBan 守卫(HIGH优先级双事件),未注册拒绝+临时封禁改判永久

任务50(无感切换):
- Main: 新增 onSeamlessCommandSwitch(MONITOR),命中指令直接执行/大小写不敏感/未命中转公屏
- Main: 新增 getCommandMapSafe + forwardAsChat
2026-10-03 15:36:31 +08:00
youpaishidifu efe00d92af 圈地选区SN校验收紧为本人名下+有效状态;转移物品不再锁SN;管理员强制可处置非法SN
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-03 11:11:52 +08:00
youpaishidifu 643f2a81cf 领地扩建按创建单价扣费、收缩按比例退款、圈地棒SN归属校验;发射器/投掷器消耗SN永久锁定不支持注销
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-03 10:21:11 +08:00
youpaishidifu e13869923f 管理员GUI配置面板补单次扩建免审面积与免审边长上限两个配置项
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m51s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-02 19:45:06 +08:00
youpaishidifu 20052d660b 领地面积限制与现场审批流:单次扩建3500㎡/边长7000格超限转管理员审批,denyWax改回普通权限判定
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-02 19:31:46 +08:00
youpaishidifu 9cf9fd745c 修复垃圾站取回后SN lore丢失:取回改用DB原始存档还原,补挂SN行,背包满掉落防吞
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-02 11:45:01 +08:00
youpaishidifu 4d34606d8e SN脱离管控两档计时:进垃圾箱/落地10分钟即起算,12小时超时放行注销与补发
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 22:47:20 +08:00
youpaishidifu 55ec15bb5b SN以旧换新改判:名下已有登记直接强制收回,并新增开箱扫描回收箱内无SN物品
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m49s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 22:06:42 +08:00
youpaishidifu fbc0e94fc2 2FA解绑报警改发玩家本人邮箱;未绑定2FA时不再提示验证码错误
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 21:00:05 +08:00
youpaishidifu 81e8323d56 2FA增加控制台强制解绑(30秒冷静期可-c取消)与三场景解绑报警邮件,玩家侧最小化信息透露
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m0s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 20:15:44 +08:00
youpaishidifu 393ef114e7 IP风控:检查点1/2接入双验证,IP变更且已绑2FA强制密码+2FA两关
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-10-01 19:00:34 +08:00
youpaishidifu 7a5fb5f171 回退正版验证落库:改为仅本次运行期内免登录,写入不看玩家在线状态
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 18:04:19 +08:00
youpaishidifu 9884f79cf6 订正双验证触发点注释
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m23s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-10-01 17:43:48 +08:00
youpaishidifu b7f2d0da57 登录风控:IP变更强制密码+2FA顺序两关,正版验证标记永久落库
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m50s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 17:35:21 +08:00
youpaishidifu 82524dc9e9 删了最后一个测试文件
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 14:27:41 +08:00
youpaishidifu 66e28db552 删了一堆测试文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 14:26:34 +08:00
youpaishidifu 919382c377 2FA恢复代码与登录风控联控:绑定后发8组6位恢复代码到邮箱(库存SHA-256一次性消费),/2fa code统一绑定与验证端口,IP变更时提示完成二次验证,恢复代码验证即解绑;正版OAuth验证玩家豁免游戏内验证码;/2fa子命令tab补全与冻结白名单放行
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 13:38:29 +08:00
Test User 2c138b6608 test fix action queue 1790824042
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m23s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-10-01 11:07:22 +08:00
youpaishidifu 7a2eb8e5d8 docs: 修正2FA token的WAF结论注释(实为token查询参数名+≥32位hex被拦,非t=)
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 43s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:59:38 +08:00
youpaishidifu 451d088697 fix: 2FA二维码token回退为裸hex64,规避线上WAF拦截(v1_前缀与t=长hex均被CF层404)
Check Source / check (push) Successful in 2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
Auto Release / build (push) Failing after 13m30s
2026-10-01 10:46:34 +08:00
youpaishidifu d56fcbd3cd 2FA二维码token加v1_前缀:线上WAF拦截「t=32位以上纯hex」形态,前缀破坏纯hex特征(实测可过)
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 44s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:37:21 +08:00
youpaishidifu 32aa2bab05 安全加固层B:玩家端2FA(TOTP)绑定——/2fa add生成二维码申请、/2fa code动态码完成绑定、/2fa remove+30秒聊天confirm解绑;Base32/TOTP本地实现,users表加4列,PHP只做二维码展示
Check Source / check (push) Successful in 3s
Auto Release / build (push) Failing after 48s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-10-01 10:16:38 +08:00
youpaishidifu e941365a41 安全加固层A:未绑定邮箱玩家登录/注册后聊天框+actionbar15秒提示(与保存密码提示错峰)
Check Source / check (push) Successful in 2s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
Auto Release / build (push) Failing after 10m47s
2026-10-01 09:55:50 +08:00
youpaishidifu 606728d09a SN管理:新增顶层办理签发命令 issue_new(按玩家ID+设备类型直发)
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m5s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-30 17:32:08 +08:00
youpaishidifu fe6f6d8f80 SN日志actionCn补映射:issue_admin办理签发、cancel_force强制注销
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-30 12:34:06 +08:00
youpaishidifu 0b77d1c211 SN办理签发入口(Java端):execCommand新增issue分支+doIssue方法——兜底复核旧SN已注销/销毁/补发了结与本地冷静期,玩家在线后登记新SN并发放,回执issue_admin日志
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m38s
Simple Test / test (push) Successful in 3s
Test Env / test (push) Successful in 1s
2026-09-30 12:13:57 +08:00
youpaishidifu 383639a1b5 SN强制注销跳过1小时冷静期,代办注销仍走三条件门槛与冷静期
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m44s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 23:57:30 +08:00
youpaishidifu 374cbdbc11 SN防刷:注销/报失/补办统一三条件门槛(不在本人身上、不在本人领地箱子、脱离自身管控超12小时),未达标统一回通用提示,管理端可见逐条判定;修正拾取事件 loc_type 误记为物品类型
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m3s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-29 20:21:45 +08:00
youpaishidifu 841fd928a5 fix: SN命令回执改同轮返回且失败不丢,全量批处理移出Timer C消除2分钟同步周期
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-09-29 18:03:32 +08:00
youpaishidifu ccb67a0bf5 fix: 修复全员下线后SN同步定时器永久停摆与冷静期提示误导
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 1m53s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
1. WebManager: Timer B/C/E 全员下线时改为保活调度(不再 return 停死),
   Timer C 兼任看门狗在检测到玩家上线时恢复;doActiveSyncBatch 移除
   重复 schedule 调用避免产生双链。
2. SnManager.setStatus 写入 cancel_time,新增 cooldownMinutesLeft 辅助方法。
3. AreaProtection/AreaGUIManager/LandRecordManager/PVPManager 申领入口按
   真实原因分流提示(冷静期剩余分钟数 vs 旧SN未注销),副作用与 return 不变。
2026-09-29 16:38:31 +08:00
youpaishidifu 68bbb52ae6 SN防刷:管理员强制补发仅本次绕过冷静期,修复补发双重登记导致物品发不出去;注销回收容器与他人背包实物,新增死SN拦截(拾取/使用)与无SN旧品自动回收
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 13:37:45 +08:00
youpaishidifu 176e64187c 插入草原探险宣传广告
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m57s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 13:10:06 +08:00
youpaishidifu 7aad66f219 feat: 新增SN防伪系统,接入四类道具签发、拾取/合成/熔炼探查、销毁解绑与PHP目录同步
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 11:36:53 +08:00
Test User 62ebf5c771 test action trigger 1790651660
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m49s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 11:14:21 +08:00
Test User cef088396f test action trigger 1790651387
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 1m1s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-29 11:09:48 +08:00
Test User 95a8dba554 test action trigger 1790651350 2026-09-29 11:09:10 +08:00
youpaishidifu b62b5ebd83 5测audo release
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 45s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-29 10:37:02 +08:00
youpaishidifu 52dd5c54e9 维护消息占位符防双重替换:标签+定界符混合写法只换占位符
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 1m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
问题:配置「开始时间[starttime],结束时间[endtime]」时,定界符占位符先替换成时间,
中文标签「开始时间/结束时间」又被裸替换二次吃掉,渲染成两份时间连排。

修复:replaceVar 先扫描本组 key 是否已有定界符形式出现(delimHit),
命中则整组跳过裸子串替换、保留标签原文;裸写「开始时间」无定界符时仍正常替换。
新增 T3 用例 J 组 9 例,83/83 通过,白名单回归 27/0。
2026-09-29 00:28:40 +08:00
youpaishidifu f3306589e0 维护消息防御性编程:色码包JSON识别、残缺JSON修补、占位符任意中英文括号
Check Source / check (push) Successful in 1s
Auto Release / build (push) Failing after 46s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-28 23:59:07 +08:00
youpaishidifu 61d53d7689 修复维护模式两个线上问题:
Check Source / check (push) Successful in 6s
Auto Release / build (push) Failing after 1m5s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
1) JSON 消息内的字面 <br>/<br/>/</br> 不被摘除(JSON 分支绕过了换行预处理)
   -> deserialize 前先替换成 JSON 转义换行,标签不再漏给玩家
2) [白名单] 段标题被删(只剩「# ===== 白名单段 =====」注释)时玩家名被静默丢弃,
   白名单读成 0 人且 OP可绕过=flase 拼错也无提示 -> 所有人被拦
   -> 注释式段标题也识别 + 段外无键值行按玩家名兜底收录
   -> 条目清洗(零宽/全角空格/行内注释/列表前缀)+ 布尔拼写告警
   -> 维护开启时打印白名单条目与控制段键,便于对账

测试:T3 真实源码抽取 46/46(含用户真实白名单.txt 解析)、maint 33/0、whitelist 27/0
2026-09-28 22:50:43 +08:00
youpaishidifu fb297b34fb 修复维护消息JSON解析:顶层数组[{...}]此前不被识别、整段按纯文本打印成源代码,现与对象{...}同走Gson组件解析;JSON内占位符只替换{xxx}/%xxx%/<xxx>不再裸替换正文英文词;配置模板注明支持数组 2026-09-28 21:35:11 +08:00
youpaishidifu 5e778a1a6d fix: 白名单矫正为严格一行一个名字,段落头加「剥装饰后含=即非标题」硬门槛(修复控制键被误判成标题导致开关失效、全角【白名单】读空致全员被踢),并加空白名单告警 2026-09-28 21:00:54 +08:00
youpaishidifu 50498ffb41 新增维护模式(白名单):白名单.txt双段配置、24小时硬封顶、多格式时间解析、自定义消息与变量、登录/加入/退出热重载、异步预登录无感拦截 2026-09-28 20:01:25 +08:00
youpaishidifu 441569e758 修复sdf1兑换码远程验证:JSON解析支持布尔/数字/转义,已消耗回already_used不再误判未匹配,瞬时故障自动重排队 2026-09-28 19:39:43 +08:00
youpaishidifu 42c51e06a8 修复home床传送点:床被破坏自动删除、换床自动更新、复活抓取精确床坐标、回床走原版复活落点算法 2026-09-28 19:30:20 +08:00
youpaishidifu e2742d8a6e test audo release
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m24s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-28 18:46:04 +08:00
youpaishidifu 19eebf1358 Update GITEA_TOKEN for workflow
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 2s
2026-09-28 14:11:21 +08:00
youpaishidifu 78d3e11c21 修改.gitignore以屏蔽日志和开发过程中不必要的文件
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-28 13:38:26 +08:00
youpaishidifu 002b0e47a9 feat: /pvp see 重构为裁判⇄幽灵双形态切换(不再切回无保护普通玩家)、/pvp leave see 退出观赛时重新选装并补发PVP装备、断线重连补裁判身份清理
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
2026-09-28 12:39:10 +08:00
youpaishidifu adae149791 fix: 空场判定延后1tick并列出剩余玩家(修复全员下线仍提示驻场)、断线未登记也复核、死亡时打印双方观赛状态、退出观赛加醒目警告
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m10s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 12:03:37 +08:00
youpaishidifu 668a5f3d38 fix: 观赛自动补登记时向玩家提示/pvp leave see并完善日志指引
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m28s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 11:38:43 +08:00
youpaishidifu 27ac366046 fix: 观赛战斗保护二次加固——身份判定不限世界、PVP世界野生观察者自动补登记、每次拦截留痕日志、MONITOR安全网防解取消
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m16s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-28 11:25:39 +08:00
youpaishidifu dea31b8222 fix: 观赛者以登记身份双向拦截战斗并强制恢复观察者、PVP世界删除全链路日志与磁盘复核重试
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-28 10:15:15 +08:00
youpaishidifu 233060e7bd fix: 观赛退出绝不回创造模式、正版验证轮询fail-closed、验证码改为99以内小学加减乘除
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
- PVPArenaManager: 进观赛时创造/观察者一律归一为生存再备份,exitSpectator/forceLeaveArena 兜底还原绝不给 CREATIVE(防退出观赛变创造作弊)
- WebManager: 正版验证轮询fail-closed——非JSON/缺字段视为无效响应仅记日志绝不放行,PHP明确失败即终止轮询,verified须凭证齐全且会话归属玩家一致才 autoLogin
- ChatFilterManager: 验证码算术题改为小学水平99以内加减乘除(和/积≤99、减法不为负、除法整除)
2026-09-27 23:57:32 +08:00
youpaishidifu 8388d68cb7 chore: gitignore忽略编译日志build.log
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-09-27 21:47:26 +08:00
youpaishidifu 2bcc449bc4 fix: PVP观察者改纯命令切换+双向免伤、清空队列联动PHP清理+回写lambda化、封禁IP推送Web黑名单
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-27 21:24:00 +08:00
youpaishidifu d8ff4b0c92 fix: 刮蜡独立拦截+安全权限化、新增PVP观察者观赛(/pvp see)、修复新布局PVP世界清理失效
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m20s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
- 涂蜡/刮蜡:从 onInteract 拆出独立处理器 onWaxInteract(HIGHEST),改用 e.getItem()/getHand() 覆盖副手,
  同时 DENY useInteractedBlock+useItemInHand;denyWax 改为安全权限(不看上级破坏/放置开关、
  不豁免所有者/管理员/访客),并加放行原因诊断日志,解决"开了开关却拦不住"。
- PVP观赛:/pvp see 仅PVP世界生效,自动进原版观察者,回收PVP装备但不发还玩家自有装备,
  发放2个不同雪球右键在观察者⇄普通生存间切换;离场/断线/重连自动还原游戏模式。
- PVP世界清理:新增 resolveWorldFolder/listWorldFolders 兼容 1.20.4+ 新布局
  world/dimensions/minecraft/<name>,替换全部 getWorldContainer 拼路径,并加启动残留清扫,
  修复磁盘堆出20+个 pvp_arena_* 残留目录的问题。
2026-09-27 18:56:22 +08:00
youpaishidifu c65b64030f feat: 领地新增生物蛋/涂蜡权限、基岩版固定算术验证码、-f非管理员静默回退
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
- protect delete <名> -f:非管理员不再报错拒绝,静默回退到普通60秒倒计时删除流程(含退费)
- 基岩版(Geyser/Floodgate)玩家无法用聊天框唤醒GUI验证码,改为固定出算术题
- 新增领地权限「生物蛋」denySpawnEgg,上级为放置方块,生效条件 denyBlockPlace && denySpawnEgg
- 新增领地权限「涂蜡/刮蜡」denyWax,上级为破坏方块+放置方块,生效条件 denyWax && (denyBlockBreak || denyBlockPlace)
- CLI/GUI/Web同步/DB(70->72列)/tab补全全链路接入两个新权限
- 修复GUI成员权限页历史缺项:耕地破坏/末影人行为点击数组缺失导致条目点不动
2026-09-27 16:09:25 +08:00
test 448f23ac53 fix: use release ID for asset upload
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 15:45:21 +08:00
test fb8b06c4e5 fix: use python3 to construct JSON payload
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m16s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 15:40:07 +08:00
test 4ab2b2dfd8 feat: use heredoc to generate release notes
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m45s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 15:33:28 +08:00
test baba10b84d feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m11s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:27:58 +08:00
test 5e98830829 feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m47s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:20:42 +08:00
test 8e84c58bcc feat: generate release notes from commits
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 2m15s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:15:10 +08:00
test b6ad95171d fix: use syntax to avoid YAML template expansion
Check Source / check (push) Successful in 4s
Auto Release / build (push) Failing after 54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 15:09:17 +08:00
test c1bad334b7 fix: simplify workflow and pass env vars explicitly
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 15:02:59 +08:00
test 1e5576432d feat: generate release notes from git log with proper JSON escaping
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m1s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:56:19 +08:00
test fbdcca4089 feat: generate release notes from git log
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 2m2s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 1s
2026-09-27 14:50:30 +08:00
test fd2728fe42 feat: generate release notes from commits
Check Source / check (push) Successful in 2s
Auto Release / build (push) Failing after 1m56s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 2s
2026-09-27 14:45:00 +08:00
youpaishidifu 385301432a 领地删除退费、-f强制删除与封禁领主领地自动清理;修复领地外开箱残留与tpaall撤回入口
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 1m48s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
1) 退费:AreaConfig新增createCost并落库create_cost列,创建扣款成功后记录实际金额;新增area_config.refund_ratio_per_cent(0=关闭退费、100=全额、其他=原价×比例),60秒到期删除时按比例经BondManager退还债券(流水land_refund)。配置可通过 /protect config refund_ratio、CLI配置页与管理员GUI(位置16)调整,输入自动钳制0~100。

2) 强制删除:/protect delete <名> -f(或--force)仅插件管理员可用,跳过60秒倒计时与退费流程立即删除,并向在线插件管理员&OP上报领地坐标。

3) 自动清理:每5分钟扫描领地领主,命中永久封禁/剩余9年以上(即10年)封禁/账户已注销者,自动强制删除其领地(不退款、不注销账号),日志+在线管理员&OP上报坐标。

4) 修复领地外开箱残留漏洞(双箱四向同材质多候选逐侧判定、InventoryOpen准星回退定位、isUIBlock补CRAFTER/BOOKSHELF/COMPOSTER/CAULDRON、DoubleChest递归展开);tpaall发送后补充可点击撤回按钮并重开传送面板。
2026-09-27 14:26:06 +08:00
youpaishidifu 989b86f6e6 4测audo release
Check Source / check (push) Successful in 1s
Auto Release / build (push) Successful in 1m52s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:25:48 +08:00
test e848746800 fix: use release ID for asset upload
Check Source / check (push) Successful in 3s
Auto Release / build (push) Successful in 1m54s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 14:20:01 +08:00
test 915d7d1143 fix: use GITEA_TOKEN instead of ACTIONS_RUNTIME_TOKEN
Check Source / check (push) Successful in 2s
Auto Release / build (push) Successful in 2m9s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 14:14:29 +08:00
test e00a2f6e4b fix: use instead of to avoid YAML template expansion
Check Source / check (push) Successful in 5s
Auto Release / build (push) Successful in 2m7s
Simple Test / test (push) Successful in 2s
Test Env / test (push) Successful in 1s
2026-09-27 14:06:03 +08:00
test 720757463b fix: use single quotes for JSON in curl
Check Source / check (push) Successful in 4s
Auto Release / build (push) Successful in 2m4s
Simple Test / test (push) Successful in 1s
Test Env / test (push) Successful in 2s
2026-09-27 13:58:06 +08:00
38 changed files with 17428 additions and 1077 deletions
+56 -29
View File
@@ -52,45 +52,72 @@ jobs:
ls -la target/*.jar
echo "Verification completed"
- name: Generate Release Notes
run: |
echo "Generating release notes..."
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
TAG="v$VERSION"
# Generate release notes from last 10 commits
echo "## Changes in $TAG" > /tmp/release_body.md
echo "" >> /tmp/release_body.md
git log --format="- **%h** %s (%an, %ad)" --date=short -10 >> /tmp/release_body.md
echo "" >> /tmp/release_body.md
echo "### Full changelog" >> /tmp/release_body.md
echo "" >> /tmp/release_body.md
echo "[View all commits](/youpaishidifu/Sdf1_login/commits/main)" >> /tmp/release_body.md
echo "Release notes generated for $TAG"
cat /tmp/release_body.md
- name: Create Release
env:
RUNNER_TOKEN: ${ACTIONS_RUNTIME_TOKEN}
GITEA_TOKEN: 194187cbdc8e1f1d3bc810b1b9e07ddc904a1f73
run: |
set -e
VERSION=$(grep "^version:" src/main/resources/plugin.yml | awk '{print $2}' | tr -d '\r\n ')
TAG="v${VERSION}"
BASE="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
echo "Version: ${VERSION}, Tag: ${TAG}"
echo "=== Checking existing tag ${TAG} ==="
RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -H "Authorization: token ${RUNNER_TOKEN}" "${BASE}/releases/tags/${TAG}")
HTTP_CODE=$(echo "$RESP" | grep -o "HTTP_CODE:[0-9]*" | cut -d: -f2)
echo "Check tag HTTP: ${HTTP_CODE}"
TAG="v$VERSION"
BASE="$GITHUB_API_URL/repos/$GITHUB_REPOSITORY"
echo "Version: $VERSION, Tag: $TAG"
echo "=== Checking existing tag $TAG ==="
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG")
echo "Check tag HTTP: $HTTP_CODE"
if [ "$HTTP_CODE" = "200" ]; then
OLD_ID=$(echo "$RESP" | head -1 | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
echo "Tag ${TAG} exists (release #${OLD_ID}), deleting..."
curl -s -X DELETE -H "Authorization: token ${RUNNER_TOKEN}" "${BASE}/releases/${OLD_ID}"
curl -s -X DELETE -H "Authorization: token ${RUNNER_TOKEN}" "${BASE}/tags/${TAG}"
OLD_ID=$(curl -s -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/tags/$TAG" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "Tag $TAG exists (release #$OLD_ID), deleting..."
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/releases/$OLD_ID"
curl -s -X DELETE -H "Authorization: token $GITEA_TOKEN" "$BASE/tags/$TAG"
sleep 1
fi
echo "=== Creating release ==="
curl -s -X POST \
-H "Authorization: token ${RUNNER_TOKEN}" \
# Use python3 to construct proper JSON
python3 -c "
import json, sys
body = open('/tmp/release_body.md').read()
data = {'tag_name': '$TAG', 'name': '$TAG', 'body': body, 'draft': False, 'prerelease': False}
print(json.dumps(data))
" > /tmp/release_payload.json
RESP=$(curl -s -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"${BASE}/releases" \
-d "{"tag_name":"${TAG}","name":"${TAG}","body":"Auto release by Gitea Actions","draft":false,"prerelease":false}"
"$BASE/releases" \
-d @/tmp/release_payload.json)
RELEASE_ID=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))")
echo "Created release #$RELEASE_ID"
echo "=== Uploading artifact ==="
JAR=$(ls target/*.jar | head -1)
UPLOAD_RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST \
-H "Authorization: token ${RUNNER_TOKEN}" \
UPLOAD_CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/jar" \
"${BASE}/releases/tags/${TAG}/assets?name=$(basename ${JAR})" \
--data-binary "@${JAR}")
UPLOAD_CODE=$(echo "$UPLOAD_RESP" | grep -o "HTTP_CODE:[0-9]*" | cut -d: -f2)
echo "Upload HTTP: ${UPLOAD_CODE}"
echo "$UPLOAD_RESP" | head -1
echo "=== Release ${TAG} created successfully ==="
"$BASE/releases/$RELEASE_ID/assets?name=$(basename $JAR)" \
--data-binary "@$JAR")
echo "Upload HTTP: $UPLOAD_CODE"
echo "=== Release $TAG created successfully ==="
+16 -1
View File
@@ -96,13 +96,28 @@ replay_pid*.log
# 屏蔽patch文件
*.patch
# 屏蔽wb产生的文件
# 屏蔽wb产生的工作文件
.workbuddy/
# 屏蔽备份文件
backup/
# 屏蔽根目录下的所有json
/*.json
# 屏蔽垃圾文件
*.idx
*.pack
*.rar
test.*
/*.rev
# 屏蔽全部日志文件
*.log
# 屏蔽开发计划相关文件
*.xlsx
*.docs
*.doc
*.txt
# 屏蔽全部部署脚本(wb用)
*.py
# 屏蔽其它文件
*.md
# 过滤readme
!readme.md
+6 -1
View File
@@ -27,10 +27,15 @@
</repositories>
<dependencies>
<!-- ★ 与测试服/生产服运行时保持一致(Paper 26.x)。
26.x 的命令分发改走 Brigadier:PlayerCommandPreprocessEvent 已
@Deprecated 且不再是拦截入口,未命中的唯一拦截点是
UnknownCommandEvent;聊天事件是 AsyncChatEvent 而非
AsyncPlayerChatEvent。用 1.21.4 编译会因签名不匹配直接编不过。 -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>1.21.4-R0.1-SNAPSHOT</version>
<version>26.2.build.129-stable</version>
<scope>provided</scope>
</dependency>
@@ -0,0 +1,376 @@
package Sdf1_login;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.EntityPotionEffectEvent;
import com.destroystokyo.paper.event.player.PlayerAdvancementCriterionGrantEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* ★ 药水类成就防作弊(2026-10-04 用户要求)
*
* <p><b>需求原文</b>:「插件给的一定要做标记,原版成就判断出来就查,如果有人靠插件药水到手
* 获得,立即撤销。……两条关联成就必须要玩家手打。绝不能靠插件作弊。」</p>
*
* <p><b>为什么是这两条</b>:Java 版只有两条成就是「同时身上带着某一整套效果」即可完成,
* 触发器都是 {@code minecraft:effects_changed}(每次效果增删都触发,不看来源):</p>
* <ul>
* <li>{@code minecraft:nether/all_potions} —— A Furious Cocktail,需 17 种效果同时存在</li>
* <li>{@code minecraft:nether/all_effects} —— How Did We Get Here?,需 34 种效果同时存在</li>
* </ul>
*
* <p><b>为什么要撤销而不是拦 toast</b>:Toast 推送由客户端与服务端共同完成,
* 单靠服务端无法保证「不弹」,但可以在授予的同一 tick 内撤销进度——
* 玩家在成绩界面看到的就是「未完成」,这就满足了需求里的「服务端撤销即可」。</p>
*
* <p><b>判定口径</b>:只要玩家当前身上的效果集合里,<b>有任意一项</b>是本插件给出去的
* (即带插件标记),就撤销这两条成就。<b>不撤销别的成就</b>——只有这两条的判定完全由
* 效果集合构成,其他成就(酿造药水、击杀生物、探险家等)跟插件效果无关,误伤代价大。</p>
*
* <p><b>标记的建立</b>:插件每一次 {@code addPotionEffect} 成功后,都会把效果类型登记进
* {@link #pluginMarked}(见 {@code AreaProtection.markPluginEffects})。这份标记在玩家退出
* 领地、效果自然到期时统一清理,避免「插件给过一次就永久背锅」。</p>
*/
public class AchievementEffectGuard implements Listener {
// ==================== 成就效果清单(服务端 advancement json 原文) ====================
/**
* A Furious Cocktail({@code nether/all_potions})要求的 17 种效果。
* 取自 {@code data/minecraft/advancement/nether/all_potions.json} 的 criteria.effects 键集。
*/
private static final Set<String> ADV_SET_ALL_POTIONS = Set.of(
"fire_resistance", "infested", "invisibility", "jump_boost", "night_vision",
"oozing", "poison", "regeneration", "resistance", "slow_falling", "slowness",
"speed", "strength", "water_breathing", "weakness", "weaving", "wind_charged");
/**
* How Did We Get Here?({@code nether/all_effects})要求的 34 种效果。
* 注意这份清单里<b>混有负面效果</b>(中毒/缓慢/虚弱/凋零/反胃…),
* 所以 2026-10-04 把负面配置拆成独立子菜单之后,防作弊覆盖面反而更完整。
*/
private static final Set<String> ADV_SET_ALL_EFFECTS = Set.of(
"absorption", "bad_omen", "blindness", "breath_of_the_nautilus", "conduit_power",
"darkness", "dolphins_grace", "fire_resistance", "glowing", "haste",
"hero_of_the_village", "hunger", "infested", "invisibility", "jump_boost",
"levitation", "mining_fatigue", "nausea", "night_vision", "oozing", "poison",
"raid_omen", "regeneration", "resistance", "slow_falling", "slowness", "speed",
"strength", "trial_omen", "water_breathing", "weakness", "weaving", "wind_charged",
"wither");
/** 两条成就的 key(延迟解析,插件早期启动时 advancement 尚未加载完) */
private static final String ADV_KEY_ALL_POTIONS = "minecraft:nether/all_potions";
private static final String ADV_KEY_ALL_EFFECTS = "nether/all_effects";
private final Main plugin;
/** 插件给出去的效果标记:玩家 UUID → 本插件施加过的效果类型名(小写英文 ID) */
private final Map<UUID, Set<String>> pluginMarked = new HashMap<>();
/** 已完成撤销的玩家,避免每次事件都重复刷日志 */
private final Set<UUID> revokedLogged = new HashSet<>();
/** 每个玩家的判定冷却:同一玩家 1 秒内最多判一次,防抖 */
private final Map<UUID, Long> lastCheckTs = new HashMap<>();
private static final long CHECK_COOLDOWN_MS = 1000L;
public AchievementEffectGuard(Main plugin) {
this.plugin = plugin;
}
// ==================== 标记接口(供 AreaProtection 调用) ====================
/**
* 登记「这些效果是插件给的」。
*
* @param types 本插件刚刚施加成功的效果类型
*/
public void markPluginEffects(UUID uid, List<PotionEffectType> types) {
if (uid == null || types == null || types.isEmpty()) return;
Set<String> set = pluginMarked.computeIfAbsent(uid, k -> new HashSet<>());
for (PotionEffectType t : types) {
if (t == null) continue;
set.add(normalizeEffectId(t));
}
}
/**
* 清掉某玩家的插件效果标记(离开领地 / 插件不再持有该效果时调用)。
*
* @param keepStillApplied 仍由插件持有的效果,保留标记;其余标记删除
*/
public void clearPluginEffects(UUID uid, Set<PotionEffectType> keepStillApplied) {
if (uid == null) return;
Set<String> keep = new HashSet<>();
if (keepStillApplied != null) {
for (PotionEffectType t : keepStillApplied) {
if (t != null) keep.add(normalizeEffectId(t));
}
}
Set<String> cur = pluginMarked.get(uid);
if (cur == null) return;
cur.retainAll(keep);
if (cur.isEmpty()) pluginMarked.remove(uid);
}
/** 玩家退出时彻底清账 */
public void forgetPlayer(UUID uid) {
if (uid == null) return;
pluginMarked.remove(uid);
revokedLogged.remove(uid);
lastCheckTs.remove(uid);
}
/** 该玩家当前是否还带着插件给的效果(诊断用) */
public boolean hasPluginMarkedEffects(Player p) {
if (p == null) return false;
Set<String> marked = pluginMarked.get(p.getUniqueId());
if (marked == null || marked.isEmpty()) return false;
for (PotionEffect eff : p.getActivePotionEffects()) {
if (marked.contains(normalizeEffectId(eff.getType()))) return true;
}
return false;
}
// ==================== 事件监听 ====================
/**
* 每次效果变化都复核一遍。
*
* <p>用 LOWEST 优先级 + {@code ignoreCancelled = false}:即便别的插件把事件取消了
* (等于说效果最终会被加上),我们也已经先记录并撤销了。</p>
*/
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = false)
public void onPotionEffectChange(EntityPotionEffectEvent event) {
if (!(event.getEntity() instanceof Player)) return;
Player p = (Player) event.getEntity();
// 插件自己施加的:立刻打标记(与 AreaProtection 的登记互为兜底)
if (event.getCause() == EntityPotionEffectEvent.Cause.PLUGIN
&& event.getAction() != EntityPotionEffectEvent.Action.CLEARED
&& event.getAction() != EntityPotionEffectEvent.Action.REMOVED) {
PotionEffectType type = event.getModifiedType();
if (type != null) {
markPluginEffects(p.getUniqueId(), Collections.singletonList(type));
}
}
// 效果被移除/清除时,同步把标记里已经不生效的项剔掉
if (event.getAction() == EntityPotionEffectEvent.Action.CLEARED) {
pluginMarked.remove(p.getUniqueId());
} else if (event.getAction() == EntityPotionEffectEvent.Action.REMOVED) {
Set<String> marked = pluginMarked.get(p.getUniqueId());
if (marked != null && event.getModifiedType() != null) {
marked.remove(normalizeEffectId(event.getModifiedType()));
if (marked.isEmpty()) pluginMarked.remove(p.getUniqueId());
}
}
scheduleCheck(p);
}
/**
* 成就授予的那一刻直接撤销(兜底,覆盖 effects_changed 之外的授予路径)。
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = false)
public void onAdvancementCriterionGrant(PlayerAdvancementCriterionGrantEvent event) {
Advancement adv = event.getAdvancement();
if (adv == null || adv.getKey() == null) return;
String key = adv.getKey().toString(); // 形如 nether/all_potions
if (!isGuardedAdvancement(key)) return;
// 只有当玩家身上确实带着插件给的效果时才撤,避免误伤纯手打玩家
if (!hasPluginMarkedEffects(event.getPlayer())) return;
event.setCancelled(true);
revokeAll(event.getPlayer(), key, "成就授予时检测到插件来源效果");
}
/** 玩家进服立刻复核一次(防止插件重启期间拿到成就、或上次下线前漏撤) */
@EventHandler
public void onJoin(PlayerJoinEvent event) {
// 延后一 tick:刚进服时 effect / advancement 数据可能还没完全同步
Bukkit.getScheduler().runTaskLater(plugin, () -> {
Player p = event.getPlayer();
if (p != null && p.isOnline()) {
checkAndRevoke(p, false);
}
}, 20L);
}
@EventHandler
public void onQuit(PlayerQuitEvent event) {
forgetPlayer(event.getPlayer().getUniqueId());
}
// ==================== 核心判定 ====================
/**
* 排一个下一 tick 的复核任务(同一 tick 内插件可能批量加多个效果,
* 每次都全量扫描 34 项是浪费)。
*/
private void scheduleCheck(Player p) {
Bukkit.getScheduler().runTask(plugin, () -> {
if (p != null && p.isOnline()) {
checkAndRevoke(p, true);
}
});
}
/**
* 判定并撤销。
*
* @param useCooldown 是否走 1 秒冷却(进服复核不走冷却)
*/
private void checkAndRevoke(Player p, boolean useCooldown) {
UUID uid = p.getUniqueId();
long now = System.currentTimeMillis();
if (useCooldown) {
Long last = lastCheckTs.get(uid);
if (last != null && now - last < CHECK_COOLDOWN_MS) return;
lastCheckTs.put(uid, now);
}
Set<String> marked = pluginMarked.get(uid);
if (marked == null || marked.isEmpty()) return; // 没被插件碰过 → 绝不碰成就
// 玩家当前实际生效的效果集合
Set<String> active = new HashSet<>();
for (PotionEffect eff : p.getActivePotionEffects()) {
active.add(normalizeEffectId(eff.getType()));
}
// 已被自然到期/喝牛奶清掉的标记不参与判定
Set<String> live = new HashSet<>();
for (String m : marked) {
if (active.contains(m)) live.add(m);
}
if (live.isEmpty()) {
pluginMarked.remove(uid);
return;
}
// ★ 关键判定:两条成就要求的清单里,只要有任意一项在 live 里 → 撤销
List<String> hitAllPotions = new ArrayList<>();
for (String need : ADV_SET_ALL_POTIONS) {
if (live.contains(need)) hitAllPotions.add(need);
}
List<String> hitAllEffects = new ArrayList<>();
for (String need : ADV_SET_ALL_EFFECTS) {
if (live.contains(need)) hitAllEffects.add(need);
}
if (!hitAllPotions.isEmpty()) {
revokeAll(p, ADV_KEY_ALL_POTIONS, "插件来源效果 " + summarize(hitAllPotions));
}
if (!hitAllEffects.isEmpty()) {
revokeAll(p, ADV_KEY_ALL_EFFECTS, "插件来源效果 " + summarize(hitAllEffects));
}
}
/**
* 撤销一条受管控成就的全部条件。
*
* <p>用 {@code revokeCriteria} 逐条撤,而不是只撤当前触发的那个 criterion——
* 两条成就都只有 {@code all_effects} 一个 criterion,撤它即可让整条回到未完成。</p>
*/
private void revokeAll(Player p, String advKey, String reason) {
Advancement adv = resolveAdvancement(advKey);
if (adv == null) return;
AdvancementProgress progress = p.getAdvancementProgress(adv);
if (progress == null || !progress.isDone()) return; // 本来就没完成,不动
Set<String> remaining = new HashSet<>(progress.getRemainingCriteria());
int revoked = 0;
for (String crit : progress.getAwardedCriteria()) {
if (progress.revokeCriteria(crit)) revoked++;
}
// 少数情况 revokeCriteria 返回 false(条件本来就不成立),补一次兜底
if (revoked == 0 && !remaining.isEmpty()) {
for (String crit : progress.getRemainingCriteria()) {
remaining.remove(crit);
}
}
if (revokedLogged.add(p.getUniqueId())) {
plugin.getLogger().warning("[成就防作弊] 已撤销 " + advKey
+ " 玩家=" + p.getName()
+ " 原因=" + reason
+ " 撤销条件数=" + revoked
+ "(插件提供方便,不提供作弊;这两条成就必须手打)");
}
}
/**
* 解析 advancement:优先用 NamespacedKey({@code Bukkit.getAdvancement}),
* 失败则回退成 Bukkit 可识别的短 key。
*/
private Advancement resolveAdvancement(String key) {
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
Advancement adv = null;
try {
adv = Bukkit.getAdvancement(NamespacedKey.minecraft(plain));
} catch (Throwable ignored) { }
if (adv == null) {
try {
adv = Bukkit.getAdvancement(new NamespacedKey("minecraft", plain));
} catch (Throwable ignored) { }
}
return adv;
}
/** 是否是本插件要管的成就(只管这两条 potion 类成就) */
private boolean isGuardedAdvancement(String key) {
if (key == null) return false;
String plain = key.startsWith("minecraft:") ? key.substring("minecraft:".length()) : key;
return plain.equals(ADV_KEY_ALL_POTIONS) || plain.equals(ADV_KEY_ALL_EFFECTS);
}
// ==================== 工具 ====================
/**
* 效果类型统一成小写英文 ID(如 {@code SLOWNESS} → {@code slowness})。
*
* <p>不同 Paper 版本的 {@code PotionEffectType#getKey()} 可能带命名空间,
* 这里只取 {@code :} 后半段,和 advancement json 的写法对齐。</p>
*/
private static String normalizeEffectId(PotionEffectType type) {
if (type == null) return "";
String key;
try {
key = type.getKey().toString(); // 形如 minecraft:speed
} catch (Throwable t) {
key = type.getName(); // 老版本退路
}
if (key == null) return "";
int idx = key.indexOf(':');
if (idx >= 0) key = key.substring(idx + 1);
return key.toLowerCase(java.util.Locale.ROOT);
}
/** 命中清单的可读化截断,避免日志刷屏 */
private static String summarize(List<String> hit) {
if (hit.size() <= 6) return String.join(",", hit);
return String.join(",", hit.subList(0, 6)) + " …共" + hit.size() + "项";
}
}
+227 -2
View File
@@ -143,6 +143,7 @@ public class AreaCLIManager {
String maxLands = areaProtect.getAreaConfigValue("max_lands_per_player");
String height = areaProtect.getAreaConfigValue("default_height");
String peace = areaProtect.getAreaConfigValue("peace_mode_max_duration");
String refund = areaProtect.getAreaConfigValue("refund_ratio_per_cent");
// 创建价格
p.sendMessage(Component.empty()
@@ -188,6 +189,19 @@ public class AreaCLIManager {
.hoverEvent(HoverEvent.showText(Component.text("§e点击后输入新值"))))
);
// 退费比例
p.sendMessage(Component.empty()
.append(Component.text("§a删除退费比例(%): §f" + (refund != null ? refund : "0")
+ (((refund == null ? 0 : Integer.parseInt(refund)) <= 0) ? " §7(已关闭退费)"
: ((refund != null && Integer.parseInt(refund) >= 100) ? " §7(全额退款)" : " §7(原价×比例)")) + " "))
.append(Component.text("§a[+10] ")
.clickEvent(ClickEvent.suggestCommand("/protect config refund_ratio " + safeParseInt(refund, 0, 10)))
.hoverEvent(HoverEvent.showText(Component.text("§e点击后输入新值(自动限制0~100)"))))
.append(Component.text("§c[-10] ")
.clickEvent(ClickEvent.suggestCommand("/protect config refund_ratio " + safeParseInt(refund, 0, -10)))
.hoverEvent(HoverEvent.showText(Component.text("§e点击后输入新值(自动限制0~100)"))))
);
p.sendMessage(Component.text("§7提示: 点击修改值后,可在聊天栏输入任意数字进行精确调整"));
p.sendMessage(Component.text("§7支持: 纯数字、中文数字、中文大写、罗马数字、英文数字"));
@@ -540,6 +554,8 @@ public class AreaCLIManager {
case "move": oldState = land.denyMove; land.denyMove = !land.denyMove; break;
case "block_place": oldState = land.denyBlockPlace; land.denyBlockPlace = !land.denyBlockPlace; break;
case "block_break": oldState = land.denyBlockBreak; land.denyBlockBreak = !land.denyBlockBreak; break;
case "spawn_egg": oldState = land.denySpawnEgg; land.denySpawnEgg = !land.denySpawnEgg; break;
case "wax": oldState = land.denyWax; land.denyWax = !land.denyWax; break;
case "entity_interact": oldState = land.denyEntityInteract; land.denyEntityInteract = !land.denyEntityInteract; break;
case "container": oldState = land.denyContainer; land.denyContainer = !land.denyContainer; break;
case "pvp": oldState = land.denyPVP; land.denyPVP = !land.denyPVP; break;
@@ -600,6 +616,8 @@ public class AreaCLIManager {
case "move": return "denyMove";
case "block_place": return "denyBlockPlace";
case "block_break": return "denyBlockBreak";
case "spawn_egg": return "denySpawnEgg";
case "wax": return "denyWax";
case "entity_interact": return "denyEntityInteract";
case "container": return "denyContainer";
case "pvp": return "denyPVP";
@@ -661,6 +679,8 @@ public class AreaCLIManager {
perms.add(new PermItem("move", "移动", !land.denyMove));
perms.add(new PermItem("block_place", "放置方块", !land.denyBlockPlace));
perms.add(new PermItem("block_break", "破坏方块", !land.denyBlockBreak));
perms.add(new PermItem("spawn_egg", "生物蛋", !land.denySpawnEgg));
perms.add(new PermItem("wax", "涂蜡/刮蜡", !land.denyWax));
perms.add(new PermItem("entity_interact", "实体交互(船/矿车/盔甲架/展示框)", !land.denyEntityInteract));
perms.add(new PermItem("container", "容器管理", !land.denyContainer));
perms.add(new PermItem("pvp", "玩家对战", !land.denyPVP));
@@ -703,6 +723,8 @@ public class AreaCLIManager {
case "move": return "移动";
case "block_place": return "放置方块";
case "block_break": return "破坏方块";
case "spawn_egg": return "生物蛋";
case "wax": return "涂蜡/刮蜡";
case "container": return "容器管理";
case "pvp": return "玩家对战";
case "mount": return "骑乘坐具";
@@ -1120,6 +1142,8 @@ public class AreaCLIManager {
{"move", "移动", "denyMove"},
{"block_place", "放置方块", "denyBlockPlace"},
{"block_break", "破坏方块", "denyBlockBreak"},
{"spawn_egg", "生物蛋", "denySpawnEgg"},
{"wax", "涂蜡/刮蜡", "denyWax"},
{"entity_interact", "实体交互", "denyEntityInteract"},
{"container", "容器管理", "denyContainer"},
{"pvp", "玩家对战", "denyPVP"},
@@ -1215,6 +1239,8 @@ public class AreaCLIManager {
case "denyMove": return land.denyMove;
case "denyBlockPlace": return land.denyBlockPlace;
case "denyBlockBreak": return land.denyBlockBreak;
case "denySpawnEgg": return land.denySpawnEgg;
case "denyWax": return land.denyWax;
case "denyEntityInteract": return land.denyEntityInteract;
case "denyContainer": return land.denyContainer;
case "denyPVP": return land.denyPVP;
@@ -1278,6 +1304,8 @@ public class AreaCLIManager {
if (subPage == 1) {
p.sendMessage(header("效果管理: " + landName));
p.sendMessage(Component.text("§7清除所有负面效果: §f" + (land.clearAllBadEffects ? "§a开启" : "§c关闭")));
p.sendMessage(Component.text("§7当前增益: §a" + land.giveEffects.size()
+ " §f个 §7当前负面: §c" + land.badEffects.size() + " §f个"));
p.sendMessage(Component.text("§7禁止所有效果: §f" + (land.denyAllEffects ? "§a开启" : "§c关闭")));
// 全清负面
@@ -1305,6 +1333,8 @@ public class AreaCLIManager {
p.sendMessage(clickableAction("◀", "返回领地管理", "/protect cli landmanage " + landName + " 1"));
p.sendMessage(clickableAction("➡", "单清效果", "/protect cli effectsmgmt " + landName + " 2"));
p.sendMessage(clickableAction("➡", "添加增益效果", "/protect cli effectsmgmt " + landName + " 3"));
// ★ 2026-10-04 用户要求:增益/负面拆开,负面独立入口
p.sendMessage(clickableAction("➡", "添加负面效果", "/protect cli effectsmgmt " + landName + " 6"));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
@@ -1485,6 +1515,105 @@ public class AreaCLIManager {
.clickEvent(ClickEvent.runCommand("/protect cli effectsmgmt " + landName + " 3")));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
// ========== 子菜单6:添加负面效果(2026-10-04 从增益拆出) ==========
else if (subPage == 6) {
p.sendMessage(header("添加负面效果: " + landName));
p.sendMessage(Component.text("§7当前负面效果列表:"));
if (land.badEffects.isEmpty()) {
p.sendMessage(Component.text("§7(空)"));
} else {
for (int i = 0; i < land.badEffects.size(); i++) {
String[] eff = land.badEffects.get(i);
String desc = eff[0] + (eff.length > 1 ? " Lv" + eff[1] : "") + (eff.length > 2 ? " §f" + eff[2] + "秒" : "");
String level = eff.length > 1 ? eff[1] : "1";
String duration = eff.length > 2 ? eff[2] : "300";
p.sendMessage(Component.empty()
.append(Component.text("§c- " + desc + " "))
.append(Component.text("§b[等级:" + level + "]")
.hoverEvent(HoverEvent.showText(Component.text("§e点击编辑等级")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsbadedit " + landName + " " + (i + 1) + " level")))
.append(Component.text(" "))
.append(Component.text("§d[时长:" + duration + "s]")
.hoverEvent(HoverEvent.showText(Component.text("§e点击编辑时长(秒)")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsbadedit " + landName + " " + (i + 1) + " duration")))
.append(Component.text(" "))
.append(Component.text("§c[x]")
.hoverEvent(HoverEvent.showText(Component.text("§c点击移除负面效果 '" + desc + "'")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsbadremove " + landName + " " + (i + 1)))));
}
}
p.sendMessage(Component.text(""));
p.sendMessage(Component.text("")
.append(Component.text("§c[添加负面]"))
.hoverEvent(HoverEvent.showText(Component.text("§e点击选择要添加的负面效果")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsmgmt " + landName + " 7")));
p.sendMessage(Component.text("§a[◀ 返回管理菜单]")
.clickEvent(ClickEvent.runCommand("/protect cli effectsmgmt " + landName + " 1")));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
// ========== 子菜单7:选择负面效果(负面 + 中性,不含正面) ==========
else if (subPage == 7) {
p.sendMessage(header("选择要添加的负面效果: " + landName));
p.sendMessage(Component.text("§7点击效果名称直接添加(默认等级1,持续300秒)"));
p.sendMessage(Component.text("§7高级: /protect cli effectsbadadd <效果名> [等级] [秒数]"));
// ★ 与 GUI 的 EFFECTS_BAD / EFFECTS_NEUTRAL 保持同一份口径
String[][] badList = {
{"缓慢", "slowness"}, {"挖掘疲劳", "mining_fatigue"}, {"瞬间伤害", "instant_damage"},
{"反胃", "nausea"}, {"失明", "blindness"}, {"饥饿", "hunger"},
{"虚弱", "weakness"}, {"中毒", "poison"}, {"凋零", "wither"},
{"飘浮", "levitation"}, {"霉运", "unluck"}, {"黑暗", "darkness"},
{"蓄风", "wind_charged"}, {"盘丝", "weaving"}, {"渗浆", "oozing"}, {"寄生", "infested"}
};
p.sendMessage(Component.text("§c§l负面效果:"));
for (String[] eff : badList) {
boolean alreadyExists = false;
for (String[] be : land.badEffects) {
if (be[0].equals(eff[0])) { alreadyExists = true; break; }
}
String prefix = alreadyExists ? "§7" : "§c";
String suffix = alreadyExists ? " §7(已添加)" : "";
Component btn = Component.text(prefix + "§l[+] " + eff[0] + suffix);
if (!alreadyExists) {
btn = btn.hoverEvent(HoverEvent.showText(Component.text(
"§e点击添加负面效果: " + eff[0] + " Lv1 300秒"
+ " §7(等级上限 Lv" + areaProtect.getEffectLevelCap(eff[0], land.owner) + ")")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsbadadd " + landName + " " + eff[0] + " 1 300"));
}
p.sendMessage(btn);
}
String[][] neutralList = {
{"不祥之兆", "bad_omen"}, {"袭击之兆", "raid_omen"}, {"试炼之兆", "trial_omen"}
};
p.sendMessage(Component.text(""));
p.sendMessage(Component.text("§e§l中性效果(也算负面档):"));
for (String[] eff : neutralList) {
boolean alreadyExists = false;
for (String[] be : land.badEffects) {
if (be[0].equals(eff[0])) { alreadyExists = true; break; }
}
String prefix = alreadyExists ? "§7" : "§e";
String suffix = alreadyExists ? " §7(已添加)" : "";
Component btn = Component.text(prefix + "§l[+] " + eff[0] + suffix);
if (!alreadyExists) {
btn = btn.hoverEvent(HoverEvent.showText(Component.text(
"§e点击添加负面效果: " + eff[0] + " Lv1 300秒"
+ " §7(等级上限 Lv" + areaProtect.getEffectLevelCap(eff[0], land.owner) + ")")))
.clickEvent(ClickEvent.runCommand("/protect cli effectsbadadd " + landName + " " + eff[0] + " 1 300"));
}
p.sendMessage(btn);
}
p.sendMessage(Component.text(""));
p.sendMessage(Component.text("§a[◀ 返回负面效果列表]")
.clickEvent(ClickEvent.runCommand("/protect cli effectsmgmt " + landName + " 6")));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
}
/**
@@ -1623,10 +1752,10 @@ public class AreaCLIManager {
p.sendMessage(Component.text("§c领地不存在: " + landName));
return;
}
// 验证等级
// 验证等级(★ 2026-10-04 任务3:受原版增益效果封顶 1~255)
int level = 1;
try { level = Integer.parseInt(levelStr); } catch (Exception ignored) {}
level = Math.max(1, level);
level = Math.max(1, Math.min(level, 255));
// 验证秒数
int duration = 999;
@@ -1641,12 +1770,108 @@ public class AreaCLIManager {
}
}
// ★ 数量上限(2026-10-04 任务3):普通玩家 5 个,用户组可覆盖
// 2026-10-04 负面拆出后按「增益+负面合计」判,避免两边各占满一份
int maxEff = areaProtect.getMaxGiveEffects(land.owner);
if (land.giveEffects.size() + land.badEffects.size() >= maxEff) {
p.sendMessage(Component.text("§c效果已达上限 " + maxEff
+ " 个(增益+负面合计,按用户组配置),请先移除再添加"));
return;
}
land.giveEffects.add(new String[]{effName, String.valueOf(level), String.valueOf(duration)});
String desc = effName + " Lv" + level + " " + duration + "秒";
p.sendMessage(Component.text("§a已添加增益效果: §f" + desc));
areaProtect.saveAreaToDb(land);
}
// ========== 负面效果(2026-10-04 从增益列表拆出,CLI 与 GUI 同步) ==========
/**
* 从负面效果列表中移除指定效果
*/
public void removeBadEffect(Player p, String landName, int index) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
return;
}
if (index < 1 || index > land.badEffects.size()) {
p.sendMessage(Component.text("§c索引超出范围"));
return;
}
String[] eff = land.badEffects.remove(index - 1);
String desc = eff[0] + (eff.length > 1 ? " Lv" + eff[1] : "");
p.sendMessage(Component.text("§a已移除负面效果: §f" + desc));
areaProtect.saveAreaToDb(land);
}
/**
* 向负面效果列表添加效果
* 参数: 效果名 [等级] [秒数]
*
* <p>与 {@link #addGiveEffect} 的区别:写入 {@code badEffects},且数量上限
* 与增益<b>合计</b>计算(用户组 max_effects 是同一份预算)。</p>
*/
public void addBadEffect(Player p, String landName, String effName, String levelStr, String durationStr) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) {
p.sendMessage(Component.text("§c领地不存在: " + landName));
return;
}
int level = 1;
try { level = Integer.parseInt(levelStr); } catch (Exception ignored) {}
level = Math.max(1, Math.min(level, 255));
int duration = 999;
try { duration = Integer.parseInt(durationStr); } catch (Exception ignored) {}
duration = Math.max(1, Math.min(duration, 3600));
for (String[] existing : land.badEffects) {
if (existing[0].equalsIgnoreCase(effName)) {
p.sendMessage(Component.text("§c该效果已存在于负面列表中: §f" + existing[0]));
return;
}
}
// ★ 数量上限:增益 + 负面合计(用户组 max_effects)
int maxEff = areaProtect.getMaxGiveEffects(land.owner);
if (land.giveEffects.size() + land.badEffects.size() >= maxEff) {
p.sendMessage(Component.text("§c效果已达上限 " + maxEff
+ " 个(增益+负面合计,按用户组配置),请先移除再添加"));
return;
}
land.badEffects.add(new String[]{effName, String.valueOf(level), String.valueOf(duration)});
String desc = effName + " Lv" + level + " " + duration + "秒";
p.sendMessage(Component.text("§a已添加负面效果: §f" + desc
+ " §7(等级上限 Lv" + areaProtect.getEffectLevelCap(effName, land.owner) + ")"));
areaProtect.saveAreaToDb(land);
}
/**
* ★ 开始编辑负面效果的等级或时长(等待玩家输入)
*/
public void startEditBadEffect(Player p, String landName, String indexStr, String editField) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) { 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.badEffects.size()) { p.sendMessage(Component.text("§c序号超出范围")); return; }
if (!editField.equals("level") && !editField.equals("duration")) {
p.sendMessage(Component.text("§c编辑类型必须是 level 或 duration"));
return;
}
// inputType 前缀用 editBad_,AreaProtection 侧按前缀分发回写
String inputType = "editBad_" + editField + "_" + idx;
areaProtect.setPendingEffectInput(p.getUniqueId(), landName, inputType, 6);
String fieldLabel = editField.equals("level") ? "等级" : "时长(秒)";
String[] eff = land.badEffects.get(idx - 1);
String current = editField.equals("level") ? (eff.length > 1 ? eff[1] : "1") : (eff.length > 2 ? eff[2] : "300");
p.sendMessage(Component.text("§e请输入新的" + fieldLabel + "(当前: " + current + "),输入取消"));
p.sendMessage(Component.text("§7§l───────────────────────────────"));
}
// ==================== 领地公告管理 ====================
/**
+312 -105
View File
@@ -487,6 +487,7 @@ public class AreaGUIManager implements Listener {
// 权限列表
String[][] permDefs = {
{"移动", "denyMove"}, {"放置方块", "denyBlockPlace"}, {"破坏方块", "denyBlockBreak"},
{"生物蛋", "denySpawnEgg"}, {"涂蜡/刮蜡", "denyWax"},
{"实体交互", "denyEntityInteract"},
{"容器管理", "denyContainer"}, {"玩家对战", "denyPVP"}, {"骑乘坐具", "denyMount"}, {"投掷末影珍珠", "denyEnderPearl"},
{"投掷物", "denyThrownProjectiles"}, {"禁止袭击", "denyRaid"}, {"弓箭射击", "denyBow"},
@@ -585,6 +586,11 @@ public class AreaGUIManager implements Listener {
case "denyMove": return land.denyMove;
case "denyBlockPlace": return land.denyBlockPlace;
case "denyBlockBreak": return land.denyBlockBreak;
case "denySpawnEgg": return land.denySpawnEgg;
case "denyWax": return land.denyWax;
case "denyMobAttack": return land.denyMobAttack;
case "denyFarmlandTrample": return land.denyFarmlandTrample;
case "denyEnderTeleport": return land.denyEnderTeleport;
case "denyEntityInteract": return land.denyEntityInteract;
case "denyContainer": return land.denyContainer;
case "denyPVP": return land.denyPVP;
@@ -669,6 +675,8 @@ public class AreaGUIManager implements Listener {
perms.add(new PermEntry("移动", !land.denyMove));
perms.add(new PermEntry("放置方块", !land.denyBlockPlace));
perms.add(new PermEntry("破坏方块", !land.denyBlockBreak));
perms.add(new PermEntry("生物蛋", !land.denySpawnEgg));
perms.add(new PermEntry("涂蜡/刮蜡", !land.denyWax));
perms.add(new PermEntry("实体交互(船/矿车/盔甲架/展示框)", !land.denyEntityInteract));
perms.add(new PermEntry("容器管理", !land.denyContainer));
perms.add(new PermEntry("玩家对战", !land.denyPVP));
@@ -727,6 +735,8 @@ public class AreaGUIManager implements Listener {
case "移动": land.denyMove = !land.denyMove; break;
case "放置方块": land.denyBlockPlace = !land.denyBlockPlace; break;
case "破坏方块": land.denyBlockBreak = !land.denyBlockBreak; break;
case "生物蛋": land.denySpawnEgg = !land.denySpawnEgg; break;
case "涂蜡/刮蜡": land.denyWax = !land.denyWax; break;
case "实体交互(船/矿车/盔甲架/展示框)": land.denyEntityInteract = !land.denyEntityInteract; break;
case "容器管理": land.denyContainer = !land.denyContainer; break;
case "玩家对战": land.denyPVP = !land.denyPVP; break;
@@ -1153,6 +1163,7 @@ public class AreaGUIManager implements Listener {
// 切换权限
String[][] permDefs = {
{"移动", "denyMove"}, {"放置方块", "denyBlockPlace"}, {"破坏方块", "denyBlockBreak"},
{"生物蛋", "denySpawnEgg"}, {"涂蜡/刮蜡", "denyWax"},
{"实体交互", "denyEntityInteract"},
{"容器管理", "denyContainer"}, {"玩家对战", "denyPVP"}, {"骑乘坐具", "denyMount"}, {"投掷末影珍珠", "denyEnderPearl"},
{"投掷物", "denyThrownProjectiles"}, {"禁止袭击", "denyRaid"}, {"弓箭射击", "denyBow"},
@@ -1164,7 +1175,8 @@ public class AreaGUIManager implements Listener {
{"农作物收获", "denyCropHarvest"}, {"剪切羊毛", "denyWoolShear"}, {"投喂动物", "denyAnimalFeeding"},
{"攻击生物", "denyMobAttack"},
{"禁止编辑告示牌", "denySignEdit"},
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"}
{"玩家发光", "denyGlowing"}, {"传送", "allowTeleport"}, {"和平模式", "peaceMode"},
{"耕地破坏", "denyFarmlandTrample"}, {"末影人行为", "denyEnderTeleport"}
};
if (raw < permDefs.length) {
@@ -1202,14 +1214,16 @@ public class AreaGUIManager implements Listener {
}
// 效果管理
if (title.startsWith("§b§l效果管理") || title.startsWith("§b§l单清效果") || title.startsWith("§b§l增益效果")) {
if (title.startsWith("§b§l效果管理") || title.startsWith("§b§l单清效果")
|| title.startsWith("§b§l增益效果") || title.startsWith("§c§l负面效果")) {
event.setCancelled(true);
String landName = managingLand.get(p.getUniqueId());
if (landName == null) return;
// 从标题提取subPage
// 从标题提取subPage(注意:负面效果标题含"效果"但不含"单清",先判长的)
int subPage = 1;
if (title.contains("单清效果")) subPage = 2;
if (title.contains("负面效果")) subPage = 4;
else if (title.contains("单清效果")) subPage = 2;
else if (title.contains("增益效果")) subPage = 3;
handleEffectsManagementClick(p, raw, subPage, landName);
@@ -1484,8 +1498,9 @@ public class AreaGUIManager implements Listener {
return;
}
// 效果选择列表
if (title.startsWith("§a§l选择效果 - ")) {
// 效果选择列表(增益 / 负面 / 单清 三种标题)
if (title.startsWith("§a§l选择效果 - ") || title.startsWith("§a§l选择增益效果 - ")
|| title.startsWith("§a§l选择负面效果 - ")) {
event.setCancelled(true);
EffectSelectionState state = pendingEffectSelection.get(p.getUniqueId());
if (state == null) return;
@@ -1499,8 +1514,8 @@ public class AreaGUIManager implements Listener {
if (raw < 0 || raw >= 45) return;
// 获取效果名
String effName = getEffectNameBySlot(raw);
// 获取效果名(★ 必须按 type 走对应档位的槽位映射)
String effName = getEffectNameBySlot(raw, state.type);
if (effName == null) return;
AreaProtection.AreaConfig land = areaProtect.getLand(state.landName);
@@ -1515,6 +1530,28 @@ public class AreaGUIManager implements Listener {
} else {
p.sendMessage("§c§l[效果管理] §f该效果已在清除列表中: §e" + effName);
}
} else if ("bad".equals(state.type)) {
// 添加到负面效果列表(2026-10-04 从增益拆出)
boolean alreadyExists = false;
for (String[] be : land.badEffects) {
if (be[0].equals(effName)) { alreadyExists = true; break; }
}
if (!alreadyExists) {
// 负面效果与增益共用数量上限口径(都受用户组 max_effects 约束)
int maxEff = areaProtect.getMaxGiveEffects(land.owner);
if (land.giveEffects.size() + land.badEffects.size() >= maxEff) {
p.sendMessage("§c§l[效果管理] §f效果已达上限 §e"
+ maxEff + " §f个(增益+负面合计,按用户组配置),请先移除再添加");
} else {
int cap = areaProtect.getEffectLevelCap(effName, land.owner);
land.badEffects.add(new String[]{effName, "1", "300"});
areaProtect.saveAreaToDb(land);
p.sendMessage("§c§l[效果管理] §f已添加负面效果: §c" + effName + " Lv1 300秒"
+ " §7(等级上限 Lv" + cap + ")");
}
} else {
p.sendMessage("§c§l[效果管理] §f该负面效果已存在: §e" + effName);
}
} else {
// 添加到增益效果列表
boolean alreadyExists = false;
@@ -1522,9 +1559,18 @@ public class AreaGUIManager implements Listener {
if (ge[0].equals(effName)) { alreadyExists = true; break; }
}
if (!alreadyExists) {
land.giveEffects.add(new String[]{effName, "1", "300"});
areaProtect.saveAreaToDb(land);
p.sendMessage("§a§l[效果管理] §f已添加增益效果: §a" + effName + " Lv1 300秒");
// ★ 数量上限(2026-10-04 任务3)
int maxEff = areaProtect.getMaxGiveEffects(land.owner);
if (land.giveEffects.size() + land.badEffects.size() >= maxEff) {
p.sendMessage("§c§l[效果管理] §f效果已达上限 §e"
+ maxEff + " §f个(增益+负面合计,按用户组配置),请先移除再添加");
} else {
int cap = areaProtect.getEffectLevelCap(effName, land.owner);
land.giveEffects.add(new String[]{effName, "1", "300"});
areaProtect.saveAreaToDb(land);
p.sendMessage("§a§l[效果管理] §f已添加增益效果: §a" + effName + " Lv1 300秒"
+ " §7(等级上限 Lv" + cap + ")");
}
} else {
p.sendMessage("§c§l[效果管理] §f该增益效果已存在: §e" + effName);
}
@@ -1538,31 +1584,39 @@ public class AreaGUIManager implements Listener {
/**
* 根据槽位获取效果名
*
* <p>槽位编排必须和 {@link #openEffectsSelection} 里实际画出来的顺序<b>完全一致</b>,
* 否则会出现「点了 A 名字却存了 B」。两类页面各按自己的档位顺序算:</p>
* <ul>
* <li>单清 / 负面:负面 → 中性 → 正面(负面页只画前两段)</li>
* <li>增益:只画正面一段,槽位从 0 开始就是正面第 1 个</li>
* </ul>
*/
private String getEffectNameBySlot(int slot) {
String[][] allEffects = {
// 负面效果 (0-15)
{"缓慢", "slowness"}, {"挖掘疲劳", "mining_fatigue"}, {"瞬间伤害", "instant_damage"},
{"反胃", "nausea"}, {"失明", "blindness"}, {"饥饿", "hunger"},
{"虚弱", "weakness"}, {"中毒", "poison"}, {"凋零", "wither"},
{"飘浮", "levitation"}, {"霉运", "unluck"}, {"黑暗", "darkness"},
{"蓄风", "wind_charged"}, {"盘丝", "weaving"}, {"渗浆", "oozing"}, {"寄生", "infested"},
// 中性效果 (16-18)
{"不祥之兆", "bad_omen"}, {"袭击之兆", "raid_omen"}, {"试炼之兆", "trial_omen"},
// 正面效果 (19-38)
{"迅捷", "speed"}, {"急迫", "haste"}, {"力量", "strength"},
{"瞬间治疗", "instant_health"}, {"跳跃提升", "jump_boost"}, {"生命恢复", "regeneration"},
{"抗性提升", "resistance"}, {"抗火", "fire_resistance"}, {"水下呼吸", "water_breathing"},
{"隐身", "invisibility"}, {"夜视", "night_vision"}, {"发光", "glowing"},
{"生命提升", "health_boost"}, {"伤害吸收", "absorption"}, {"饱和", "saturation"},
{"幸运", "luck"}, {"村庄英雄", "hero_of_the_village"}, {"缓降", "slow_falling"},
{"潮涌能量", "conduit_power"}, {"海豚的恩惠", "dolphins_grace"}
};
private String getEffectNameBySlot(int slot, String type) {
boolean isGive = "give".equals(type);
boolean isBad = "bad".equals(type);
if (slot >= 0 && slot < allEffects.length) {
return allEffects[slot][0];
if (isGive) {
if (slot >= 0 && slot < EFFECTS_GOOD.length) return EFFECTS_GOOD[slot][0];
return null;
}
return null;
if (isBad) {
// 负面页:负面(16) + 中性(3)
int total = EFFECTS_BAD.length + EFFECTS_NEUTRAL.length;
if (slot >= 0 && slot < total) {
if (slot < EFFECTS_BAD.length) return EFFECTS_BAD[slot][0];
return EFFECTS_NEUTRAL[slot - EFFECTS_BAD.length][0];
}
return null;
}
// 单清:三种全列,顺序与 openEffectsSelection 一致
int nBad = EFFECTS_BAD.length;
int nNeu = EFFECTS_NEUTRAL.length;
int total = nBad + nNeu + EFFECTS_GOOD.length;
if (slot < 0 || slot >= total) return null;
if (slot < nBad) return EFFECTS_BAD[slot][0];
if (slot < nBad + nNeu) return EFFECTS_NEUTRAL[slot - nBad][0];
return EFFECTS_GOOD[slot - nBad - nNeu][0];
}
/**
@@ -1655,6 +1709,9 @@ public class AreaGUIManager implements Listener {
int maxLands = 5;
int defaultHeight = 255;
int peaceDuration = 3600;
int refundRatio = 0;
int maxFreeArea = 3500;
int maxLandSide = 7000;
try {
String val = areaProtect.getAreaConfigValue("create_price_per_sqm");
if (val != null) pricePerSqm = Integer.parseInt(val);
@@ -1664,6 +1721,12 @@ public class AreaGUIManager implements Listener {
if (val != null) defaultHeight = Integer.parseInt(val);
val = areaProtect.getAreaConfigValue("peace_mode_max_duration");
if (val != null) peaceDuration = Integer.parseInt(val);
val = areaProtect.getAreaConfigValue("refund_ratio_per_cent");
if (val != null) refundRatio = Integer.parseInt(val);
val = areaProtect.getAreaConfigValue("max_free_area_sqm");
if (val != null) maxFreeArea = Integer.parseInt(val);
val = areaProtect.getAreaConfigValue("max_land_side_blocks");
if (val != null) maxLandSide = Integer.parseInt(val);
} catch (Exception ignored) {}
// 每平米价格(位置11)
@@ -1684,6 +1747,34 @@ public class AreaGUIManager implements Listener {
"",
"§e左键+32 / 右键-32"));
// 删除退费比例(位置16)
inv.setItem(16, createItem(Material.GOLD_INGOT, "§e§l删除领地退费比例",
"§7当前: §f" + refundRatio + "% " + (refundRatio <= 0 ? "§c(已关闭退费)"
: (refundRatio >= 100 ? "§a(全额退款)" : "§e(原价×比例)")),
"",
"§70=不退款 100=全额退款 其他=原价×比例%",
"§7强制删除(-f)与自动清理不退费",
"",
"§e左键+10 / 右键-10(自动限制0~100)"));
// 单次扩建免审面积上限(位置17)
inv.setItem(17, createItem(Material.PAPER, "§e§l单次扩建免审上限",
"§7当前: §f" + maxFreeArea + " ㎡",
"",
"§7单次扩建超出即需管理员到领地现场审批",
"§7(管理员与OP不受此限制)",
"",
"§e点击后在聊天栏输入新值"));
// 领地免审边长上限(位置21)
inv.setItem(21, createItem(Material.COMPASS, "§e§l领地免审边长上限",
"§7当前: §f" + maxLandSide + " 格 × " + maxLandSide + " 格",
"",
"§7单边超过即需管理员到现场审批扩建",
"§7最大可设 7000(7000×7000)",
"",
"§e点击后在聊天栏输入新值"));
// 和平模式最大时间(位置22)
inv.setItem(22, createItem(Material.SHIELD, "§e§l和平模式最长时间",
"§7当前: §f" + peaceDuration + " 秒",
@@ -1740,6 +1831,30 @@ public class AreaGUIManager implements Listener {
return;
}
}
// ★ SN 管控:1人同种类仅限1个
if (plugin.getSnManager() != null
&& plugin.getSnManager().isBlocked(
p, SnManager.TYPE_WAND)) {
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
if (cdMin > 0) {
p.sendMessage("§e注销冷静期未结束,还剩约 §f" + cdMin + " §e分钟,期间无法申领区域选择工具");
} else {
p.sendMessage("§e你已有区域选择工具的有效登记,"
+ "旧SN未注销前不能重新申领");
}
areaProtect.wandCooldownMap.put(p.getUniqueId(), now);
return;
}
if (plugin.getSnManager() != null) {
String sn = plugin.getSnManager().applySn(
p, SnManager.TYPE_WAND, "区域选择工具");
if (sn == null) {
areaProtect.wandCooldownMap.put(p.getUniqueId(), now);
return;
}
plugin.getSnManager().writeSn(
wand, sn, SnManager.TYPE_WAND);
}
p.getInventory().addItem(wand);
// ★ GUI模式不发送聊天消息
areaProtect.wandCooldownMap.put(p.getUniqueId(), now);
@@ -1768,6 +1883,21 @@ public class AreaGUIManager implements Listener {
p.closeInventory();
p.sendMessage("§e§l[配置] §f请输入新的默认高度:");
areaProtect.setPendingConfigInput(p.getUniqueId(), "default_height");
} else if (raw == 16) {
// 删除退费比例
p.closeInventory();
p.sendMessage("§e§l[配置] §f请输入删除领地的退费比例(%),0=关闭,100=全额退款:");
areaProtect.setPendingConfigInput(p.getUniqueId(), "refund_ratio_per_cent");
} else if (raw == 17) {
// 单次扩建免审面积上限
p.closeInventory();
p.sendMessage("§e§l[配置] §f请输入单次扩建免审面积上限(㎡),超出需管理员现场审批:");
areaProtect.setPendingConfigInput(p.getUniqueId(), "max_free_area_sqm");
} else if (raw == 21) {
// 领地免审边长上限
p.closeInventory();
p.sendMessage("§e§l[配置] §f请输入领地免审边长上限(格),单边超过即需审批:");
areaProtect.setPendingConfigInput(p.getUniqueId(), "max_land_side_blocks");
} else if (raw == 22) {
// 和平模式时间
p.closeInventory();
@@ -2347,6 +2477,37 @@ public class AreaGUIManager implements Listener {
// ==================== 效果管理 GUI ====================
/**
* ★ 效果清单唯一来源(2026-10-04 用户要求拆分后集中)
*
* <p>此前 {@code openEffectsSelection} 与 {@code getEffectNameBySlot} 各写了一份数组,
* 两份一旦漂移就会出现「点了没反应 / 槽位对不上」。现统一到这两个常量。</p>
*/
/** 负面效果:缓慢、中毒、凋零这类会拖垮玩家的效果(用户要求单独一个子菜单)。 */
private static final String[][] EFFECTS_BAD = {
{"缓慢", "slowness"}, {"挖掘疲劳", "mining_fatigue"}, {"瞬间伤害", "instant_damage"},
{"反胃", "nausea"}, {"失明", "blindness"}, {"饥饿", "hunger"},
{"虚弱", "weakness"}, {"中毒", "poison"}, {"凋零", "wither"},
{"飘浮", "levitation"}, {"霉运", "unluck"}, {"黑暗", "darkness"},
{"蓄风", "wind_charged"}, {"盘丝", "weaving"}, {"渗浆", "oozing"}, {"寄生", "infested"}
};
/** 中性效果:不吉不利也不算增益,单列一档。 */
private static final String[][] EFFECTS_NEUTRAL = {
{"不祥之兆", "bad_omen"}, {"袭击之兆", "raid_omen"}, {"试炼之兆", "trial_omen"}
};
/** 增益效果:全部正面效果(用户要求「增益效果只归纳增益」)。 */
private static final String[][] EFFECTS_GOOD = {
{"迅捷", "speed"}, {"急迫", "haste"}, {"力量", "strength"},
{"瞬间治疗", "instant_health"}, {"跳跃提升", "jump_boost"}, {"生命恢复", "regeneration"},
{"抗性提升", "resistance"}, {"抗火", "fire_resistance"}, {"水下呼吸", "water_breathing"},
{"隐身", "invisibility"}, {"夜视", "night_vision"}, {"发光", "glowing"},
{"生命提升", "health_boost"}, {"伤害吸收", "absorption"}, {"饱和", "saturation"},
{"幸运", "luck"}, {"村庄英雄", "hero_of_the_village"}, {"缓降", "slow_falling"},
{"潮涌能量", "conduit_power"}, {"海豚的恩惠", "dolphins_grace"}
};
/**
* 打开效果管理菜单
*/
@@ -2363,7 +2524,10 @@ public class AreaGUIManager implements Listener {
if (subPage == 1) {
Inventory inv = Bukkit.createInventory(null, 54, "§b§l效果管理 - " + landName);
// 全清负面效果
// ★ 槽位布局(2026-10-04 修正):原实现 11/13 各被两个按钮占用,
// 后写的「添加增益」「添加负面」把「清除所有负面」「禁止所有效果」直接覆盖掉了。
// 现在 11~15 连排五格,与 handleEffectsManagementClick 的分发一一对应。
// 11 全清负面效果
Material clearAllMat = land.clearAllBadEffects ? Material.LIME_DYE : Material.GRAY_DYE;
inv.setItem(11, createItem(clearAllMat, "§e§l清除所有负面效果",
"§7当前: " + (land.clearAllBadEffects ? "§a已开启" : "§c已关闭"),
@@ -2371,23 +2535,34 @@ public class AreaGUIManager implements Listener {
"",
"§e点击切换"));
// 禁止所有效果
// 12 禁止所有效果
Material denyAllMat = land.denyAllEffects ? Material.RED_DYE : Material.GRAY_DYE;
inv.setItem(13, createItem(denyAllMat, "§e§l禁止所有效果",
inv.setItem(12, createItem(denyAllMat, "§e§l禁止所有效果",
"§7当前: " + (land.denyAllEffects ? "§a已开启" : "§c已关闭"),
"§7进入领地时禁止接收所有药水效果",
"",
"§e点击切换"));
// 单清效果列表
inv.setItem(15, createItem(Material.MAGMA_CREAM, "§b§l单清指定效果",
// 13 单清指定效果
inv.setItem(13, createItem(Material.MAGMA_CREAM, "§b§l单清指定效果",
"§7当前列表: §f" + land.clearEffects.size() + " 个效果",
"§7进入领地时清除玩家身上的这些效果",
"",
"§e点击查看/编辑"));
// 添加增益效果
inv.setItem(17, createItem(Material.BREWING_STAND, "§a§l添加增益效果",
// 14 添加增益效果(只放正面效果)
inv.setItem(14, createItem(Material.BREWING_STAND, "§a§l添加增益效果",
"§7当前增益: §f" + land.giveEffects.size() + " 个",
"§7只列正面效果(力量、抗性、跳跃提升…)",
"§7等级上限按原版生存可获取档位封顶",
"",
"§e点击查看/编辑"));
// 15 添加负面效果(独立子菜单,2026-10-04 用户要求拆出)
inv.setItem(15, createItem(Material.WITHER_SKELETON_SKULL, "§c§l添加负面效果",
"§7当前负面: §f" + land.badEffects.size() + " 个",
"§7缓慢、中毒、凋零、虚弱、蓄风…",
"§7含不祥之兆等中性效果,不与增益混排",
"",
"§e点击查看/编辑"));
@@ -2422,7 +2597,7 @@ public class AreaGUIManager implements Listener {
p.openInventory(inv);
}
// ========== 子菜单3:增益效果列表 ==========
// ========== 子菜单3:增益效果列表(只列正面效果) ==========
else if (subPage == 3) {
Inventory inv = Bukkit.createInventory(null, 54, "§b§l增益效果 - " + landName);
@@ -2437,33 +2612,8 @@ public class AreaGUIManager implements Listener {
String desc = eff[0] + (eff.length > 1 ? " Lv" + eff[1] : "") + (eff.length > 2 ? " " + eff[2] + "秒" : "");
inv.setItem(i, createItem(Material.GOLDEN_CARROT, "§a" + desc,
"§7点击移除此增益效果",
"§e序号: " + (i + 1)));
}
}
inv.setItem(48, createItem(Material.ARROW, "§c§l返回效果管理", ""));
inv.setItem(53, createItem(Material.BOOK, "§a§l添加效果",
"§7点击选择要清除的效果"));
p.openInventory(inv);
}
// ========== 子菜单3:增益效果列表 ==========
else if (subPage == 3) {
Inventory inv = Bukkit.createInventory(null, 54, "§b§l增益效果 - " + landName);
if (land.giveEffects.isEmpty()) {
inv.setItem(22, createItem(Material.BARRIER, "§7§l暂无增益效果",
"§7使用快捷指令添加: /protect cli effectsaddadd <效果名> [等级] [秒数]",
"",
"§e示例: /protect cli effectsaddadd 力量 2 300"));
} else {
for (int i = 0; i < Math.min(land.giveEffects.size(), 36); i++) {
String[] eff = land.giveEffects.get(i);
String desc = eff[0] + (eff.length > 1 ? " Lv" + eff[1] : "") + (eff.length > 2 ? " " + eff[2] + "秒" : "");
inv.setItem(i, createItem(Material.GOLDEN_CARROT, "§a" + desc,
"§7点击移除此增益效果",
"§e序号: " + (i + 1)));
"§e序号: " + (i + 1),
"§7等级上限: §fLv" + areaProtect.getEffectLevelCap(eff[0], land.owner)));
}
}
@@ -2473,6 +2623,33 @@ public class AreaGUIManager implements Listener {
p.openInventory(inv);
}
// ========== 子菜单4:负面效果列表(2026-10-04 从增益里拆出) ==========
else if (subPage == 4) {
Inventory inv = Bukkit.createInventory(null, 54, "§c§l负面效果 - " + landName);
if (land.badEffects.isEmpty()) {
inv.setItem(22, createItem(Material.BARRIER, "§7§l暂无负面效果",
"§7使用快捷指令添加: /protect cli effectsbadadd <效果名> [等级] [秒数]",
"",
"§e示例: /protect cli effectsbadadd 缓慢 1 300"));
} else {
for (int i = 0; i < Math.min(land.badEffects.size(), 36); i++) {
String[] eff = land.badEffects.get(i);
String desc = eff[0] + (eff.length > 1 ? " Lv" + eff[1] : "") + (eff.length > 2 ? " " + eff[2] + "秒" : "");
inv.setItem(i, createItem(Material.WITHER_SKELETON_SKULL, "§c" + desc,
"§7点击移除此负面效果",
"§e序号: " + (i + 1),
"§7等级上限: §fLv" + areaProtect.getEffectLevelCap(eff[0], land.owner)));
}
}
inv.setItem(48, createItem(Material.ARROW, "§c§l返回效果管理", ""));
inv.setItem(53, createItem(Material.BOOK, "§c§l添加负面",
"§7点击选择要添加的负面效果"));
p.openInventory(inv);
}
}
/**
@@ -2489,17 +2666,20 @@ public class AreaGUIManager implements Listener {
land.clearAllBadEffects = !land.clearAllBadEffects;
areaProtect.saveAreaToDb(land);
openEffectsManagement(p, landName, 1);
} else if (raw == 13) {
} else if (raw == 12) {
// 切换禁止所有效果
land.denyAllEffects = !land.denyAllEffects;
areaProtect.saveAreaToDb(land);
openEffectsManagement(p, landName, 1);
} else if (raw == 15) {
} else if (raw == 13) {
// 打开单清效果列表
openEffectsManagement(p, landName, 2);
} else if (raw == 17) {
// 打开增益效果列表
} else if (raw == 14) {
// 打开增益效果列表(只列正面效果)
openEffectsManagement(p, landName, 3);
} else if (raw == 15) {
// 打开负面效果列表(2026-10-04 从增益拆出)
openEffectsManagement(p, landName, 4);
} else if (raw == 48) {
// 返回管理领地
openLandManage(p, landName);
@@ -2532,77 +2712,104 @@ public class AreaGUIManager implements Listener {
} else if (raw == 48) {
openEffectsManagement(p, landName, 1);
} else if (raw == 53) {
// 打开效果选择列表(与单清一致)
// 打开增益效果选择列表(只列正面效果)
openEffectsSelection(p, landName, "give", 3);
}
}
// 子菜单4:负面效果列表(2026-10-04 从增益拆出)
else if (subPage == 4) {
if (raw < land.badEffects.size()) {
// 移除负面效果
String[] removed = land.badEffects.remove(raw);
String desc = removed[0] + (removed.length > 1 ? " Lv" + removed[1] : "") + (removed.length > 2 ? " " + removed[2] + "秒" : "");
areaProtect.saveAreaToDb(land);
p.sendMessage("§c§l[效果管理] §f已移除负面效果: §c" + desc);
openEffectsManagement(p, landName, 4);
} else if (raw == 48) {
openEffectsManagement(p, landName, 1);
} else if (raw == 53) {
// 打开负面效果选择列表
openEffectsSelection(p, landName, "bad", 4);
}
}
}
/**
* 打开效果选择列表(可点击选择)
* @param type "clear"=清除效果, "give"=增益效果
*
* <p>2026-10-04 用户要求:增益只列增益、负面单独一个子菜单,
* 原来三种混排在一页里,加错效果很难发现。现按 {@code type} 分类只列对应档位。</p>
*
* @param type "clear"=单清指定效果(负面+中性+正面全列)、
* "give"=增益效果(只列正面)、
* "bad"=负面效果(只列负面+中性)
* @param returnSubPage 返回时的子菜单页码
*/
private void openEffectsSelection(Player p, String landName, String type, int returnSubPage) {
AreaProtection.AreaConfig land = areaProtect.getLand(landName);
if (land == null) return;
String title = "§a§l选择效果 - " + landName;
String title;
if ("bad".equals(type)) {
title = "§a§l选择负面效果 - " + landName;
} else if ("give".equals(type)) {
title = "§a§l选择增益效果 - " + landName;
} else {
title = "§a§l选择效果 - " + landName;
}
Inventory inv = Bukkit.createInventory(null, 54, title);
// 负面效果
String[][] badEffects = {
{"缓慢", "slowness"}, {"挖掘疲劳", "mining_fatigue"}, {"瞬间伤害", "instant_damage"},
{"反胃", "nausea"}, {"失明", "blindness"}, {"饥饿", "hunger"},
{"虚弱", "weakness"}, {"中毒", "poison"}, {"凋零", "wither"},
{"飘浮", "levitation"}, {"霉运", "unluck"}, {"黑暗", "darkness"},
{"蓄风", "wind_charged"}, {"盘丝", "weaving"}, {"渗浆", "oozing"}, {"寄生", "infested"}
};
// 中性效果
String[][] neutralEffects = {
{"不祥之兆", "bad_omen"}, {"袭击之兆", "raid_omen"}, {"试炼之兆", "trial_omen"}
};
// 正面效果
String[][] goodEffects = {
{"迅捷", "speed"}, {"急迫", "haste"}, {"力量", "strength"},
{"瞬间治疗", "instant_health"}, {"跳跃提升", "jump_boost"}, {"生命恢复", "regeneration"},
{"抗性提升", "resistance"}, {"抗火", "fire_resistance"}, {"水下呼吸", "water_breathing"},
{"隐身", "invisibility"}, {"夜视", "night_vision"}, {"发光", "glowing"},
{"生命提升", "health_boost"}, {"伤害吸收", "absorption"}, {"饱和", "saturation"},
{"幸运", "luck"}, {"村庄英雄", "hero_of_the_village"}, {"缓降", "slow_falling"},
{"潮涌能量", "conduit_power"}, {"海豚的恩惠", "dolphins_grace"}
};
// 按用途决定列出哪些档位
boolean isClear = "clear".equals(type);
boolean isGive = "give".equals(type);
boolean isBad = "bad".equals(type);
// 单清:三种都列(这是"移除指定效果",语义与增益/负面无关,保持原样)
// 增益:只列正面;负面:列负面+中性
boolean showBad = isClear || isBad;
boolean showNeutral = isClear || isBad;
boolean showGood = isClear || isGive;
int slot = 0;
String[][] badEffects = showBad ? EFFECTS_BAD : new String[0][];
String[][] neutralEffects = showNeutral ? EFFECTS_NEUTRAL : new String[0][];
String[][] goodEffects = showGood ? EFFECTS_GOOD : new String[0][];
// 负面效果
for (String[] eff : badEffects) {
if (slot >= 45) break;
boolean alreadyInList = type.equals("clear") && land.clearEffects.contains(eff[0]);
boolean alreadyInList = isClear && land.clearEffects.contains(eff[0]);
Material mat = alreadyInList ? Material.GRAY_DYE : Material.RED_DYE;
String name = alreadyInList ? "§7" + eff[0] + " (已添加)" : "§c" + eff[0];
inv.setItem(slot, createItem(mat, name, "§7点击添加到清除列表", "§e英文名: " + eff[1]));
inv.setItem(slot, createItem(mat, name,
isClear ? "§7点击添加到清除列表" : "§7点击添加到负面效果列表",
"§e英文名: " + eff[1],
"§7等级上限: §fLv" + areaProtect.getEffectLevelCap(eff[0], land.owner)));
slot++;
}
// 中性效果
for (String[] eff : neutralEffects) {
if (slot >= 45) break;
boolean alreadyInList = type.equals("clear") && land.clearEffects.contains(eff[0]);
boolean alreadyInList = isClear && land.clearEffects.contains(eff[0]);
Material mat = alreadyInList ? Material.GRAY_DYE : Material.YELLOW_DYE;
String name = alreadyInList ? "§7" + eff[0] + " (已添加)" : "§e" + eff[0];
inv.setItem(slot, createItem(mat, name, "§7点击添加到清除列表", "§e英文名: " + eff[1]));
inv.setItem(slot, createItem(mat, name,
isClear ? "§7点击添加到清除列表" : "§7点击添加到负面效果列表",
"§e英文名: " + eff[1],
"§7等级上限: §fLv" + areaProtect.getEffectLevelCap(eff[0], land.owner)));
slot++;
}
// 正面效果
for (String[] eff : goodEffects) {
if (slot >= 45) break;
boolean alreadyInList = type.equals("clear") && land.clearEffects.contains(eff[0]);
boolean alreadyInList = isClear && land.clearEffects.contains(eff[0]);
Material mat = alreadyInList ? Material.GRAY_DYE : Material.LIME_DYE;
String name = alreadyInList ? "§7" + eff[0] + " (已添加)" : "§a" + eff[0];
inv.setItem(slot, createItem(mat, name, "§7点击添加到清除列表", "§e英文名: " + eff[1]));
inv.setItem(slot, createItem(mat, name,
isClear ? "§7点击添加到清除列表" : "§7点击添加到增益效果列表",
"§e英文名: " + eff[1],
"§7等级上限: §fLv" + areaProtect.getEffectLevelCap(eff[0], land.owner)));
slot++;
}
File diff suppressed because it is too large Load Diff
+249 -18
View File
@@ -59,17 +59,20 @@ public class BondManager {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
new File(plugin.getDataFolder(), "财务债券"),
"bond.db");
dbFile.getParentFile().mkdirs();
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
// 债券余额(含冻结状态)
// 债券余额(含冻结状态 + 冻结到期时间)
st.execute("CREATE TABLE IF NOT EXISTS bonds ("
+ "player_name TEXT PRIMARY KEY,"
+ "amount INTEGER DEFAULT 0,"
+ "status TEXT DEFAULT 'normal')");
+ "status TEXT DEFAULT 'normal',"
+ "freeze_until INTEGER DEFAULT 0)");
// 兼容旧表
try {
@@ -77,6 +80,12 @@ public class BondManager {
+ "ADD COLUMN status "
+ "TEXT DEFAULT 'normal'");
} catch (SQLException ignored) {}
// ★ 2026-10-04:冻结到期时间(Unix 毫秒,0=永久冻结)
try {
st.execute("ALTER TABLE bonds "
+ "ADD COLUMN freeze_until "
+ "INTEGER DEFAULT 0");
} catch (SQLException ignored) {}
// 旧流水(保留兼容)
st.execute("CREATE TABLE IF NOT EXISTS bond_log ("
@@ -122,47 +131,111 @@ public class BondManager {
// ======================== 账户状态 ========================
/**
* 账户是否处于冻结状态。
*
* <p>★ 2026-10-04 支持临时冻结:status='frozen' 且
* {@code freeze_until>0} 时,到期自动解冻并回写 status='normal';
* {@code freeze_until=0} 表示永久冻结。
*/
public boolean isFrozen(String player) {
boolean frozen = false;
long until = 0L;
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
"SELECT status, freeze_until FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return "frozen".equals(s);
frozen = "frozen".equals(rs.getString("status"));
until = rs.getLong("freeze_until");
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return false;
} catch (SQLException ignored) {
return false;
}
if (!frozen) return false;
if (until > 0L && until <= System.currentTimeMillis()) {
unfreezeAccount(player); // 临时冻结到期 -> 自动解冻
return false;
}
return true;
}
/** 冻结到期时间(Unix 毫秒);0=永久冻结;-1=未冻结 */
public long getFreezeUntil(String player) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status, freeze_until FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
long until = -1L;
if (rs.next()) {
if (!"frozen".equals(rs.getString("status"))) {
rs.close(); ps.close();
return -1L;
}
until = rs.getLong("freeze_until");
}
rs.close(); ps.close();
if (until > 0L && until <= System.currentTimeMillis()) {
unfreezeAccount(player);
return -1L;
}
return until;
} catch (SQLException ignored) {
return -1L;
}
}
public String getAccountStatus(String player) {
String s = "normal";
long until = 0L;
try {
PreparedStatement ps = db.prepareStatement(
"SELECT status FROM bonds "
"SELECT status, freeze_until FROM bonds "
+ "WHERE player_name=?");
ps.setString(1, player);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
String s = rs.getString("status");
rs.close(); ps.close();
return s != null ? s : "normal";
s = rs.getString("status");
until = rs.getLong("freeze_until");
if (s == null) s = "normal";
}
rs.close(); ps.close();
} catch (SQLException ignored) {}
return "normal";
} catch (SQLException ignored) {
return "normal";
}
if ("frozen".equals(s)
&& until > 0L
&& until <= System.currentTimeMillis()) {
unfreezeAccount(player); // 到期自动解冻
return "normal";
}
return s;
}
/** 永久冻结 */
public void freezeAccount(String player) {
freezeAccount(player, 0L);
}
/**
* 冻结账户(★ 2026-10-04 支持到期时间)。
*
* @param expireMs Unix 毫秒到期时间;{@code <=0} 表示永久冻结
*/
public void freezeAccount(String player, long expireMs) {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='frozen' "
"UPDATE bonds SET status='frozen', "
+ "freeze_until=? "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.setLong(1, expireMs > 0L ? expireMs : 0L);
ps.setString(2, player);
ps.executeUpdate(); ps.close();
} catch (SQLException e) { e.printStackTrace(); }
}
@@ -171,7 +244,8 @@ public class BondManager {
ensureAccount(player);
try {
PreparedStatement ps = db.prepareStatement(
"UPDATE bonds SET status='normal' "
"UPDATE bonds SET status='normal', "
+ "freeze_until=0 "
+ "WHERE player_name=?");
ps.setString(1, player);
ps.executeUpdate(); ps.close();
@@ -402,6 +476,31 @@ public class BondManager {
type, targetPlayer, operator, reason);
}
// ===== 冻结豁免通道(★ 2026-10-04 冻结规则定案) =====
// 冻结期间「仅允许 PHP 后端动账 + 管理员手动 give/remove」,
// 玩家主动消费(商城/领地/商店)与他人转账一律被上面的
// isFrozen 拦掉;只有走下面两个豁免方法的调用方才能动账。
/** 管理员/PHP 后台加款:冻结期也放行(只记流水,不查冻结) */
public int addBondsAdmin(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
return addBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
/** 管理员/PHP 后台扣款:冻结期也放行(管理员手动 remove) */
public boolean deductBondsAdmin(String player, int amount,
String type,
String targetPlayer,
String operator,
String reason) {
return deductBondsInternal(player, amount,
type, targetPlayer, operator, reason);
}
public boolean checkoutCart(String player,
String[] itemNames,
int[] subtotals) {
@@ -772,6 +871,36 @@ public class BondManager {
}
}
/**
* 交易流水总笔数(用于开服与PHP对账)
*/
public long getTxCount() {
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery("SELECT COUNT(*) FROM bond_transaction");
long n = rs.next() ? rs.getLong(1) : 0L;
rs.close(); st.close();
return n;
} catch (SQLException e) {
return 0L;
}
}
/**
* 交易流水最大序列号(bond_transaction.id 自增主键,即对账用的「交易序列号」)
*/
public long getMaxTxId() {
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery("SELECT COALESCE(MAX(id), 0) FROM bond_transaction");
long n = rs.next() ? rs.getLong(1) : 0L;
rs.close(); st.close();
return n;
} catch (SQLException e) {
return 0L;
}
}
/**
* 获取指定时间之后的所有交易记录(用于Web同步)
*/
@@ -804,4 +933,106 @@ public class BondManager {
}
return transactions;
}
/**
* ★ 周期对账:水位线之后「还没推给 PHP」的笔数(只 COUNT,不拉行)。
* 用来区分 PHP 的缺口是「正常的待推增量」还是「运行期被清空的历史」。
*
* @return 待推笔数;本地查不出来时返回 -1,调用方跳过本轮判定
*/
public long countTransactionsAfterTime(long afterTime) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT COUNT(*) FROM bond_transaction WHERE time > ?");
ps.setLong(1, afterTime);
ResultSet rs = ps.executeQuery();
long n = rs.next() ? rs.getLong(1) : 0L;
rs.close();
ps.close();
return n;
} catch (SQLException e) {
return -1L;
}
}
/**
* ★ 阶段E 多退少补:本机全部流水序列号(开服与 PHP 双向点验用)
*/
public List<Long> getAllTxIds() {
List<Long> ids = new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT id FROM bond_transaction ORDER BY id ASC");
while (rs.next()) ids.add(rs.getLong(1));
rs.close();
st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return ids;
}
/**
* ★ 阶段E 多退少补:把「PHP 有、本机没有」的流水收回本地。
* 只补流水记录(INSERT OR IGNORE 指定主键):
* 不改余额、不触发交易事件、不发货,纯对账补记录。
* PHP 的 tx_time 是秒级、本地 time 是毫秒 → 超过 1e12 视为毫秒,否则乘 1000。
*
* @param rows PHP pull_tx 返回的交易数组
* @return 实际新写入的笔数
*/
public int importTransactionsFromWeb(List<Map<String, Object>> rows) {
if (rows == null || rows.isEmpty()) return 0;
int imported = 0;
try {
PreparedStatement ps = db.prepareStatement(
"INSERT OR IGNORE INTO bond_transaction "
+ "(id, player_name, type, amount,"
+ " target_player, operator,"
+ " reason, balance_before,"
+ " balance_after, time)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?)");
for (Map<String, Object> row : rows) {
Object idv = row.get("id");
if (!(idv instanceof Number)) continue;
long txId = ((Number) idv).longValue();
if (txId <= 0) continue;
long time = 0L;
Object tv = row.get("time");
if (tv instanceof Number) {
time = ((Number) tv).longValue();
if (time > 0 && time < 1000000000000L) time *= 1000L;
}
ps.setLong(1, txId);
ps.setString(2, row.get("player_name") == null ? ""
: String.valueOf(row.get("player_name")));
ps.setString(3, row.get("type") == null ? ""
: String.valueOf(row.get("type")));
ps.setInt(4, row.get("amount") instanceof Number
? ((Number) row.get("amount")).intValue() : 0);
ps.setString(5, row.get("target_player") == null ? ""
: String.valueOf(row.get("target_player")));
ps.setString(6, row.get("operator") == null ? ""
: String.valueOf(row.get("operator")));
ps.setString(7, row.get("reason") == null ? ""
: String.valueOf(row.get("reason")));
ps.setInt(8, row.get("balance_before") instanceof Number
? ((Number) row.get("balance_before")).intValue() : 0);
ps.setInt(9, row.get("balance_after") instanceof Number
? ((Number) row.get("balance_after")).intValue() : 0);
ps.setLong(10, time);
imported += ps.executeUpdate();
}
ps.close();
} catch (SQLException e) {
e.printStackTrace();
}
return imported;
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ public class CDKManager {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(), "bond.db");
new File(plugin.getDataFolder(), "财务债券"),
"bond.db");
dbFile.getParentFile().mkdirs();
db = DriverManager.getConnection(
"jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
+162 -21
View File
@@ -97,6 +97,9 @@ public class ChatFilterManager {
/** 已验证过的玩家名单(永久,跨session持久化到内存) */
private final Set<String> verifiedPlayers = ConcurrentHashMap.newKeySet();
/** ★ 解禁后强制重新人机验证名单(2026-10-08:解禁≠解除人机验证) */
private final Set<String> reverifyRequired = ConcurrentHashMap.newKeySet();
/** 验证码数据存储(public供Main.java访问清理) */
public final Map<String, VerificationData> verificationData = new ConcurrentHashMap<>();
@@ -109,7 +112,7 @@ public class ChatFilterManager {
static class VerificationData {
long createTime;
int a, b, op; // 0=加法, 1=减法, 2=乘法(九九表), 3=除法(九九表)
int a, b, op; // 0=加法, 1=减法, 2=乘法, 3=除法(均为99以内小学运算)
String guiTargets; // GUI模式下需要选择的物品名列表("|"分隔)
VerificationType type; // 验证码类型
boolean completed;
@@ -149,12 +152,41 @@ public class ChatFilterManager {
*/
public VerificationResult checkNewPlayerVerification(Player p) {
String name = p.getName();
// ★ 解禁 ≠ 解除人机验证(2026-10-08):解禁后必须重新过人机验证才能发言。
// 该分支优先级最高——连 verifiedPlayers 与正版OAuth豁免也压过。
if (reverifyRequired.contains(name)) {
VerificationData vd0 = verificationData.get(name);
if (vd0 != null && vd0.completed) {
verifiedPlayers.add(name);
verificationData.remove(name);
reverifyRequired.remove(name);
return VerificationResult.VERIFIED;
}
if (vd0 != null && vd0.isExpired()) {
verificationData.remove(name);
p.sendMessage("§c§l[验证码] §c上一题超时,已为你换新题");
generateMathVerification(name);
return VerificationResult.TIMEOUT;
}
if (vd0 != null) {
return VerificationResult.PENDING;
}
return VerificationResult.NEED_VERIFICATION;
}
// ★ 永久已验证的玩家 → 直接放行
if (verifiedPlayers.contains(name)) {
return VerificationResult.VERIFIED;
}
// ★ 联控:已通过正版 OAuth 验证(提交时已过人机验证)→ 豁免游戏内验证码
if (((Main) plugin).isVerifiedPremiumPlayer(name)) {
verifiedPlayers.add(name);
verificationData.remove(name);
return VerificationResult.VERIFIED;
}
// 正在验证中(等待答案)
if (verificationData.containsKey(name)) {
VerificationData vd = verificationData.get(name);
@@ -188,7 +220,7 @@ public class ChatFilterManager {
/**
* 生成随机验证码:加 / 减 / 乘 / 除 / GUI 五种每次随机抽一种。
* 乘法与除法严格取自九九乘法表。
* ★ 算术题统一为小学水平「99以内加减乘除」(和/积≤99、减法不为负、除法整除)。
*
* ★ 出题前必须先关掉可能还开着的旧验证码GUI:旧GUI 的 InventoryCloseEvent
* 会清 verificationData,让它先跑完,否则刚生成的新题会被清掉(点哪儿都没用)。
@@ -201,6 +233,10 @@ public class ChatFilterManager {
}
int kind = rand.nextInt(5); // 0加 1减 2乘 3除 4GUI
// ★ 基岩版特性:没法用聊天框唤醒GUI验证码 → 基岩版固定出算术题
if (kind == 4 && online != null && isBedrockPlayer(online)) {
kind = rand.nextInt(4); // 0加 1减 2乘 3除
}
if (kind == 4) {
generateGUIChallenge(playerName, rand);
} else {
@@ -208,6 +244,20 @@ public class ChatFilterManager {
}
}
/** ★ 基岩版玩家判定:UUID特征 + Geyser/Floodgate API 兜底 */
private boolean isBedrockPlayer(Player p) {
if (!(plugin instanceof Main)) return false;
Main main = (Main) plugin;
try {
VerificationManager vm = main.getVerification();
if (vm != null && vm.isBedrockPlayer(p)) return true;
TeleportManager tm = main.getTeleport();
return tm != null && tm.isBedrockPlayer(p);
} catch (Throwable t) {
return false;
}
}
/** 关掉还开着的验证码GUI(标题匹配才关,避免误关玩家自己的箱子/菜单) */
private void closeVerificationGuiIfOpen(Player p) {
try {
@@ -219,26 +269,30 @@ public class ChatFilterManager {
}
}
/** 生成数学题验证码(op: 0加 1减 2乘 3除;乘/除每次从九九乘法表随机抽一题) */
/**
* 生成数学题验证码(op: 0加 1减 2乘 3除)
* ★ 小学水平「99以内加减乘除」:加法和≤99、减法不出现负数、乘法积≤99、除法整除且被除数≤99
*/
private void generateMathChallenge(String playerName, int op, Random rand) {
int a, b;
switch (op) {
case 0: // 两位数加法
a = 10 + rand.nextInt(90);
b = 10 + rand.nextInt(90);
case 0: // 加法:两数之和不超过99
a = 1 + rand.nextInt(98); // a ∈ [1, 98]
b = 1 + rand.nextInt(99 - a); // b ∈ [1, 99-a] → a+b ≤ 99
break;
case 1: // 两位数减法(保证结果仍是两位数,不出现负数)
a = 20 + rand.nextInt(80);
b = 10 + rand.nextInt(a - 19); // b ∈ [10, a-10],结果 ≥ 10
case 1: // 减法:被减数不超过99,结果≥1(小学不出现负数/零)
a = 2 + rand.nextInt(98); // a ∈ [2, 99]
b = 1 + rand.nextInt(a - 1); // b ∈ [1, a-1] → 结果 ∈ [1, a-1]
break;
case 2: // 九九乘法表:1×1 ~ 9×9
a = 1 + rand.nextInt(9);
b = 1 + rand.nextInt(9);
case 2: // 乘法:积不超过99(如 12×7=84、11×9=99)
b = 2 + rand.nextInt(8); // b ∈ [2, 9]
a = 2 + rand.nextInt((99 / b) - 1); // a ∈ [2, floor(99/b)] → a*b ≤ 99
break;
default: // 九九乘法表除法(整除):(i×j) ÷ j = i
b = 1 + rand.nextInt(9);
a = (1 + rand.nextInt(9)) * b;
default: // 除法:整除且被除数不超过99(如 96÷8=12、99÷9=11)
b = 2 + rand.nextInt(8); // b ∈ [2, 9]
int q = 2 + rand.nextInt((99 / b) - 1); // 商 ∈ [2, floor(99/b)]
a = b * q; // a ≤ 99
break;
}
@@ -466,13 +520,14 @@ public class ChatFilterManager {
case 0 -> expected = vd.a + vd.b;
case 1 -> expected = vd.a - vd.b;
case 2 -> expected = vd.a * vd.b;
default -> expected = vd.a / vd.b; // 九九表整除
default -> expected = vd.a / vd.b; // 生成时保证整除(a % b == 0)
}
if (ans == expected) {
vd.completed = true;
verificationData.remove(playerName);
verifiedPlayers.add(playerName); // 永久记录 - 验证通过
reverifyRequired.remove(playerName); // 解禁重验已通过
return VerificationResult.VERIFIED;
} else {
// ★ 回答错误 → 不清除验证码也不加入verifiedPlayers,让玩家重新答题
@@ -522,6 +577,7 @@ public class ChatFilterManager {
vd.completed = true;
verificationData.remove(name);
verifiedPlayers.add(name);
reverifyRequired.remove(name); // 解禁重验已通过
return VerificationResult.VERIFIED;
}
@@ -623,7 +679,15 @@ public class ChatFilterManager {
+ "[_.][\\p{L}]{2,}");
private static final Pattern DOT_VARIANTS =
Pattern.compile(
"[\uff0e\u3002\u2025\u2026\u00b7]");
"[\uff0e\u3002\u2025\u2026\u00b7\uff65\ufe52]");
/** 零宽/不可见字符:变形URL归一前剔除(如 baidu.<零宽空格>com) */
private static final Pattern INVISIBLE =
Pattern.compile(
"[\u200b\u200c\u200d\u2060\ufeff\u00ad]");
/** 点周带空白的变形域名: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,}");
private static final Pattern COLOR_CODES =
Pattern.compile(
"\u00a7[0-9a-fk-orA-FK-OR]");
@@ -669,7 +733,8 @@ public class ChatFilterManager {
sensitiveWords.clear(); // 重新加载前先清空
File f = new File(
plugin.getDataFolder(), "chat.txt");
new File(plugin.getDataFolder(), "发言规则管控"), "chat.txt");
f.getParentFile().mkdirs();
if (!f.exists()) writeDefaultFile(f);
boolean hasSensitiveSection = false; // 记录文件是否已有敏感词列表节
try (BufferedReader r =
@@ -712,8 +777,8 @@ public class ChatFilterManager {
String[] kv = t.split(":", 2);
String k = kv[0].trim();
String v = kv[1].trim();
if (equals("启用过滤")
|| equals("enabled")) {
if (k.equals("启用过滤")
|| k.equals("enabled")) {
enabled = parseBool(v);
continue;
}
@@ -891,7 +956,8 @@ public class ChatFilterManager {
public void saveConfig() {
File f = new File(
plugin.getDataFolder(), "chat.txt");
new File(plugin.getDataFolder(), "发言规则管控"), "chat.txt");
f.getParentFile().mkdirs();
try (PrintWriter pw = new PrintWriter(
new OutputStreamWriter(
new FileOutputStream(f),
@@ -948,6 +1014,8 @@ public class ChatFilterManager {
// ========== URL检测 ==========
private String normalizeMessage(String msg) {
msg = INVISIBLE.matcher(msg)
.replaceAll("");
msg = COLOR_CODES.matcher(msg)
.replaceAll("");
msg = DOT_VARIANTS.matcher(msg)
@@ -970,6 +1038,19 @@ public class ChatFilterManager {
"");
if (!url.isEmpty()) urls.add(url);
}
// ★ 变形URL加强(2026-10-08):点周带空白(baidu . com)压缩空白后再判定;
// 仅当顶级域命中「非法域名后缀」才收录,避免误伤 "OK. Good" 之类正常英文句子
Matcher m2 = SPACED_DOT_URL.matcher(normalized);
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();
int dot = compact.lastIndexOf('.');
if (dot < 0) continue;
if (!illegalDomainSuffixes.contains(compact.substring(dot + 1))) continue;
if (!urls.contains(compact)) urls.add(compact);
}
return urls;
}
@@ -1042,6 +1123,11 @@ public class ChatFilterManager {
muteIssuers.remove(name);
muteStarts.remove(name);
clearMuteDb(name);
// ★ 2026-10-08 用户裁定:玩家已通过人机验证后,禁言到期自动解禁
// **不再**吊销验证状态。此前这里调 markReverify 导致
// 「验证码通过 → 被禁言 → 到期解禁 → 又要重验」的死循环。
// 2026-10-08 用户裁定再收紧:命令解禁 /chat unmute 与重置 /chat reset
// 也【不再】吊销——解禁/重置发言违规 与 人机验证 两件事彻底隔开。
return false;
}
return true;
@@ -1224,6 +1310,21 @@ public class ChatFilterManager {
muteIssuers.remove(name);
muteStarts.remove(name);
clearMuteDb(name);
// ★ 解禁≠解除人机验证(2026-10-08 用户裁定,且必须落到代码上):
// 解禁只清禁言状态,绝不触碰人机验证(markReverify 调用已移除)。
}
/**
* ★ 显式重置人机验证(2026-10-08 起无内部调用方,保留为对外能力):
* 移出已验证名单、清掉挂起的题与缓存消息,下次发言必须重新过人机验证。
* 注意:unmutePlayer(解禁)/ resetPlayer(重置发言违规)已按用户裁定
* 与本方法解耦,不再调用。
*/
public void markReverify(String name) {
reverifyRequired.add(name);
verifiedPlayers.remove(name);
verificationData.remove(name);
pendingMessages.remove(name);
}
// ========== 禁言持久化(重启后仍生效) ==========
@@ -1314,7 +1415,47 @@ public class ChatFilterManager {
public void resetPlayer(String name) {
mutedPlayers.remove(name);
muteReasons.remove(name);
muteIssuers.remove(name);
muteStarts.remove(name);
violationCount.remove(name);
clearMuteDb(name); // 同步清 DB 的 muted_until,避免重启后禁言复活
// ★ 2026-10-08 用户裁定:「重置发言违规」与「重置人机验证」彻底隔开——
// 这里只清禁言与违规计数,不再调用 markReverify,
// 人机验证状态(verifiedPlayers/reverifyRequired 等)原样保留。
}
/**
* 下一次违规真正会吃到的处罚描述(命中时给玩家预告),
* 没达到任何处罚阈值时返回 null。
* ★ 2026-10-08 修正(用户反馈「不是每次违规都罚15分钟,要按规则配置」):
* 旧实现取「大于当前计数的下一个阈值」(k > violation),
* 但 applyPunishment 的判据是 violation >= 阈值 取最高档——
* 例如规则 3:mute:300 / 6:mute:900 时,第3次违规实际按第3档禁言5分,
* 旧预告却已跳到第6档「禁言15分」,且第4、5次也一直报15分,
* 预告与实际处罚长期对不上。这里改为按与 applyPunishment 完全相同的
* 判据,计算「计数 + 1」时真正命中的那一档,时长取该档配置。
* 另:文案只报处罚档位、不带「第 N 次违规」前缀——
* 否则与上一行的「本次为第 N 次违规」并列会被读成计数跳号。
*/
public String nextPunishHint(int violation) {
int target = violation + 1; // 下一次违规时的计数
int bestKey = Integer.MIN_VALUE;
for (Integer k : punishmentRules.keySet()) {
if (k <= target && k > bestKey) {
bestKey = k; // 与 applyPunishment 同款:violation >= 阈值 取最高档
}
}
if (bestKey == Integer.MIN_VALUE) {
return null; // 尚未达到任何处罚阈值
}
String type = punishmentRules.get(bestKey);
int dur = punishmentDurations.getOrDefault(bestKey, muteDuration);
String cn = switchPunishType(type);
if ("mute".equals(type)) {
cn += " " + fmtDuration(dur);
}
return cn;
}
public boolean isPlayerWhitelisted(
@@ -184,6 +184,13 @@ public class ConfigManager {
changed = true;
}
// 检查管理员报警邮箱配置项(备用收件人;2FA 解绑报警现发玩家本人,见 TwoFactorManager)
if (!smtpSettings.containsKey("管理员邮箱")) {
logger.info("[Sdf1_login] 邮件配置缺失: 管理员邮箱,自动补全");
smtpSettings.put("管理员邮箱", "");
changed = true;
}
// 如果有变更,保存配置文件
if (changed) {
logger.info("[Sdf1_login] 邮件配置有变更,保存配置文件");
@@ -197,6 +204,11 @@ public class ConfigManager {
return smtpSettings.getOrDefault(key, "");
}
/** 管理员报警邮箱(备用收件人,当前 2FA 解绑报警不使用),未配置返回空串 */
public String getAdminEmail() {
return getSmtp("管理员邮箱").trim();
}
public void setSmtp(String key, String value) {
smtpSettings.put(key, value);
}
@@ -341,6 +353,12 @@ public class ConfigManager {
}
writtenKeys.add("邮箱后缀列表");
// 保存管理员报警邮箱(2FA 解绑等安全事件收件人)
L.add("");
L.add("# --- 管理员报警邮箱(备用收件人,2FA解绑报警现发玩家本人,留空则只记日志) ---");
L.add("管理员邮箱=" + smtpSettings.getOrDefault("管理员邮箱", ""));
writtenKeys.add("管理员邮箱");
// ★ 追加所有未写入的未知key(防止丢失用户自定义配置项)
java.util.Set<String> allKeys = new java.util.LinkedHashSet<>(smtpSettings.keySet());
allKeys.removeAll(writtenKeys);
@@ -376,6 +394,8 @@ public class ConfigManager {
L.add("# 示例: qq.com");
L.add("# 示例: gmail.com");
L.add("邮箱后缀列表=");
L.add("# --- 管理员报警邮箱(备用收件人,2FA解绑报警现发玩家本人,留空则只记日志) ---");
L.add("管理员邮箱=");
writeLines(f, L);
}
+82 -25
View File
@@ -67,9 +67,7 @@ public class CypayCommand
+ " 余额: §a" + bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(status)
? "§c冻结中"
: "§a正常"));
+ freezeText(name));
return true;
}
@@ -95,8 +93,10 @@ public class CypayCommand
return true;
}*/
// ===== /cypay freeze =====
if (args.length == 2
// ===== /cypay freeze <玩家> [时长] =====
// ★ 2026-10-04 改版:时长可选,硬编封顶 30 天,
// 不传时长 = 永久冻结;支持 1分钟/壹分钟/one min/时间戳
if (args.length >= 2
&& "freeze".equalsIgnoreCase(
args[0])) {
if (!hasAdminPerm(sender)) {
@@ -104,20 +104,57 @@ public class CypayCommand
return true;
}
String target = args[1];
if (plugin.getBonds().isFrozen(target)) {
sender.sendMessage("§c" + target
+ " 已处于冻结状态");
return true;
long until = 0L; // 0 = 永久冻结
String durNote = "永久";
if (args.length >= 3) {
String durStr = String.join(" ",
Arrays.copyOfRange(args, 2,
args.length));
long expireMs;
try {
expireMs = DurationParser
.parseToExpireMs(durStr);
} catch (IllegalArgumentException ex) {
sender.sendMessage("§c时长格式无法解析: "
+ ex.getMessage());
sender.sendMessage("§7可用写法: 30分钟 / "
+ "壹小时 / one min / 7天 / "
+ "253402271999(时间戳)");
sender.sendMessage("§7不传时长 = 永久冻结,"
+ "时长封顶 30 天");
return true;
}
long now = System.currentTimeMillis();
long MAX_MS =
30L * 24 * 3600 * 1000L; // 封顶30天
if (expireMs <= now) {
// 过期/为零的时间戳(如 0000000000)-> 永久
sender.sendMessage("§7该时间点已过期,"
+ "按 §c永久冻结 §7处理");
until = 0L;
} else if (expireMs - now > MAX_MS) {
until = now + MAX_MS;
sender.sendMessage("§7时长超过 30 天,"
+ "已按封顶 §e30天 §7处理");
} else {
until = expireMs;
}
durNote = until <= 0L ? "永久"
: "至 " + DurationParser
.formatExpire(until);
}
plugin.getBonds().freezeAccount(target);
plugin.getBonds().freezeAccount(target, until);
sender.sendMessage("§a已冻结 " + target
+ " 的债券账户");
+ " 的债券账户(" + durNote + ")");
Player tp = plugin.getServer()
.getPlayerExact(target);
if (tp != null)
tp.sendMessage(
"§c§l[系统] 你的债券账户"
+ "已被管理员冻结");
"§c§l[系统] 你的债券账户已被管理员冻结"
+ (until <= 0L ? ""
: ",到期时间: "
+ DurationParser
.formatExpire(until)));
return true;
}
@@ -182,9 +219,7 @@ public class CypayCommand
+ plugin.getBonds()
.getBonds(target)
+ " §f状态: "
+ (plugin.getBonds()
.isFrozen(target)
? "§c冻结" : "§a正常"));
+ freezeText(target));
if (txs.isEmpty()) {
sender.sendMessage("§7 (暂无记录)");
@@ -276,7 +311,8 @@ public class CypayCommand
: "管理员扣除");
if ("give".equals(action)) {
plugin.getBonds().addBonds(
// ★ 冻结期豁免:管理员手动 give 允许动账
plugin.getBonds().addBondsAdmin(
target, amount,
txType, "",
opName, reason);
@@ -292,8 +328,10 @@ public class CypayCommand
+ amount
+ " 债券");
} else {
// ★ 冻结期豁免:管理员手动 remove
// 允许动账(deductBondsAdmin 不查冻结)
if (plugin.getBonds()
.deductBonds(target,
.deductBondsAdmin(target,
amount,
txType, "",
opName,
@@ -315,8 +353,7 @@ public class CypayCommand
} else {
sender.sendMessage(
"§c" + target
+ " 债券不足"
+ "或账户冻结");
+ " 债券不足");
}
}
} catch (NumberFormatException e) {
@@ -393,10 +430,7 @@ public class CypayCommand
+ bonds);
sender.sendMessage(
"§e§l[债券] §f状态: "
+ ("frozen".equals(
status)
? "§c冻结中"
: "§a正常"));
+ freezeText(target));
} else {
int bonds = plugin.getBonds()
.getBonds(
@@ -412,6 +446,19 @@ public class CypayCommand
return true;
}
// ===== 冻结状态文案(含到期时间) =====
/** 正常 / 冻结中(永久) / 冻结中(至 yyyy年M月d日 HH:mm) */
private String freezeText(String target) {
if (!plugin.getBonds().isFrozen(target))
return "§a正常";
long until = plugin.getBonds()
.getFreezeUntil(target);
if (until <= 0L) return "§c冻结中(永久)";
return "§c冻结中(至 "
+ DurationParser.formatExpire(until) + ")";
}
// ===== 流水行格式化 =====
private String formatTxLine(
@@ -489,7 +536,8 @@ public class CypayCommand
sender.sendMessage(
"§e/cypay import <文件> §7- 导入CDK");
sender.sendMessage(
"§e/cypay freeze <玩家> §7- 冻结");
"§e/cypay freeze <玩家> [时长] §7- 冻结"
+ "(不填=永久,封顶30天)");
sender.sendMessage(
"§e/cypay unfreeze <玩家> §7- 解冻");
sender.sendMessage(
@@ -559,6 +607,15 @@ public class CypayCommand
if (args.length == 3) {
String first = args[0].toLowerCase();
if ("freeze".equals(first)) {
list.add("10分钟");
list.add("1小时");
list.add("one min");
list.add("7天");
list.add("30天");
return filter(list,
args[2].toLowerCase());
}
if ("give".equals(first)
|| "remove".equals(first)) {
list.add("10");
+162 -2
View File
@@ -21,8 +21,12 @@ public class DatabaseManager {
public void init() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(dataFolder,
"login.db");
// ★ 2026-10-08 归位:login.db 创建即放入「登录凭证/」;
// 根目录存量由 Main.migrateRootDataFiles() 启动最早期整组迁移
// (.db + -shm + -wal 同进同退,严禁人工移动)。
File dbDir = new File(dataFolder, "登录凭证");
dbDir.mkdirs();
File dbFile = new File(dbDir, "login.db");
db = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
@@ -110,6 +114,27 @@ public class DatabaseManager {
// ★ 正版OAuth登录IP(用于异地登录风控)
safeAdd(st, "last_oauth_ip",
"TEXT DEFAULT ''");
// ===== 2FA 二次验证(TOTP,仅Java本地校验)=====
safeAdd(st, "twofa_secret",
"TEXT DEFAULT ''");
safeAdd(st, "twofa_enabled",
"INTEGER DEFAULT 0");
safeAdd(st, "twofa_pending_secret",
"TEXT DEFAULT ''");
safeAdd(st, "twofa_pending_at",
"INTEGER DEFAULT 0");
// ===== 2FA 恢复代码(SHA-256 逗号连接存库,明文只发邮件,一次性有效)=====
safeAdd(st, "twofa_recovery_codes",
"TEXT DEFAULT ''");
// ★ 一次性自愈:历史版本 setField(null) 曾把 temp_password
// 写成字面量 'null',会让 credentials 指纹永久不一致、
// 对账每轮补推。启动时统一清回 SQL NULL。
try {
st.executeUpdate("UPDATE users SET temp_password=NULL "
+ "WHERE temp_password='null'");
} catch (SQLException ignored) {
}
// ===== 背包备份表:安全迁移 =====
// 检查旧表是否存在(用 inventory_data 列判断)
@@ -379,6 +404,22 @@ public class DatabaseManager {
+ "save_time INTEGER DEFAULT 0"
+ ")");
// ★ 充值回执镜像表(2026-10-06,Bug「清空PHP后充值记录回不来」的修复)
// 充值流水一式两份:PHP 持 web_transactions(type=recharge) 一份,
// Java 持这里一份。每轮全量同步时双方点差集、多退少补:
// PHP 缺 → Java 补推重建流水(只补记录,绝不重复加钱)
// Java 缺 → 收回来补齐基线(同样只补记录)
// tx_id = PHP web_transactions.id,作为两边共同的主键。
st.execute("CREATE TABLE IF NOT EXISTS "
+ "web_tx_receipts ("
+ "tx_id INTEGER PRIMARY KEY,"
+ "player_name TEXT NOT NULL DEFAULT '',"
+ "type TEXT NOT NULL DEFAULT '',"
+ "amount INTEGER NOT NULL DEFAULT 0,"
+ "detail TEXT NOT NULL DEFAULT '',"
+ "created_at INTEGER NOT NULL DEFAULT 0"
+ ")");
// 注:传送请求不再落库,仅驻留内存(配置项才落库),故不再建 teleport_requests 表
st.close();
logger.info("[Sdf1_login] 数据库初始化完成");
@@ -461,6 +502,65 @@ public class DatabaseManager {
return db;
}
// ==================== 充值回执镜像(web_tx_receipts,2026-10-06) ====================
// 充值流水一式两份:PHP 持 web_transactions(type=recharge),Java 持本表。
// 两边按 tx_id 点差集、多退少补,任何一边被清空都能从另一边补回来。
/** 写入/更新一条回执(主键 tx_id = PHP web_transactions.id) */
public void upsertWebTxReceipt(long txId, String playerName, String type,
int amount, String detail, long createdAt) {
try (java.sql.PreparedStatement ps = db.prepareStatement(
"INSERT OR REPLACE INTO web_tx_receipts "
+ "(tx_id, player_name, type, amount, detail, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?)")) {
ps.setLong(1, txId);
ps.setString(2, playerName == null ? "" : playerName);
ps.setString(3, type == null ? "" : type);
ps.setInt(4, amount);
ps.setString(5, detail == null ? "" : detail);
ps.setLong(6, createdAt);
ps.executeUpdate();
} catch (Exception e) {
logger.warning("[回执镜像] 写入失败 tx_id=" + txId + ": " + e.getMessage());
}
}
/** 取全部回执(对账用,字段名与 PHP web_transactions 对齐) */
public List<Map<String, Object>> getAllWebTxReceipts() {
List<Map<String, Object>> out = new ArrayList<>();
try (java.sql.PreparedStatement ps = db.prepareStatement(
"SELECT tx_id, player_name, type, amount, detail, created_at "
+ "FROM web_tx_receipts ORDER BY tx_id ASC");
java.sql.ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
Map<String, Object> row = new LinkedHashMap<>();
row.put("id", rs.getLong("tx_id"));
row.put("player_name", rs.getString("player_name"));
row.put("type", rs.getString("type"));
row.put("amount", rs.getInt("amount"));
row.put("detail", rs.getString("detail"));
row.put("created_at", rs.getLong("created_at"));
out.add(row);
}
} catch (Exception e) {
logger.warning("[回执镜像] 读取失败: " + e.getMessage());
}
return out;
}
/** 是否已有某条回执 */
public boolean webTxReceiptExists(long txId) {
try (java.sql.PreparedStatement ps = db.prepareStatement(
"SELECT 1 FROM web_tx_receipts WHERE tx_id = ? LIMIT 1")) {
ps.setLong(1, txId);
try (java.sql.ResultSet rs = ps.executeQuery()) {
return rs.next();
}
} catch (Exception e) {
return false;
}
}
// ==================== UI偏好 ====================
/**
@@ -588,6 +688,29 @@ public class DatabaseManager {
}
}
/**
* ★ 封禁防护用:大小写不敏感的注册校验。
* 返回 false 仅表示「确认未注册」;SQL 异常时返回 true 并告警
* (宁可放行,也不因 DB 抖动误拒正常的封禁操作)。
*/
public boolean userExistsIgnoreCase(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("[封禁防护] 注册校验查询失败(放行): "
+ e.getMessage());
return true;
}
}
public void createUser(String name, String hash,
String salt) {
try {
@@ -813,6 +936,11 @@ public class DatabaseManager {
ps.setInt(1, (Integer) val);
else if (val instanceof Long)
ps.setLong(1, (Long) val);
// ★ null 必须写 SQL NULL:走 String.valueOf(null) 会把
// temp_password 写成字面量 'null',两端指纹永不一致,
// 对账每轮恒定补推(2026-10-07 测试服 credentials 补推1 根因)
else if (val == null)
ps.setNull(1, java.sql.Types.VARCHAR);
else
ps.setString(1,
String.valueOf(val));
@@ -2542,6 +2670,38 @@ public class DatabaseManager {
return list;
}
/**
* 全量服务商名单(含 active=0 的停用记录)。
* 对账必须拿到与 PHP 完全相同的集合:只查 active=1 会让停用记录在 PHP 侧永远是 extra,
* 进而被「整表 DELETE」的推送一条条抹掉。
*/
public List<Map<String, Object>>
getAllServiceProvidersFull() {
List<Map<String, Object>> list =
new ArrayList<>();
try {
Statement st = db.createStatement();
ResultSet rs = st.executeQuery(
"SELECT * FROM service_providers");
while (rs.next()) {
Map<String, Object> row =
new LinkedHashMap<>();
for (int i = 1; i <= rs.getMetaData()
.getColumnCount(); i++) {
row.put(rs.getMetaData()
.getColumnName(i),
rs.getObject(i));
}
list.add(row);
}
rs.close();
st.close();
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
// ==================== 领地方块/容器记录 ====================
/** 记录领地方块破坏/放置 */
+147 -2
View File
@@ -16,6 +16,11 @@ import java.util.regex.Pattern;
* 2) 中文日期(绝对): 2026年7月9日
* 3) ISO 日期(绝对): 2025-07-09
* 4) 相对时长(中文/英文单位): 1秒 / 1s / 1年 / 1年2月3天 / 2天12小时
* 5) 英文数字词(2026-10-04 新增,供 /cypay freeze 用):
* one min / two hours / twenty days / twenty one minutes
*
* <p>注意:纯数字仍按「Unix 秒时间戳」处理(如 0000000000);
* 过期时间戳由调用方自行判定(/cypay freeze 视为永久冻结)。
*
* 解析规则优先级:纯数字 → 时间戳;含「年/月/日」且为完整日期格式 → 绝对日期;
* 否则按「数字+单位」逐段累加为相对时长。
@@ -64,7 +69,15 @@ public class DurationParser {
throws IllegalArgumentException {
if (input == null || input.trim().isEmpty())
throw new IllegalArgumentException("时长为空");
String s = input.trim().replace(" ", "");
String in = input.trim();
// ★ 0) 日期+时间(绝对):26-10-08 14:43 / 2026-10-08 14:43 / 2026年10月8日 14:43
// 必须在去空格之前解析:否则日期与时间粘连(26-10-08 14:43 → 26-10-0814:43)无法区分
long dt = parseDateTime(in);
if (dt >= 0) return dt;
// ★ 先把英文数字词换成阿拉伯数字(one min -> 1 min)
String s = normalizeEnglishNumber(in).replace(" ", "");
// 1) 纯数字 → Unix 秒时间戳
if (s.matches("\\d+")) {
@@ -99,6 +112,138 @@ public class DurationParser {
throw new IllegalArgumentException("无法解析时长: " + input);
}
/** 英文数字词 -> 阿拉伯数字(zero..ninety,支持 twenty one / twenty-one 连写) */
private static String normalizeEnglishNumber(String in) {
try {
String[] words = {"zero", "one", "two", "three", "four",
"five", "six", "seven", "eight", "nine", "ten",
"eleven", "twelve", "thirteen", "fourteen",
"fifteen", "sixteen", "seventeen", "eighteen",
"nineteen", "twenty", "thirty", "forty", "fifty",
"sixty", "seventy", "eighty", "ninety"};
StringBuilder alt = new StringBuilder();
for (int i = 0; i < words.length; i++) {
if (i > 0) alt.append('|');
alt.append(words[i]);
}
Pattern p = Pattern.compile(
"(?i)\\b(" + alt + ")(?:[\\s-]+(" + alt + "))?\\b");
Matcher m = p.matcher(in);
StringBuffer sb = new StringBuffer();
while (m.find()) {
int v = enWordValue(m.group(1));
if (m.group(2) != null) v += enWordValue(m.group(2));
m.appendReplacement(sb, Matcher.quoteReplacement(
String.valueOf(v)));
}
m.appendTail(sb);
return sb.toString();
} catch (Exception e) {
return in; // 归一化失败就原样返回
}
}
private static int enWordValue(String w) {
switch (w.toLowerCase(java.util.Locale.ROOT)) {
case "zero": return 0;
case "one": return 1;
case "two": return 2;
case "three": return 3;
case "four": return 4;
case "five": return 5;
case "six": return 6;
case "seven": return 7;
case "eight": return 8;
case "nine": return 9;
case "ten": return 10;
case "eleven": return 11;
case "twelve": return 12;
case "thirteen": return 13;
case "fourteen": return 14;
case "fifteen": return 15;
case "sixteen": return 16;
case "seventeen": return 17;
case "eighteen": return 18;
case "nineteen": return 19;
case "twenty": return 20;
case "thirty": return 30;
case "forty": return 40;
case "fifty": return 50;
case "sixty": return 60;
case "seventy": return 70;
case "eighty": return 80;
case "ninety": return 90;
default: return 0;
}
}
// ===== 日期+时间绝对解析(2026-10-08 新增:支持 26-10-08 14:43 写法) =====
/** 中文日期+时间:2026年10月8日 14:43(“日”已界定日期边界,时间可紧贴) */
private static final Pattern DT_CN = Pattern.compile(
"(\\d{4})年(\\d{1,2})月(\\d{1,2})日(?:[ T\\u3000]+)?"
+ "(\\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
* 两位年份按 20xx 解释;日期与时间之间必须有空格或 T,
* 防止 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])");
/** 两位年份无时间:26-10-08
* lookbehind 防止吃掉 4 位年份的后半段(2026-07-09 不能被读成 07-09) */
private static final Pattern DT_YMD2 = Pattern.compile(
"(?<!\\d{2}[-/.])(\\d{2})[-/.](\\d{1,2})[-/.](\\d{1,2})(?!\\d|[A-Za-z])");
/** 解析「日期+时间」为绝对毫秒;认不出来返回 -1 交后续分支处理 */
private static long parseDateTime(String in) {
Matcher m = DT_CN.matcher(in);
if (m.find()) {
return buildDateTimeMillis(Integer.parseInt(m.group(1)),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
timeOf(m.group(4), m.group(5), m.group(6)));
}
m = DT_SEP.matcher(in);
if (m.find()) {
int year = Integer.parseInt(m.group(1));
if (year < 100) year += 2000; // 26 → 2026
return buildDateTimeMillis(year, Integer.parseInt(m.group(2)),
Integer.parseInt(m.group(3)),
timeOf(m.group(4), m.group(5), m.group(6)));
}
m = DT_YMD2.matcher(in);
if (m.find()) {
return buildDateTimeMillis(2000 + Integer.parseInt(m.group(1)),
Integer.parseInt(m.group(2)), Integer.parseInt(m.group(3)),
new int[]{-1, -1, -1});
}
return -1;
}
/** 解析 HH:mm[:ss];无时间(null)返回 {-1,-1,-1} 表示取 00:00:00 */
private static int[] timeOf(String h, String mi, String s) {
if (h == null) return new int[]{-1, -1, -1};
int H = Integer.parseInt(h), M = Integer.parseInt(mi);
int S = (s == null) ? 0 : Integer.parseInt(s);
if (H > 23 || M > 59 || S > 59)
throw new IllegalArgumentException("无效的时间: " + h + ":" + mi);
return new int[]{H, M, S};
}
/** 绝对日期+时间(t[0..2] 为 -1 表示 00:00:00) */
private static long buildDateTimeMillis(int y, int mo, int d, int[] t) {
if (mo < 1 || mo > 12 || d < 1 || d > 31)
throw new IllegalArgumentException("无效的日期: " + y + "-" + mo + "-" + d);
Calendar c = Calendar.getInstance();
c.set(y, mo - 1, d,
t[0] < 0 ? 0 : t[0],
t[1] < 0 ? 0 : t[1],
t[2] < 0 ? 0 : t[2]);
c.set(Calendar.MILLISECOND, 0);
return c.getTimeInMillis();
}
private static long parseRelative(String s) {
// 按「数字 + 单位」逐段匹配并累加
StringBuilder units = new StringBuilder();
@@ -108,7 +253,7 @@ public class DurationParser {
units.append(Pattern.quote(u));
}
Pattern p = Pattern.compile(
"([0-9零一二两三四五六七八九十百千万]+)(" + units + ")");
"([0-9零〇一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖]+)(" + units + ")");
Matcher m = p.matcher(s);
long total = 0;
int found = 0;
+92 -29
View File
@@ -37,8 +37,9 @@ public class GarbageManager {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(),
new File(plugin.getDataFolder(), "垃圾箱"),
"garbage.db");
dbFile.getParentFile().mkdirs();
db = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
@@ -173,41 +174,56 @@ public class GarbageManager {
}
private void doCleanup() {
// 删除过期轮次的物品
int deleteRound =
// ★ 过期轮次删除是阻塞 DB -> 先异步执行;
// 轮次递增 / 实体收集 / 广播 必须主线程 -> 异步完成后回主线程
final int deleteRound =
currentRound - maxRounds;
if (deleteRound >= 0) {
int deleted = deleteOldItems(
deleteRound);
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
}
Bukkit.getScheduler().runTaskAsynchronously(
plugin, () -> {
final int deleted =
deleteRound >= 0
? deleteOldItems(deleteRound)
: 0;
Bukkit.getScheduler().runTask(
plugin, () -> {
if (deleted > 0) {
broadcast("§7[垃圾站] §f永久清理了 §e"
+ deleted + " §f个过期物品");
}
// 递增轮次
currentRound++;
setConfigValue("round",
String.valueOf(currentRound));
// 递增轮次
currentRound++;
final int roundNow =
currentRound;
// ★ 轮次落库是 DB -> 异步
Bukkit.getScheduler()
.runTaskAsynchronously(
plugin, () ->
setConfigValue("round",
String.valueOf(roundNow)));
// 收集当前掉落物
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 收集当前掉落物
// (实体移除必须主线程,落库已在内部转异步)
int count = collectItems();
broadcast("§a§l[垃圾站] §f清理完成,"
+ "共清理 §e" + count
+ " §f个掉落物,存入回收站");
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
// 打印下次清理时间
long nextMs = System.currentTimeMillis()
+ (long) cleanupInterval * 1000L;
String nextTime =
new SimpleDateFormat("HH:mm:ss")
.format(new Date(nextMs));
broadcast("§e§l[垃圾站] §f下次清理时间: §a"
+ nextTime);
});
});
}
public int collectItems() {
int count = 0;
List<ItemStack> picked = new ArrayList<>();
for (org.bukkit.World w :
Bukkit.getWorlds()) {
Collection<Item> items =
@@ -219,16 +235,31 @@ public class GarbageManager {
|| stack.getType()
== Material.AIR)
continue;
saveItem(stack);
// ★ 先取快照再移除实体(实体移除必须主线程)
picked.add(stack.clone());
item.remove();
count++;
}
}
// ★ 落库是阻塞 DB(含 SN 脱管登记)-> 丢异步批量写
if (!picked.isEmpty()) {
final List<ItemStack> toSave = picked;
Bukkit.getScheduler()
.runTaskAsynchronously(plugin, () -> {
for (ItemStack s : toSave)
saveItem(s);
});
}
return count;
}
public void saveItem(ItemStack stack) {
try {
// ★ SN 物品进垃圾箱 = 脱离玩家本人管控,起算 12 小时找回期。
// shift 投入 / 光标放入 / 拖拽 / 扫地机收地面掉落全走这里。
if (plugin.getSnManager() != null) {
plugin.getSnManager().onConfiscated(stack, "垃圾站");
}
String data = stackToBase64(stack);
if (data.isEmpty()) return;
PreparedStatement ps = db.prepareStatement(
@@ -514,6 +545,38 @@ public class GarbageManager {
}
}
/**
* 取回前按 id 读原始存档(含原 lore 与 PDC)。
* GUI 展示副本的 lore 已被覆盖成
* 「ID/点击取出」,取回必须用这个还原,
* 否则 §8SN 行与物品说明 lore 会丢。
*
* @param id items 表主键
* @return 原始物品;读不到返回 null
*/
public ItemStack fetchItem(int id) {
try {
PreparedStatement ps = db.prepareStatement(
"SELECT item_data, amount FROM items "
+ "WHERE id=?");
ps.setInt(1, id);
ResultSet rs = ps.executeQuery();
ItemStack result = null;
if (rs.next()) {
result = base64ToStack(
rs.getString("item_data"));
if (result != null) {
result.setAmount(rs.getInt("amount"));
}
}
rs.close();
ps.close();
return result;
} catch (Exception e) {
return null;
}
}
public int getItemCount() {
try {
Statement st = db.createStatement();
+321 -23
View File
@@ -1,6 +1,8 @@
package Sdf1_login;
import org.bukkit.*;
import org.bukkit.block.Block;
import org.bukkit.block.data.type.Bed;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
@@ -9,6 +11,9 @@ import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.player.PlayerBedEnterEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
@@ -128,6 +133,7 @@ public class HomeManager implements Listener {
player.sendMessage("§a家 §e" + homeName + " §a已设置!");
player.sendMessage("§7使用 §e/home " + homeName + " §7传送回家");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
@@ -150,7 +156,7 @@ public class HomeManager implements Listener {
deleteHome(player.getName(), homeName);
player.sendMessage("§a家 §e" + homeName + " §a已删除!");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
@@ -164,6 +170,7 @@ public class HomeManager implements Listener {
if (homes.isEmpty()) {
player.sendMessage("§c你还没有设置任何家");
player.sendMessage("§7使用 §e/sethome <名称> §7设置一个家");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
teleportToHome(player, homes.get(0));
@@ -207,48 +214,199 @@ public class HomeManager implements Listener {
player.sendMessage("§7使用 §e/home <名称> §7传送");
player.sendMessage("§7使用 §e/sethome <名称> §7设置新家");
player.sendMessage("§7使用 §e/delhome <名称> §7删除家");
player.sendMessage("§7—————————————————————");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
return true;
}
// ==================== 床事件监听 ====================
private static final org.bukkit.block.BlockFace[] HORIZ_FACES = {
org.bukkit.block.BlockFace.NORTH, org.bukkit.block.BlockFace.SOUTH,
org.bukkit.block.BlockFace.EAST, org.bukkit.block.BlockFace.WEST };
/** 危险方块:落点优先级判断用 */
private static final Set<Material> DANGEROUS = EnumSet.of(
Material.LAVA, Material.FIRE, Material.SOUL_FIRE, Material.MAGMA_BLOCK,
Material.CACTUS, Material.CAMPFIRE, Material.SOUL_CAMPFIRE,
Material.SWEET_BERRY_BUSH, Material.WITHER_ROSE, Material.POWDER_SNOW,
Material.POINTED_DRIPSTONE);
/** 是否是床方块 */
private static boolean isBed(Block b) {
return b.getBlockData() instanceof Bed;
}
/** 取床头方块(配对缺失时返回原方块);非床返回 null */
private static Block getBedHead(Block b) {
if (!isBed(b)) return null;
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) return b;
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
return head != null ? head : b;
}
private static Block horizontalNeighborWithPart(Block b, Bed.Part part) {
for (org.bukkit.block.BlockFace f : HORIZ_FACES) {
Block n = b.getRelative(f);
if (isBed(n) && ((Bed) n.getBlockData()).getPart() == part) return n;
}
return null;
}
/** 床的两半 [head, foot(可能为null)];非床返回 null */
private static Block[] getBedParts(Block b) {
if (!isBed(b)) return null;
if (((Bed) b.getBlockData()).getPart() == Bed.Part.HEAD) {
return new Block[]{ b, horizontalNeighborWithPart(b, Bed.Part.FOOT) };
}
Block head = horizontalNeighborWithPart(b, Bed.Part.HEAD);
return new Block[]{ head != null ? head : b, b };
}
/**
* 玩家上床时自动记录床位置
* 玩家与床交互(上床/白天点床)→ bed 传送点始终更新为该床的精确坐标
*/
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerBedEnter(PlayerBedEnterEvent event) {
// 下界/末地床爆炸、怪物附近、距离过远等未真正交互的情况不记录
PlayerBedEnterEvent.BedEnterResult r = event.getBedEnterResult();
if (r != PlayerBedEnterEvent.BedEnterResult.OK
&& r != PlayerBedEnterEvent.BedEnterResult.NOT_POSSIBLE_NOW
&& r != PlayerBedEnterEvent.BedEnterResult.OBSTRUCTED) {
return;
}
Player player = event.getPlayer();
Location bedLoc = event.getBed().getLocation();
Block head = getBedHead(event.getBed());
if (head == null) return;
Location bedLoc = head.getLocation();
bedLocations.put(player.getName(), bedLoc);
// 自动保存为"bed"家(如果不存在)
if (!homeExists(player.getName(), "bed")) {
HomeData existing = getHome(player.getName(), "bed");
if (existing == null) {
saveHome(player.getName(), "bed", bedLoc);
player.sendMessage("§a床位置已自动保存为 §e/home bed");
} else if (!sameBlock(existing, bedLoc)) {
// 与其它床交互 → 更新传送点坐标
updateHome(player.getName(), "bed", bedLoc);
player.sendMessage("§a床传送点已更新为该床的位置");
player.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
}
}
/**
* 玩家重生时更新床位置
* 玩家复活 → 抓取重生点附近的精确床坐标并更新 bed 传送点
*/
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerRespawn(PlayerRespawnEvent event) {
Player player = event.getPlayer();
Location respawnLoc = event.getRespawnLocation();
// 如果重生点是床位置,更新bed家
Location bedLoc = bedLocations.get(player.getName());
if (bedLoc != null && bedLoc.getWorld() != null) {
if (respawnLoc.getWorld() != null &&
respawnLoc.getWorld().getName().equals(bedLoc.getWorld().getName()) &&
respawnLoc.distance(bedLoc) < 5) {
// 重生点就是床,更新bed家
updateHome(player.getName(), "bed", respawnLoc);
if (respawnLoc == null || respawnLoc.getWorld() == null) return;
Block bed = findNearbyBed(respawnLoc);
if (bed == null) return; // 世界出生点复活等,不碰传送点
Block head = getBedHead(bed);
Location exact = (head != null ? head : bed).getLocation();
HomeData existing = getHome(player.getName(), "bed");
if (existing == null) {
saveHome(player.getName(), "bed", exact);
} else if (!sameBlock(existing, exact)) {
updateHome(player.getName(), "bed", exact);
}
bedLocations.put(player.getName(), exact);
}
/** 在重生点附近(水平±3、垂直-1~+2)寻找最近的床方块 */
private Block findNearbyBed(Location loc) {
World w = loc.getWorld();
if (w == null) return null;
int bx = loc.getBlockX(), by = loc.getBlockY(), bz = loc.getBlockZ();
Block best = null;
double bestD = Double.MAX_VALUE;
for (int dx = -3; dx <= 3; dx++)
for (int dy = -1; dy <= 2; dy++)
for (int dz = -3; dz <= 3; dz++) {
Block b = w.getBlockAt(bx + dx, by + dy, bz + dz);
if (!isBed(b)) continue;
double d = (double) dx * dx + (double) dy * dy + (double) dz * dz;
if (d < bestD) { bestD = d; best = b; }
}
return best;
}
private boolean sameBlock(HomeData home, Location loc) {
return home.world != null && loc.getWorld() != null
&& home.world.equals(loc.getWorld().getName())
&& Math.floor(home.x) == loc.getBlockX()
&& Math.floor(home.y) == loc.getBlockY()
&& Math.floor(home.z) == loc.getBlockZ();
}
/**
* 床没了 → 自动删除对应玩家的 bed 传送点(破坏/爆炸均触发)
*/
private void removeBedHomesFor(Block bedPart) {
Block[] parts = getBedParts(bedPart);
if (parts == null) return;
Set<String> owners = new HashSet<>();
for (Block part : parts) {
if (part == null) continue;
owners.addAll(queryBedHomeOwners(part));
}
for (String owner : owners) {
deleteHome(owner, "bed");
bedLocations.remove(owner);
Player p = Bukkit.getPlayerExact(owner);
if (p != null && p.isOnline()) {
p.sendMessage("§c你的床已被破坏,/home bed 传送点已自动删除");
p.sendMessage("§a§l欢迎来到草原探险服务器,服务器ip:mc2.ypshidifu.cn 端口(基岩版需要):30679");
}
}
}
/** 查询 bed 传送点坐标与指定床方块重合的玩家列表 */
private Set<String> queryBedHomeOwners(Block bedBlock) {
Set<String> owners = new HashSet<>();
try {
java.sql.Connection db = plugin.getDb().getConnection();
if (db == null) return owners;
PreparedStatement ps = db.prepareStatement(
"SELECT player_name FROM homes WHERE home_name='bed' AND world=? "
+ "AND ABS(x-?)<0.01 AND ABS(y-?)<0.01 AND ABS(z-?)<0.01");
ps.setString(1, bedBlock.getWorld().getName());
ps.setDouble(2, bedBlock.getX());
ps.setDouble(3, bedBlock.getY());
ps.setDouble(4, bedBlock.getZ());
ResultSet rs = ps.executeQuery();
while (rs.next()) owners.add(rs.getString(1));
rs.close();
ps.close();
} catch (SQLException e) {
plugin.getLogger().warning("[Home] 查询bed传送点失败: " + e.getMessage());
}
return owners;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (isBed(event.getBlock())) removeBedHomesFor(event.getBlock());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onEntityExplode(EntityExplodeEvent event) {
for (Block b : event.blockList()) {
if (isBed(b)) removeBedHomesFor(b);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBlockExplode(BlockExplodeEvent event) {
for (Block b : event.blockList()) {
if (isBed(b)) removeBedHomesFor(b);
}
}
// ==================== 家数量限制 ====================
@@ -413,14 +571,19 @@ public class HomeManager implements Listener {
* 传送玩家到家
*/
private void teleportToHome(Player player, HomeData home) {
// bed 家走原版复活落点算法(自写,不模拟死亡、无死亡提示)
if ("bed".equalsIgnoreCase(home.name)) {
teleportToBedHome(player, home);
return;
}
World world = Bukkit.getWorld(home.world);
if (world == null) {
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
return;
}
Location loc = home.toLocation(world);
// 安全检查: 确保位置安全
if (loc.getBlock().getType().isSolid()) {
// 向上寻找安全位置
@@ -432,10 +595,145 @@ public class HomeManager implements Listener {
}
}
}
player.teleport(loc);
player.sendMessage("§a已传送到家 §e" + home.name);
}
/**
* 回床:先校验床还在(没了自动删传送点),再用自写算法模拟原版复活落点
* (床周合法站位 + 视线水平看向床头),直接传送,不模拟死亡。
*/
private void teleportToBedHome(Player player, HomeData home) {
World world = Bukkit.getWorld(home.world);
if (world == null) {
player.sendMessage("§c世界 §e" + home.world + " §c不存在");
return;
}
Location saved = home.toLocation(world);
Block savedBlock = world.getBlockAt(saved);
Block head = getBedHead(savedBlock);
// 保存坐标上没有床 → 床已没,自动删传送点
if (head == null) {
deleteHome(player.getName(), "bed");
bedLocations.remove(player.getName());
player.sendMessage("§c你的床已不存在,/home bed 传送点已自动删除");
return;
}
Location dest = findVanillaRespawnSpot(world, head);
if (dest == null) {
player.sendMessage("§c床周围被阻挡,暂时无法回到床边");
return;
}
// 若坐标与记录不一致(床被重摆),顺手修正传送点
if (!sameBlock(home, head.getLocation())) {
updateHome(player.getName(), "bed", head.getLocation());
bedLocations.put(player.getName(), head.getLocation());
}
player.teleport(dest);
player.sendMessage("§a已回到床边");
}
/**
* 自写算法模拟原版床复活落点:
* 1. 候选 = 床头8邻(原版7块优先区,排除床自身占位)→ 床尾8邻兜底;
* 排序遵循原版"西北(低X低Z)最先,其次向南(+Z)再向东(+X)"。
* 2. 合法性 = 落点方块及上方一格无碰撞 + 下方一格有碰撞(可站立),
* 优先无危险方块的落点(火/岩浆/仙人掌等降级为次选)。
* 3. 落地朝向:视线水平看向床头。
* 全部被阻挡时回退到床位置向上找安全格。
*/
private Location findVanillaRespawnSpot(World world, Block head) {
Block[] parts = getBedParts(head);
Block foot = (parts != null && parts.length > 1) ? parts[1] : null;
int bedY = head.getY();
// 收集候选:床头邻格(排除床两半自身),再床尾邻格
List<Block> candidates = new ArrayList<>();
collectRing(head, foot, candidates);
if (foot != null) collectRing(foot, head, candidates);
// 排序:先床头环(保持顺序),床尾环在后;环内按 X→Z 升序(西北优先、南先于东)
candidates.sort(Comparator
.comparingInt((Block b) -> b.getY() == bedY ? 0 : 1)
.thenComparingInt(Block::getX)
.thenComparingInt(Block::getZ));
// 两轮:先无危险落点,再放宽(原版同样优先安全位)
for (int pass = 0; pass < 2; pass++) {
for (Block c : candidates) {
if (!isStandable(world, c.getX(), c.getY(), c.getZ())) continue;
boolean dangerous = isDangerous(world, c.getX(), c.getY(), c.getZ())
|| DANGEROUS.contains(world.getBlockAt(c.getX(), c.getY() - 1, c.getZ()).getType());
if (pass == 0 && dangerous) continue;
float yaw = faceBlockYaw(c, head);
return new Location(world, c.getX() + 0.5, c.getY(), c.getZ() + 0.5, yaw, 0f);
}
}
// 全被阻挡:回退到床本身向上找安全格
Location loc = new Location(world, head.getX() + 0.5, head.getY(), head.getZ() + 0.5);
for (int y = head.getY(); y < world.getMaxHeight(); y++) {
if (isStandable(world, head.getX(), y, head.getZ())) {
float yaw = faceBlockYaw(head.getX(), y, head.getZ(), head);
return new Location(world, head.getX() + 0.5, y, head.getZ() + 0.5, yaw, 0f);
}
loc.setY(y + 1);
}
return null;
}
/** 收集 center 的8个水平邻格(排除 exclude 床块及其配对半块) */
private void collectRing(Block center, Block exclude, List<Block> out) {
for (int dx = -1; dx <= 1; dx++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dz == 0) continue;
Block n = center.getRelative(dx, 0, dz);
if (isBed(n)) continue;
if (exclude != null && isBedPartOf(n, exclude)) continue;
out.add(n);
}
}
}
private boolean isBedPartOf(Block b, Block anyBedPart) {
Block[] parts = getBedParts(anyBedPart);
if (parts == null) return false;
for (Block p : parts) {
if (p != null && p.equals(b)) return true;
}
return false;
}
/** 站位合法性:落点与上方一格无碰撞,脚下有支撑 */
private boolean isStandable(World world, int x, int y, int z) {
if (y - 1 < world.getMinHeight()) return false;
Block feet = world.getBlockAt(x, y, z);
Block headSpace = world.getBlockAt(x, y + 1, z);
Block ground = world.getBlockAt(x, y - 1, z);
if (feet.getType() == Material.LAVA || feet.getType() == Material.WATER) return false;
if (!feet.isPassable() || !headSpace.isPassable()) return false;
return !ground.isPassable() || ground.getType() == Material.LADDER
|| ground.getType() == Material.VINE;
}
private boolean isDangerous(World world, int x, int y, int z) {
return DANGEROUS.contains(world.getBlockAt(x, y, z).getType());
}
private float faceBlockYaw(Block from, Block target) {
return faceBlockYaw(from.getX(), from.getY(), from.getZ(), target);
}
private float faceBlockYaw(int x, int y, int z, Block target) {
double dx = (target.getX() + 0.5) - (x + 0.5);
double dz = (target.getZ() + 0.5) - (z + 0.5);
return (float) Math.toDegrees(Math.atan2(-dx, dz));
}
// ==================== Tab补全 ====================
@@ -70,11 +70,18 @@ public class InventoryAutoBackup {
return;
}
int level = p.getLevel();
double experience = p.getExp(); // 当前等级进度 0.0~1.0
plugin.getDb().saveAutoBackup(
name, contentsB64, armorB64, extraB64,
level, experience);
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
final int level = p.getLevel();
final double experience = p.getExp(); // 当前等级进度 0.0~1.0
// ★ 读背包/等级必须主线程;落库是阻塞 DB 写 -> 投递异步执行
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.getDb().saveAutoBackup(
name, contentsB64, armorB64, extraB64,
level, experience);
plugin.getLogger().info("[自动备份] " + name + " 背包已备份");
} catch (Throwable t) {
plugin.getLogger().warning("[自动备份] 落库失败 (" + name + "): " + t.getMessage());
}
});
}
}
@@ -110,7 +110,30 @@ public class LandRecordManager implements Listener {
/** 直接发放一枚回声碎片给玩家(GUI 菜单调用) */
public void giveEchoShard(Player p) {
// ★ SN 管控:1人同种类仅限1个
if (plugin.getSnManager() != null
&& plugin.getSnManager().isBlocked(
p, SnManager.TYPE_ECHO)) {
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
if (cdMin > 0) {
p.sendMessage("§c§l[回声碎片] §f注销冷静期未结束,还剩约 §e" + cdMin + " §f分钟,期间无法申领新设备");
} else {
p.sendMessage("§c§l[回声碎片] §f你已有回声碎片的有效登记,"
+ "旧SN未注销前不能重新申领");
}
return;
}
ItemStack shard = createEchoShard();
if (plugin.getSnManager() != null) {
String sn = plugin.getSnManager().applySn(
p, SnManager.TYPE_ECHO, "回声碎片");
if (sn == null) {
p.sendMessage("§c§l[回声碎片] §f申领被SN系统拒绝");
return;
}
plugin.getSnManager().writeSn(
shard, sn, SnManager.TYPE_ECHO);
}
HashMap<Integer, ItemStack> left =
p.getInventory().addItem(shard);
if (!left.isEmpty()) {
+83 -73
View File
@@ -68,7 +68,7 @@ public class LoginManager {
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name);
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
}
p.setAllowFlight(false);
p.setFlying(false);
@@ -188,49 +188,9 @@ public class LoginManager {
plugin.getDb().checkPassword(
name, hash);
if (matchMain) {
// ★ 登录成功,重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name);
}
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setLoggedIn(
name, true);
plugin.getDb().setField(name,
"last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name,
"last_online_check",
System.currentTimeMillis());
// ★ 立即同步在线状态到PHP(异步,避免阻塞主线程)
if (plugin.webManager != null) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.webManager.syncOnlinePlayers();
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 异步syncOnlinePlayers异常: " + e.getMessage());
}
});
}
// 登录成功关闭飞行
p.setAllowFlight(false);
p.setFlying(false);
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
p.sendMessage(plugin.getConfig2()
.msg("login_success"));
activateCY(p, name);
// ★ 手动登录后恢复区域效果
Sdf1_login.AreaProtection areaProt =
plugin.getAreaProtection();
if (areaProt != null) {
areaProt.onPlayerJoin(p);
}
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, false)) return true;
finishPasswordLogin(p, false);
return true;
}
@@ -239,35 +199,9 @@ public class LoginManager {
.checkPasswordWithFallback(
name, hash);
if (pwdResult != null) {
// ★ 临时密码登录成功,也重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name);
}
plugin.getDb().setLoggedIn(name, true);
p.setAllowFlight(false);
p.setFlying(false);
plugin.getDb().setField(name, "last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name, "last_online_check",
System.currentTimeMillis());
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
p.sendMessage(plugin.getConfig2().msg("login_success"));
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage("§7用法: /sdf1_login pw");
activateCY(p, name);
// ★ 临时密码登录后恢复区域效果
Sdf1_login.AreaProtection areaProt =
plugin.getAreaProtection();
if (areaProt != null) {
areaProt.onPlayerJoin(p);
}
// ★ IP风控双验证第一步:只认密码,不解冻、不恢复背包
if (markDualStep1(p, true)) return true;
finishPasswordLogin(p, true);
return true;
}
@@ -297,6 +231,82 @@ public class LoginManager {
}
/**
* IP风控「密码 + 2FA」顺序两关的第一步。
* 密码正确只记录进度:<b>不加入 loggedIn、不恢复背包</b>,必须等第二步 /2fa code 通过才收口。
*
* @return true = 该玩家处于双验证流程,本轮 /login 到此为止
*/
private boolean markDualStep1(Player p, boolean tempPassword) {
TwoFactorManager twofa = plugin.getTwofa();
String name = p.getName();
if (twofa == null || !twofa.isDualVerify(p)) return false;
twofa.onDualPasswordPassed(p, tempPassword);
// 密码这一步确实对了 → 重置「密码错误次数」风控(不代表已登录,仍不解冻)
plugin.getRiskControl().onLoginSuccess(name);
p.sendMessage("§a§l[双重验证] §a第一步密码验证通过(尚未解冻)");
p.sendMessage("§7第二步: 执行 §f/2fa code <动态码|恢复代码>");
p.sendMessage("§c两步全部通过后才会解冻并恢复背包");
return true;
}
/**
* 密码登录成功的统一收口:解冻 + 恢复背包 + 记录登录信息。
* 调用点:/login 主密码、/login 临时密码、IP风控双验证第二步通过后。
*/
public void finishPasswordLogin(Player p, boolean tempPassword) {
String name = p.getName();
// ★ 登录成功,重置风控
plugin.getRiskControl().onLoginSuccess(name);
plugin.getLoggedIn().add(name);
// ★ 记录Java手动登录:5分钟内重连可直接放行(检查点1)
if (plugin.webManager != null) {
plugin.webManager.recordJavaLogin(name, plugin.getPlayerIP(p));
}
plugin.getDb().setLoggedIn(name, true);
if (!tempPassword) {
// 主密码路径保留原有的重复写入(行为与历史版本一致)
plugin.getDb().setLoggedIn(name, true);
}
plugin.getDb().setField(name, "last_login_time",
System.currentTimeMillis());
plugin.getDb().setField(name, "last_online_check",
System.currentTimeMillis());
// ★ 立即同步在线状态到PHP(异步,避免阻塞主线程)
if (!tempPassword && plugin.webManager != null) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
plugin.webManager.syncOnlinePlayers();
} catch (Exception e) {
plugin.getLogger().warning("[Web通信] 异步syncOnlinePlayers异常: " + e.getMessage());
}
});
}
// 登录成功关闭飞行
p.setAllowFlight(false);
p.setFlying(false);
// 只在有有效备份时恢复
plugin.restoreInventory(p);
plugin.recordIPLogin(p);
plugin.giveMenuSnowball(p);
if (tempPassword) {
// ★ 确保这行存在
plugin.getNeedsPasswordChange().add(name);
}
p.sendMessage(plugin.getConfig2().msg("login_success"));
if (tempPassword) {
p.sendMessage("§c§l[警告] §f您使用的是临时密码,请尽快修改密码!");
p.sendMessage("§7用法: /sdf1_login pw");
}
activateCY(p, name);
// ★ 登录后恢复区域效果
Sdf1_login.AreaProtection areaProt =
plugin.getAreaProtection();
if (areaProt != null) {
areaProt.onPlayerJoin(p);
}
}
public void handleReset(Player p) {
String name = p.getName();
String emailAddr = (String)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,962 @@
package Sdf1_login;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.text.format.TextColor;
import net.kyori.adventure.text.format.TextDecoration;
import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.Charset;
import java.nio.charset.CharsetDecoder;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.logging.Logger;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 维护模式(白名单)管理器
*
* 配置文件:plugins/Sdf1_login/白名单.txt,分两个段落:
* [控制段] 启用 / 开始时间 / 结束时间 / 时长 / 消息 等键值对
* [白名单段] 每行一个玩家名(也可用逗号分隔多个)
*
* 特性:
* - 24 小时硬封顶:无论怎么配,单次维护窗口最长 24 小时,到点自动结束
* - 多格式时间:10位秒级时间戳 / 13位毫秒 / yyyy-MM-dd HH:mm / 2026年9月28日14时30分 / 只写 HH:mm(今天)
* - 未写时长与结束时间时默认 24 小时
* - 消息支持 & 与 § 颜色码、整段 JSON 文本组件、\n 与 &lt;br&gt; 换行
* - 变量:username / 用户名、starttime / 开始时间、endtime / 结束时间
* - 玩家登录(异步预登录)、加入、退出时热重载配置,改完文件即生效,无需重启
* - 拦截发生在异步预登录阶段,不给未授权客户端发送任何世界/区块数据包
* - 独立于 MC 自带白名单,互不影响
*/
public class MaintenanceManager implements Listener {
/** 24小时硬封顶 */
private static final long MAX_WINDOW_MS = 24L * 60L * 60L * 1000L;
/** 未配置时长/结束时间时的默认窗口 */
private static final long DEFAULT_WINDOW_MS = MAX_WINDOW_MS;
private static final String DEFAULT_MESSAGE =
"&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]*)");
private static final Pattern AMP_COLOR_PAT = Pattern.compile("&([0-9a-fk-orA-FK-OR])");
private final Main plugin;
private final File file;
/** 解析互斥:异步登录线程与主线程(join/quit)都可能触发热重载 */
private final Object lock = new Object();
private volatile long lastMtime = -1L;
private volatile boolean enabled = false;
private volatile long windowStart = 0L;
private volatile long windowEnd = 0L;
private volatile boolean opBypass = true;
private volatile String rawMessage = DEFAULT_MESSAGE;
private volatile Set<String> whitelistLower = Collections.emptySet();
/** 未显式写「开始时间」时记住本轮起点,避免热重载把窗口反复清零 */
private long implicitStart = 0L;
/** 到期日志只打一次 */
private volatile boolean expiryLogged = false;
/** OP 查询缓存(异步线程里只查一次) */
private final ConcurrentHashMap<String, Boolean> opCache = new ConcurrentHashMap<>();
public MaintenanceManager(Main plugin) {
this.plugin = plugin;
this.file = new File(plugin.getDataFolder(), "白名单.txt");
try {
Files.createDirectories(plugin.getDataFolder().toPath());
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 创建数据目录失败: " + e.getMessage());
}
if (!file.exists()) {
writeDefaultFile();
}
reload(true);
}
// ==================== 对外接口 ====================
/** 维护窗口是否正在生效 */
public boolean isActive() {
if (!enabled) return false;
long now = System.currentTimeMillis();
return now >= windowStart && now < windowEnd;
}
/** 玩家是否在白名单内(大小写不敏感) */
public boolean isWhitelisted(String name) {
if (name == null || name.isEmpty()) return false;
return whitelistLower.contains(name.toLowerCase(Locale.ROOT));
}
public long getWindowStart() {
return windowStart;
}
public long getWindowEnd() {
return windowEnd;
}
public Set<String> getWhitelist() {
return whitelistLower;
}
// ==================== 配置文件 ====================
private void writeDefaultFile() {
String tpl = String.join("\n",
"# =========================================================",
"# 维护模式(白名单)配置文件",
"# 修改保存后无需重启:玩家尝试登录 / 加入 / 退出时自动热加载",
"#",
"# [控制段] 用「键 = 值」写服务器开关、时间与提示语",
"# [白名单段] 严格一行一个玩家名(一行只写一个,不支持逗号/空格分隔)",
"# (标题行漏写也能兜底收录,但请尽量保留 [白名单] 标题)",
"#",
"# 可用键:",
"# 启用 = true / false",
"# 开始时间 = 时间戳 / 2026-09-28 20:00 / 2026年9月28日20时00分(留空=立即开始)",
"# 结束时间 = 同上(留空则按时长或默认24小时计算)",
"# 时长 = 24h / 12小时 / 90分钟(单次维护最长 24 小时,硬封顶)",
"# OP可绕过 = true / false(OP 是否可以绕过维护直接进服)",
"# 消息 = 支持 &a§a 颜色码、整段 JSON(对象 {...} 或数组 [...])、\\n 与 <br> 换行",
"# 可用变量 {username} {starttime} {endtime}(带花括号,JSON 内同样生效)",
"# =========================================================",
"",
"# ===== 控制段 =====",
"启用 = false",
"开始时间 =",
"结束时间 =",
"时长 = 24h",
"OP可绕过 = true",
"消息 = " + DEFAULT_MESSAGE,
"",
"# ===== 白名单段 =====",
"[白名单]",
"");
try {
Files.write(file.toPath(), tpl.getBytes(StandardCharsets.UTF_8));
plugin.getLogger().info("[维护模式] 已生成默认配置: " + file.getAbsolutePath());
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 写入默认配置失败: " + e.getMessage());
}
}
/** 玩家登录/加入/退出时调用:文件变了就重新解析 */
public void reloadIfChanged() {
if (!file.exists()) return;
long m;
try {
m = file.lastModified();
} catch (Exception e) {
return;
}
if (m == lastMtime) return;
reload(true);
}
private void reload(boolean force) {
synchronized (lock) {
long m;
try {
m = file.lastModified();
} catch (Exception e) {
return;
}
if (!force && m == lastMtime) return;
String text;
try {
text = decode(Files.readAllBytes(file.toPath()));
} catch (IOException e) {
plugin.getLogger().warning("[维护模式] 读取配置失败: " + e.getMessage());
return;
}
// 行扫描抽成 static parseConfig(含段标题丢失兜底 / 条目清洗),便于单测
ParsedConfig pc = parseConfig(text, plugin.getLogger());
Map<String, String> ctl = pc.ctl;
Set<String> wl = pc.wl;
boolean wasActive = isActive();
boolean wasEnabled = this.enabled;
String enRaw = pick(ctl,
"启用", "开启", "开关", "维护", "enable", "enabled", "maintenance", "on");
boolean en = toBool(enRaw, false);
Long st = parseTime(pick(ctl, "开始时间", "开始", "起始时间", "start", "starttime", "begintime"));
Long enT = parseTime(pick(ctl, "结束时间", "结束", "截止时间", "end", "endtime"));
Long dur = parseDuration(pick(ctl, "时长", "持续时间", "持续", "duration", "hours", "小时"));
String opRaw = pick(ctl, "OP可绕过", "op可绕过", "OP绕过", "管理员绕过",
"opbypass", "op_bypass", "op", "bypass");
boolean op = toBool(opRaw, true);
if (enRaw != null && !isBoolWord(enRaw)) {
plugin.getLogger().warning("[维护模式] 「启用」的值不是可识别的布尔词: ["
+ enRaw + "],按 false 处理(请写 true / false)");
}
if (opRaw != null && !isBoolWord(opRaw)) {
plugin.getLogger().warning("[维护模式] 「OP可绕过」的值不是可识别的布尔词: ["
+ opRaw + "],按 false 处理(请写 true / false)");
}
String msg = pick(ctl, "消息", "提示", "维护消息", "踢出消息", "message", "msg", "kickmessage");
if (msg == null || msg.isEmpty()) msg = DEFAULT_MESSAGE;
long now = System.currentTimeMillis();
if (st == null) {
// 没写开始时间:沿用上一轮未过期的起点,否则从现在开始
if (implicitStart > 0L && implicitStart + MAX_WINDOW_MS > now) {
st = implicitStart;
} else {
st = now;
}
}
implicitStart = st;
long end;
if (enT != null) {
end = enT;
} else if (dur != null) {
end = st + dur;
} else {
end = st + DEFAULT_WINDOW_MS;
}
// ★ 24小时硬封顶
if (end > st + MAX_WINDOW_MS) end = st + MAX_WINDOW_MS;
if (end < st) end = st;
this.enabled = en;
this.windowStart = st;
this.windowEnd = end;
this.opBypass = op;
this.rawMessage = msg;
Set<String> lowered = new LinkedHashSet<>();
for (String n : wl) lowered.add(n.toLowerCase(Locale.ROOT));
this.whitelistLower = Collections.unmodifiableSet(lowered);
this.lastMtime = m;
if (wasEnabled && !en) {
this.implicitStart = 0L; // 手动关掉后,下次开启重新计时
}
this.expiryLogged = false;
this.opCache.clear();
boolean nowActive = isActive();
if (nowActive && !wasActive) {
plugin.getLogger().info("[维护模式] ★ 维护已开启,白名单 " + whitelistLower.size()
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
} else if (nowActive) {
plugin.getLogger().info("[维护模式] 配置已热重载,白名单 " + whitelistLower.size()
+ " 人,窗口 " + fmt(windowStart) + " ~ " + fmt(windowEnd));
} else if (wasActive) {
plugin.getLogger().info("[维护模式] 维护已结束");
}
// ★ 直接把读到的条目与控制段键打出来,线上「配了却进不去」时一眼能对账
if (nowActive && !whitelistLower.isEmpty()) {
plugin.getLogger().info("[维护模式] 白名单条目: [" + summarize(whitelistLower) + "]");
}
if (nowActive && whitelistLower.isEmpty()) {
plugin.getLogger().warning("[维护模式] ⚠ 维护已开启但白名单为 0 人!段标题[白名单]识别="
+ pc.sawWhiteHeader
+ (opBypass ? ",OP 仍可绕过" : ",且 OP 不可绕过:所有玩家都会被拦在外面")
+ ";控制段读到的键: " + ctl.keySet() + ",请检查 [白名单] 段标题与条目写法");
}
}
}
// ==================== 登录拦截 ====================
@EventHandler(priority = EventPriority.LOWEST)
public void onPreLogin(AsyncPlayerPreLoginEvent e) {
// 登录阶段热重载:改完文件直接重连即可生效
try {
reloadIfChanged();
} catch (Throwable t) {
// 配置读取异常不能影响正常登录
}
if (!isActive()) {
// 到期后只提示一次
if (enabled && !expiryLogged && System.currentTimeMillis() >= windowEnd) {
expiryLogged = true;
plugin.getLogger().info("[维护模式] 维护窗口已到期(" + fmt(windowEnd) + "),自动结束");
}
return;
}
String name = e.getName();
if (isWhitelisted(name)) return;
if (opBypass && isOp(name)) {
plugin.getLogger().info("[维护模式] 放行OP: " + name);
return;
}
// ★ 无感化拦截:在异步预登录阶段直接断开,客户端只看到自定义提示,
// 服务端不会为他分配世界/区块,也不产生多余的网络往返
e.disallow(AsyncPlayerPreLoginEvent.Result.KICK_BANNED, buildMessage(name));
plugin.getLogger().info("[维护模式] 已拦截未在白名单的玩家: " + name
+ "(当前白名单 " + whitelistLower.size() + " 人)");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent e) {
try {
reloadIfChanged();
} catch (Throwable ignored) {
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent e) {
try {
reloadIfChanged();
} catch (Throwable ignored) {
}
}
// ==================== 提示消息 ====================
private Component buildMessage(String playerName) {
String msg = this.rawMessage;
long s = this.windowStart;
long e = this.windowEnd;
msg = replaceVar(msg, new String[]{"username", "用户名", "用户", "player", "玩家名",
"玩家", "name", "昵称"}, playerName);
msg = replaceVar(msg, new String[]{"starttime", "start_time", "开始时间",
"起始时间", "start"}, fmt(s));
msg = replaceVar(msg, new String[]{"endtime", "end_time", "结束时间",
"截止时间", "end"}, fmt(e));
return format(msg);
}
// ==================== 占位符定界符 ====================
/**
* 占位符可用的定界符对:中英文小括号、中括号、花括号,外加 %…% 与 &lt;…&gt;。
* 例:{username} [username] (username) 【用户】 (用户) {username} [username]
* 「玩家名」 『开始时间』 %endtime% &lt;结束时间&gt;
* JSON 模式会跳过花括号 {} 这一对——JSON 对象结构本身就是花括号,避免不必要的冲突。
*/
private static final String[][] PLACEHOLDER_DELIMS = {
{"{", "}"},
{"[", "]"},
{"(", ")"},
{"【", "】"},
{"(", ")"},
{"{", "}"},
{"[", "]"},
{"「", "」"},
{"『", "』"},
{"%", "%"},
{"<", ">"}
};
/** 是否 Legacy 色码字符(0-9a-fk-or,大小写不敏感,含 hex 的 x) */
private static boolean isLegacyCodeChar(char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|| "kKlLoOmMnNrRxX".indexOf(c) >= 0;
}
/**
* 防御性剥离:去掉首尾空白与 Legacy 色码(&amp;c / §c / &amp;x&amp;R&amp;R&amp;R&amp;R&amp;R&amp;R)。
* headDeco(可为 null)收集头部被剥离的色码原串,JSON 解析成功后回贴到组件上;
* 尾部色码只剥离不收集——它后面已无文本,应用它没有意义。
* 这层剥离专门解决「&amp;c&amp;l 包着 JSON → 判不出 JSON → 原样吐源码给玩家」的冲突。
*/
private static String stripDecoration(String s, StringBuilder headDeco) {
if (s == null) return "";
int b = 0, e = s.length();
while (b < e) {
char c = s.charAt(b);
if (Character.isWhitespace(c)) {
b++;
continue;
}
if ((c == '&' || c == '§') && b + 1 < e && isLegacyCodeChar(s.charAt(b + 1))) {
if (headDeco != null) headDeco.append(c).append(s.charAt(b + 1));
b += 2;
continue;
}
break;
}
while (e > b) {
char last = s.charAt(e - 1);
if (Character.isWhitespace(last)) {
e--;
continue;
}
if (e - 2 >= b && (s.charAt(e - 2) == '&' || s.charAt(e - 2) == '§')
&& isLegacyCodeChar(last)) {
e -= 2;
continue;
}
break;
}
return s.substring(b, e);
}
/** Legacy 色码字符 → Adventure 命名色(0-9a-f),未知字符返回 null */
private static NamedTextColor legacyColor(char c) {
switch (c) {
case '0': return NamedTextColor.BLACK;
case '1': return NamedTextColor.DARK_BLUE;
case '2': return NamedTextColor.DARK_GREEN;
case '3': return NamedTextColor.DARK_AQUA;
case '4': return NamedTextColor.DARK_RED;
case '5': return NamedTextColor.DARK_PURPLE;
case '6': return NamedTextColor.GOLD;
case '7': return NamedTextColor.GRAY;
case '8': return NamedTextColor.DARK_GRAY;
case '9': return NamedTextColor.BLUE;
case 'a': return NamedTextColor.GREEN;
case 'b': return NamedTextColor.AQUA;
case 'c': return NamedTextColor.RED;
case 'd': return NamedTextColor.LIGHT_PURPLE;
case 'e': return NamedTextColor.YELLOW;
case 'f': return NamedTextColor.WHITE;
default: return null;
}
}
/**
* 把剥离出来的前置色码(&amp;c&amp;l / §c§l / &amp;x&amp;R…)贴回解析好的组件:
* 包一层空组件承载,色码只是「默认样式」,JSON 片段里显式写了颜色/样式的仍然优先。
*/
private static Component applyDecoration(Component c, String deco) {
if (c == null) return Component.empty();
if (deco == null || deco.isEmpty()) return c;
Component out = Component.empty();
for (int i = 0; i + 1 < deco.length(); i++) {
char sep = deco.charAt(i);
if (sep != '&' && sep != '§') continue;
char code = Character.toLowerCase(deco.charAt(i + 1));
if (code == 'x') {
// 六位十六进制:&x&R&R&R&R&R&R(成对色码,只吃 hex 数字)
StringBuilder hex = new StringBuilder();
int j = i + 2;
while (j + 1 < deco.length()
&& (deco.charAt(j) == '&' || deco.charAt(j) == '§')
&& isLegacyCodeChar(deco.charAt(j + 1))) {
char h = Character.toLowerCase(deco.charAt(j + 1));
if (h < '0' || (h > '9' && h < 'a') || h > 'f') break;
hex.append(h);
j += 2;
}
if (hex.length() == 6) {
TextColor col = TextColor.fromHexString("#" + hex);
if (col != null) out = out.color(col);
i = j - 2;
}
continue;
}
if ((code >= '0' && code <= '9') || (code >= 'a' && code <= 'f')) {
NamedTextColor col = legacyColor(code);
if (col != null) out = out.color(col);
} else if (code == 'l') {
out = out.decorate(TextDecoration.BOLD);
} else if (code == 'o') {
out = out.decorate(TextDecoration.ITALIC);
} else if (code == 'n') {
out = out.decorate(TextDecoration.UNDERLINED);
} else if (code == 'm') {
out = out.decorate(TextDecoration.STRIKETHROUGH);
} else if (code == 'k') {
out = out.decorate(TextDecoration.OBFUSCATED);
} else if (code == 'r') {
out = out.color(null)
.decoration(TextDecoration.BOLD, TextDecoration.State.FALSE)
.decoration(TextDecoration.ITALIC, TextDecoration.State.FALSE)
.decoration(TextDecoration.UNDERLINED, TextDecoration.State.FALSE)
.decoration(TextDecoration.STRIKETHROUGH, TextDecoration.State.FALSE)
.decoration(TextDecoration.OBFUSCATED, TextDecoration.State.FALSE);
}
}
return out.append(c);
}
/** 解析 JSON 文本组件;失败返回 null(不抛异常) */
private static Component tryParseJson(String json) {
try {
return GsonComponentSerializer.gson().deserialize(json);
} catch (Throwable t) {
return null;
}
}
/**
* 「像 JSON 但没闭合」的防御判定:以 { 或 [ 开头,且含引号后跟冒号的键值特征。
* 普通文本([公告] 他说你好)不含该特征,挡在外面,不会被误修。
*/
private static boolean looksRepairableJson(String s) {
if (s == null || s.length() < 2) return false;
char f = s.charAt(0);
return (f == '{' || f == '[') && s.indexOf("\":") >= 0;
}
/**
* 尽力修补残缺 JSON:引号数为奇数则补一个闭合引号,再按括号栈补齐右括号。
* 只在原解析失败后调用;修不好就返回原串(调用方退回纯文本渲染)。
*/
private static String repairJson(String s) {
String out = s;
int quotes = 0;
for (int i = 0; i < out.length(); i++) {
if (out.charAt(i) == '"') quotes++;
}
if (quotes % 2 == 1) out = out + '"';
StringBuilder stack = new StringBuilder();
boolean inStr = false;
for (int i = 0; i < out.length(); i++) {
char c = out.charAt(i);
if (c == '"' && (i == 0 || out.charAt(i - 1) != '\\')) inStr = !inStr;
if (inStr) continue;
if (c == '{') stack.append('}');
else if (c == '[') stack.append(']');
else if (c == '}' || c == ']') {
if (stack.length() > 0 && stack.charAt(stack.length() - 1) == c) {
stack.deleteCharAt(stack.length() - 1);
}
}
}
for (int i = stack.length() - 1; i >= 0; i--) out = out + stack.charAt(i);
return out;
}
/**
* 占位符替换,定界符见 PLACEHOLDER_DELIMS(中英文小/中/大括号、%…%、&lt;…&gt;)。
* JSON 消息(含 &amp;c&amp;l 包着 JSON 的情形):跳过花括号 {}、不做裸子串替换——
* 花括号是 JSON 对象结构本身,裸替换会吃掉正文里的 end/start/player 等英文词
* ("end soon" -> "2026-09-28 10:00 soon")。JSON 消息里请用 [username]、(用户)、%username% 等形式。
* 普通 &amp;c 文本:全部定界符 + 裸替换都生效({username}、username 均可)。
* 「标签+定界符」混合写法(开始时间[starttime]):只替换定界符占位符、保留标签文字,
* 避免标签被裸替换二次吃掉、渲染成双份时间。
*/
private static String replaceVar(String msg, String[] keys, String value) {
if (msg == null || msg.isEmpty()) return msg;
String v = value == null ? "" : value;
boolean jsonMode = looksLikeJson(msg);
// 「标签+定界符」混合写法防御:如「开始时间[starttime],结束时间:{endtime}」。
// 只要本组任一 key 已以定界符形式出现,就说明用户写的是「标签文字 + 占位符」,
// 此时整组跳过裸子串替换、保留 开始时间/结束时间 这类标签原文;
// 否则标签会被二次替换成时间,渲染成「时间 时间」双份连排。
boolean delimHit = false;
for (String k : keys) {
for (String[] d : PLACEHOLDER_DELIMS) {
if (jsonMode && d[0].equals("{")) continue;
if (msg.contains(d[0] + k + d[1])) {
delimHit = true;
break;
}
}
if (delimHit) break;
}
for (String k : keys) {
for (String[] d : PLACEHOLDER_DELIMS) {
if (jsonMode && d[0].equals("{")) continue; // JSON 跳过花括号,避免与 JSON 结构冲突
msg = msg.replace(d[0] + k + d[1], v);
}
if (!jsonMode && !delimHit) msg = msg.replace(k, v);
}
return msg;
}
/**
* 整段是否为 JSON 文本组件:形如 {...} 或 [...],且含双引号。
* 先剥离首尾色码与空白再判定——&amp;c&amp;l 包着 JSON 也认得出来(防御层)。
* 「含双引号」用于把 [玩家名]、[公告] 这类普通文本挡在 JSON 分支之外——
* Gson 默认宽松解析会把 [玩家名] 当字符串数组吃掉,导致正文被吞。
*/
private static boolean looksLikeJson(String s) {
if (s == null) return false;
String t = stripDecoration(s, null);
int n = t.length();
if (n < 2 || t.indexOf('"') < 0) return false;
char f = t.charAt(0);
char l = t.charAt(n - 1);
return (f == '{' && l == '}') || (f == '[' && l == ']');
}
/** JSON 内的 &lt;br&gt; 变体(<br> <br/> <br /> </br>,大小写不敏感) */
private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)</?br\\s*/?>");
/**
* 把 JSON 文本里的字面 &lt;br&gt; 换成 JSON 转义换行(反斜杠 + n 两字符)。
* 必须是两字符序列:真实换行在 JSON 字符串里非法,写进去整段就解析失败了。
*/
private static String replaceBrInJson(String json) {
return BR_IN_JSON_PAT.matcher(json).replaceAll("\\\\n");
}
/**
* 消息格式化:
* 0) 防御层:剥掉首尾色码/空白再判定 JSON;残缺 JSON 尽力修补——
* 绝不把 JSON 源码原样吐给玩家(剥掉的色码如 &amp;c&amp;l 作为默认样式贴回结果)
* 1) 整段是 JSON(对象 {...} 或顶层数组 [...],后者是 tellraw 常见写法)
* → 按文本组件解析(支持 text/color/extra 等);字面 &lt;br&gt; 会先换成 JSON 转义换行
* 2) 否则处理 &lt;br&gt; 与字面 \n 换行、&amp; 颜色码与 § 颜色码
*/
private static Component format(String msg) {
if (msg == null) msg = "";
// 防御层:先剥掉包在外面的 Legacy 色码(&c&l / §c§l 等)再判定,
// 否则「&c&l 包着 JSON」首字符是 & → 判不出 JSON → JSON 源码原样吐给玩家。
StringBuilder headDeco = new StringBuilder();
String core = stripDecoration(msg.trim(), headDeco);
if (looksLikeJson(core) || looksRepairableJson(core)) {
// ★ JSON 字符串里的字面 <br>/<br/> 同样要变成换行,否则标签会原样显示给玩家。
// Gson 只认 JSON 转义(反斜杠 + n),所以替换成两字符序列,而不是真实换行
// (真实换行在 JSON 字符串里是非法字符,会让整段解析失败退回纯文本)。
Component parsed = tryParseJson(replaceBrInJson(core));
if (parsed == null) {
// 残缺 JSON(缺引号/右括号)→ 尽力补齐再试一次,同样是为了不原样吐源码
String fixed = repairJson(core);
if (!fixed.equals(core)) parsed = tryParseJson(replaceBrInJson(fixed));
}
if (parsed != null) return applyDecoration(parsed, headDeco.toString());
}
String s = msg;
s = s.replace("<br/>", "\n").replace("<br />", "\n").replace("<br>", "\n");
s = s.replace("</br>", "\n").replace("<BR>", "\n").replace("<Br>", "\n");
s = s.replace("\\n", "\n");
s = AMP_COLOR_PAT.matcher(s).replaceAll("§$1");
String[] lines = s.split("\n", -1);
Component out = Component.empty();
for (int i = 0; i < lines.length; i++) {
if (i > 0) out = out.append(Component.newline());
out = out.append(LegacyComponentSerializer.legacySection().deserialize(lines[i]));
}
return out;
}
private static String fmt(long ms) {
try {
return new SimpleDateFormat("yyyy-MM-dd HH:mm").format(new Date(ms));
} catch (Exception e) {
return String.valueOf(ms);
}
}
// ==================== 工具 ====================
private boolean isOp(String name) {
Boolean cached = opCache.get(name);
if (cached != null) return cached;
boolean r = false;
try {
OfflinePlayer p = Bukkit.getOfflinePlayer(name);
r = p.isOp();
} catch (Throwable t) {
r = false;
}
opCache.put(name, r);
return r;
}
/** 段落头:[白名单] / 【白名单】 / 白名单 / ===== 白名单段 ===== / 玩家白名单 / [WHITELIST](含 = 的行一律不是段落头) */
private static boolean isWhitelistHeader(String line) {
String s = line.trim();
if (s.isEmpty()) return false;
// ★ 注释形式的段标题也算:用户常把「[白名单]」行删掉,只留下「# ===== 白名单段 =====」这条注释。
// 先剥掉 # /// 注释前缀再判;键值对类注释(含 =)仍被下面的硬门槛挡住,不会误判成段落头。
while (s.startsWith("#") || s.startsWith("//")) {
s = s.substring(s.startsWith("//") ? 2 : 1).trim();
}
if (s.isEmpty()) return false;
// ★ 硬门槛:先剥掉首尾装饰符号(=====、----、::: 等),再看中间是否还剩「=」。
// 剩下的 = 说明这是控制段的键值对(如「挂机白名单 = xxx」),绝不是段落头;
// 只有装饰等号的 =====白名单段===== 剥完就干净了,不会被误杀。
// 没有这道门槛时,控制键一旦被误判成段落头,后续所有行都滑进白名单段,
// 「启用 = true」就读不到 → 维护开关直接失效。
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 low = stripped.toLowerCase(Locale.ROOT);
// 精确匹配常见写法;再兜底 endsWith 兼容「玩家白名单」「ALLOWLIST」这类变体标题
//(有了上面的 = 门槛,endsWith 已经不会再吞掉控制段键值对)
return low.equals("白名单") || low.equals("白名单段") || low.equals("白名单列表")
|| low.equals("允许名单") || low.equals("whitelist") || low.equals("allowlist")
|| low.endsWith("白名单") || low.endsWith("whitelist") || low.endsWith("allowlist");
}
// ==================== 配置文本解析(static,便于单测) ====================
/** parseConfig 的结果:控制段键值对 + 白名单条目 + 是否见过 [白名单] 段标题 */
static final class ParsedConfig {
final Map<String, String> ctl;
final Set<String> wl;
final boolean sawWhiteHeader;
ParsedConfig(Map<String, String> ctl, Set<String> wl, boolean sawWhiteHeader) {
this.ctl = ctl;
this.wl = wl;
this.sawWhiteHeader = sawWhiteHeader;
}
}
/**
* 解析配置文本。三层兜底,防止管理员把自己关在门外:
* 1) 段标题判断在注释跳过之前——「# ===== 白名单段 =====」这种注释形式的标题也认
* 2) 段外的无键值行(既没有 = 也没有 :)按玩家名收录——标题行被删时靠它救
* 3) 条目先清洗零宽字符 / 行内注释 / 列表前缀再收录,避免肉眼看着对却匹配不上
*/
static ParsedConfig parseConfig(String text, Logger log) {
Map<String, String> ctl = new LinkedHashMap<>();
Set<String> wl = new LinkedHashSet<>();
boolean inWhite = false;
boolean sawHeader = false;
for (String rawLine : text.split("\n")) {
String line = rawLine.trim();
if (line.isEmpty()) continue;
if (isWhitelistHeader(line)) {
inWhite = true;
sawHeader = true;
continue;
}
if (line.startsWith("#") || line.startsWith("//")) continue;
if (inWhite) {
addWhitelistEntry(wl, line, log, null);
} else {
int eq = line.indexOf('=');
if (eq < 0) eq = line.indexOf(':');
if (eq < 0) {
addWhitelistEntry(wl, line, log,
"该行不在 [白名单] 段内也不是键值对,已按玩家名兜底收录");
continue;
}
String k = normKey(line.substring(0, eq));
if (k.isEmpty()) continue;
ctl.put(k, line.substring(eq + 1).trim());
}
}
if (!sawHeader && !wl.isEmpty()) {
warn(log, "未识别到 [白名单] 段标题,已按「无键值=玩家名」兜底收录 "
+ wl.size() + " 个条目");
}
return new ParsedConfig(ctl, wl, sawHeader);
}
/** 清洗并收录一条白名单条目(note 为附加告警说明,可为 null) */
private static void addWhitelistEntry(Set<String> wl, String raw, Logger log, String note) {
String s = stripInvisible(raw);
if (!s.equals(raw)) {
warn(log, "白名单条目含不可见字符(零宽/全角空格/不间断空格),已清洗: ["
+ raw + "] -> [" + s + "]");
}
int c = indexOfInlineComment(s);
if (c >= 0) {
warn(log, "白名单条目的行内注释已忽略: " + s.substring(c).trim());
s = s.substring(0, c).trim();
}
// 列表装饰前缀:- > * · •(合法 MC 名不会以这些字符开头)
String noDecor = s.replaceFirst("^[-*>\\u00b7\\u2022]\\s+", "");
if (!noDecor.equals(s)) {
warn(log, "白名单条目前缀符号已忽略: [" + s + "] -> [" + noDecor + "]");
s = noDecor;
}
s = s.trim();
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) {
warn(log, "白名单条目不符合「一行一个玩家名」,已按整行收录: " + s);
}
wl.add(s);
}
/** 去掉肉眼看不见却会让匹配失败的字符:零宽/BOM/不间断空格/全角空格 */
private static String stripInvisible(String s) {
if (s == null) return null;
return s.replaceAll("[\\u200B-\\u200D\\u2060\\uFEFF\\u00A0\\u3000]", "");
}
/** 行内注释起点(# / # / //),返回 -1 表示没有 */
private static int indexOfInlineComment(String s) {
int h = s.indexOf('#');
if (h < 0) h = s.indexOf('\uFF03');
if (h < 0) h = s.indexOf("//");
return h <= 0 ? -1 : h;
}
private static void warn(Logger log, String msg) {
if (log != null) log.warning("[维护模式] " + msg);
}
/** 布尔词识别(与 toBool 口径一致),用于把 flase / ture 这类拼写错误提示出来 */
private static boolean isBoolWord(String v) {
if (v == null) return false;
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return false;
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用")
|| s.equals("false") || s.equals("no") || s.equals("n") || s.equals("off")
|| s.equals("0") || s.equals("否") || s.equals("关") || s.equals("关闭") || s.equals("停用");
}
/** 白名单条目摘要(最多 20 个),日志里直接对账用 */
private static String summarize(Set<String> names) {
StringBuilder sb = new StringBuilder();
int i = 0;
for (String n : names) {
if (i == 20) {
sb.append(", …共").append(names.size()).append("人");
return sb.toString();
}
if (i++ > 0) sb.append(", ");
sb.append(n);
}
return sb.toString();
}
private static String normKey(String k) {
if (k == null) return "";
return k.trim().toLowerCase(Locale.ROOT).replace(" ", "").replace("_", "").replace(" ", "");
}
private static String pick(Map<String, String> ctl, String... keys) {
for (String k : keys) {
String v = ctl.get(normKey(k));
if (v != null && !v.trim().isEmpty()) return v.trim();
}
return null;
}
private static boolean toBool(String v, boolean def) {
if (v == null) return def;
String s = v.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return def;
return s.equals("true") || s.equals("yes") || s.equals("y") || s.equals("on")
|| s.equals("1") || s.equals("是") || s.equals("开") || s.equals("开启") || s.equals("启用");
}
/**
* 时间解析,支持:
* - 10位秒级时间戳 / 13位毫秒时间戳
* - 2026-09-28 20:00 / 2026/9/28 20:00:33 / 2026.09.28
* - 2026年9月28日20时30分 / 2026年9月28日 20:30
* - 只写 20:00(今天该时刻)
* 解析失败返回 null
*/
static Long parseTime(String raw) {
if (raw == null) return null;
String s = raw.trim().replace("\"", "");
if (s.isEmpty()) return null;
if (s.matches("\\d{10}")) return Long.parseLong(s) * 1000L;
if (s.matches("\\d{13}")) return Long.parseLong(s);
List<Long> n = new ArrayList<>();
Matcher m = NUM_PAT.matcher(s);
while (m.find()) {
try {
n.add(Long.parseLong(m.group()));
} catch (Exception ignore) {
}
}
if (n.isEmpty()) return null;
String first = String.valueOf(n.get(0));
if (first.length() == 10) return n.get(0) * 1000L;
if (first.length() == 13) return n.get(0);
if (n.size() == 2 && n.get(0) <= 23 && n.get(1) <= 59) {
LocalDateTime now = LocalDateTime.now();
return now.withHour(n.get(0).intValue()).withMinute(n.get(1).intValue())
.withSecond(0).withNano(0)
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
}
if (n.size() < 3) return null;
int y = n.get(0).intValue();
int mo = n.get(1).intValue();
int d = n.get(2).intValue();
int h = n.size() > 3 ? n.get(3).intValue() : 0;
int mi = n.size() > 4 ? n.get(4).intValue() : 0;
int se = n.size() > 5 ? n.get(5).intValue() : 0;
if (y < 1970 || y > 2100 || mo < 1 || mo > 12 || d < 1 || d > 31
|| h > 23 || mi > 59 || se > 59) {
return null;
}
try {
return LocalDateTime.of(y, mo, d, h, mi, se)
.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli();
} catch (Exception e) {
return null;
}
}
/** 时长解析:24 / 24h / 12小时 / 90分钟 / 2天,默认单位小时 */
static Long parseDuration(String raw) {
if (raw == null) return null;
String s = raw.trim().toLowerCase(Locale.ROOT);
if (s.isEmpty()) return null;
Matcher m = DUR_PAT.matcher(s);
if (!m.find()) return null;
double v;
try {
v = Double.parseDouble(m.group(1));
} catch (Exception e) {
return null;
}
String unit = m.group(2) == null ? "" : m.group(2);
long ms;
if (unit.contains("毫秒")) ms = 1L;
else if (unit.startsWith("s") || unit.contains("秒")) ms = 1000L;
else if (unit.startsWith("m") || unit.contains("分")) ms = 60_000L;
else if (unit.startsWith("d") || unit.contains("天")) ms = 86_400_000L;
else ms = 3_600_000L;
long r = (long) (v * ms);
return r <= 0 ? null : r;
}
/** 优先按 UTF-8 严格解码,失败则按 GBK(Windows 记事本默认编码) */
private static String decode(byte[] bytes) {
if (bytes == null || bytes.length == 0) return "";
try {
CharsetDecoder dec = StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT);
dec.decode(ByteBuffer.wrap(bytes));
return new String(bytes, StandardCharsets.UTF_8);
} catch (CharacterCodingException e) {
try {
return new String(bytes, Charset.forName("GBK"));
} catch (Exception e2) {
return new String(bytes, StandardCharsets.UTF_8);
}
}
}
}
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1340,10 +1340,22 @@ public class OrderManager implements Listener {
} if (cur != null && cur.uuid != null) orders.add(cur); br.close();
} catch (Exception e) { plugin.getLogger().warning("[Order]加载失败: " + e.getMessage()); }
}
/** 写盘互斥锁:与 orders 锁分离,避免持 orders 锁做文件 IO(异步清理写盘时主线程 add 会被卡) */
private final Object saveLock = new Object();
private void saveOrders() {
// ★ 先在 orders 锁内取快照(极快),再持 saveLock 写盘
// ① 主线程 recordOrder 的 add 不会被慢速写盘阻塞
// ② 主线程与异步清理任务不会同时写 orders.dat 互相截断
final List<OrderRecord> snap;
synchronized (orders) { snap = new ArrayList<>(orders); }
synchronized (saveLock) { saveOrdersTo(snap); }
}
private void saveOrdersTo(List<OrderRecord> snap) {
File f = new File(plugin.getDataFolder(), "orders.dat");
try { PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(f), StandardCharsets.UTF_8));
synchronized (orders) { for (OrderRecord r : orders) { pw.println("==");
{ for (OrderRecord r : snap) { pw.println("==");
pw.println("id=" + r.orderId); pw.println("uuid=" + r.uuid); pw.println("player=" + r.player);
pw.println("totalOriginal=" + r.totalOriginal); pw.println("totalPaid=" + r.totalPaid);
pw.println("discount=" + r.discount); pw.println("discountType=" + nn(r.discountType));
@@ -1355,7 +1367,9 @@ public class OrderManager implements Listener {
} } pw.flush(); pw.close();
} catch (Exception e) { plugin.getLogger().warning("[Order]保存失败: " + e.getMessage()); }
}
private void startCleanupTask() { new BukkitRunnable() { public void run() { synchronized (orders) { if (orders.removeIf(OrderManager.this::isExpired)) saveOrders(); } } }.runTaskTimer(plugin, 36000L, 36000L); }
// ★ 过期清理 = 遍历 + 全量落盘(文件 IO)→ 异步定时器;
// removeIf 在 orders 锁内完成即释放,写盘走 saveLock,不长期占着 orders 锁
private void startCleanupTask() { new BukkitRunnable() { public void run() { boolean changed; synchronized (orders) { changed = orders.removeIf(OrderManager.this::isExpired); } if (changed) saveOrders(); } }.runTaskTimerAsynchronously(plugin, 36000L, 36000L); }
/**
* 将小票书塞入潜影盒
*/
File diff suppressed because it is too large Load Diff
+44 -4
View File
@@ -108,8 +108,9 @@ public class PVPManager implements Listener {
Class.forName(
"org.sqlite.JDBC");
File dbFile = new File(
plugin.getDataFolder(),
new File(plugin.getDataFolder(), "pvp"),
"pvp.db");
dbFile.getParentFile().mkdirs();
pvpDb = DriverManager.getConnection(
"jdbc:sqlite:"
+ dbFile.getAbsolutePath());
@@ -485,10 +486,32 @@ public class PVPManager implements Listener {
break;
}
}
if (!has) {
p.getInventory()
.addItem(createTool());
if (has) return;
// ★ SN 管控:1人同种类仅限1个
if (plugin.getSnManager() != null
&& plugin.getSnManager().isBlocked(
p, SnManager.TYPE_PVP)) {
long cdMin = plugin.getSnManager().cooldownMinutesLeft(p.getName());
if (cdMin > 0) {
p.sendMessage("§c[PVP] 注销冷静期未结束,还剩约 " + cdMin + " 分钟,期间无法申领新设备");
} else {
p.sendMessage("§c[PVP] 你已有PVP圈地棒的有效登记,"
+ "旧SN未注销前不能重新申领");
}
return;
}
ItemStack tool = createTool();
if (plugin.getSnManager() != null) {
String sn = plugin.getSnManager().applySn(
p, SnManager.TYPE_PVP, "PVP圈地棒");
if (sn == null) {
p.sendMessage("§c[PVP] 申领被SN系统拒绝");
return;
}
plugin.getSnManager().writeSn(
tool, sn, SnManager.TYPE_PVP);
}
p.getInventory().addItem(tool);
}
// ===== 边框粒子 =====
@@ -1123,6 +1146,11 @@ public class PVPManager implements Listener {
p, args);
case "join":
case "arena":
// ★ /pvp join see → 直接进入观赛(默认幽灵形态)
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "enter");
return true;
}
plugin.getPVPArenaManager().joinArena(p);
return true;
case "equip":
@@ -1133,8 +1161,17 @@ public class PVPManager implements Listener {
}
plugin.getPVPArenaManager().openEquipmentSelection(p);
return true;
case "see":
// PVP观赛:/pvp see=在裁判⇄幽灵两种观赛形态间切换(仅PVP世界,绝不切回无保护的普通玩家)
plugin.getPVPArenaManager().handleSeeCommand(p);
return true;
case "leave":
case "exit":
// ★ /pvp leave see → 彻底退出观赛:恢复身份并重新触发选装、补发PVP装备
if (args.length >= 2 && args[1].equalsIgnoreCase("see")) {
plugin.getPVPArenaManager().handleSeeCommand(p, "exit");
return true;
}
plugin.leavePVP(p);
return true;
default:
@@ -1142,6 +1179,9 @@ public class PVPManager implements Listener {
p.sendMessage("§7——— PVP指令 ———");
p.sendMessage("§e/pvp join §7进入PVP竞技场");
p.sendMessage("§e/pvp equip §7调整装备(食物/盾牌/弓弩)");
p.sendMessage("§e/pvp see §7观赛:切换裁判⇄幽灵形态(仅PVP世界)");
p.sendMessage("§e/pvp join see §7加入观赛:进入幽灵形态");
p.sendMessage("§e/pvp leave see §7退出观赛:重新选装并补发PVP装备");
p.sendMessage("§e/pvp leave §7离开PVP竞技场");
p.sendMessage("§e/pvp stats [玩家] §7查看战绩");
p.sendMessage("§e/pvp list §7查看PVP区域列表");
+3 -2
View File
@@ -159,8 +159,9 @@ public class PVPTestManager implements Listener {
* FLAT 世界生成极快,不会卡服。
*/
public void ensurePVPTestWorldExists() {
File folder = new File(Bukkit.getWorldContainer(), PVP_TEST_WORLD_NAME);
boolean fresh = !folder.exists();
// ★ 兼容 1.20.4+ 新存储布局(world/dimensions/minecraft/<名字>),旧的根目录拼接会恒定 exists()==false
File folder = PVPArenaManager.resolveWorldFolder(PVP_TEST_WORLD_NAME);
boolean fresh = folder == null || !folder.exists();
World w = Bukkit.getWorld(PVP_TEST_WORLD_NAME);
if (w == null) {
+4 -1
View File
@@ -12,8 +12,11 @@ public class PlayerStorage {
Logger logger) {
this.logger = logger;
File dbFile = new File(
dataFolder,
new File(dataFolder,
"任务面板"),
"quest_data.db");
dbFile.getParentFile()
.mkdirs();
try {
Class.forName(
"org.sqlite.JDBC");
+3 -1
View File
@@ -23,7 +23,9 @@ public class QuestStorage {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(
dataFolder, "quest_data.db");
new File(dataFolder, "任务面板"),
"quest_data.db");
dbFile.getParentFile().mkdirs();
path = dbFile.getPath();
} catch (Exception e) {
e.printStackTrace();
+9 -1
View File
@@ -125,7 +125,15 @@ public class QuickBack implements Listener, CommandExecutor, TabCompleter {
private void recordLocation(Player player) {
Location loc = player.getLocation();
lastKnownLocations.put(player.getName(), loc);
saveToDB(player.getName(), loc);
// ★ 读坐标/更新内存缓存留主线程;写库是阻塞 DB 操作 -> 异步
final String name = player.getName();
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
saveToDB(name, loc);
} catch (Throwable t) {
plugin.getLogger().warning("[快捷返回] 坐标落库失败 (" + name + "): " + t.getMessage());
}
});
}
public Location getLastKnownLocation(Player player) {
+2 -2
View File
@@ -1635,7 +1635,7 @@ public class ShopManager implements Listener {
// ===== maxSell 配置持久化 =====
/** 加载 maxSell 配置从插件数据文件夹下的 maxsell_config.txt */
private void loadMaxSellConfig() {
File cfgFile = new File(plugin.getDataFolder(), "maxsell_config.txt");
File cfgFile = new File(new File(plugin.getDataFolder(), "shop"), "maxsell_config.txt");
try {
if (cfgFile.exists()) {
BufferedReader br = new BufferedReader(new FileReader(cfgFile));
@@ -1673,7 +1673,7 @@ public class ShopManager implements Listener {
try {
File dir = new File(plugin.getDataFolder(), "shop");
if (!dir.exists()) dir.mkdirs();
File cfgFile = new File(dir.getParentFile(), "maxsell_config.txt");
File cfgFile = new File(dir, "maxsell_config.txt");
BufferedWriter bw = new BufferedWriter(new FileWriter(cfgFile, StandardCharsets.UTF_8));
bw.write("maxSellLimit=" + maxSellLimit);
bw.newLine();
File diff suppressed because it is too large Load Diff
+17 -3
View File
@@ -258,7 +258,8 @@ public class TeleportManager implements Listener {
/**
* 判断是否为基岩版玩家(通过Geyser/Floodgate API)
*/
private boolean isBedrockPlayer(Player player) {
// ★ 公开:验证码(ChatFilterManager)复用此判定,保证基岩版不出GUI题
public boolean isBedrockPlayer(Player player) {
// 检查 Geyser-Spigot 插件
Plugin geyserPlugin = Bukkit.getPluginManager().getPlugin("Geyser-Spigot");
if (geyserPlugin == null || !geyserPlugin.isEnabled()) {
@@ -1144,7 +1145,19 @@ public class TeleportManager implements Listener {
count++;
}
player.sendMessage("§a[传送] 已向 §e" + count + " §a名玩家发送传送请求");
if (isBedrockPlayer(player)) {
player.sendMessage("§a[传送] 已向 §e" + count + " §a名玩家发送传送请求");
player.sendMessage("§7使用 §e/tpacancel §7撤回全部请求");
} else {
player.sendMessage(Component.empty()
.append(Component.text("§a[传送] 已向 §e" + count + " §a名玩家发送传送请求")));
// ★ 撤回按钮:发错了可立即撤销,避免更多人接受
player.sendMessage(Component.empty()
.append(Component.text("§7全服玩家接受前 "))
.append(Component.text("§c[↩ 撤回全部请求]")
.clickEvent(ClickEvent.runCommand("/tpacancel"))
.hoverEvent(HoverEvent.showText(Component.text("立即撤回这次 tpaall 的全部待处理请求")))));
}
return true;
}
@@ -1577,7 +1590,8 @@ public class TeleportManager implements Listener {
case 4: // 全服传送
event.setCancelled(true);
handleTPAll(player);
player.closeInventory();
// ★ 发送后重开面板:slot 5「取消已发出的请求」立即可用作撤回入口
openTeleportPanel(player);
break;
case 5: // 取消已发出请求
event.setCancelled(true);
+91 -65
View File
@@ -704,6 +704,11 @@ public class TicketManager {
"§e§l===================");
}
/**
* ★ 由异步定时器调用(Main.startTicketAutoProcess)。
* DB 查询/写回留在当前(异步)线程;
* 取玩家 / 发消息 / 备份背包 / 踢人 一律经 sync() 回主线程。
*/
public void autoProcessCompleted() {
long timeoutMs = 10 * 60 * 1000L;
@@ -737,25 +742,26 @@ public class TicketManager {
.getUnconfirmedCompleted(
timeoutMs);
for (Map<String, Object> t : list) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 已自动完结");
String assigned =
sv(t, "assigned_to");
if (!assigned.isEmpty()) {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline())
pp.sendMessage(
final String req = sv(t, "requester");
final String asg = sv(t, "assigned_to");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
+ id + " 已自动完结");
if (!asg.isEmpty()) {
Player pp =
Bukkit.getPlayer(asg);
if (pp != null && pp.isOnline())
pp.sendMessage(
"§e§l[工单] §f工单 #"
+ id
+ " 已自动完结");
}
});
settleTicket(id);
}
@@ -765,14 +771,16 @@ public class TicketManager {
.getRepliedStaleTickets(
timeoutMs);
for (Map<String, Object> t : stale) {
int id = nv(t, "id");
final int id = nv(t, "id");
plugin.getDb().confirmTicket(id, 3);
Player rp = Bukkit.getPlayer(
sv(t, "requester"));
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
final String req = sv(t, "requester");
sync(() -> {
Player rp = Bukkit.getPlayer(req);
if (rp != null && rp.isOnline())
rp.sendMessage(
"§e§l[工单] §f工单 #"
+ id + " 超时自动完结");
});
settleTicket(id);
}
}
@@ -1266,13 +1274,23 @@ public class TicketManager {
// ========== 私有 ==========
/**
* 把 Bukkit 操作切回主线程执行。
* 已在主线程时立即执行(保持原调用方的同步语义)。
* 供异步定时器路径(autoProcessCompleted / settleTicket / processDeleteAccount)使用。
*/
private void sync(Runnable r) {
if (Bukkit.isPrimaryThread()) r.run();
else Bukkit.getScheduler().runTask(plugin, r);
}
private void settleTicket(int tid) {
Map<String, Object> t =
plugin.getDb().getTicket(tid);
if (t == null) return;
String requester =
final String requester =
sv(t, "requester");
String assigned =
final String assigned =
sv(t, "assigned_to");
int reward = nv(t, "reward_amount");
int score = nv(t, "score");
@@ -1285,14 +1303,16 @@ public class TicketManager {
requester, reward,
BondManager.TX_TICKET_REWARD, "",
"System", "工单 #" + tid + " 奖励");
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 债券奖励");
}
sync(() -> {
Player rp = Bukkit.getPlayer(
requester);
if (rp != null && rp.isOnline()) {
rp.sendMessage(
"§e§l[工单] §f获得 "
+ reward
+ " 债券奖励");
}
});
}
// ★ 服务商获得债券奖励
@@ -1307,17 +1327,20 @@ public class TicketManager {
+ " 服务奖励(评分"
+ score + ")");
}
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (bondReward > 0)
pp.sendMessage("§7债券: §e"
+ bondReward + " 枚");
}
final int br = bondReward;
sync(() -> {
Player pp =
Bukkit.getPlayer(assigned);
if (pp != null && pp.isOnline()) {
pp.sendMessage(
"§a§l[工单] §f工单 #"
+ tid
+ " 确认完结");
if (br > 0)
pp.sendMessage("§7债券: §e"
+ br + " 枚");
}
});
}
}
@@ -1405,31 +1428,34 @@ public class TicketManager {
*/
public void processDeleteAccount(int tid,
Map<String, Object> t) {
String requester = sv(t, "requester");
final String requester = sv(t, "requester");
plugin.getDb().updateTicketStatus(
tid, "resolved");
plugin.getDb().updateTicketField(tid,
"admin_confirmed", 1);
Player target =
Bukkit.getPlayerExact(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
// 延迟执行删号
// ★ 备份背包 / 发消息 / 踢人 必须主线程
sync(() -> {
Player target =
Bukkit.getPlayerExact(requester);
if (target != null && target.isOnline()) {
plugin.backupInventory(target);
target.sendMessage(
"§a§l[工单] §f您的删号请求已通过");
target.sendMessage(
"§7物品已备份,下次登录可取回");
target.kickPlayer(
"§a删号成功,物品已备份。\n"
+ "请等待5分钟后重新注册取回物品。");
} else {
// 离线:仅标记,不操作数据库
plugin.getLogger().info(
"[Sdf1_login] " + requester
+ " 删号工单已通过,等待上线执行");
}
});
// 延迟执行删号(DB 写 -> 异步,不占主线程)
final String name = requester;
Bukkit.getScheduler().runTaskLater(
Bukkit.getScheduler().runTaskLaterAsynchronously(
plugin, () -> {
int deleted =
plugin.getDb().deleteUser(name);
File diff suppressed because it is too large Load Diff
+67 -4
View File
@@ -35,7 +35,10 @@ public class UserGroupManager {
private void initDB() {
try {
Class.forName("org.sqlite.JDBC");
File dbFile = new File(plugin.getDataFolder(), "usergroup.db");
// ★ 2026-10-08 归位:usergroup.db 创建即放入「用户组/」
File dbDir = new File(plugin.getDataFolder(), "用户组");
dbDir.mkdirs();
File dbFile = new File(dbDir, "usergroup.db");
db = DriverManager.getConnection("jdbc:sqlite:" + dbFile.getAbsolutePath());
Statement st = db.createStatement();
st.execute("PRAGMA journal_mode=WAL");
@@ -52,7 +55,8 @@ public class UserGroupManager {
+ "max_lands INTEGER DEFAULT -1," // -1 = 使用全局值
+ "default_perms TEXT DEFAULT '{}'," // JSON: 默认deny_*标志
+ "is_permanent INTEGER DEFAULT 1," // 保留兼容
+ "duration_minutes INTEGER DEFAULT 0" // 保留兼容
+ "duration_minutes INTEGER DEFAULT 0," // 保留兼容
+ "max_effect_level INTEGER DEFAULT 256" // 药效等级上限,<=0=按原版256
+ ")");
// 玩家↔用户组关联
@@ -76,6 +80,10 @@ public class UserGroupManager {
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN join_price INTEGER DEFAULT 0"); } catch (Exception ignored) {}
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN auto_renew INTEGER DEFAULT 0"); } catch (Exception ignored) {}
try { st.executeUpdate("ALTER TABLE user_group_config ADD COLUMN renew_price INTEGER DEFAULT 0"); } catch (Exception ignored) {}
// ★ 领地增益效果上限(2026-10-04 任务3):默认5个,<=0=不限
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) {}
// duration_minutes已有(旧列),复用
st.close();
@@ -106,6 +114,10 @@ public class UserGroupManager {
cfg.autoRenew = rs.getInt("auto_renew") == 1;
cfg.renewPrice = rs.getInt("renew_price");
cfg.durationMinutes = rs.getLong("duration_minutes");
cfg.maxEffects = rs.getInt("max_effects");
if (rs.wasNull()) cfg.maxEffects = 5;
cfg.maxEffectLevel = rs.getInt("max_effect_level");
if (rs.wasNull()) cfg.maxEffectLevel = UserGroupManager.DEFAULT_MAX_EFFECT_LEVEL;
if (cfg.name != null && !cfg.name.isEmpty()) {
groupConfigs.put(cfg.name, cfg);
}
@@ -126,8 +138,9 @@ public class UserGroupManager {
"INSERT OR REPLACE INTO user_group_config "
+ "(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)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?)");
+ " home_limit, join_price, auto_renew, renew_price, duration_minutes,"
+ " max_effects, max_effect_level)"
+ " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)");
ps.setString(1, cfg.name);
ps.setString(2, cfg.displayName);
ps.setString(3, cfg.displayColor);
@@ -140,6 +153,8 @@ public class UserGroupManager {
ps.setInt(10, cfg.autoRenew ? 1 : 0);
ps.setInt(11, cfg.renewPrice);
ps.setLong(12, cfg.durationMinutes);
ps.setInt(13, cfg.maxEffects);
ps.setInt(14, cfg.maxEffectLevel);
ps.executeUpdate();
ps.close();
} catch (SQLException e) {
@@ -960,6 +975,44 @@ public class UserGroupManager {
return cfg.maxLands >= 0 ? cfg.maxLands : globalDefault;
}
/**
* ★ 领地增益效果数量上限(2026-10-04 任务3)
* 普通玩家(无用户组)走 globalDefault=5;用户组按组配置;
* 组配置 <=0 表示不限。
* @return >0=上限数量;Integer.MAX_VALUE=不限
*/
public int getPlayerMaxEffects(String player, int globalDefault) {
if (player == null || player.isEmpty()) return globalDefault;
UserGroupConfig cfg = getHighestGroup(player);
if (cfg == null) return globalDefault;
if (cfg.maxEffects <= 0) return Integer.MAX_VALUE;
return cfg.maxEffects;
}
/**
* ★ 药效等级上限(2026-10-04 任务4)。
* 普通玩家(无用户组)严格按原版最大药效强度 256 封顶;
* 用户组玩家按组配置 max_effect_level,配置多少就是多少。
* 组配置 <=0 视为「不额外限制」,仍受原版 256 硬上限约束。
*
* @param player 玩家名
* @param globalDefault 无组时的全局默认(原版上限 256)
* @return 药效强度上限,恒 >=1 且 <=256
*/
public int getPlayerMaxEffectLevel(String player, int globalDefault) {
int cap = DEFAULT_MAX_EFFECT_LEVEL;
if (globalDefault > 0 && globalDefault < cap) {
cap = globalDefault;
}
if (player != null && !player.isEmpty()) {
UserGroupConfig cfg = getHighestGroup(player);
if (cfg != null && cfg.maxEffectLevel > 0 && cfg.maxEffectLevel < cap) {
cap = cfg.maxEffectLevel;
}
}
return cap;
}
/**
* 获取玩家的默认领地权限 JSON
* 返回:组默认权限 或 空JSON "{}"
@@ -1131,6 +1184,14 @@ public class UserGroupManager {
// ==================== 配置类 ====================
/**
* ★ 原版最大药效强度(2026-10-04 任务4)。
* Minecraft 药效强度范围 1~256,amplifier = 强度 - 1,即 0~255,
* /effect 命令的 amplifier 参数硬上限就是 255。
* 普通玩家(无用户组)一律以此为上限,严禁超限。
*/
public static final int DEFAULT_MAX_EFFECT_LEVEL = 256;
public static class UserGroupConfig {
public String name;
public String displayName = "";
@@ -1139,6 +1200,8 @@ public class UserGroupManager {
// 领地相关
public int landPricePerSqm = -1; // -1 = 使用全局
public int maxLands = -1; // -1 = 使用全局
public int maxEffects = 5; // 领地增益效果上限,<=0=不限
public int maxEffectLevel = DEFAULT_MAX_EFFECT_LEVEL; // 药效等级上限,<=0=按原版256
public String defaultPerms = "{}"; // JSON
// Home相关
public int homeLimit = 0; // 0=跟随全局, >0=独立限制, -1=无限
File diff suppressed because it is too large Load Diff
+82
View File
@@ -0,0 +1,82 @@
{
"OP白名单": "服务器的好管家",
"为什么要OP白名单": {
"根因有几点": {
"第一点": "已有OP乱给权限防不住",
"第二点": "有人利用服务器漏洞拿到OP权限",
"第三点": "不信任OP",
"第四点": "想临时给某个玩家一个op,原生不支持。必须到期手动/deop user"
}
},
"我们做了什么": {
"第一层": "临时授权,允许你给OP设置到期时间,到期自动撤销",
"第二层": "接管原生OP名单,不在白名单的一律撤销+报警"
},
"你需要做什么": [
"共3步",
"第一步:安装sdf1_login插件",
"第二步改这个文件",
"第三步按参考格式或后台指令添加授权",
"第四步:玩家自动获得授权,整点自动生效(手动改本文件时,会在15、30、45、0秒的整点自动增量热重载)"
],
"----": "----",
"启用状态": true,
"无固定期限授权列表": [],
"有固定期限授权列表": {},
"非法持有报警信息": "§8[§cOP白名单§8] §f{user} §7不持有授权却持有 OP,已于 §f{time} §7自动撤销 §8| §8[OPWL] §f{user} §7held OP illegally, revoked at §f{time}",
"默认时长": "24h",
"时长支持格式": [
"可以是中文口语:一个小时",
"可以是英文one hour",
"可以是标准写法:24H",
"格式很宽松,只要是标准json字符串格式就行。至于你怎么写,插件会解析"
],
"报警模板变量": {
"{user}": "非法持有的玩家名(也认 player / 用户 / 玩家 / username)",
"{time}": "发现时间(也认 时间 / date / 日期)",
"写法": "大小写不敏感,中英文变量名都支持;& 颜色码可用,渲染后自动转成§"
},
"接管说明": [
"本插件【接管原生OP名单】:插件首次加载时,会把当前所有原生OP自动导入「无固定期限授权列表」并写回本文件。",
"之后:任何持有OP但不在白名单的玩家(不在永久表/ 临时已过期 / 时长无法解析),一律自动撤销OP并按「非法持有报警信息」报警。",
"所以以后新增OP请用 /opwl add <玩家>,别用原版 /op;否则会被15秒内的巡检撤掉。",
"白名单全部写在本文件里(单文件),没有子目录也没有附属名单文件。"
],
"以下是模板文件": {
"# 启用状态": true,
"# 无固定期限授权列表": [
"user1",
"user2"
],
"# 有固定期限授权列表": {
"user1": "2026-10-06 23:00",
"user2": "1小时",
"user3": "two hour"
},
"# 非法持有报警信息": "支持 {user} 与 {time} 变量,中英文写法都认;留空则用内置默认文案",
"# 默认时长": "24小时"
},
"为什么这次选择json": {
"根因依旧2点": {
"1": "防止非专业人士修改本文件",
"2": "使用专业格式,让小白试探着劝退(一旦解析失败,所有玩家OP全部卸载)"
}
},
"指令参数": {
"opwl": "帮助菜单",
"opwl add user": "添加user用户为无固定期限用户",
"opwl addtime user": "按默认时长添加临时授权用户",
"opwl addtime user 1h": "给user用户价1小时授权",
"opwl removetime user 1h": "给user用户扣1小时授权",
"opwl remove user": "移除user的授权",
"opwl list user": "获取user的授权状态",
"opwl list": "列出OP白名单",
"opwl reload": "重载OP白名单",
"温馨提示": "opwl命令仅限控台执行,普通用户无权",
"加授权完全无感": "因为用户本身没有op时,被授权后,插件立即给予OP并记录授权状态。删除也是。不需要先添加用户在添加授权。"
},
"安全升级": [
"本文件即是配置文件也是说明书。这就意味着,你可以把其它混淆内容一起打包进本json文件,只要保留配置项。其它的你随便删改",
"编辑本文件时,如果担心改坏了,直接联网找一个在线json解析器,把本文件扔上去。json的写法是双引号包裹内容,一对双引号就是一个键值对。用花括号做嵌套式。就像本文件的解释说明字段一样"
]
}
+7 -4
View File
@@ -1,5 +1,5 @@
name: Sdf1_login
version: 2.91
version: 2.92
main: Sdf1_login.Main
api-version: '1.21'
commands:
@@ -63,6 +63,9 @@ commands:
email:
description: 绑定邮箱
usage: /email <邮箱>
2fa:
description: 二次验证(TOTP)绑定
usage: /2fa add|code <6位>|remove <6位>
绑定:
description: 绑定邮箱(中文)
usage: /绑定 <邮箱>
@@ -78,9 +81,9 @@ commands:
垃圾清理:
description: 手动清理掉落物
usage: /垃圾清理
menu:
description: 雪球菜单开关控制
usage: /menu <开/关>
# menu:
# description: 雪球菜单开关控制
# usage: /menu <开/关>
pvp :
description: PVP榜单
usage: /pvp