Compare commits
138
Commits
v2.92-test
...
v2.92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbfdfb016b | ||
|
|
47301edaed | ||
|
|
0ab7c7052c | ||
|
|
19e03c5fb4 | ||
|
|
58f9a756e9 | ||
|
|
bdd55cc9bf | ||
|
|
3217e53cda | ||
|
|
881fb85408 | ||
|
|
581475e760 | ||
|
|
32fe81f908 | ||
|
|
31f2a60096 | ||
|
|
cd0a45d284 | ||
|
|
e2447f0b95 | ||
|
|
ca1fe6f252 | ||
|
|
84285e5009 | ||
|
|
a39e26caf7 | ||
|
|
d19ecc5ce0 | ||
|
|
e745010220 | ||
|
|
a580fbbcb7 | ||
|
|
dfdb951c8a | ||
|
|
f3a987dde9 | ||
|
|
ed558517ba | ||
|
|
9f8a881d62 | ||
|
|
d0d3b48000 | ||
|
|
d8d8c18d53 | ||
|
|
c118e8e708 | ||
|
|
07d0a5a192 | ||
|
|
9c2d5db41e | ||
|
|
64cb501321 | ||
|
|
e5e460ce20 | ||
|
|
19aecebc90 | ||
|
|
53cadf2282 | ||
|
|
ec4bd415d2 | ||
|
|
6a2bfaf1ff | ||
|
|
5d89ff54c9 | ||
|
|
ebb17d69bf | ||
|
|
478aa97ca8 | ||
|
|
88db479ede | ||
|
|
2822ba9c9a | ||
|
|
0c18137401 | ||
|
|
b64e7e336c | ||
|
|
6f9202859d | ||
|
|
f2808a5923 | ||
|
|
494ab22227 | ||
|
|
ba613ff953 | ||
|
|
932623ed88 | ||
|
|
dc5633c740 | ||
|
|
24897c2b97 | ||
|
|
ab0f15f248 | ||
|
|
77635b8db2 | ||
|
|
8486adb53c | ||
|
|
c07c97b7ea | ||
|
|
c17c606ff2 | ||
|
|
f8756a24df | ||
|
|
be8d0cb615 | ||
|
|
2e0fdea5be | ||
|
|
21e23bbc89 | ||
|
|
1e15bfb523 | ||
|
|
a1007a19f9 | ||
|
|
dd4bb7baba | ||
|
|
31c7077a64 | ||
|
|
9656ff4e8f | ||
|
|
14cc943068 | ||
|
|
96aef279cf | ||
|
|
efe00d92af | ||
|
|
643f2a81cf | ||
|
|
e13869923f | ||
|
|
20052d660b | ||
|
|
9cf9fd745c | ||
|
|
4d34606d8e | ||
|
|
55ec15bb5b | ||
|
|
fbc0e94fc2 | ||
|
|
81e8323d56 | ||
|
|
393ef114e7 | ||
|
|
7a5fb5f171 | ||
|
|
9884f79cf6 | ||
|
|
b7f2d0da57 | ||
|
|
82524dc9e9 | ||
|
|
66e28db552 | ||
|
|
919382c377 | ||
|
|
2c138b6608 | ||
|
|
7a2eb8e5d8 | ||
|
|
451d088697 | ||
|
|
d56fcbd3cd | ||
|
|
32aa2bab05 | ||
|
|
e941365a41 | ||
|
|
606728d09a | ||
|
|
fe6f6d8f80 | ||
|
|
0b77d1c211 | ||
|
|
383639a1b5 | ||
|
|
374cbdbc11 | ||
|
|
841fd928a5 | ||
|
|
ccb67a0bf5 | ||
|
|
68bbb52ae6 | ||
|
|
176e64187c | ||
|
|
7aad66f219 | ||
|
|
62ebf5c771 | ||
|
|
cef088396f | ||
|
|
95a8dba554 | ||
|
|
b62b5ebd83 | ||
|
|
52dd5c54e9 | ||
|
|
f3306589e0 | ||
|
|
61d53d7689 | ||
|
|
fb297b34fb | ||
|
|
5e778a1a6d | ||
|
|
50498ffb41 | ||
|
|
441569e758 | ||
|
|
42c51e06a8 | ||
|
|
e2742d8a6e | ||
|
|
19eebf1358 | ||
|
|
78d3e11c21 | ||
|
|
002b0e47a9 | ||
|
|
adae149791 | ||
|
|
668a5f3d38 | ||
|
|
27ac366046 | ||
|
|
dea31b8222 | ||
|
|
233060e7bd | ||
|
|
8388d68cb7 | ||
|
|
2bcc449bc4 | ||
|
|
d8ff4b0c92 | ||
|
|
c65b64030f | ||
|
|
448f23ac53 | ||
|
|
fb8b06c4e5 | ||
|
|
4ab2b2dfd8 | ||
|
|
baba10b84d | ||
|
|
5e98830829 | ||
|
|
8e84c58bcc | ||
|
|
b6ad95171d | ||
|
|
c1bad334b7 | ||
|
|
1e5576432d | ||
|
|
fbdcca4089 | ||
|
|
fd2728fe42 | ||
|
|
385301432a | ||
|
|
989b86f6e6 | ||
|
|
e848746800 | ||
|
|
915d7d1143 | ||
|
|
e00a2f6e4b | ||
|
|
720757463b |
@@ -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
@@ -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
|
||||
|
||||
@@ -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() + "项";
|
||||
}
|
||||
}
|
||||
@@ -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───────────────────────────────"));
|
||||
}
|
||||
|
||||
// ==================== 领地公告管理 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ==================== 领地方块/容器记录 ====================
|
||||
|
||||
/** 记录领地方块破坏/放置 */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+2370
-63
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 与 <br> 换行
|
||||
* - 变量: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);
|
||||
}
|
||||
|
||||
// ==================== 占位符定界符 ====================
|
||||
|
||||
/**
|
||||
* 占位符可用的定界符对:中英文小括号、中括号、花括号,外加 %…% 与 <…>。
|
||||
* 例:{username} [username] (username) 【用户】 (用户) {username} [username]
|
||||
* 「玩家名」 『开始时间』 %endtime% <结束时间>
|
||||
* 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 色码(&c / §c / &x&R&R&R&R&R&R)。
|
||||
* headDeco(可为 null)收集头部被剥离的色码原串,JSON 解析成功后回贴到组件上;
|
||||
* 尾部色码只剥离不收集——它后面已无文本,应用它没有意义。
|
||||
* 这层剥离专门解决「&c&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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把剥离出来的前置色码(&c&l / §c§l / &x&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(中英文小/中/大括号、%…%、<…>)。
|
||||
* JSON 消息(含 &c&l 包着 JSON 的情形):跳过花括号 {}、不做裸子串替换——
|
||||
* 花括号是 JSON 对象结构本身,裸替换会吃掉正文里的 end/start/player 等英文词
|
||||
* ("end soon" -> "2026-09-28 10:00 soon")。JSON 消息里请用 [username]、(用户)、%username% 等形式。
|
||||
* 普通 &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 文本组件:形如 {...} 或 [...],且含双引号。
|
||||
* 先剥离首尾色码与空白再判定——&c&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 内的 <br> 变体(<br> <br/> <br /> </br>,大小写不敏感) */
|
||||
private static final Pattern BR_IN_JSON_PAT = Pattern.compile("(?i)</?br\\s*/?>");
|
||||
|
||||
/**
|
||||
* 把 JSON 文本里的字面 <br> 换成 JSON 转义换行(反斜杠 + n 两字符)。
|
||||
* 必须是两字符序列:真实换行在 JSON 字符串里非法,写进去整段就解析失败了。
|
||||
*/
|
||||
private static String replaceBrInJson(String json) {
|
||||
return BR_IN_JSON_PAT.matcher(json).replaceAll("\\\\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息格式化:
|
||||
* 0) 防御层:剥掉首尾色码/空白再判定 JSON;残缺 JSON 尽力修补——
|
||||
* 绝不把 JSON 源码原样吐给玩家(剥掉的色码如 &c&l 作为默认样式贴回结果)
|
||||
* 1) 整段是 JSON(对象 {...} 或顶层数组 [...],后者是 tellraw 常见写法)
|
||||
* → 按文本组件解析(支持 text/color/extra 等);字面 <br> 会先换成 JSON 转义换行
|
||||
* 2) 否则处理 <br> 与字面 \n 换行、& 颜色码与 § 颜色码
|
||||
*/
|
||||
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
@@ -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
@@ -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区域列表");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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的写法是双引号包裹内容,一对双引号就是一个键值对。用花括号做嵌套式。就像本文件的解释说明字段一样"
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user