Skip to content

Commit 44ee924

Browse files
更新
1 parent 1fc5cce commit 44ee924

16 files changed

Lines changed: 375 additions & 248 deletions

.claude/settings.local.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(wc -l /e/GitHub-Repo/Peek/src/main/java/ict/minesunshineone/peek/**/*.java)",
5+
"Bash(mvn clean:*)"
6+
]
7+
}
8+
}

CLAUDE.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Build
6+
7+
```bash
8+
mvn clean package
9+
```
10+
11+
Output JAR: `target/peek-*.jar`. Deploy by copying to a Paper/Folia server's `plugins/` directory.
12+
13+
**Requirements:** JDK 17+ (CI uses 21), Maven 3.6+
14+
15+
There are no unit tests in this project. CI (`.github/workflows/build.yml`) runs `mvn clean package` on Ubuntu with JDK 21.
16+
17+
## What This Is
18+
19+
Peek is a Minecraft Paper/Folia plugin (Java 17, API 1.20+) that lets players observe other players' perspectives by switching to spectator mode. The plugin uses a fantasy/magic theme in its user-facing messages. All code is in the `ict.minesunshineone.peek` package.
20+
21+
## Architecture
22+
23+
**Entry point:** `PeekPlugin` extends `JavaPlugin` — initializes all managers/handlers, registers listeners and commands, validates config.
24+
25+
**Package layout:**
26+
27+
- `command/``PeekCommand` handles `/peek` (alias `/p`) with subcommands: `exit`, `stats`, `privacy`, `accept`, `deny`, `self`, `random`, `<player>`
28+
- `handler/` — Core peek mechanics. `PeekStateHandler` orchestrates peek sessions and delegates to `BossBarHandler` (distance visualization), `RangeChecker` (distance monitoring with functional callbacks), and `PlayerStateRestorer` (gamemode/health/location recovery). `PeekTargetHandler` validates targets and checks privacy/cooldown.
29+
- `manager/` — Business logic: `StateManager` (YAML file persistence for disconnect recovery), `PrivacyManager` (privacy mode via `PersistentDataContainer`, request lifecycle with timeouts), `CooldownManager` (concurrent cooldown tracking), `StatisticsManager` (peek counts/duration with async auto-save)
30+
- `listener/` — Event handlers: `PeekListener` (join/quit/death), `PeekInteractionListener` (blocks container access during peek), `PeekPacketListener` (ProtocolLib packet interception to prevent spectate target switching)
31+
- `data/``PeekData` immutable DTO storing peek session state (original location, gamemode, health, food, potions, target UUID)
32+
- `util/``Messages` (i18n with language files in `lang/`), `PlayerStateUtil` (player state helpers)
33+
- `placeholder/``PeekPlaceholderExpansion` for PlaceholderAPI integration
34+
35+
## Key Patterns
36+
37+
- **Folia thread safety:** Uses Paper's entity scheduler (`player.getScheduler().execute/run/runAtFixedRate`) throughout, not Bukkit scheduler. All location-aware operations run on the correct region thread.
38+
- **Async teleport:** Uses `teleportAsync()` with `thenAccept()` callbacks, never blocking teleport.
39+
- **Optional dependencies:** ProtocolLib and PlaceholderAPI are soft dependencies — the plugin detects their presence at runtime and gracefully degrades without them.
40+
- **State recovery:** Player state is serialized to YAML files in `states/` on peek start. If a player disconnects mid-peek, state is restored on rejoin.
41+
- **Concurrency:** `ConcurrentHashMap` in CooldownManager/StatisticsManager, `synchronized` blocks in PrivacyManager, copy-on-write iteration over `activePeeks`.
42+
43+
## Permissions
44+
45+
- `peek.use` — base command access (default: op)
46+
- `peek.self` — self-peek in spectator mode (default: op)
47+
- `peek.bypass` — bypass privacy mode, silent peek (default: op)
48+
- `peek.nocooldown` — bypass cooldown (default: op)
49+
- `peek.stats` — view statistics (default: op)
50+
51+
## Configuration
52+
53+
- `config.yml` — main config (limits, privacy, sounds, bossbar, statistics)
54+
- `lang/zh_CN.yml`, `lang/en_US.yml` — localized messages
55+
- `stats.yml` — persisted statistics
56+
- `states/` directory — player state recovery files
57+
58+
Language defaults to `zh_CN`. Messages use `LegacyComponentSerializer.legacyAmpersand()` for color codes.

