mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
Improve generation and lifecycle safety
- Reduce terrain, noise, hydrology, and decorator sampling overhead. - Reduce object transaction allocations while preserving rollback behavior. - Optimize structure placement and cave anchor sampling. - Parallelize Studio cache warmup and protect world entry and hotload recovery. - Validate pack fingerprints and publish pack replacements atomically. - Notify modded operators about outdated packs without synchronizing pack data. - Harden world creation, loading, evacuation, and teleport failure recovery. - Coalesce queued region flushes while preserving chunk save ordering. - Remove obsolete mantle, pack, and cave planning code. - Add regression tests and reusable gameplay fixtures.
This commit is contained in:
1 parent
a2bcd7a302
commit
7f5ce70f49
197 files changed
+7560
-4420
No files matched your search
+1
-1
@@ -50,7 +50,7 @@ public final class ModdedDecoratorHooks implements DecoratorPlatformHooks.FaceFi
|
||||
int xx = rX + dx;
|
||||
int yy = y + dy;
|
||||
int zz = rZ + dz;
|
||||
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy > hunk.getHeight()
|
||||
return xx < 0 || xx > 15 || zz < 0 || zz > 15 || yy < 0 || yy >= hunk.getHeight()
|
||||
? null : hunk.get(xx, yy, zz);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import art.arcane.iris.modded.service.ModdedEngineMaintenanceService;
|
||||
import art.arcane.iris.modded.service.ModdedEntitySpawnService;
|
||||
import art.arcane.iris.modded.service.ModdedLogFilterService;
|
||||
import art.arcane.iris.modded.service.ModdedPreservationService;
|
||||
import art.arcane.iris.modded.service.ModdedPackUpdateService;
|
||||
import art.arcane.iris.modded.service.ModdedSettingsHotloadService;
|
||||
import art.arcane.iris.modded.service.ModdedStudioHotloadService;
|
||||
import art.arcane.iris.modded.service.ModdedTreeFellerService;
|
||||
@@ -477,6 +478,7 @@ public final class ModdedEngineBootstrap {
|
||||
createdServices.register(ModdedLogFilterService.class, new ModdedLogFilterService());
|
||||
createdServices.register(ModdedEngineMaintenanceService.class, new ModdedEngineMaintenanceService());
|
||||
createdServices.register(ModdedSettingsHotloadService.class, new ModdedSettingsHotloadService());
|
||||
createdServices.register(ModdedPackUpdateService.class, new ModdedPackUpdateService());
|
||||
ModdedStudioHotloadService studioHotloadService = createdServices.register(
|
||||
ModdedStudioHotloadService.class, new ModdedStudioHotloadService());
|
||||
createdServices.register(ModdedChunkUpdateService.class, new ModdedChunkUpdateService());
|
||||
|
||||
@@ -27,7 +27,6 @@ import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.ThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Owns the Iris generation pool and the single decision of whether this loader already runs chunk
|
||||
@@ -43,7 +42,7 @@ public final class ModdedGenPool {
|
||||
private static final long SHUTDOWN_DRAIN_MILLIS = 2_000L;
|
||||
private static final AtomicInteger GEN_THREAD_SEQ = new AtomicInteger();
|
||||
private static final ChunkSystem CHUNK_SYSTEM = detectChunkSystem();
|
||||
private static final AtomicReference<ExecutorService> GEN_POOL = new AtomicReference<>(createGenPool());
|
||||
private static ExecutorService generationPool = createGenPool();
|
||||
|
||||
private ModdedGenPool() {
|
||||
}
|
||||
@@ -63,39 +62,32 @@ public final class ModdedGenPool {
|
||||
return CHUNK_SYSTEM.description();
|
||||
}
|
||||
|
||||
static ExecutorService pool() {
|
||||
ExecutorService pool = GEN_POOL.get();
|
||||
if (pool != null && !pool.isShutdown()) {
|
||||
return pool;
|
||||
}
|
||||
start();
|
||||
ExecutorService restarted = GEN_POOL.get();
|
||||
if (restarted == null) {
|
||||
static synchronized ExecutorService pool() {
|
||||
if (generationPool.isShutdown()) {
|
||||
throw new RejectedExecutionException("Iris gen pool is shut down");
|
||||
}
|
||||
return restarted;
|
||||
return generationPool;
|
||||
}
|
||||
|
||||
static void start() {
|
||||
while (true) {
|
||||
ExecutorService current = GEN_POOL.get();
|
||||
if (current != null && !current.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
ExecutorService created = createGenPool();
|
||||
if (GEN_POOL.compareAndSet(current, created)) {
|
||||
return;
|
||||
}
|
||||
created.shutdownNow();
|
||||
static synchronized void start() {
|
||||
if (!generationPool.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
if (!generationPool.isTerminated()) {
|
||||
throw new IllegalStateException("Iris gen pool cannot restart while prior workers are still active");
|
||||
}
|
||||
generationPool = createGenPool();
|
||||
}
|
||||
|
||||
static void shutdown() {
|
||||
ExecutorService pool = GEN_POOL.getAndSet(null);
|
||||
if (pool == null) {
|
||||
return;
|
||||
ExecutorService pool;
|
||||
synchronized (ModdedGenPool.class) {
|
||||
pool = generationPool;
|
||||
if (pool.isTerminated()) {
|
||||
return;
|
||||
}
|
||||
pool.shutdown();
|
||||
}
|
||||
pool.shutdown();
|
||||
try {
|
||||
if (pool.awaitTermination(SHUTDOWN_DRAIN_MILLIS, TimeUnit.MILLISECONDS)) {
|
||||
return;
|
||||
|
||||
@@ -20,13 +20,11 @@ package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.iris.configuration.IrisSettings;
|
||||
import art.arcane.iris.diagnostics.splash.IrisSplashComposer;
|
||||
import art.arcane.iris.diagnostics.splash.IrisSplashRenderer;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
public final class ModdedIrisSplash {
|
||||
|
||||
@@ -34,18 +32,11 @@ public final class ModdedIrisSplash {
|
||||
}
|
||||
|
||||
public static void print(NativeModdedLoader loader) {
|
||||
printPacks(loader);
|
||||
if (isLogoEnabled()) {
|
||||
printLogo(loader);
|
||||
}
|
||||
}
|
||||
|
||||
private static void printPacks(NativeModdedLoader loader) {
|
||||
File packFolder = IrisPlatforms.get().packsFolderNoCreate();
|
||||
IrisSplashComposer.composePackLines(packFolder, IrisLogging::reportError)
|
||||
.thenAccept(lines -> lines.forEach(IrisLogging::info));
|
||||
}
|
||||
|
||||
private static void printLogo(NativeModdedLoader loader) {
|
||||
String serverLine = loader.platformName() + " / Minecraft " + loader.minecraftVersion();
|
||||
String[] splash = IrisSplashRenderer.renderPlain();
|
||||
|
||||
+53
-32
@@ -27,16 +27,15 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
public final class ModdedPrimaryWorldRouter {
|
||||
private static final int TICK_INTERVAL = 20;
|
||||
|
||||
private static final Set<UUID> routed = ConcurrentHashMap.newKeySet();
|
||||
private static final Set<UUID> inFlight = ConcurrentHashMap.newKeySet();
|
||||
private static final ConcurrentMap<UUID, CompletableFuture<Boolean>> routes = new ConcurrentHashMap<>();
|
||||
private static int tickCounter = 0;
|
||||
private static volatile String loginRefusal;
|
||||
private static volatile String absentPrimary;
|
||||
@@ -45,8 +44,12 @@ public final class ModdedPrimaryWorldRouter {
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
routed.clear();
|
||||
inFlight.clear();
|
||||
routes.forEach((id, route) -> {
|
||||
if (routes.remove(id, route)) {
|
||||
route.cancel(false);
|
||||
}
|
||||
});
|
||||
tickCounter = 0;
|
||||
loginRefusal = null;
|
||||
absentPrimary = null;
|
||||
}
|
||||
@@ -57,8 +60,10 @@ public final class ModdedPrimaryWorldRouter {
|
||||
*/
|
||||
public static void forget(UUID player) {
|
||||
if (player != null) {
|
||||
routed.remove(player);
|
||||
inFlight.remove(player);
|
||||
CompletableFuture<Boolean> route = routes.remove(player);
|
||||
if (route != null) {
|
||||
route.cancel(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,36 +115,52 @@ public final class ModdedPrimaryWorldRouter {
|
||||
return;
|
||||
}
|
||||
|
||||
server.forEachPlayer(player -> {
|
||||
UUID id = player.id();
|
||||
if (routed.contains(id) || !inFlight.add(id)) {
|
||||
return;
|
||||
server.forEachPlayer(player -> routePlayer(server, player, overworld, target, primary));
|
||||
}
|
||||
|
||||
private static void routePlayer(NativeModdedServer server, NativeProtocolPlayer player,
|
||||
NativeWorld overworld, NativeWorld target, String primary) {
|
||||
UUID id = player.id();
|
||||
if (routes.containsKey(id)) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture<Boolean> route = new CompletableFuture<>();
|
||||
if (routes.putIfAbsent(id, route) != null) {
|
||||
return;
|
||||
}
|
||||
route.whenComplete((success, failure) -> {
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
routes.remove(id, route);
|
||||
}
|
||||
if (!player.isInWorld(overworld)) {
|
||||
inFlight.remove(id);
|
||||
routed.add(id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
|
||||
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
|
||||
ModdedTeleportDeadline.fromNow()));
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
inFlight.remove(id);
|
||||
if (Boolean.TRUE.equals(success) && failure == null) {
|
||||
routed.add(id);
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
});
|
||||
if (!player.isInWorld(overworld)) {
|
||||
route.complete(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
CompletableFuture<Boolean> teleport = NativeWorldTeleport.teleport(player,
|
||||
new NativeWorldTeleport.Destination(server, target, player.x(), Double.MIN_VALUE, player.z(),
|
||||
ModdedTeleportDeadline.fromNow()));
|
||||
route.whenComplete((success, failure) -> {
|
||||
if (!Boolean.TRUE.equals(success) && !teleport.isDone()) {
|
||||
teleport.cancel(false);
|
||||
}
|
||||
});
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
if (failure != null) {
|
||||
if (route.completeExceptionally(failure)) {
|
||||
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'",
|
||||
id, primary, failure);
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
inFlight.remove(id);
|
||||
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, e);
|
||||
} else {
|
||||
route.complete(Boolean.TRUE.equals(success));
|
||||
}
|
||||
});
|
||||
} catch (Throwable failure) {
|
||||
if (route.completeExceptionally(failure)) {
|
||||
ModdedIrisLog.error("Iris failed to route player {} to primary world '{}'", id, primary, failure);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,7 @@ import art.arcane.iris.platform.protocol.IrisVisionRequestService;
|
||||
import art.arcane.iris.generation.runtime.Engine;
|
||||
import art.arcane.iris.spi.IrisServices;
|
||||
import art.arcane.iris.spi.protocol.IrisProtocol;
|
||||
import art.arcane.iris.modded.service.ModdedPackUpdateService;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
@@ -128,6 +129,10 @@ public final class ModdedProtocolHandler {
|
||||
}
|
||||
String sessionId = sessionId(player);
|
||||
ModdedStartup.warnStartupFailuresTo(player);
|
||||
ModdedPackUpdateService packUpdates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
|
||||
if (packUpdates != null) {
|
||||
packUpdates.notifyPlayer(player);
|
||||
}
|
||||
IrisSessionRegistry current = registry;
|
||||
ModdedProtocolTransport currentTransport = transport;
|
||||
if (current == null || currentTransport == null) {
|
||||
|
||||
@@ -18,10 +18,13 @@
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.pack.PackFingerprints;
|
||||
|
||||
import art.arcane.iris.pack.BrokenPackException;
|
||||
import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.pack.PackDownloader;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationCache;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
@@ -31,15 +34,11 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
public final class ModdedStartup {
|
||||
private static final int COMPAT_BOOT_KEY_CAP = 3;
|
||||
@@ -60,36 +59,9 @@ public final class ModdedStartup {
|
||||
if (!PREPARED.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
reportLegacyPacksDirectory();
|
||||
validateAllPacks();
|
||||
}
|
||||
|
||||
/**
|
||||
* Older builds mkdir'd (and stale guidance sometimes populated) config/iris/packs, but modded
|
||||
* packs live under config/irisworldgen/packs. Never auto-move user content: warn loudly when
|
||||
* the legacy directory holds packs, and quietly remove it when it is empty.
|
||||
*/
|
||||
private static void reportLegacyPacksDirectory() {
|
||||
try {
|
||||
File legacy = ModdedEngineBootstrap.loader().configDir().resolve("iris").resolve("packs").toFile();
|
||||
if (!legacy.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
if (!PackDirectoryResolver.listVisiblePackDirectories(legacy).isEmpty()) {
|
||||
File real = art.arcane.iris.spi.IrisPlatforms.get().packsFolderNoCreate();
|
||||
ModdedIrisLog.warn("Iris found packs under the legacy directory {} - modded packs load from {} only. Move them there.",
|
||||
legacy.getAbsolutePath(), real.getAbsolutePath());
|
||||
return;
|
||||
}
|
||||
String[] entries = legacy.list();
|
||||
if (entries == null || entries.length == 0) {
|
||||
legacy.delete();
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.debug("Iris legacy packs directory check failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot trigger for the forced datapack. Runs on its own daemon thread rather than the Iris scheduler:
|
||||
* ModdedEngineBootstrap.start clears the async queue at SERVER_STARTING, which would silently drop this
|
||||
@@ -140,8 +112,7 @@ public final class ModdedStartup {
|
||||
}
|
||||
for (File packDir : packDirs) {
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packDir);
|
||||
PackValidationRegistry.publish(result);
|
||||
PackValidationResult result = validatePack(packDir);
|
||||
String minecraftVersion = IrisPlatforms.isBound() ? IrisPlatforms.get().minecraftVersion() : null;
|
||||
String compatSummary = PackValidator.compatSummary(result, minecraftVersion);
|
||||
String compatSuffix = compatSummary.isEmpty() ? "" : " " + compatSummary;
|
||||
@@ -184,16 +155,15 @@ public final class ModdedStartup {
|
||||
throw new BrokenPackException(pack, List.of(
|
||||
"Pack folder does not exist under " + ModdedPackCommands.packsRoot().getAbsolutePath() + "."));
|
||||
}
|
||||
PackValidationResult cached = PackValidationRegistry.get(pack);
|
||||
if (cached != null && cached.getValidatedAtMillis() >= newestModificationMillis(packDir.toPath())) {
|
||||
// prepareForStartup already validated this pack and nothing in it changed since; re-validating per
|
||||
// persistent dimension at boot costs a full pack parse each time.
|
||||
return PackValidationRegistry.requireLoadable(pack);
|
||||
}
|
||||
Path packRoot = packDir.toPath();
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(packDir);
|
||||
PackValidationRegistry.publish(result);
|
||||
return PackValidationRegistry.requireLoadable(pack);
|
||||
PackValidationResult cached = PackValidationRegistry.getMatching(
|
||||
packRoot, PackFingerprints.computePackTreeFingerprint(packDir));
|
||||
if (cached != null) {
|
||||
return PackValidationRegistry.requireLoadable(packRoot);
|
||||
}
|
||||
validatePack(packDir);
|
||||
return PackValidationRegistry.requireLoadable(packRoot);
|
||||
} catch (BrokenPackException e) {
|
||||
throw e;
|
||||
} catch (Throwable e) {
|
||||
@@ -212,6 +182,42 @@ public final class ModdedStartup {
|
||||
}
|
||||
}
|
||||
|
||||
public static PackValidationResult validatePack(File packDir) {
|
||||
Path packRoot = packDir.toPath();
|
||||
PackValidationRegistry.ValidationTicket ticket = PackValidationRegistry.tryBeginValidation(packRoot);
|
||||
if (ticket == null) {
|
||||
throw new BrokenPackException(packDir.getName(), List.of("Pack content is currently being changed."));
|
||||
}
|
||||
String contentFingerprint = PackFingerprints.computePackTreeFingerprint(packDir);
|
||||
String contextFingerprint = PackValidationCache.contextFingerprint();
|
||||
PackValidationResult result;
|
||||
try {
|
||||
result = PackValidator.validate(packDir);
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris pack validation failed for '{}'", packDir.getName(), failure);
|
||||
String detail = failure.getMessage();
|
||||
if (detail == null || detail.isBlank()) {
|
||||
detail = failure.getClass().getSimpleName();
|
||||
}
|
||||
result = new PackValidationResult(
|
||||
packDir.getName(),
|
||||
List.of("Pack validation failed with " + failure.getClass().getSimpleName() + ": " + detail),
|
||||
List.of(),
|
||||
System.currentTimeMillis());
|
||||
}
|
||||
if (!contentFingerprint.equals(PackFingerprints.computePackTreeFingerprint(packDir))
|
||||
|| !contextFingerprint.equals(PackValidationCache.contextFingerprint())) {
|
||||
PackValidationRegistry.remove(packRoot);
|
||||
PackValidationRegistry.remove(packDir.getName());
|
||||
throw new BrokenPackException(packDir.getName(), List.of("Pack content or validation context changed during validation."));
|
||||
}
|
||||
if (!PackValidationRegistry.publishIfCurrent(ticket, result, contentFingerprint, contextFingerprint)) {
|
||||
throw new BrokenPackException(packDir.getName(), List.of("Pack content changed during validation."));
|
||||
}
|
||||
PackValidationRegistry.publish(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
static void reinjectPersistentDimensions(NativeModdedServer server) {
|
||||
List<ModdedDimensionRegistryStore.PersistentDimension> dimensions =
|
||||
ModdedDimensionRegistryStore.loadForStartup(server);
|
||||
@@ -266,23 +272,6 @@ public final class ModdedStartup {
|
||||
+ GenerationRefusalNotice.summary(failure));
|
||||
}
|
||||
|
||||
private static long newestModificationMillis(Path root) {
|
||||
long newest = 0L;
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
for (Path path : (Iterable<Path>) walk::iterator) {
|
||||
BasicFileAttributes attributes = Files.readAttributes(path, BasicFileAttributes.class);
|
||||
long modified = attributes.lastModifiedTime().toMillis();
|
||||
if (modified > newest) {
|
||||
newest = modified;
|
||||
}
|
||||
}
|
||||
} catch (IOException | RuntimeException unreadable) {
|
||||
ModdedIrisLog.debug("Iris could not stat {} for validation reuse; revalidating", root, unreadable);
|
||||
return Long.MAX_VALUE;
|
||||
}
|
||||
return newest;
|
||||
}
|
||||
|
||||
/**
|
||||
* True once the first tick with a player list has re-injected the persistent dimensions. Before that a runtime
|
||||
* dimension that is not loaded yet is still on its way.
|
||||
@@ -296,7 +285,7 @@ public final class ModdedStartup {
|
||||
* the console. Tell the operators who can actually act on it when they join.
|
||||
*/
|
||||
public static void warnStartupFailuresTo(NativeProtocolPlayer player) {
|
||||
if (player == null || !player.isGameMaster()) {
|
||||
if (player == null || (!player.isGameMaster() && !player.isServerOwner())) {
|
||||
return;
|
||||
}
|
||||
for (String failure : DIMENSION_FAILURES) {
|
||||
|
||||
+7
@@ -33,6 +33,7 @@ import art.arcane.iris.modded.IrisModdedChunkGenerator;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.service.ModdedPackUpdateService;
|
||||
import art.arcane.iris.modded.ModdedForcedDatapack;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedLoader;
|
||||
import art.arcane.iris.modded.ModdedScheduler;
|
||||
@@ -445,6 +446,12 @@ public final class IrisModdedCommands {
|
||||
);
|
||||
String completionMessage = downloadCompletionMessage(result);
|
||||
if (result != null) {
|
||||
if (result.changed()) {
|
||||
ModdedPackUpdateService updates = ModdedEngineBootstrap.services().service(ModdedPackUpdateService.class);
|
||||
if (updates != null) {
|
||||
dispatchDownloadFeedback(source, updates::refresh);
|
||||
}
|
||||
}
|
||||
if (completionMessage != null) {
|
||||
dispatchDownloadFeedback(source, () -> ok(source, completionMessage));
|
||||
}
|
||||
|
||||
+3
-3
@@ -18,6 +18,8 @@
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import art.arcane.iris.modded.ModdedStartup;
|
||||
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import art.arcane.iris.pack.validation.PackCompatReport;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
@@ -25,7 +27,6 @@ import art.arcane.iris.pack.PackDirectoryResolver;
|
||||
import art.arcane.iris.pack.PackResourceCleanup;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
@@ -138,8 +139,7 @@ public final class ModdedPackCommands {
|
||||
int broken = 0;
|
||||
for (File target : targets) {
|
||||
try {
|
||||
PackValidationResult result = PackValidator.validate(target);
|
||||
PackValidationRegistry.publish(result);
|
||||
PackValidationResult result = ModdedStartup.validatePack(target);
|
||||
if (!result.isLoadable()) {
|
||||
broken++;
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
package art.arcane.iris.modded.service;
|
||||
|
||||
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner;
|
||||
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
|
||||
import art.arcane.iris.pack.BuiltInPackUpdates;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Function;
|
||||
|
||||
public final class ModdedPackUpdateService implements ModdedTickableService {
|
||||
private final Function<File, CompletableFuture<PackCheck>> checker;
|
||||
private final Set<UUID> notified = new HashSet<>();
|
||||
private CompletableFuture<PackCheck> pending;
|
||||
private List<String> notices = List.of();
|
||||
private File packsFolder;
|
||||
private boolean enabled;
|
||||
private boolean started;
|
||||
|
||||
public ModdedPackUpdateService() {
|
||||
this(ModdedPackUpdateService::checkPacks);
|
||||
}
|
||||
|
||||
ModdedPackUpdateService(Function<File, CompletableFuture<PackCheck>> checker) {
|
||||
this.checker = checker;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
enabled = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
enabled = false;
|
||||
if (pending != null) {
|
||||
pending.cancel(true);
|
||||
pending = null;
|
||||
}
|
||||
started = false;
|
||||
notices = List.of();
|
||||
notified.clear();
|
||||
packsFolder = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServerTick(NativeModdedServer server) {
|
||||
if (!enabled || server == null || !server.hasPlayerList()) {
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
packsFolder = IrisPlatforms.get().packsFolderNoCreate().getAbsoluteFile();
|
||||
pending = checker.apply(packsFolder);
|
||||
}
|
||||
if (pending == null || !pending.isDone()) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture<PackCheck> completed = pending;
|
||||
pending = null;
|
||||
try {
|
||||
publish(completed.join());
|
||||
server.forEachPlayer(this::notifyPlayer);
|
||||
} catch (CompletionException error) {
|
||||
IrisLogging.reportError("Iris pack update check failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
public void refresh() {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
if (pending != null) {
|
||||
pending.cancel(true);
|
||||
pending = null;
|
||||
}
|
||||
started = false;
|
||||
notices = List.of();
|
||||
notified.clear();
|
||||
}
|
||||
|
||||
public void notifyPlayer(NativeProtocolPlayer player) {
|
||||
if (!enabled || notices.isEmpty() || player == null || !player.connected()
|
||||
|| (!player.isGameMaster() && !player.isServerOwner()) || !notified.add(player.id())) {
|
||||
return;
|
||||
}
|
||||
for (String notice : notices) {
|
||||
player.sendMessage(notice);
|
||||
}
|
||||
}
|
||||
|
||||
private static CompletableFuture<PackCheck> checkPacks(File folder) {
|
||||
return CompletableFuture.supplyAsync(() -> IrisSplashPackScanner.collect(folder, IrisLogging::reportError))
|
||||
.thenCompose(packs -> BuiltInPackUpdates.check(packs.stream().map(SplashPackMetadata::name).toList())
|
||||
.thenApply(updates -> new PackCheck(packs, updates)));
|
||||
}
|
||||
|
||||
private void publish(PackCheck result) {
|
||||
List<String> updates = new ArrayList<>();
|
||||
IrisLogging.info("Iris packs: " + packsFolder);
|
||||
for (SplashPackMetadata pack : result.packs()) {
|
||||
BuiltInPackUpdates.Update update = result.updates().get(pack.name());
|
||||
String version = pack.version().matches("[0-9]+") ? "v" + pack.version() : "version unknown";
|
||||
IrisLogging.info(" " + pack.name() + " " + version
|
||||
+ (update == null ? "" : update.suffix(pack.version())));
|
||||
if (update != null && update.newerThan(pack.version())) {
|
||||
updates.add("Iris " + pack.name() + " release metadata is outdated: installed v" + pack.version()
|
||||
+ ", latest built-in release v" + update.version() + ".");
|
||||
updates.add("Back up and review custom edits in " + new File(packsFolder, pack.name())
|
||||
+ "; close Studio, then update with /iris download pack=" + pack.name() + " overwrite=true.");
|
||||
updates.add("Existing worlds retain their recorded production pack; this updates the authoring pack for new worlds.");
|
||||
}
|
||||
}
|
||||
notices = List.copyOf(updates);
|
||||
for (String notice : notices) {
|
||||
IrisLogging.warn(notice);
|
||||
}
|
||||
}
|
||||
|
||||
record PackCheck(List<SplashPackMetadata> packs, Map<String, BuiltInPackUpdates.Update> updates) {
|
||||
PackCheck {
|
||||
packs = List.copyOf(packs);
|
||||
updates = Map.copyOf(updates);
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-6
@@ -57,6 +57,7 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ModdedStudioHotloadService implements ModdedTickableService, EnginePlatformHooks {
|
||||
@@ -64,6 +65,8 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
|
||||
private static final long POLL_MILLIS = 250L;
|
||||
private static final long CHECK_LATCH_MILLIS = 1_000L;
|
||||
private static final long RECENT_GENERATION_HOLDOFF_MILLIS = 2_000L;
|
||||
private static final long SHUTDOWN_TIMEOUT_SECONDS = 30L;
|
||||
private static final long INTERRUPT_DRAIN_TIMEOUT_SECONDS = 5L;
|
||||
|
||||
private final ConcurrentHashMap<String, Watch> watches = new ConcurrentHashMap<>();
|
||||
private volatile ExecutorService executor;
|
||||
@@ -81,8 +84,12 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
if (executor != null) {
|
||||
return;
|
||||
ExecutorService current = executor;
|
||||
if (current != null && !current.isTerminated()) {
|
||||
if (!current.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException("Iris Studio hotload cannot restart while prior workers are still active");
|
||||
}
|
||||
executor = Executors.newSingleThreadExecutor((Runnable task) -> {
|
||||
Thread thread = new Thread(task, "Iris Studio Hotload");
|
||||
@@ -96,10 +103,10 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
|
||||
@Override
|
||||
public void onDisable() {
|
||||
ExecutorService active = executor;
|
||||
executor = null;
|
||||
if (active != null) {
|
||||
active.shutdownNow();
|
||||
if (!shutdownAndDrain(active)) {
|
||||
throw new IllegalStateException("Iris Studio hotload worker remained active during service shutdown");
|
||||
}
|
||||
executor = null;
|
||||
for (Watch watch : watches.values()) {
|
||||
watch.close();
|
||||
}
|
||||
@@ -197,7 +204,7 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
|
||||
@Override
|
||||
public void onServerTick(NativeModdedServer server) {
|
||||
ExecutorService active = executor;
|
||||
if (active == null) {
|
||||
if (active == null || active.isShutdown()) {
|
||||
return;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
@@ -284,6 +291,30 @@ public final class ModdedStudioHotloadService implements ModdedTickableService,
|
||||
&& !isPregeneratorActive(engine);
|
||||
}
|
||||
|
||||
private static boolean shutdownAndDrain(ExecutorService active) {
|
||||
if (active == null) {
|
||||
return true;
|
||||
}
|
||||
active.shutdown();
|
||||
try {
|
||||
if (active.awaitTermination(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
return true;
|
||||
}
|
||||
active.shutdownNow();
|
||||
if (active.awaitTermination(INTERRUPT_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
return true;
|
||||
}
|
||||
ModdedIrisLog.error("Iris Studio hotload worker did not stop after shutdownNow",
|
||||
new IllegalStateException("Iris Studio hotload worker is still active"));
|
||||
return false;
|
||||
} catch (InterruptedException exception) {
|
||||
active.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
ModdedIrisLog.error("Interrupted while draining Iris Studio hotload", exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean throttled(IrisModdedChunkGenerator generator, Engine engine, long now) {
|
||||
if (now - generator.lastChunkGenAt() < RECENT_GENERATION_HOLDOFF_MILLIS) {
|
||||
return true;
|
||||
|
||||
+31
@@ -39,6 +39,37 @@ public class ModdedClientPackageIsolationTest {
|
||||
"art/arcane/iris/nativegen");
|
||||
private static final int MINIMUM_SCANNED = 50;
|
||||
|
||||
@Test
|
||||
public void clientPresentationCannotAccessPackOrWorldPersistence() throws IOException, URISyntaxException {
|
||||
Path root = classesRoot();
|
||||
Path directory = root.resolve(CLIENT_IRIS);
|
||||
List<String> forbidden = List.of(
|
||||
"art/arcane/iris/pack/",
|
||||
"art/arcane/iris/world/storage/",
|
||||
"art/arcane/iris/world/history/",
|
||||
"art/arcane/iris/generation/runtime/",
|
||||
"art/arcane/iris/configuration/",
|
||||
"java/nio/file/",
|
||||
"java/nio/channels/FileChannel",
|
||||
"java/io/File",
|
||||
"java/io/RandomAccessFile");
|
||||
List<String> violations = new ArrayList<>();
|
||||
int scanned = 0;
|
||||
try (Stream<Path> walk = Files.walk(directory)) {
|
||||
for (Path classFile : walk.filter(ModdedClientPackageIsolationTest::isClassFile).toList()) {
|
||||
scanned++;
|
||||
String bytecode = readAsLatin1(classFile);
|
||||
for (String dependency : forbidden) {
|
||||
if (bytecode.contains(dependency)) {
|
||||
violations.add(root.relativize(classFile) + " -> " + dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue("client presentation classes were not scanned", scanned >= 20);
|
||||
assertEquals("client presentation can access persistent generation data: " + violations, List.of(), violations);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void serverSidePackagesNeverReferenceClientOnlyTypes() throws IOException, URISyntaxException {
|
||||
Path root = classesRoot();
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeBlockFaces;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeBlockState;
|
||||
import art.arcane.volmlib.util.hunk.Hunk;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedDecoratorHooksTest {
|
||||
@Test
|
||||
public void faceFixingExcludesTheUpperBoundaryWhileKeepingTheLastRowAccessible() {
|
||||
NativeBlockState state = mock(NativeBlockState.class);
|
||||
NativeBlockState lastRow = mock(NativeBlockState.class);
|
||||
Hunk<NativeBlockState> hunk = mock(Hunk.class);
|
||||
when(hunk.getHeight()).thenReturn(4);
|
||||
when(hunk.get(1, 3, 2)).thenReturn(lastRow);
|
||||
when(hunk.get(1, 4, 2)).thenThrow(new IndexOutOfBoundsException("upper boundary"));
|
||||
try (MockedStatic<NativeBlockFaces> faces = mockStatic(NativeBlockFaces.class)) {
|
||||
faces.when(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
NativeBlockFaces.Neighbors neighbors = invocation.getArgument(1);
|
||||
assertNull(neighbors.secondary(0, 1, 0));
|
||||
assertSame(lastRow, neighbors.secondary(0, 0, 0));
|
||||
return state;
|
||||
});
|
||||
|
||||
assertSame(state, new ModdedDecoratorHooks().fixFaces(state, hunk, 1, 2, 99, 3, 108, null));
|
||||
|
||||
faces.verify(() -> NativeBlockFaces.fixFaces(eq(state), any(NativeBlockFaces.Neighbors.class)));
|
||||
}
|
||||
verify(hunk, never()).get(1, 4, 2);
|
||||
verify(hunk).get(1, 3, 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.RejectedExecutionException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotSame;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
|
||||
public class ModdedGenPoolTest {
|
||||
@Test
|
||||
public void executorLookupDoesNotRestartStoppedPool() throws Exception {
|
||||
ModdedGenPool.start();
|
||||
ExecutorService original = ModdedGenPool.pool();
|
||||
ModdedGenPool.shutdown();
|
||||
try {
|
||||
ModdedGenPool.pool();
|
||||
fail("Stopped generation pool must reject executor lookup");
|
||||
} catch (RejectedExecutionException expected) {
|
||||
assertTrue(original.isShutdown());
|
||||
}
|
||||
ModdedGenPool.start();
|
||||
try {
|
||||
assertNotSame(original, ModdedGenPool.pool());
|
||||
} finally {
|
||||
ModdedGenPool.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restartWaitsForPreviousWorkersToTerminate() throws Exception {
|
||||
ModdedGenPool.start();
|
||||
ExecutorService original = ModdedGenPool.pool();
|
||||
CountDownLatch entered = new CountDownLatch(1);
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
original.execute(() -> {
|
||||
entered.countDown();
|
||||
boolean released = false;
|
||||
while (!released) {
|
||||
try {
|
||||
release.await();
|
||||
released = true;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.interrupted();
|
||||
}
|
||||
}
|
||||
});
|
||||
assertTrue(entered.await(5L, TimeUnit.SECONDS));
|
||||
try {
|
||||
Thread.currentThread().interrupt();
|
||||
ModdedGenPool.shutdown();
|
||||
assertTrue(Thread.interrupted());
|
||||
assertFalse(original.isTerminated());
|
||||
try {
|
||||
ModdedGenPool.start();
|
||||
fail("Generation pool must retain workers until they terminate");
|
||||
} catch (IllegalStateException expected) {
|
||||
assertFalse(original.isTerminated());
|
||||
}
|
||||
} finally {
|
||||
Thread.interrupted();
|
||||
release.countDown();
|
||||
assertTrue(original.awaitTermination(5L, TimeUnit.SECONDS));
|
||||
ModdedGenPool.start();
|
||||
assertNotSame(original, ModdedGenPool.pool());
|
||||
ExecutorService restarted = ModdedGenPool.pool();
|
||||
ModdedGenPool.start();
|
||||
assertSame(restarted, ModdedGenPool.pool());
|
||||
ModdedGenPool.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import art.arcane.iris.modded.command.ModdedPackCommands;
|
||||
import art.arcane.iris.pack.BrokenPackException;
|
||||
import art.arcane.iris.pack.PackValidationRegistry;
|
||||
import art.arcane.iris.pack.PackValidationResult;
|
||||
import art.arcane.iris.pack.PackValidator;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertNull;
|
||||
import static org.junit.Assert.assertSame;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
|
||||
public class ModdedPackValidationPublicationTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@After
|
||||
public void clearValidation() {
|
||||
PackValidationRegistry.clear();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validationPublishesTheCanonicalRootAndPackName() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
File equivalent = new File(root, ".");
|
||||
PackValidationResult result = loadable(root);
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
|
||||
validator.when(() -> PackValidator.validate(equivalent)).thenReturn(result);
|
||||
|
||||
assertSame(result, ModdedStartup.validatePack(equivalent));
|
||||
|
||||
assertSame(result, PackValidationRegistry.requireLoadable(root.toPath()));
|
||||
assertSame(result, PackValidationRegistry.requireLoadable(root.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rootInvalidationCannotReusePackNameValidation() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
PackValidationRegistry.publish(loadable(root));
|
||||
PackValidationResult replacement = loadable(root);
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
|
||||
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
|
||||
validator.when(() -> PackValidator.validate(root)).thenReturn(replacement);
|
||||
|
||||
assertSame(replacement, ModdedStartup.requirePackForWorldCreation(root.getName()));
|
||||
|
||||
validator.verify(() -> PackValidator.validate(root));
|
||||
assertSame(replacement, PackValidationRegistry.requireLoadable(root.toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void changedContentWithPreservedTimestampCannotReuseValidation() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
File content = new File(root, "dimension.json");
|
||||
Files.writeString(content.toPath(), "valid");
|
||||
PackValidationResult original = loadable(root);
|
||||
PackValidationResult broken = new PackValidationResult(root.getName(), List.of("invalid"), List.of(),
|
||||
System.currentTimeMillis());
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||
MockedStatic<ModdedPackCommands> commands = mockStatic(ModdedPackCommands.class)) {
|
||||
commands.when(ModdedPackCommands::packsRoot).thenReturn(root.getParentFile());
|
||||
validator.when(() -> PackValidator.validate(root)).thenReturn(original, broken);
|
||||
ModdedStartup.validatePack(root);
|
||||
Files.writeString(content.toPath(), "wrong");
|
||||
Files.setLastModifiedTime(content.toPath(), FileTime.fromMillis(1L));
|
||||
Files.setLastModifiedTime(root.toPath(), FileTime.fromMillis(1L));
|
||||
|
||||
assertThrows(BrokenPackException.class,
|
||||
() -> ModdedStartup.requirePackForWorldCreation(root.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void externalEditDuringValidationCannotPublishMixedSnapshot() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
File content = new File(root, "dimension.json");
|
||||
Files.writeString(content.toPath(), "original");
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||
Files.writeString(content.toPath(), "changed");
|
||||
return loadable(root);
|
||||
});
|
||||
|
||||
assertThrows(BrokenPackException.class, () -> ModdedStartup.validatePack(root));
|
||||
assertNull(PackValidationRegistry.get(root.toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mutationDuringValidationPreventsStalePublication() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenAnswer(invocation -> {
|
||||
try (PackValidationRegistry.RootMutation mutation = PackValidationRegistry.beginRootMutation(root.toPath())) {
|
||||
return loadable(root);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
ModdedStartup.validatePack(root);
|
||||
fail("Changed pack must reject stale validation");
|
||||
} catch (BrokenPackException expected) {
|
||||
assertNull(PackValidationRegistry.get(root.toPath()));
|
||||
assertNull(PackValidationRegistry.get(root.getName()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validationFailurePublishesBlockedRoot() throws Exception {
|
||||
File root = temporaryFolder.newFolder("terrain");
|
||||
try (MockedStatic<PackValidator> validator = mockStatic(PackValidator.class);
|
||||
MockedStatic<ModdedIrisLog> log = mockStatic(ModdedIrisLog.class)) {
|
||||
validator.when(() -> PackValidator.validate(root)).thenThrow(new IllegalStateException("Invalid terrain"));
|
||||
|
||||
PackValidationResult result = ModdedStartup.validatePack(root);
|
||||
|
||||
assertFalse(result.isLoadable());
|
||||
assertSame(result, PackValidationRegistry.get(root.toPath()));
|
||||
assertSame(result, PackValidationRegistry.get(root.getName()));
|
||||
}
|
||||
}
|
||||
|
||||
private static PackValidationResult loadable(File root) {
|
||||
return new PackValidationResult(root.getName(), List.of(), List.of(), System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
+44
@@ -4,7 +4,10 @@ import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeWorldTeleport;
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import org.junit.After;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.mockito.invocation.InvocationOnMock;
|
||||
@@ -38,6 +41,12 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
private static final int TICK_INTERVAL = 20;
|
||||
private static final String PRIMARY = "iris:primary";
|
||||
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@After
|
||||
public void clearRouter() {
|
||||
ModdedPrimaryWorldRouter.clear();
|
||||
@@ -233,6 +242,41 @@ public class ModdedPrimaryWorldRouterTest {
|
||||
return new ModdedDimensionRegistryStore.PersistentDimension(PRIMARY, "overworld", "overworld", 1337L);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disconnectCancelsOldRoutingWithoutAffectingReconnectedPlayer() {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
NativeWorld overworld = mock(NativeWorld.class);
|
||||
NativeWorld primary = mock(NativeWorld.class);
|
||||
when(overworld.nativeHandle()).thenReturn(new Object());
|
||||
when(primary.nativeHandle()).thenReturn(new Object());
|
||||
when(server.overworld()).thenReturn(overworld);
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
UUID id = UUID.randomUUID();
|
||||
when(player.id()).thenReturn(id);
|
||||
when(player.isInWorld(overworld)).thenReturn(true);
|
||||
onlinePlayers(server, player);
|
||||
CompletableFuture<Boolean> oldRoute = new CompletableFuture<>();
|
||||
CompletableFuture<Boolean> newRoute = new CompletableFuture<>();
|
||||
ModdedModConfig config = config(true, PRIMARY);
|
||||
try (MockedStatic<ModdedModConfig> configs = mockStatic(ModdedModConfig.class);
|
||||
MockedStatic<ModdedDimensionManager> manager = mockStatic(ModdedDimensionManager.class);
|
||||
MockedStatic<NativeWorldTeleport> teleport = mockStatic(NativeWorldTeleport.class)) {
|
||||
configs.when(ModdedModConfig::get).thenReturn(config);
|
||||
manager.when(() -> ModdedDimensionManager.level(server, PRIMARY)).thenReturn(primary);
|
||||
teleport.when(() -> NativeWorldTeleport.teleport(any(), any())).thenReturn(oldRoute, newRoute);
|
||||
evaluate(server);
|
||||
ModdedPrimaryWorldRouter.forget(id);
|
||||
assertTrue(oldRoute.isCancelled());
|
||||
evaluate(server);
|
||||
oldRoute.complete(true);
|
||||
evaluate(server);
|
||||
teleport.verify(() -> NativeWorldTeleport.teleport(any(), any()), times(2));
|
||||
assertFalse(newRoute.isDone());
|
||||
ModdedPrimaryWorldRouter.clear();
|
||||
assertTrue(newRoute.isCancelled());
|
||||
}
|
||||
}
|
||||
|
||||
private static void evaluate(NativeModdedServer server) {
|
||||
for (int tick = 0; tick < TICK_INTERVAL; tick++) {
|
||||
ModdedPrimaryWorldRouter.tick(server);
|
||||
|
||||
+5
@@ -57,6 +57,11 @@ public class ModdedStartupReinjectFailureTest {
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
ModdedStartup.warnStartupFailuresTo(operator);
|
||||
ModdedStartup.warnStartupFailuresTo(player);
|
||||
NativeProtocolPlayer owner = mock(NativeProtocolPlayer.class);
|
||||
when(owner.isServerOwner()).thenReturn(true);
|
||||
ModdedStartup.warnStartupFailuresTo(owner);
|
||||
verify(owner).sendMessage(argThat((String message) -> message.contains("iris:moon")
|
||||
&& message.contains("generation history is unusable")));
|
||||
|
||||
verify(operator).sendMessage(argThat((String message) -> message.contains("iris:moon")
|
||||
&& message.contains("generation history is unusable")));
|
||||
|
||||
+3
@@ -21,6 +21,8 @@ import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.entity.EntityDimensions;
|
||||
import net.minecraft.world.entity.Pose;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
@@ -98,6 +100,7 @@ public class IrisModdedCommandsTeleportTest {
|
||||
when(server.getLevel(dimension)).thenReturn(serverLevel);
|
||||
ServerPlayer serverPlayer = mock(ServerPlayer.class);
|
||||
when(serverPlayer.getUUID()).thenReturn(UUID.randomUUID());
|
||||
when(serverPlayer.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
|
||||
NativeWorld level = mock(NativeWorld.class);
|
||||
when(level.nativeHandle()).thenReturn(serverLevel);
|
||||
|
||||
|
||||
+209
@@ -0,0 +1,209 @@
|
||||
package art.arcane.iris.modded.service;
|
||||
|
||||
import art.arcane.iris.diagnostics.splash.IrisSplashPackScanner.SplashPackMetadata;
|
||||
import art.arcane.iris.pack.BuiltInPackUpdates;
|
||||
import art.arcane.iris.spi.IrisPlatform;
|
||||
import art.arcane.iris.spi.IrisPlatforms;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeModdedServer;
|
||||
import art.arcane.volmlib.nativelib.minecraft26_2.modded.NativeProtocolPlayer;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.server.permissions.LevelBasedPermissionSet;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.doAnswer;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedPackUpdateServiceTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void pendingCheckNotifiesOnlineAndLaterOperatorsOnceOnServerTick() {
|
||||
CompletableFuture<ModdedPackUpdateService.PackCheck> pending = new CompletableFuture<>();
|
||||
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> pending);
|
||||
NativeProtocolPlayer online = player(true, false);
|
||||
NativeModdedServer server = server(online);
|
||||
File root = new File("config/irisworldgen/packs").getAbsoluteFile();
|
||||
try (MockedStatic<IrisPlatforms> platforms = platform(root)) {
|
||||
service.onEnable();
|
||||
service.onServerTick(server);
|
||||
service.notifyPlayer(online);
|
||||
verify(online, never()).sendMessage(anyString());
|
||||
pending.complete(outdated());
|
||||
verify(online, never()).sendMessage(anyString());
|
||||
service.onServerTick(server);
|
||||
service.onServerTick(server);
|
||||
service.notifyPlayer(online);
|
||||
verify(online, times(1)).sendMessage(contains("installed v1, latest built-in release v2"));
|
||||
verify(online, times(1)).sendMessage(contains(new File(root, "overworld").toString()));
|
||||
verify(online, times(1)).sendMessage(contains("/iris download pack=overworld overwrite=true"));
|
||||
verify(online, never()).sendMessage(contains("plugins/"));
|
||||
NativeProtocolPlayer later = player(true, false);
|
||||
service.notifyPlayer(later);
|
||||
verify(later).sendMessage(contains("release metadata is outdated"));
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void integratedOwnerWithoutCheatsReceivesNoticeAndRemoteOrdinaryPlayersDoNot() {
|
||||
NativeProtocolPlayer owner = player(false, true);
|
||||
NativeProtocolPlayer remote = player(false, false);
|
||||
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(outdated()));
|
||||
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
|
||||
service.onEnable();
|
||||
service.onServerTick(server(owner));
|
||||
verify(owner).sendMessage(contains("release metadata is outdated"));
|
||||
service.notifyPlayer(remote);
|
||||
verify(remote, never()).sendMessage(anyString());
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void disableCancelsCheckAndRestartDoesNotPublishOldNotices() {
|
||||
CompletableFuture<ModdedPackUpdateService.PackCheck> first = new CompletableFuture<>();
|
||||
CompletableFuture<ModdedPackUpdateService.PackCheck> second = new CompletableFuture<>();
|
||||
AtomicInteger checks = new AtomicInteger();
|
||||
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0 ? first : second);
|
||||
NativeProtocolPlayer operator = player(true, false);
|
||||
NativeModdedServer server = server(operator);
|
||||
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
|
||||
service.onEnable();
|
||||
service.onServerTick(server);
|
||||
service.onDisable();
|
||||
assertTrue(first.isCancelled());
|
||||
assertFalse(first.complete(outdated()));
|
||||
service.onEnable();
|
||||
service.onServerTick(server);
|
||||
second.complete(new ModdedPackUpdateService.PackCheck(List.of(), Map.of()));
|
||||
service.onServerTick(server);
|
||||
verify(operator, never()).sendMessage(anyString());
|
||||
assertEquals(2, checks.get());
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void currentCustomUnknownAndUnavailableVersionsNeverBecomeOutdated() {
|
||||
ModdedPackUpdateService.PackCheck result = new ModdedPackUpdateService.PackCheck(List.of(
|
||||
new SplashPackMetadata("overworld", "unknown"),
|
||||
new SplashPackMetadata("underworld", "3"),
|
||||
new SplashPackMetadata("custom", "1")), Map.of(
|
||||
"overworld", new BuiltInPackUpdates.Update("2"),
|
||||
"underworld", new BuiltInPackUpdates.Update("2")));
|
||||
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> CompletableFuture.completedFuture(result));
|
||||
NativeProtocolPlayer operator = player(true, false);
|
||||
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
|
||||
service.onEnable();
|
||||
service.onServerTick(server(operator));
|
||||
verify(operator, never()).sendMessage(anyString());
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void successfulAuthoringUpdateClearsOldNoticesAndRescansInstalledVersion() {
|
||||
CompletableFuture<ModdedPackUpdateService.PackCheck> current = new CompletableFuture<>();
|
||||
AtomicInteger checks = new AtomicInteger();
|
||||
ModdedPackUpdateService service = new ModdedPackUpdateService(folder -> checks.getAndIncrement() == 0
|
||||
? CompletableFuture.completedFuture(outdated()) : current);
|
||||
NativeProtocolPlayer operator = player(true, false);
|
||||
NativeModdedServer server = server(operator);
|
||||
try (MockedStatic<IrisPlatforms> platforms = platform(new File("config/irisworldgen/packs"))) {
|
||||
service.onEnable();
|
||||
service.onServerTick(server);
|
||||
verify(operator).sendMessage(contains("release metadata is outdated"));
|
||||
service.refresh();
|
||||
NativeProtocolPlayer later = player(true, false);
|
||||
service.notifyPlayer(later);
|
||||
verify(later, never()).sendMessage(anyString());
|
||||
service.onServerTick(server);
|
||||
current.complete(new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "2")),
|
||||
Map.of("overworld", new BuiltInPackUpdates.Update("2"))));
|
||||
service.onServerTick(server);
|
||||
service.notifyPlayer(later);
|
||||
verify(later, never()).sendMessage(anyString());
|
||||
assertEquals(2, checks.get());
|
||||
service.onDisable();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nativeOwnerCheckDoesNotGrantGameMasterPermissions() {
|
||||
ServerPlayer handle = mock(ServerPlayer.class);
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
MinecraftServer server = mock(MinecraftServer.class);
|
||||
when(handle.level()).thenReturn(level);
|
||||
when(level.getServer()).thenReturn(server);
|
||||
when(handle.permissions()).thenReturn(LevelBasedPermissionSet.ALL);
|
||||
NativeProtocolPlayer player = NativeProtocolPlayer.fromHandle(handle);
|
||||
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(true);
|
||||
assertTrue(player.isServerOwner());
|
||||
assertFalse(player.isGameMaster());
|
||||
when(server.isSingleplayerOwner(handle.nameAndId())).thenReturn(false);
|
||||
assertFalse(player.isServerOwner());
|
||||
assertFalse(player.isGameMaster());
|
||||
}
|
||||
|
||||
private static ModdedPackUpdateService.PackCheck outdated() {
|
||||
return new ModdedPackUpdateService.PackCheck(List.of(new SplashPackMetadata("overworld", "1")),
|
||||
Map.of("overworld", new BuiltInPackUpdates.Update("2")));
|
||||
}
|
||||
|
||||
private static MockedStatic<IrisPlatforms> platform(File root) {
|
||||
IrisPlatform platform = mock(IrisPlatform.class);
|
||||
when(platform.packsFolderNoCreate()).thenReturn(root);
|
||||
MockedStatic<IrisPlatforms> platforms = mockStatic(IrisPlatforms.class);
|
||||
platforms.when(IrisPlatforms::get).thenReturn(platform);
|
||||
return platforms;
|
||||
}
|
||||
|
||||
private static NativeProtocolPlayer player(boolean operator, boolean owner) {
|
||||
NativeProtocolPlayer player = mock(NativeProtocolPlayer.class);
|
||||
when(player.id()).thenReturn(UUID.randomUUID());
|
||||
when(player.connected()).thenReturn(true);
|
||||
when(player.isGameMaster()).thenReturn(operator);
|
||||
when(player.isServerOwner()).thenReturn(owner);
|
||||
return player;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static NativeModdedServer server(NativeProtocolPlayer player) {
|
||||
NativeModdedServer server = mock(NativeModdedServer.class);
|
||||
when(server.hasPlayerList()).thenReturn(true);
|
||||
doAnswer(invocation -> {
|
||||
invocation.getArgument(0, Consumer.class).accept(player);
|
||||
return null;
|
||||
}).when(server).forEachPlayer(any());
|
||||
return server;
|
||||
}
|
||||
}
|
||||
+57
@@ -4,14 +4,65 @@ import art.arcane.iris.generation.terrain.IrisDimension;
|
||||
import art.arcane.iris.modded.ModdedScheduler;
|
||||
import art.arcane.volmlib.util.collection.KList;
|
||||
import org.junit.Test;
|
||||
import org.mockito.InOrder;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.fail;
|
||||
import static org.mockito.Mockito.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class ModdedStudioHotloadServiceTest {
|
||||
@Test
|
||||
public void shutdownDrainsWorkerBeforeAllowingRestart() throws Exception {
|
||||
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
|
||||
ExecutorService executor = mock(ExecutorService.class);
|
||||
setExecutor(service, executor);
|
||||
when(executor.awaitTermination(30L, TimeUnit.SECONDS)).thenReturn(false);
|
||||
when(executor.awaitTermination(5L, TimeUnit.SECONDS)).thenReturn(true);
|
||||
|
||||
service.onDisable();
|
||||
|
||||
InOrder order = inOrder(executor);
|
||||
order.verify(executor).shutdown();
|
||||
order.verify(executor).awaitTermination(30L, TimeUnit.SECONDS);
|
||||
order.verify(executor).shutdownNow();
|
||||
order.verify(executor).awaitTermination(5L, TimeUnit.SECONDS);
|
||||
service.onEnable();
|
||||
service.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void undrainedWorkerPreventsRestart() throws Exception {
|
||||
ModdedStudioHotloadService service = new ModdedStudioHotloadService();
|
||||
ExecutorService executor = mock(ExecutorService.class);
|
||||
setExecutor(service, executor);
|
||||
when(executor.isShutdown()).thenReturn(true);
|
||||
try {
|
||||
service.onDisable();
|
||||
fail("Hotload shutdown must report a worker that did not terminate");
|
||||
} catch (IllegalStateException expected) {
|
||||
verify(executor).shutdownNow();
|
||||
}
|
||||
try {
|
||||
service.onEnable();
|
||||
fail("Hotload must not start another worker before the previous worker terminates");
|
||||
} catch (IllegalStateException expected) {
|
||||
assertFalse(executor.isTerminated());
|
||||
}
|
||||
when(executor.isTerminated()).thenReturn(true);
|
||||
service.onEnable();
|
||||
service.onDisable();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void detectsDatapackImportsAcrossLoadedDimensions() {
|
||||
IrisDimension empty = new IrisDimension();
|
||||
@@ -45,4 +96,10 @@ public class ModdedStudioHotloadServiceTest {
|
||||
scheduler.shutdown();
|
||||
}
|
||||
}
|
||||
private static void setExecutor(ModdedStudioHotloadService service, ExecutorService executor) throws Exception {
|
||||
Field field = ModdedStudioHotloadService.class.getDeclaredField("executor");
|
||||
field.setAccessible(true);
|
||||
field.set(service, executor);
|
||||
}
|
||||
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package art.arcane.volmlib.nativelib.minecraft26_2.modded;
|
||||
|
||||
import art.arcane.volmlib.nativelib.terrain.NativeWorld;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.core.registries.Registries;
|
||||
import net.minecraft.resources.Identifier;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.Bootstrap;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerChunkCache;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.EntityDimensions;
|
||||
import net.minecraft.world.entity.Pose;
|
||||
import net.minecraft.world.level.ChunkPos;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.block.Blocks;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraft.world.phys.AABB;
|
||||
import org.junit.BeforeClass;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.Mockito.RETURNS_DEEP_STUBS;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class NativeTeleportSafetyTest {
|
||||
@BeforeClass
|
||||
public static void bootstrap() {
|
||||
SharedConstants.tryDetectVersion();
|
||||
Bootstrap.bootStrap();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTallAndHazardousSupportsAndAcceptsSolidFloor() {
|
||||
ServerPlayer player = player();
|
||||
ServerLevel level = level();
|
||||
for (BlockState support : new BlockState[]{Blocks.OAK_FENCE.defaultBlockState(),
|
||||
Blocks.MAGMA_BLOCK.defaultBlockState(), Blocks.CACTUS.defaultBlockState()}) {
|
||||
setSupport(level, support);
|
||||
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
|
||||
}
|
||||
setSupport(level, Blocks.STONE.defaultBlockState());
|
||||
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fractionalPositionUsesWholePlayerBodyAndRequiresNeighborChunk() {
|
||||
ServerPlayer player = player();
|
||||
ServerLevel level = level();
|
||||
setSupport(level, Blocks.STONE.defaultBlockState());
|
||||
when(level.noCollision(any(), any(AABB.class))).thenAnswer(invocation -> {
|
||||
AABB body = invocation.getArgument(1);
|
||||
return body.maxX <= 16D;
|
||||
});
|
||||
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.5D, 4, 8.5D));
|
||||
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
|
||||
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
|
||||
when(level.getChunkSource().hasChunk(1, 0)).thenReturn(false);
|
||||
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 15.9D, 4, 8.5D));
|
||||
EntityDimensions dimensions = player.getDimensions(Pose.STANDING);
|
||||
assertEquals(0, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.5D, 4D, 8.5D), new ChunkPos(0, 0)));
|
||||
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(15.9D, 4D, 8.5D), new ChunkPos(0, 0)));
|
||||
assertEquals(1, NativeWorldTeleport.warmRadius(dimensions.makeBoundingBox(-16.1D, 4D, -8.5D), new ChunkPos(-2, -1)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void scaledStandingBodyStaysInsideActualWorldHeight() {
|
||||
ServerLevel level = level();
|
||||
setSupport(level, Blocks.STONE.defaultBlockState());
|
||||
ServerPlayer player = player();
|
||||
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 13F));
|
||||
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
|
||||
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 12F));
|
||||
assertTrue(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 4, 8.5D));
|
||||
assertFalse(NativeWorldTeleport.isSafeStandingPosition(level, player, 8.5D, 0, 8.5D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void reconnectDuringChunkWarmupDoesNotTeleportReplacementSession() {
|
||||
MinecraftServer server = mock(MinecraftServer.class, RETURNS_DEEP_STUBS);
|
||||
when(server.isSameThread()).thenReturn(true);
|
||||
ServerLevel level = level();
|
||||
ResourceKey<Level> key = ResourceKey.create(Registries.DIMENSION, Identifier.parse("iris:test"));
|
||||
when(level.getServer()).thenReturn(server);
|
||||
when(level.dimension()).thenReturn(key);
|
||||
when(server.getLevel(key)).thenReturn(level);
|
||||
ServerChunkCache chunks = level.getChunkSource();
|
||||
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(false);
|
||||
CompletableFuture<Void> loaded = new CompletableFuture<>();
|
||||
doReturn(loaded).when(chunks).addTicketAndLoadWithRadius(any(), any(), anyInt());
|
||||
ServerPlayer original = player();
|
||||
UUID id = UUID.randomUUID();
|
||||
when(original.getUUID()).thenReturn(id);
|
||||
when(server.getPlayerList().getPlayer(id)).thenReturn(original);
|
||||
NativeWorld world = mock(NativeWorld.class);
|
||||
when(world.nativeHandle()).thenReturn(level);
|
||||
CompletableFuture<Boolean> result = NativeWorldTeleport.teleport(NativeProtocolPlayer.fromHandle(original),
|
||||
new NativeWorldTeleport.Destination(NativeModdedServer.fromHandle(server), world, 8.5D, 4D, 8.5D, 0L));
|
||||
assertFalse(result.isDone());
|
||||
ServerPlayer replacement = player();
|
||||
when(server.getPlayerList().getPlayer(id)).thenReturn(replacement);
|
||||
loaded.complete(null);
|
||||
assertFalse(result.join());
|
||||
verify(chunks).removeTicketWithRadius(any(), any(), anyInt());
|
||||
}
|
||||
|
||||
private ServerPlayer player() {
|
||||
ServerPlayer player = mock(ServerPlayer.class);
|
||||
when(player.getDimensions(Pose.STANDING)).thenReturn(EntityDimensions.scalable(0.6F, 1.8F));
|
||||
return player;
|
||||
}
|
||||
|
||||
private ServerLevel level() {
|
||||
ServerLevel level = mock(ServerLevel.class);
|
||||
ServerChunkCache chunks = mock(ServerChunkCache.class);
|
||||
when(level.getChunkSource()).thenReturn(chunks);
|
||||
when(level.getMinY()).thenReturn(0);
|
||||
when(level.getMaxY()).thenReturn(16);
|
||||
when(chunks.hasChunk(anyInt(), anyInt())).thenReturn(true);
|
||||
when(level.noCollision(any(), any(AABB.class))).thenReturn(true);
|
||||
return level;
|
||||
}
|
||||
|
||||
private void setSupport(ServerLevel level, BlockState support) {
|
||||
when(level.getBlockState(any(BlockPos.class))).thenAnswer(invocation -> {
|
||||
BlockPos position = invocation.getArgument(0);
|
||||
return position.getY() == 3 ? support : Blocks.AIR.defaultBlockState();
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user