forked from EliteScouter/EliteEssentials
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathWarmupService.java
More file actions
353 lines (306 loc) · 14.1 KB
/
WarmupService.java
File metadata and controls
353 lines (306 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
package com.eliteessentials.services;
import com.eliteessentials.EliteEssentials;
import com.eliteessentials.config.ConfigManager;
import com.eliteessentials.util.MessageFormatter;
import com.hypixel.hytale.component.Ref;
import com.hypixel.hytale.component.Store;
import com.hypixel.hytale.math.vector.Vector3d;
import com.hypixel.hytale.server.core.entity.entities.Player;
import com.hypixel.hytale.server.core.modules.entity.component.TransformComponent;
import com.hypixel.hytale.server.core.universe.PlayerRef;
import com.hypixel.hytale.server.core.universe.world.World;
import com.hypixel.hytale.server.core.universe.world.storage.EntityStore;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.*;
import java.util.logging.Logger;
/**
* Service for handling teleport warmups - players must stand still for a duration.
* Uses a polling approach similar to HomeManager for reliable movement detection.
*/
public class WarmupService {
private static final Logger logger = Logger.getLogger("EliteEssentials");
// Movement threshold squared (1 block) - same as HomeManager
private static final double MOVE_EPSILON_SQUARED = 1.0;
// Poll interval in milliseconds - frequent polling catches movement reliably
private static final long POLL_INTERVAL_MS = 100;
// Maximum warmup lifetime in nanos - safety net to clear stuck entries
// (e.g. world.execute() silently dropped the task, world was destroyed, etc.)
private static final long MAX_WARMUP_LIFETIME_NANOS = TimeUnit.SECONDS.toNanos(60);
private final ScheduledExecutorService poller;
private final Map<UUID, PendingWarmup> pending = new ConcurrentHashMap<>();
private ScheduledFuture<?> pollTask;
public WarmupService() {
this.poller = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "EliteEssentials-Warmup");
t.setDaemon(true);
return t;
});
}
/**
* Start a warmup for a player. They must stand still for the duration.
*/
public void startWarmup(PlayerRef player, Vector3d startPosition, int warmupSeconds,
Runnable onComplete, String commandName,
World world, Store<EntityStore> store, Ref<EntityStore> ref) {
startWarmup(player, startPosition, warmupSeconds, onComplete, commandName, world, store, ref, false);
}
/**
* Start a warmup for a player with optional silent mode.
* @param silent If true, suppress countdown messages (but still show cancel message if moved)
*/
public void startWarmup(PlayerRef player, Vector3d startPosition, int warmupSeconds,
Runnable onComplete, String commandName,
World world, Store<EntityStore> store, Ref<EntityStore> ref, boolean silent) {
UUID playerId = player.getUuid();
// Cancel any existing warmup for this player
PendingWarmup existing = pending.remove(playerId);
if (existing != null) {
existing.cancelled = true;
}
// If no warmup needed, execute immediately on the correct world thread.
// We may be called from a different world's thread (e.g., cross-world TPA
// where acceptor's world thread calls startWarmup for the requester).
// Dispatching via world.execute() ensures the onComplete callback runs on
// the teleporting player's world thread, avoiding IllegalStateException
// from Store.assertThread().
if (warmupSeconds <= 0) {
if (world != null) {
world.execute(onComplete);
} else {
onComplete.run();
}
return;
}
// If missing required context, execute immediately on correct thread if possible
if (startPosition == null || world == null || store == null || ref == null) {
logger.warning("[Warmup] Missing context for " + commandName + ", executing immediately");
if (world != null) {
world.execute(onComplete);
} else {
onComplete.run();
}
return;
}
ConfigManager configManager = EliteEssentials.getInstance().getConfigManager();
if (configManager.isDebugEnabled()) {
logger.info("[Warmup] Starting " + warmupSeconds + "s warmup for " + commandName);
}
// Create pending warmup with end time in nanos (like HomeManager)
long endTimeNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(warmupSeconds);
PendingWarmup warmup = new PendingWarmup(
playerId, ref, new Vector3d(startPosition), endTimeNanos,
onComplete, commandName, world, store, warmupSeconds, silent
);
pending.put(playerId, warmup);
ensurePollerRunning();
}
/**
* Overload for commands that don't have world/store/ref - no movement checking.
*/
public void startWarmup(PlayerRef player, Vector3d startPosition, int warmupSeconds,
Runnable onComplete, String commandName) {
startWarmup(player, startPosition, warmupSeconds, onComplete, commandName, null, null, null);
}
private void ensurePollerRunning() {
// Check isDone() as well as isCancelled(): if the poller task threw an
// uncaught exception, ScheduledExecutorService silently kills it.
// isDone() returns true but isCancelled() returns false in that case.
if (pollTask != null && !pollTask.isCancelled() && !pollTask.isDone()) {
return;
}
pollTask = poller.scheduleAtFixedRate(
this::pollWarmups,
POLL_INTERVAL_MS, POLL_INTERVAL_MS, TimeUnit.MILLISECONDS
);
}
private void pollWarmups() {
// CRITICAL: This method runs inside ScheduledExecutorService.scheduleAtFixedRate().
// If ANY uncaught exception escapes, the executor permanently and silently kills
// the recurring task. The entire warmup system goes dead — no player can teleport.
// Every code path must be wrapped in try/catch.
try {
if (pending.isEmpty()) {
return;
}
long now = System.nanoTime();
for (PendingWarmup warmup : pending.values()) {
try {
if (warmup.cancelled) {
pending.remove(warmup.playerUuid);
continue;
}
// Safety net: clear warmups that have been pending far too long.
// This catches cases where world.execute() silently drops the task,
// the world was destroyed but the reference is non-null, etc.
if ((now - warmup.createdAtNanos) > MAX_WARMUP_LIFETIME_NANOS) {
logger.warning("[Warmup] Clearing stale warmup for " + warmup.playerUuid
+ " (" + warmup.commandName + ") - exceeded max lifetime");
pending.remove(warmup.playerUuid);
continue;
}
World world = warmup.world;
if (world == null) {
pending.remove(warmup.playerUuid);
continue;
}
// Execute the tick on the game thread
world.execute(() -> tickWarmup(warmup));
} catch (Exception e) {
// Per-warmup catch: one bad warmup must never kill processing for others.
// This typically fires when world.execute() throws because the world
// was destroyed between the null check and the execute call.
logger.warning("[Warmup] Error polling warmup for " + warmup.playerUuid
+ " (" + warmup.commandName + "): " + e.getMessage() + " - removing");
pending.remove(warmup.playerUuid);
}
}
// Stop poller if no more pending warmups
if (pending.isEmpty() && pollTask != null) {
pollTask.cancel(false);
pollTask = null;
}
} catch (Exception e) {
// Outer catch: absolute last resort. If we somehow get here, log it
// but do NOT let the exception propagate or the poller dies permanently.
logger.severe("[Warmup] Unexpected error in pollWarmups: " + e.getMessage());
}
}
private void tickWarmup(PendingWarmup warmup) {
Store<EntityStore> store = warmup.store;
Ref<EntityStore> ref = warmup.playerRef;
// Validate ref is still valid before accessing components
if (ref == null || !ref.isValid()) {
pending.remove(warmup.playerUuid);
return;
}
// Get Player component to send messages
Player playerComponent;
try {
playerComponent = (Player) store.getComponent(ref, Player.getComponentType());
} catch (Exception e) {
pending.remove(warmup.playerUuid);
return;
}
if (playerComponent == null) {
pending.remove(warmup.playerUuid);
return;
}
// Get current position
Vector3d currentPos = getPlayerPosition(ref, store);
if (currentPos == null) {
pending.remove(warmup.playerUuid);
return;
}
ConfigManager configManager = EliteEssentials.getInstance().getConfigManager();
// Check if player moved (using squared distance like HomeManager)
if (hasMoved(warmup.startPos, currentPos)) {
pending.remove(warmup.playerUuid);
playerComponent.sendMessage(MessageFormatter.formatWithFallback(configManager.getMessage("warmupCancelled"), "#FF5555"));
return;
}
// Check if warmup time has elapsed
long now = System.nanoTime();
long remainingNanos = warmup.endTimeNanos - now;
if (remainingNanos <= 0) {
// Warmup complete - execute the teleport
pending.remove(warmup.playerUuid);
// Final validation before executing teleport
if (ref == null || !ref.isValid()) {
return;
}
try {
warmup.onComplete.run();
} catch (Exception e) {
logger.warning("[Warmup] Error executing teleport: " + e.getMessage());
}
return;
}
// Announce countdown (only when seconds change, and not in silent mode)
int remainingSeconds = (int) Math.ceil(remainingNanos / 1_000_000_000.0);
if (!warmup.silent && remainingSeconds != warmup.lastAnnouncedSeconds && remainingSeconds > 0) {
warmup.lastAnnouncedSeconds = remainingSeconds;
playerComponent.sendMessage(MessageFormatter.formatWithFallback(configManager.getMessage("warmupCountdown", "seconds", String.valueOf(remainingSeconds)), "#FFAA00"));
}
}
private boolean hasMoved(Vector3d start, Vector3d current) {
double dx = current.getX() - start.getX();
double dy = current.getY() - start.getY();
double dz = current.getZ() - start.getZ();
double distanceSquared = dx*dx + dy*dy + dz*dz;
return distanceSquared > MOVE_EPSILON_SQUARED;
}
private Vector3d getPlayerPosition(Ref<EntityStore> ref, Store<EntityStore> store) {
try {
if (ref == null || !ref.isValid()) {
return null;
}
TransformComponent transform = (TransformComponent) store.getComponent(ref, TransformComponent.getComponentType());
if (transform == null) {
return null;
}
return transform.getPosition();
} catch (Exception e) {
// Entity may have been removed
return null;
}
}
public void cancelWarmup(UUID playerId) {
PendingWarmup warmup = pending.remove(playerId);
if (warmup != null) {
warmup.cancelled = true;
}
}
public boolean hasActiveWarmup(UUID playerId) {
return pending.containsKey(playerId);
}
public void shutdown() {
for (UUID playerId : pending.keySet()) {
cancelWarmup(playerId);
}
if (pollTask != null) {
pollTask.cancel(false);
}
poller.shutdown();
try {
if (!poller.awaitTermination(5, TimeUnit.SECONDS)) {
poller.shutdownNow();
}
} catch (InterruptedException e) {
poller.shutdownNow();
Thread.currentThread().interrupt();
}
}
/**
* Internal class to track pending warmup state.
*/
private static class PendingWarmup {
final UUID playerUuid;
final Ref<EntityStore> playerRef;
final Vector3d startPos;
final long endTimeNanos;
final long createdAtNanos;
final Runnable onComplete;
final String commandName;
final World world;
final Store<EntityStore> store;
final boolean silent;
volatile boolean cancelled = false;
int lastAnnouncedSeconds;
PendingWarmup(UUID playerUuid, Ref<EntityStore> playerRef, Vector3d startPos,
long endTimeNanos, Runnable onComplete, String commandName,
World world, Store<EntityStore> store, int initialSeconds, boolean silent) {
this.playerUuid = playerUuid;
this.playerRef = playerRef;
this.startPos = startPos;
this.endTimeNanos = endTimeNanos;
this.createdAtNanos = System.nanoTime();
this.onComplete = onComplete;
this.commandName = commandName;
this.world = world;
this.store = store;
this.silent = silent;
this.lastAnnouncedSeconds = initialSeconds + 1; // So first tick announces
}
}
}