src/main/java/ict/minesunshineone/peek/PeekPlugin.java

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,20 +102,46 @@ public void onDisable() {
102102
if (!player.isDead()) {
103103
try {
104104
ict.minesunshineone.peek.util.PlayerStateUtil.forceExitRidingState(player);
105-
player.teleport(data.getOriginalLocation());
106-
stateHandler.getStateRestorer().applyRestoredState(player, data);
107-
} catch (Exception e) {
108-
getLogger().warning(String.format("关服时无法恢复玩家 %s 的状态,将在重连时恢复: %s",
109-
player.getName(), e.getMessage()));
110-
// 状态文件保留在磁盘上,下次登录时 onPlayerJoin 自动恢复
105+
} catch (Throwable ignored) {
106+
// 忽略骑乘清理异常
107+
}
108+
// 尝试把玩家同步传送回原位置;Folia 上跨区域同步传送会抛异常
109+
boolean teleported;
110+
try {
111+
teleported = player.teleport(data.getOriginalLocation());
112+
} catch (Throwable t) {
113+
teleported = false;
114+
getLogger().warning(String.format("关服时无法将玩家 %s 同步传送回原位置: %s",
115+
player.getName(), t.getMessage()));
116+
}
117+
118+
if (teleported) {
119+
// 传送成功:完整恢复状态并清理状态文件
120+
try {
121+
stateHandler.getStateRestorer().applyRestoredState(player, data);
122+
} catch (Exception e) {
123+
getLogger().warning(String.format("关服时无法恢复玩家 %s 的状态,将在重连时恢复: %s",
124+
player.getName(), e.getMessage()));
125+
// applyRestoredState 失败时状态文件保留,下次登录时 onPlayerJoin 自动恢复
126+
}
127+
} else {
128+
// 传送失败:至少恢复游戏模式,确保玩家绝不会卡在旁观模式;
129+
// 同时保留状态文件,下次登录时由 onPlayerJoin 传送回原位置完成完整恢复
130+
try {
131+
player.setGameMode(data.getOriginalGameMode());
132+
} catch (Throwable t) {
133+
getLogger().warning(String.format("关服时无法恢复玩家 %s 的游戏模式,将在重连时恢复: %s",
134+
player.getName(), t.getMessage()));
135+
}
111136
}
112137
}
113138
// 死亡玩家的状态文件保留,下次登录时自动恢复
114139
}
115140
}
116141

117142
if (statisticsManager != null) {
118-
statisticsManager.saveStats();
143+
// shutdown() 会取消自动保存任务并保存统计数据
144+
statisticsManager.shutdown();
119145
}
120146

121147
// 注意:隐私管理器现在使用 PersistentData,无需手动保存

src/main/java/ict/minesunshineone/peek/command/PeekCommand.java

Lines changed: 31 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import java.util.ArrayList;
44
import java.util.Collections;
55
import java.util.List;
6-
import java.util.Random;
6+
import java.util.concurrent.ThreadLocalRandom;
77
import java.util.stream.Collectors;
88

99
import org.bukkit.command.Command;
@@ -13,6 +13,11 @@
1313
import org.bukkit.entity.Player;
1414

1515
import ict.minesunshineone.peek.PeekPlugin;
16+
import net.kyori.adventure.text.Component;
17+
import net.kyori.adventure.text.event.ClickEvent;
18+
import net.kyori.adventure.text.event.HoverEvent;
19+
import net.kyori.adventure.text.format.NamedTextColor;
20+
import net.kyori.adventure.text.format.TextDecoration;
1621

1722
public class PeekCommand implements CommandExecutor, TabCompleter {
1823

@@ -169,8 +174,7 @@ private boolean handleRandomPeek(Player player) {
169174
}
170175

171176
// 随机选择一个目标
172-
Random random = new Random();
173-
Player target = availableTargets.get(random.nextInt(availableTargets.size()));
177+
Player target = availableTargets.get(ThreadLocalRandom.current().nextInt(availableTargets.size()));
174178

175179
// 发送随机选择提示
176180
plugin.getMessages().send(player, "random-peek-selected", "player", target.getName());
@@ -181,58 +185,45 @@ private boolean handleRandomPeek(Player player) {
181185
}
182186

183187
private void sendHelp(Player player) {
184-
player.sendMessage(net.kyori.adventure.text.Component.empty());
185-
player.sendMessage(net.kyori.adventure.text.Component
186-
.text("═══════ ", net.kyori.adventure.text.format.NamedTextColor.DARK_GRAY)
187-
.append(net.kyori.adventure.text.Component.text("观察指令帮助",
188-
net.kyori.adventure.text.format.NamedTextColor.GREEN,
189-
net.kyori.adventure.text.format.TextDecoration.BOLD))
190-
.append(net.kyori.adventure.text.Component.text(" ═══════",
191-
net.kyori.adventure.text.format.NamedTextColor.DARK_GRAY)));
192-
player.sendMessage(net.kyori.adventure.text.Component.empty());
193-
194-
player.sendMessage(
195-
net.kyori.adventure.text.Component.text("▸ 观察功能", net.kyori.adventure.text.format.NamedTextColor.AQUA,
196-
net.kyori.adventure.text.format.TextDecoration.BOLD));
188+
player.sendMessage(Component.empty());
189+
player.sendMessage(Component
190+
.text("═══════ ", NamedTextColor.DARK_GRAY)
191+
.append(Component.text("观察指令帮助", NamedTextColor.GREEN, TextDecoration.BOLD))
192+
.append(Component.text(" ═══════", NamedTextColor.DARK_GRAY)));
193+
player.sendMessage(Component.empty());
194+
195+
player.sendMessage(Component.text("▸ 观察功能", NamedTextColor.AQUA, TextDecoration.BOLD));
197196
sendCmd(player, "/peek <玩家名>", "观察指定玩家的视角");
198197
sendCmd(player, "/peek random", "随机观察一位在线玩家");
199198
sendCmd(player, "/peek self", "原地peek自己");
200199
sendCmd(player, "/peek exit", "退出观察模式");
201200

202-
player.sendMessage(net.kyori.adventure.text.Component.empty());
203-
player.sendMessage(net.kyori.adventure.text.Component.text("▸ 隐私与统计",
204-
net.kyori.adventure.text.format.NamedTextColor.YELLOW,
205-
net.kyori.adventure.text.format.TextDecoration.BOLD));
201+
player.sendMessage(Component.empty());
202+
player.sendMessage(Component.text("▸ 隐私与统计", NamedTextColor.YELLOW, TextDecoration.BOLD));
206203
sendCmd(player, "/peek privacy", "切换隐私模式");
207204
sendCmd(player, "/peek accept", "接受观察请求");
208205
sendCmd(player, "/peek deny", "拒绝观察请求");
209206
if (player.hasPermission("peek.stats")) {
210207
sendCmd(player, "/peek stats", "查看观察统计数据");
211208
}
212209

213-
player.sendMessage(net.kyori.adventure.text.Component.empty());
214-
player.sendMessage(
215-
net.kyori.adventure.text.Component.text("提示: ", net.kyori.adventure.text.format.NamedTextColor.GRAY)
216-
.append(net.kyori.adventure.text.Component.text("点击命令可快速填入聊天框",
217-
net.kyori.adventure.text.format.NamedTextColor.DARK_GRAY,
218-
net.kyori.adventure.text.format.TextDecoration.ITALIC)));
219-
player.sendMessage(net.kyori.adventure.text.Component.empty());
210+
player.sendMessage(Component.empty());
211+
player.sendMessage(Component.text("提示: ", NamedTextColor.GRAY)
212+
.append(Component.text("点击命令可快速填入聊天框",
213+
NamedTextColor.DARK_GRAY, TextDecoration.ITALIC)));
214+
player.sendMessage(Component.empty());
220215
}
221216

222217
private void sendCmd(Player player, String cmd, String desc) {
223-
net.kyori.adventure.text.Component cmdComponent = net.kyori.adventure.text.Component
224-
.text(" " + cmd, net.kyori.adventure.text.format.NamedTextColor.GREEN)
225-
.hoverEvent(net.kyori.adventure.text.event.HoverEvent.showText(
226-
net.kyori.adventure.text.Component
227-
.text(desc, net.kyori.adventure.text.format.NamedTextColor.WHITE)
228-
.append(net.kyori.adventure.text.Component.newline())
229-
.append(net.kyori.adventure.text.Component.text("点击填入命令",
230-
net.kyori.adventure.text.format.NamedTextColor.GRAY,
231-
net.kyori.adventure.text.format.TextDecoration.ITALIC))))
232-
.clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand(cmd.split("<")[0].trim() + " "));
233-
234-
net.kyori.adventure.text.Component descComponent = net.kyori.adventure.text.Component.text(" - " + desc,
235-
net.kyori.adventure.text.format.NamedTextColor.GRAY);
218+
Component cmdComponent = Component
219+
.text(" " + cmd, NamedTextColor.GREEN)
220+
.hoverEvent(HoverEvent.showText(Component
221+
.text(desc, NamedTextColor.WHITE)
222+
.append(Component.newline())
223+
.append(Component.text("点击填入命令", NamedTextColor.GRAY, TextDecoration.ITALIC))))
224+
.clickEvent(ClickEvent.suggestCommand(cmd.split("<")[0].trim() + " "));
225+
226+
Component descComponent = Component.text(" - " + desc, NamedTextColor.GRAY);
236227

237228
player.sendMessage(cmdComponent.append(descComponent));
238229
}

src/main/java/ict/minesunshineone/peek/data/PeekData.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ public class PeekData {
1919
private final int foodLevel;
2020
private final float saturation;
2121
private final Collection<PotionEffect> potionEffects;
22-
private boolean exiting;
22+
// volatile:exiting 会被区域线程的延迟任务读取(sessionEnded),而由 endPeek 写入
23+
private volatile boolean exiting;
2324

2425
public PeekData(Location originalLocation, GameMode originalGameMode, UUID targetUUID,
2526
long startTime, double health, int foodLevel, float saturation, Collection<PotionEffect> potionEffects) {

src/main/java/ict/minesunshineone/peek/handler/BossBarHandler.java

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -200,15 +200,6 @@ public void safeRemoveDistanceBossBar(Player peeker) {
200200
}
201201
}
202202

203-
/**
204-
* 检查是否启用 BossBar
205-
*
206-
* @return 是否启用
207-
*/
208-
public boolean isEnabled() {
209-
return enabled;
210-
}
211-
212203
/**
213204
* 清理所有 BossBar
214205
*/

0 commit comments

Comments
 (0)