mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-08-27 04:37:47 +00:00
Way too much
This commit is contained in:
+29
-4
@@ -11,6 +11,7 @@ import art.arcane.iris.engine.framework.GenerationSessionLease;
|
||||
import art.arcane.iris.engine.object.IrisBiome;
|
||||
import art.arcane.iris.engine.object.IrisBiomeCustom;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.util.project.context.IrisContext;
|
||||
import art.arcane.volmlib.util.collection.KMap;
|
||||
import art.arcane.volmlib.util.math.RNG;
|
||||
@@ -521,6 +522,16 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
int y,
|
||||
int z,
|
||||
Climate.Sampler sampler
|
||||
) {
|
||||
return getVisibleNoiseBiomeWithActiveGenerationLease(x, y, z, sampler, null);
|
||||
}
|
||||
|
||||
Holder<Biome> getVisibleNoiseBiomeWithActiveGenerationLease(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
Climate.Sampler sampler,
|
||||
IrisDimensionCarvingResolver.State resolverState
|
||||
) {
|
||||
long cacheKey = packNoiseKey(x, y, z);
|
||||
Holder<Biome> cachedHolder = noiseBiomeCache.get(cacheKey);
|
||||
@@ -528,7 +539,7 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
return cachedHolder;
|
||||
}
|
||||
|
||||
Holder<Biome> resolvedHolder = resolveVisibleBiomeHolder(x, y, z);
|
||||
Holder<Biome> resolvedHolder = resolveVisibleBiomeHolder(x, y, z, resolverState);
|
||||
Holder<Biome> existingHolder = noiseBiomeCache.putIfAbsent(cacheKey, resolvedHolder);
|
||||
if (existingHolder != null) {
|
||||
return existingHolder;
|
||||
@@ -602,8 +613,13 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
return holder;
|
||||
}
|
||||
|
||||
private Holder<Biome> resolveVisibleBiomeHolder(int x, int y, int z) {
|
||||
BiomeResolution resolution = resolveBiomeResolution(x, y, z);
|
||||
private Holder<Biome> resolveVisibleBiomeHolder(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
IrisDimensionCarvingResolver.State resolverState
|
||||
) {
|
||||
BiomeResolution resolution = resolveBiomeResolution(x, y, z, resolverState);
|
||||
if (resolution == null) {
|
||||
return getFallbackBiome();
|
||||
}
|
||||
@@ -636,6 +652,15 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
}
|
||||
|
||||
private BiomeResolution resolveBiomeResolution(int x, int y, int z) {
|
||||
return resolveBiomeResolution(x, y, z, null);
|
||||
}
|
||||
|
||||
private BiomeResolution resolveBiomeResolution(
|
||||
int x,
|
||||
int y,
|
||||
int z,
|
||||
IrisDimensionCarvingResolver.State resolverState
|
||||
) {
|
||||
if (engine == null || engine.isClosed()) {
|
||||
return null;
|
||||
}
|
||||
@@ -657,7 +682,7 @@ public class CustomBiomeSource extends BiomeSource {
|
||||
int surfaceInternalY = engine.getComplex().getHeightStream().get(blockX, blockZ).intValue();
|
||||
underground = internalY <= surfaceInternalY - 8;
|
||||
irisBiome = underground
|
||||
? engine.getCaveBiome(blockX, internalY, blockZ)
|
||||
? engine.getCaveBiome(blockX, internalY, blockZ, resolverState)
|
||||
: engine.getComplex().getTrueBiomeStream().get(blockX, blockZ);
|
||||
} else {
|
||||
irisBiome = engine.getComplex().getTrueBiomeStream().get(blockX, blockZ);
|
||||
|
||||
+4
-1
@@ -10,6 +10,7 @@ import art.arcane.iris.engine.framework.NativeStructureGenerationPolicy;
|
||||
import art.arcane.iris.engine.IrisEngine;
|
||||
import art.arcane.iris.engine.platform.BukkitChunkGenerator;
|
||||
import art.arcane.iris.engine.object.IrisDimension;
|
||||
import art.arcane.iris.engine.object.IrisDimensionCarvingResolver;
|
||||
import art.arcane.iris.engine.object.IrisMaterialPalette;
|
||||
import art.arcane.iris.engine.object.IrisNativeStructureDecision;
|
||||
import art.arcane.iris.nativegen.NativeStructureGenerationException;
|
||||
@@ -643,8 +644,10 @@ public class IrisChunkGenerator extends CustomChunkGenerator {
|
||||
GenerationSessionLease lease = requireGenerationLease("bukkit_nms_create_biomes");
|
||||
IrisContext.Scope ignored = IrisContext.open(engine, lease.sessionId(), null)) {
|
||||
customBiomeSource.prepareVisibleBiomeBatch();
|
||||
IrisDimensionCarvingResolver.State resolverState = new IrisDimensionCarvingResolver.State();
|
||||
ichunkaccess.fillBiomesFromNoise(
|
||||
customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease,
|
||||
(x, y, z, sampler) -> customBiomeSource.getVisibleNoiseBiomeWithActiveGenerationLease(
|
||||
x, y, z, sampler, resolverState),
|
||||
randomstate.sampler());
|
||||
return CompletableFuture.completedFuture(ichunkaccess);
|
||||
}
|
||||
|
||||
+2
-1
@@ -258,7 +258,8 @@ public class IrisChunkGeneratorFailureContractTest {
|
||||
|
||||
assertTrue(createBiomes.contains("requireGenerationStage(\"bukkit_nms_create_biomes\")"));
|
||||
assertTrue(createBiomes.contains("customBiomeSource.prepareVisibleBiomeBatch()"));
|
||||
assertTrue(createBiomes.contains("customBiomeSource::getVisibleNoiseBiomeWithActiveGenerationLease"));
|
||||
assertTrue(createBiomes.contains("new IrisDimensionCarvingResolver.State()"));
|
||||
assertTrue(createBiomes.contains("sampler, resolverState"));
|
||||
assertTrue(buildSurface.contains("delegate.buildSurface("));
|
||||
assertTrue(carvers.contains("delegate.applyCarvers("));
|
||||
assertTrue(noise.contains("requireNoiseGenerationStage("));
|
||||
|
||||
@@ -573,7 +573,7 @@ public class Iris extends VolmitPlugin implements Listener, ReloadAware {
|
||||
}
|
||||
|
||||
private static String logPrefix(Iris plugin) {
|
||||
return plugin == null ? "[Iris] " : plugin.getTag();
|
||||
return plugin == null ? ComponentLog.discriminator("Iris", "&a") : plugin.getTag();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,6 +9,7 @@ import art.arcane.iris.core.lifecycle.WorldReplacementBootstrap;
|
||||
import art.arcane.iris.core.lifecycle.WorldReplacementBootstrapMarker;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner;
|
||||
import art.arcane.iris.core.pack.DefaultPackBootstrapProvisioner.ProvisionResult;
|
||||
import art.arcane.iris.util.common.misc.SlimJar;
|
||||
import io.papermc.paper.plugin.bootstrap.BootstrapContext;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginBootstrap;
|
||||
import io.papermc.paper.plugin.lifecycle.event.LifecycleEvent;
|
||||
@@ -26,6 +27,7 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
public void bootstrap(BootstrapContext context) {
|
||||
WorldReplacementBootstrapMarker.markBootstrapped();
|
||||
try {
|
||||
loadRuntimeLibraries(context);
|
||||
BukkitStartupPaths startupPaths = BukkitStartupPaths.resolveCurrent();
|
||||
reconcilePendingWorldReplacements(context, startupPaths);
|
||||
quarantineWorthlessHusks(startupPaths, message -> context.getLogger().warn(message));
|
||||
@@ -38,6 +40,27 @@ public final class IrisBootstrap implements PluginBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private static void loadRuntimeLibraries(BootstrapContext context) {
|
||||
SlimJar.loadBootstrap(
|
||||
context.getDataDirectory().resolve("cache").resolve("libraries"),
|
||||
new SlimJar.BootstrapLogger() {
|
||||
@Override
|
||||
public void info(String message) {
|
||||
context.getLogger().info(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String message) {
|
||||
context.getLogger().error(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String message) {
|
||||
context.getLogger().debug(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void reconcilePendingWorldReplacements(
|
||||
BootstrapContext context,
|
||||
BukkitStartupPaths startupPaths
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import art.arcane.iris.util.common.misc.SlimJar;
|
||||
import io.papermc.paper.plugin.bootstrap.PluginProviderContext;
|
||||
import io.papermc.paper.plugin.loader.PluginClasspathBuilder;
|
||||
import io.papermc.paper.plugin.loader.PluginLoader;
|
||||
import io.papermc.paper.plugin.loader.library.impl.JarLibrary;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public final class IrisPluginLoader implements PluginLoader {
|
||||
@Override
|
||||
public void classloader(PluginClasspathBuilder classpathBuilder) {
|
||||
PluginProviderContext context = classpathBuilder.getContext();
|
||||
Path libraryRoot = context.getDataDirectory().resolve("cache").resolve("libraries");
|
||||
SlimJar.loadBootstrap(libraryRoot, new SlimJar.BootstrapLogger() {
|
||||
@Override
|
||||
public void info(String message) {
|
||||
context.getLogger().info(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void error(String message) {
|
||||
context.getLogger().error(message);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void debug(String message) {
|
||||
context.getLogger().debug(message);
|
||||
}
|
||||
});
|
||||
for (Path library : relocatedLibraries(libraryRoot)) {
|
||||
classpathBuilder.addLibrary(new JarLibrary(library));
|
||||
}
|
||||
}
|
||||
|
||||
static List<Path> relocatedLibraries(Path libraryRoot) {
|
||||
try (Stream<Path> paths = Files.walk(libraryRoot)) {
|
||||
List<Path> libraries = paths
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(path -> path.getFileName().toString().endsWith(".jar"))
|
||||
.filter(IrisPluginLoader::isRelocatedLibrary)
|
||||
.sorted()
|
||||
.toList();
|
||||
if (libraries.isEmpty()) {
|
||||
throw new IllegalStateException("Iris runtime library provisioning produced no relocated libraries.");
|
||||
}
|
||||
return libraries;
|
||||
} catch (IOException failure) {
|
||||
throw new IllegalStateException("Unable to enumerate provisioned Iris runtime libraries.", failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isRelocatedLibrary(Path path) {
|
||||
int count = path.getNameCount();
|
||||
for (int i = 0; i < count - 2; i++) {
|
||||
if ("relocated".equals(path.getName(i).toString())
|
||||
&& "Iris".equals(path.getName(i + 1).toString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -96,6 +96,7 @@ import java.time.temporal.ChronoUnit;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -412,7 +413,8 @@ public class CommandStudio implements DirectorExecutor {
|
||||
return;
|
||||
}
|
||||
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine());
|
||||
UUID openerId = sender().isPlayer() ? player().getUniqueId() : null;
|
||||
VisionGUI.launch(IrisToolbelt.access(world).getEngine(), openerId);
|
||||
sender().sendMessage(IrisLanguage.text(BukkitCommandMessagesExtended.COMMAND_STUDIO_OPENING_MAP));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import org.bukkit.event.Listener;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
@@ -72,8 +73,8 @@ public final class BukkitGuiHost implements GuiHost.Provider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public GuiOverlay overlayFor(Engine engine) {
|
||||
return engine == null ? null : new BukkitVisionOverlay(engine);
|
||||
public GuiOverlay overlayFor(Engine engine, UUID openerId) {
|
||||
return engine == null ? null : new BukkitVisionOverlay(engine, openerId);
|
||||
}
|
||||
|
||||
private static final class HotloadListener implements Listener {
|
||||
|
||||
+32
-65
@@ -18,7 +18,6 @@
|
||||
|
||||
package art.arcane.iris.core.gui;
|
||||
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.render.RenderType;
|
||||
@@ -28,7 +27,6 @@ import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.spi.IrisLogging;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import art.arcane.volmlib.util.format.Form;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.LivingEntity;
|
||||
@@ -39,6 +37,7 @@ import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
@@ -50,14 +49,16 @@ import static art.arcane.iris.util.common.data.registry.Attributes.MAX_HEALTH;
|
||||
|
||||
public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
private final Engine engine;
|
||||
private final UUID openerId;
|
||||
private final AtomicBoolean nativeTeleportActive = new AtomicBoolean();
|
||||
private final AtomicBoolean playerRefreshQueued = new AtomicBoolean();
|
||||
private final AtomicLong teleportSequence = new AtomicLong();
|
||||
private final AtomicReference<VisionTeleportRequest> latestTeleport = new AtomicReference<>();
|
||||
private volatile List<GuiMarker> playerMarkers = List.of();
|
||||
|
||||
public BukkitVisionOverlay(Engine engine) {
|
||||
public BukkitVisionOverlay(Engine engine, UUID openerId) {
|
||||
this.engine = engine;
|
||||
this.openerId = openerId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -177,67 +178,20 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
return;
|
||||
}
|
||||
List<Player> players = BukkitWorldBinding.players(target);
|
||||
if (players.isEmpty()) {
|
||||
Player player = selectPlayer(players);
|
||||
if (player == null) {
|
||||
finish(request);
|
||||
return;
|
||||
}
|
||||
Player player = players.get(0);
|
||||
requestTeleportChunk(request, target, player, world);
|
||||
});
|
||||
if (!scheduled) {
|
||||
finish(request);
|
||||
}
|
||||
}
|
||||
|
||||
private void requestTeleportChunk(
|
||||
VisionTeleportRequest request,
|
||||
IrisWorld target,
|
||||
Player player,
|
||||
World world
|
||||
) {
|
||||
int blockX = request.blockX;
|
||||
int blockZ = request.blockZ;
|
||||
int chunkX = blockX >> 4;
|
||||
int chunkZ = blockZ >> 4;
|
||||
CompletableFuture<Chunk> requested;
|
||||
try {
|
||||
requested = WorldRuntimeControlService.get().requestChunkAsync(
|
||||
world,
|
||||
chunkX,
|
||||
chunkZ,
|
||||
true,
|
||||
true
|
||||
);
|
||||
} catch (Throwable failure) {
|
||||
fail(request, target, world, failure);
|
||||
return;
|
||||
}
|
||||
if (requested == null) {
|
||||
fail(request, target, world, new IllegalStateException(
|
||||
"Vision destination chunk request returned no future."));
|
||||
return;
|
||||
}
|
||||
requested.whenComplete((chunk, failure) -> {
|
||||
if (!isCurrent(request, target)) {
|
||||
finish(request);
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
fail(request, target, world, failure);
|
||||
return;
|
||||
}
|
||||
if (chunk == null || chunk.getWorld() != world) {
|
||||
fail(request, target, world, new IllegalStateException(
|
||||
"Vision destination chunk request returned no chunk."));
|
||||
return;
|
||||
}
|
||||
boolean scheduled = J.runRegion(world, chunkX, chunkZ, () -> {
|
||||
if (!isCurrent(request, target)) {
|
||||
finish(request);
|
||||
return;
|
||||
}
|
||||
int yy = world.getHighestBlockYAt(blockX, blockZ) + 1;
|
||||
Location destination = new Location(world, blockX, yy, blockZ);
|
||||
int blockX = request.blockX;
|
||||
int blockZ = request.blockZ;
|
||||
try {
|
||||
int blockY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
|
||||
Location destination = new Location(
|
||||
world,
|
||||
blockX + 0.5D,
|
||||
blockY,
|
||||
blockZ + 0.5D);
|
||||
if (!J.runEntity(player, () -> delegateTeleport(
|
||||
request,
|
||||
target,
|
||||
@@ -247,12 +201,25 @@ public final class BukkitVisionOverlay implements GuiOverlay {
|
||||
fail(request, target, world, new IllegalStateException(
|
||||
"Failed to schedule the Vision teleport on the player entity."));
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
fail(request, target, world, new IllegalStateException(
|
||||
"Failed to schedule the Vision surface lookup on its owning region."));
|
||||
} catch (Throwable failure) {
|
||||
fail(request, target, world, failure);
|
||||
}
|
||||
});
|
||||
if (!scheduled) {
|
||||
finish(request);
|
||||
}
|
||||
}
|
||||
|
||||
private Player selectPlayer(List<Player> players) {
|
||||
if (openerId == null) {
|
||||
return players.isEmpty() ? null : players.get(0);
|
||||
}
|
||||
for (Player player : players) {
|
||||
if (openerId.equals(player.getUniqueId())) {
|
||||
return player;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void delegateTeleport(
|
||||
|
||||
@@ -2,6 +2,7 @@ name: ${name}
|
||||
version: ${version}
|
||||
main: ${main}
|
||||
bootstrapper: ${bootstrapper}
|
||||
loader: art.arcane.iris.IrisPluginLoader
|
||||
folia-supported: true
|
||||
api-version: '${apiVersion}'
|
||||
load: STARTUP
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package art.arcane.iris;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class IrisPluginLoaderTest {
|
||||
@Rule
|
||||
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void exposesOnlyRelocatedRuntimeLibrariesInStableOrder() throws Exception {
|
||||
Path root = temporaryFolder.newFolder("libraries").toPath();
|
||||
Path original = Files.createDirectories(root.resolve("gson/2.14.0"))
|
||||
.resolve("gson.jar");
|
||||
Path relocatedGson = Files.createDirectories(root.resolve("gson/2.14.0/relocated/Iris"))
|
||||
.resolve("gson.jar");
|
||||
Path relocatedCaffeine = Files.createDirectories(root.resolve("caffeine/3.2.4/relocated/Iris"))
|
||||
.resolve("caffeine.jar");
|
||||
Files.createFile(original);
|
||||
Files.createFile(relocatedGson);
|
||||
Files.createFile(relocatedCaffeine);
|
||||
|
||||
List<Path> libraries = IrisPluginLoader.relocatedLibraries(root);
|
||||
|
||||
assertEquals(List.of(relocatedCaffeine, relocatedGson), libraries);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ public class PaperPluginMetadataTest {
|
||||
}
|
||||
|
||||
assertTrue(metadata.contains("bootstrapper: " + IrisBootstrap.class.getName()));
|
||||
assertTrue(metadata.contains("loader: " + IrisPluginLoader.class.getName()));
|
||||
assertTrue(metadata.contains("folia-supported: true"));
|
||||
assertTrue(metadata.contains("load: STARTUP"));
|
||||
assertFalse(metadata.contains("commands:"));
|
||||
|
||||
+40
-131
@@ -1,108 +1,67 @@
|
||||
package art.arcane.iris.core.gui;
|
||||
|
||||
import art.arcane.iris.core.runtime.WorldRuntimeControlService;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.object.IrisWorld;
|
||||
import art.arcane.iris.platform.bukkit.BukkitPlatform;
|
||||
import art.arcane.iris.platform.bukkit.BukkitWorldBinding;
|
||||
import art.arcane.iris.util.common.scheduling.J;
|
||||
import org.bukkit.Chunk;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
public class BukkitVisionOverlayFoliaContractTest {
|
||||
@Test
|
||||
public void teleportLoadsTheDestinationChunkBeforeItsOwningRegionReadsTheSurface() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java"
|
||||
)).replace("\r\n", "\n");
|
||||
String request = method(source, "private void requestTeleportChunk(");
|
||||
|
||||
assertBefore(request, "requestChunkAsync(", "requested.whenComplete(");
|
||||
assertBefore(request, "requested.whenComplete(", "J.runRegion(");
|
||||
assertBefore(request, "J.runRegion(", "world.getHighestBlockYAt(");
|
||||
assertBefore(request, "world.getHighestBlockYAt(", "J.runEntity(");
|
||||
assertTrue(request.contains("chunkX,\n chunkZ,\n true,\n true"));
|
||||
assertEquals(1, occurrences(request, "world.getHighestBlockYAt("));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teleportReportsAsyncAndSchedulingFailuresWithDestinationContext() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java"
|
||||
)).replace("\r\n", "\n");
|
||||
String request = method(source, "private void requestTeleportChunk(");
|
||||
String reporter = method(source, "private void reportTeleportFailure(");
|
||||
|
||||
assertTrue(request.contains("if (requested == null)"));
|
||||
assertTrue(request.contains("if (failure != null)"));
|
||||
assertTrue(request.contains("if (chunk == null ||"));
|
||||
assertTrue(request.contains("if (!J.runEntity("));
|
||||
assertTrue(request.contains("if (!scheduled)"));
|
||||
assertTrue(reporter.contains("IrisLogging.reportError("));
|
||||
assertTrue(reporter.contains("world.getName()"));
|
||||
assertTrue(reporter.contains("blockX + \",\" + blockZ"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teleportObservesNativeCompletionAndRejectsFalseSettlement() throws Exception {
|
||||
String source = Files.readString(Path.of(
|
||||
"src/main/java/art/arcane/iris/core/gui/BukkitVisionOverlay.java"
|
||||
)).replace("\r\n", "\n");
|
||||
String delegate = method(source, "private void delegateTeleport(");
|
||||
|
||||
assertTrue(delegate.contains("teleport.whenComplete("));
|
||||
assertTrue(delegate.contains("!Boolean.TRUE.equals(success)"));
|
||||
assertTrue(delegate.contains("restartLatest(request)"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void staleChunkCompletionCannotTeleportOverTheLatestRequest() {
|
||||
public void teleportDelegatesImmediatelyToTheNativeAsyncPath() {
|
||||
VisionHarness harness = new VisionHarness();
|
||||
CompletableFuture<Chunk> firstChunk = new CompletableFuture<>();
|
||||
CompletableFuture<Chunk> secondChunk = new CompletableFuture<>();
|
||||
harness.stubChunk(0, 0, firstChunk);
|
||||
harness.stubChunk(2, 2, secondChunk);
|
||||
|
||||
try (harness) {
|
||||
harness.overlay.teleport(1.5D, 1.5D);
|
||||
harness.overlay.teleport(33.5D, 33.5D);
|
||||
|
||||
firstChunk.complete(harness.chunk);
|
||||
assertEquals(0, harness.destinations.size());
|
||||
secondChunk.complete(harness.chunk);
|
||||
assertEquals(1, harness.destinations.size());
|
||||
Location destination = harness.destinations.get(0);
|
||||
assertEquals(33.5D, destination.getX(), 0D);
|
||||
assertEquals(76D, destination.getY(), 0D);
|
||||
assertEquals(33.5D, destination.getZ(), 0D);
|
||||
verify(harness.engine).getHeight(33, 33, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teleportFloorsNegativeCoordinatesBeforeCenteringTheDestination() {
|
||||
VisionHarness harness = new VisionHarness();
|
||||
|
||||
try (harness) {
|
||||
harness.overlay.teleport(-0.25D, -16.01D);
|
||||
|
||||
assertEquals(1, harness.destinations.size());
|
||||
assertEquals(33, harness.destinations.get(0).getBlockX());
|
||||
assertEquals(33, harness.destinations.get(0).getBlockZ());
|
||||
Location destination = harness.destinations.get(0);
|
||||
assertEquals(-0.5D, destination.getX(), 0D);
|
||||
assertEquals(-16.5D, destination.getZ(), 0D);
|
||||
verify(harness.engine).getHeight(-1, -17, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void latestRequestRunsAfterAnOlderNativeTeleportSettles() {
|
||||
VisionHarness harness = new VisionHarness();
|
||||
harness.stubChunk(0, 0, CompletableFuture.completedFuture(harness.chunk));
|
||||
harness.stubChunk(2, 2, CompletableFuture.completedFuture(harness.chunk));
|
||||
CompletableFuture<Boolean> firstTeleport = new CompletableFuture<>();
|
||||
CompletableFuture<Boolean> secondTeleport = new CompletableFuture<>();
|
||||
harness.nativeTeleports.add(firstTeleport);
|
||||
@@ -121,42 +80,17 @@ public class BukkitVisionOverlayFoliaContractTest {
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source contract token: " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source contract token: " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must occur before " + second, firstIndex < secondIndex);
|
||||
}
|
||||
@Test
|
||||
public void missingOpenerDoesNotTeleportAnotherPlayer() {
|
||||
VisionHarness harness = new VisionHarness();
|
||||
harness.binding.when(() -> BukkitWorldBinding.players(harness.target))
|
||||
.thenReturn(List.of(harness.otherPlayer));
|
||||
|
||||
private static int occurrences(String source, String match) {
|
||||
int count = 0;
|
||||
int offset = 0;
|
||||
while ((offset = source.indexOf(match, offset)) >= 0) {
|
||||
count++;
|
||||
offset += match.length();
|
||||
}
|
||||
return count;
|
||||
}
|
||||
try (harness) {
|
||||
harness.overlay.teleport(1.5D, 1.5D);
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
assertTrue("Missing source contract signature: " + signature, start >= 0);
|
||||
int openBrace = source.indexOf('{', start);
|
||||
assertTrue("Missing source contract method body: " + signature, openBrace >= 0);
|
||||
int depth = 0;
|
||||
for (int index = openBrace; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
assertEquals(0, harness.destinations.size());
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source contract method: " + signature);
|
||||
}
|
||||
|
||||
private static final class VisionHarness implements AutoCloseable {
|
||||
@@ -164,11 +98,10 @@ public class BukkitVisionOverlayFoliaContractTest {
|
||||
private final IrisWorld target;
|
||||
private final World world;
|
||||
private final Player player;
|
||||
private final Chunk chunk;
|
||||
private final WorldRuntimeControlService runtime;
|
||||
private final Player otherPlayer;
|
||||
private final UUID openerId;
|
||||
private final MockedStatic<J> scheduling;
|
||||
private final MockedStatic<BukkitWorldBinding> binding;
|
||||
private final MockedStatic<WorldRuntimeControlService> runtimeAccess;
|
||||
private final MockedStatic<BukkitPlatform> platform;
|
||||
private final List<Location> destinations;
|
||||
private final List<CompletableFuture<Boolean>> nativeTeleports;
|
||||
@@ -180,33 +113,26 @@ public class BukkitVisionOverlayFoliaContractTest {
|
||||
target = mock(IrisWorld.class);
|
||||
world = mock(World.class);
|
||||
player = mock(Player.class);
|
||||
chunk = mock(Chunk.class);
|
||||
runtime = mock(WorldRuntimeControlService.class);
|
||||
otherPlayer = mock(Player.class);
|
||||
openerId = UUID.randomUUID();
|
||||
destinations = new ArrayList<>();
|
||||
nativeTeleports = new ArrayList<>();
|
||||
nativeTeleportIndex = new AtomicInteger();
|
||||
|
||||
when(engine.getWorld()).thenReturn(target);
|
||||
when(engine.getMinHeight()).thenReturn(-64);
|
||||
when(engine.getHeight(anyInt(), anyInt(), eq(false))).thenReturn(138);
|
||||
when(target.hasPlatformWorld()).thenReturn(true);
|
||||
when(player.isOnline()).thenReturn(true);
|
||||
when(player.getWorld()).thenReturn(world);
|
||||
when(chunk.getWorld()).thenReturn(world);
|
||||
when(world.getHighestBlockYAt(anyInt(), anyInt())).thenReturn(70);
|
||||
when(player.getUniqueId()).thenReturn(openerId);
|
||||
when(otherPlayer.getUniqueId()).thenReturn(UUID.randomUUID());
|
||||
|
||||
scheduling = mockStatic(J.class);
|
||||
scheduling.when(() -> J.runGlobal(any(Runnable.class))).thenAnswer(invocation -> {
|
||||
invocation.getArgument(0, Runnable.class).run();
|
||||
return true;
|
||||
});
|
||||
scheduling.when(() -> J.runRegion(
|
||||
same(world),
|
||||
anyInt(),
|
||||
anyInt(),
|
||||
any(Runnable.class)))
|
||||
.thenAnswer(invocation -> {
|
||||
invocation.getArgument(3, Runnable.class).run();
|
||||
return true;
|
||||
});
|
||||
scheduling.when(() -> J.runEntity(same(player), any(Runnable.class))).thenAnswer(invocation -> {
|
||||
invocation.getArgument(1, Runnable.class).run();
|
||||
return true;
|
||||
@@ -214,10 +140,7 @@ public class BukkitVisionOverlayFoliaContractTest {
|
||||
|
||||
binding = mockStatic(BukkitWorldBinding.class);
|
||||
binding.when(() -> BukkitWorldBinding.world(target)).thenReturn(world);
|
||||
binding.when(() -> BukkitWorldBinding.players(target)).thenReturn(List.of(player));
|
||||
|
||||
runtimeAccess = mockStatic(WorldRuntimeControlService.class);
|
||||
runtimeAccess.when(WorldRuntimeControlService::get).thenReturn(runtime);
|
||||
binding.when(() -> BukkitWorldBinding.players(target)).thenReturn(List.of(otherPlayer, player));
|
||||
|
||||
platform = mockStatic(BukkitPlatform.class);
|
||||
platform.when(() -> BukkitPlatform.teleportAsync(same(player), any(Location.class)))
|
||||
@@ -228,26 +151,12 @@ public class BukkitVisionOverlayFoliaContractTest {
|
||||
? nativeTeleports.get(index)
|
||||
: CompletableFuture.completedFuture(true);
|
||||
});
|
||||
overlay = new BukkitVisionOverlay(engine);
|
||||
}
|
||||
|
||||
private void stubChunk(
|
||||
int chunkX,
|
||||
int chunkZ,
|
||||
CompletableFuture<Chunk> requested
|
||||
) {
|
||||
when(runtime.requestChunkAsync(
|
||||
same(world),
|
||||
eq(chunkX),
|
||||
eq(chunkZ),
|
||||
eq(true),
|
||||
eq(true))).thenReturn(requested);
|
||||
overlay = new BukkitVisionOverlay(engine, openerId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
platform.close();
|
||||
runtimeAccess.close();
|
||||
binding.close();
|
||||
scheduling.close();
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ package art.arcane.iris.fabric;
|
||||
import art.arcane.iris.modded.ModdedLoader;
|
||||
import art.arcane.iris.modded.service.ModdedTreeFellerService;
|
||||
import net.fabricmc.api.EnvType;
|
||||
import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLevelEvents;
|
||||
import net.fabricmc.fabric.api.event.player.PlayerBlockBreakEvents;
|
||||
import net.fabricmc.fabric.api.permission.v1.PermissionContextOwner;
|
||||
import net.fabricmc.loader.api.FabricLoader;
|
||||
@@ -71,6 +72,16 @@ public final class FabricModdedLoader implements ModdedLoader {
|
||||
// map). Off-thread readers rely on the ModdedServerLevels snapshot, which the caller republishes.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) {
|
||||
ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) {
|
||||
ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clientEnvironment() {
|
||||
return FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT;
|
||||
|
||||
@@ -28,6 +28,7 @@ import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.minecraftforge.common.util.Result;
|
||||
import net.minecraftforge.event.level.BlockEvent;
|
||||
import net.minecraftforge.event.level.LevelEvent;
|
||||
import net.minecraftforge.fml.ModList;
|
||||
import net.minecraftforge.fml.loading.FMLEnvironment;
|
||||
import net.minecraftforge.fml.loading.FMLLoader;
|
||||
@@ -77,6 +78,16 @@ public final class ForgeModdedLoader implements ModdedLoader {
|
||||
server.markWorldsDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) {
|
||||
LevelEvent.Load.BUS.post(new LevelEvent.Load(level));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) {
|
||||
LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clientEnvironment() {
|
||||
return FMLEnvironment.dist.isClient();
|
||||
|
||||
+285
-46
@@ -56,13 +56,16 @@ import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
public final class ModdedDimensionManager {
|
||||
private static final int TELEPORT_WARM_RADIUS = 0;
|
||||
private static final Object LOCK = new Object();
|
||||
private static final ConcurrentHashMap<String, Handle> HANDLES = new ConcurrentHashMap<>();
|
||||
private static final TicketType TELEPORT_WARM_TICKET = new TicketType(TicketType.NO_TIMEOUT,
|
||||
TicketType.FLAG_LOADING | TicketType.FLAG_KEEP_DIMENSION_ACTIVE);
|
||||
private static final long TELEPORT_WARM_TIMEOUT_SECONDS = 30L;
|
||||
private static final long TELEPORT_TIMEOUT_SECONDS = 10L;
|
||||
private static volatile ModdedServerAccess access;
|
||||
|
||||
private ModdedDimensionManager() {
|
||||
@@ -213,15 +216,16 @@ public final class ModdedDimensionManager {
|
||||
instanceof IrisModdedChunkGenerator irisGenerator
|
||||
? irisGenerator
|
||||
: null;
|
||||
boolean generatorUnbound = false;
|
||||
boolean unloadEventStarted = false;
|
||||
try {
|
||||
evacuate(server, level);
|
||||
level.save(null, true, false);
|
||||
unloadEventStarted = true;
|
||||
ModdedEngineBootstrap.loader().fireDynamicLevelUnload(server, level);
|
||||
if (generator != null) {
|
||||
generator.unbindEngine(level);
|
||||
generatorUnbound = true;
|
||||
}
|
||||
ModdedWorldEngines.evictOrThrow(level);
|
||||
level.save(null, true, false);
|
||||
serverAccess.removeLevel(server, key);
|
||||
// Undo snapshots pin the ServerLevel and could replay into the dead level.
|
||||
art.arcane.iris.modded.command.ModdedObjectUndo.forget(level);
|
||||
@@ -233,7 +237,7 @@ public final class ModdedDimensionManager {
|
||||
ModdedIrisLog.info("Iris removed runtime dimension '{}'", dimensionId);
|
||||
return true;
|
||||
} catch (Throwable e) {
|
||||
rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e);
|
||||
rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e);
|
||||
ModdedIrisLog.error("Iris failed to remove runtime dimension '{}'", dimensionId, e);
|
||||
throw new IllegalStateException("Iris runtime dimension removal failed for " + dimensionId, e);
|
||||
}
|
||||
@@ -242,13 +246,16 @@ public final class ModdedDimensionManager {
|
||||
|
||||
private static void rollbackRemoval(MinecraftServer server, ModdedServerAccess serverAccess,
|
||||
ResourceKey<Level> key, ServerLevel level,
|
||||
IrisModdedChunkGenerator generator, boolean generatorUnbound,
|
||||
Throwable failure) {
|
||||
IrisModdedChunkGenerator generator, boolean unloadEventStarted,
|
||||
Throwable failure) {
|
||||
try {
|
||||
if (!generatorUnbound || generator == null || !serverAccess.hasLevel(server, key)) {
|
||||
if (!unloadEventStarted || !serverAccess.hasLevel(server, key)) {
|
||||
return;
|
||||
}
|
||||
generator.bindLevel(level);
|
||||
if (generator != null) {
|
||||
generator.bindLevel(level);
|
||||
}
|
||||
ModdedEngineBootstrap.loader().fireDynamicLevelLoad(server, level);
|
||||
} catch (Throwable rollbackFailure) {
|
||||
if (rollbackFailure != failure) {
|
||||
failure.addSuppressed(rollbackFailure);
|
||||
@@ -258,44 +265,263 @@ public final class ModdedDimensionManager {
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean teleport(ServerPlayer player, MinecraftServer server, String dimensionId, double x, double y, double z) {
|
||||
ServerLevel level = level(server, dimensionId);
|
||||
if (level == null) {
|
||||
return false;
|
||||
}
|
||||
int blockX = (int) Math.floor(x);
|
||||
int blockZ = (int) Math.floor(z);
|
||||
ChunkPos chunkPos = new ChunkPos(blockX >> 4, blockZ >> 4);
|
||||
if (level.getChunkSource().hasChunk(chunkPos.x(), chunkPos.z())) {
|
||||
completeTeleport(player, level, x, y, z, blockX, blockZ);
|
||||
return true;
|
||||
}
|
||||
UUID playerId = player.getUUID();
|
||||
CompletableFuture
|
||||
.supplyAsync(() -> level.getChunkSource().addTicketAndLoadWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1), server)
|
||||
.thenCompose((CompletableFuture<?> inner) -> inner)
|
||||
// The ticket has no timeout of its own: bound the wait so the release below always runs.
|
||||
.orTimeout(TELEPORT_WARM_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.whenComplete((Object result, Throwable error) -> server.execute(() -> {
|
||||
level.getChunkSource().removeTicketWithRadius(TELEPORT_WARM_TICKET, chunkPos, 1);
|
||||
if (error != null) {
|
||||
ModdedIrisLog.warn("Iris chunk warm for teleport into '{}' at {},{} failed: {}", dimensionId, chunkPos.x(), chunkPos.z(), error.toString());
|
||||
}
|
||||
ServerPlayer target = server.getPlayerList().getPlayer(playerId);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
completeTeleport(target, level, x, y, z, blockX, blockZ);
|
||||
}));
|
||||
return true;
|
||||
public static CompletableFuture<Boolean> teleportAsync(
|
||||
ServerPlayer player,
|
||||
MinecraftServer server,
|
||||
String dimensionId,
|
||||
double x,
|
||||
double y,
|
||||
double z
|
||||
) {
|
||||
return teleportAsync(player, server, dimensionId, x, y, z,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(TELEPORT_TIMEOUT_SECONDS));
|
||||
}
|
||||
|
||||
private static void completeTeleport(ServerPlayer player, ServerLevel level, double x, double y, double z, int blockX, int blockZ) {
|
||||
double targetY = y;
|
||||
if (y == Double.MIN_VALUE) {
|
||||
targetY = level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ);
|
||||
public static CompletableFuture<Boolean> teleportAsync(
|
||||
ServerPlayer player,
|
||||
MinecraftServer server,
|
||||
String dimensionId,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
long deadlineNanos
|
||||
) {
|
||||
ServerLevel level = level(server, dimensionId);
|
||||
if (level == null) {
|
||||
return CompletableFuture.completedFuture(false);
|
||||
}
|
||||
player.teleportTo(level, x, targetY, z, Set.<Relative>of(), player.getYRot(), player.getXRot(), false);
|
||||
return teleportAsync(player, server, level, x, y, z, deadlineNanos);
|
||||
}
|
||||
|
||||
public static CompletableFuture<Boolean> teleportAsync(
|
||||
ServerPlayer player,
|
||||
MinecraftServer server,
|
||||
ServerLevel level,
|
||||
double x,
|
||||
double y,
|
||||
double z
|
||||
) {
|
||||
return teleportAsync(player, server, level, x, y, z, 0L);
|
||||
}
|
||||
|
||||
public static CompletableFuture<Boolean> teleportAsync(
|
||||
ServerPlayer player,
|
||||
MinecraftServer server,
|
||||
ServerLevel level,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
long deadlineNanos
|
||||
) {
|
||||
CompletableFuture<Boolean> result = new CompletableFuture<>();
|
||||
if (player == null || server == null || level == null || level.getServer() != server) {
|
||||
result.complete(false);
|
||||
return result;
|
||||
}
|
||||
if (!Double.isFinite(x) || !Double.isFinite(z)
|
||||
|| (y != Double.MIN_VALUE && !Double.isFinite(y))) {
|
||||
result.completeExceptionally(new IllegalArgumentException("Teleport coordinates must be finite."));
|
||||
return result;
|
||||
}
|
||||
if (deadlineNanos != 0L) {
|
||||
long remainingNanos = deadlineNanos - System.nanoTime();
|
||||
if (remainingNanos <= 0L) {
|
||||
result.completeExceptionally(teleportTimeout(level, x, z));
|
||||
return result;
|
||||
}
|
||||
result.orTimeout(remainingNanos, TimeUnit.NANOSECONDS);
|
||||
}
|
||||
UUID playerId = player.getUUID();
|
||||
runOnServer(server, () -> beginTeleport(
|
||||
result,
|
||||
playerId,
|
||||
server,
|
||||
level,
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
deadlineNanos));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void beginTeleport(
|
||||
CompletableFuture<Boolean> result,
|
||||
UUID playerId,
|
||||
MinecraftServer server,
|
||||
ServerLevel level,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
long deadlineNanos
|
||||
) {
|
||||
if (result.isDone()) {
|
||||
return;
|
||||
}
|
||||
if (deadlineNanos != 0L && System.nanoTime() >= deadlineNanos) {
|
||||
result.completeExceptionally(teleportTimeout(level, x, z));
|
||||
return;
|
||||
}
|
||||
ServerLevel active = level(server, level.dimension().identifier().toString());
|
||||
if (active != level) {
|
||||
result.complete(false);
|
||||
return;
|
||||
}
|
||||
int blockX = ModdedTeleportBounds.blockCoordinate(x);
|
||||
int blockZ = ModdedTeleportBounds.blockCoordinate(z);
|
||||
ChunkPos chunkPos = new ChunkPos(blockX >> 4, blockZ >> 4);
|
||||
if (level.getChunkSource().hasChunk(chunkPos.x(), chunkPos.z())) {
|
||||
completeTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, deadlineNanos);
|
||||
return;
|
||||
}
|
||||
warmAndTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, chunkPos, deadlineNanos);
|
||||
}
|
||||
|
||||
private static void warmAndTeleport(
|
||||
CompletableFuture<Boolean> result,
|
||||
UUID playerId,
|
||||
MinecraftServer server,
|
||||
ServerLevel level,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
ChunkPos chunkPos,
|
||||
long deadlineNanos
|
||||
) {
|
||||
AtomicBoolean ticketReleased = new AtomicBoolean();
|
||||
CompletableFuture<?> chunkLoad;
|
||||
try {
|
||||
chunkLoad = level.getChunkSource().addTicketAndLoadWithRadius(
|
||||
TELEPORT_WARM_TICKET,
|
||||
chunkPos,
|
||||
TELEPORT_WARM_RADIUS);
|
||||
} catch (Throwable failure) {
|
||||
result.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
if (chunkLoad == null) {
|
||||
releaseTeleportTicket(level, chunkPos, ticketReleased);
|
||||
result.completeExceptionally(new IllegalStateException(
|
||||
"Chunk warm returned no completion future for " + level.dimension().identifier()
|
||||
+ " at " + chunkPos.x() + "," + chunkPos.z() + "."));
|
||||
return;
|
||||
}
|
||||
result.whenComplete((success, failure) -> runOnServer(server,
|
||||
() -> releaseTeleportTicket(level, chunkPos, ticketReleased)));
|
||||
chunkLoad.whenComplete((ignored, failure) -> runOnServer(server, () -> {
|
||||
releaseTeleportTicket(level, chunkPos, ticketReleased);
|
||||
if (result.isDone()) {
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
result.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
completeTeleport(result, playerId, server, level, x, y, z, blockX, blockZ, deadlineNanos);
|
||||
}));
|
||||
}
|
||||
|
||||
private static void releaseTeleportTicket(
|
||||
ServerLevel level,
|
||||
ChunkPos chunkPos,
|
||||
AtomicBoolean ticketReleased
|
||||
) {
|
||||
if (ticketReleased.compareAndSet(false, true)) {
|
||||
level.getChunkSource().removeTicketWithRadius(
|
||||
TELEPORT_WARM_TICKET,
|
||||
chunkPos,
|
||||
TELEPORT_WARM_RADIUS);
|
||||
}
|
||||
}
|
||||
|
||||
private static void completeTeleport(
|
||||
CompletableFuture<Boolean> result,
|
||||
UUID playerId,
|
||||
MinecraftServer server,
|
||||
ServerLevel level,
|
||||
double x,
|
||||
double y,
|
||||
double z,
|
||||
int blockX,
|
||||
int blockZ,
|
||||
long deadlineNanos
|
||||
) {
|
||||
if (result.isDone()) {
|
||||
return;
|
||||
}
|
||||
if (deadlineNanos != 0L && System.nanoTime() >= deadlineNanos) {
|
||||
result.completeExceptionally(teleportTimeout(level, x, z));
|
||||
return;
|
||||
}
|
||||
ServerLevel active = level(server, level.dimension().identifier().toString());
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(playerId);
|
||||
if (active != level || player == null) {
|
||||
result.complete(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
int targetY = resolveSafeTeleportY(level, blockX, blockZ, y);
|
||||
boolean teleported = player.teleportTo(
|
||||
level,
|
||||
x,
|
||||
targetY,
|
||||
z,
|
||||
Set.<Relative>of(),
|
||||
player.getYRot(),
|
||||
player.getXRot(),
|
||||
false);
|
||||
result.complete(teleported);
|
||||
} catch (Throwable failure) {
|
||||
result.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static int resolveSafeTeleportY(ServerLevel level, int blockX, int blockZ, double requestedY) {
|
||||
int initialY = requestedY == Double.MIN_VALUE
|
||||
? level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ)
|
||||
: (int) Math.floor(requestedY);
|
||||
int startY = ModdedTeleportBounds.clampY(level.getMinY(), level.getMaxY(), initialY);
|
||||
int maximumY = ModdedTeleportBounds.maximumY(level.getMinY(), level.getMaxY());
|
||||
int minimumY = ModdedTeleportBounds.minimumY(level.getMinY(), level.getMaxY());
|
||||
for (int candidateY = startY; candidateY <= maximumY; candidateY++) {
|
||||
if (isSafeStandingPosition(level, blockX, candidateY, blockZ)) {
|
||||
return candidateY;
|
||||
}
|
||||
}
|
||||
for (int candidateY = startY - 1; candidateY >= minimumY; candidateY--) {
|
||||
if (isSafeStandingPosition(level, blockX, candidateY, blockZ)) {
|
||||
return candidateY;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("No safe teleport position exists in "
|
||||
+ level.dimension().identifier() + " at " + blockX + "," + blockZ + ".");
|
||||
}
|
||||
|
||||
private static boolean isSafeStandingPosition(ServerLevel level, int blockX, int blockY, int blockZ) {
|
||||
BlockPos feet = new BlockPos(blockX, blockY, blockZ);
|
||||
BlockPos head = feet.above();
|
||||
BlockPos support = feet.below();
|
||||
return level.getBlockState(feet).getCollisionShape(level, feet).isEmpty()
|
||||
&& level.getBlockState(feet).getFluidState().isEmpty()
|
||||
&& level.getBlockState(head).getCollisionShape(level, head).isEmpty()
|
||||
&& level.getBlockState(head).getFluidState().isEmpty()
|
||||
&& !level.getBlockState(support).getCollisionShape(level, support).isEmpty();
|
||||
}
|
||||
|
||||
private static TimeoutException teleportTimeout(ServerLevel level, double x, double z) {
|
||||
return new TimeoutException("Teleport into " + level.dimension().identifier()
|
||||
+ " at " + ModdedTeleportBounds.blockCoordinate(x) + ","
|
||||
+ ModdedTeleportBounds.blockCoordinate(z)
|
||||
+ " exceeded " + TELEPORT_TIMEOUT_SECONDS + " seconds.");
|
||||
}
|
||||
|
||||
private static void runOnServer(MinecraftServer server, Runnable task) {
|
||||
if (server.isSameThread()) {
|
||||
task.run();
|
||||
return;
|
||||
}
|
||||
server.execute(task);
|
||||
}
|
||||
|
||||
private static Holder<DimensionType> resolveDimensionType(RegistryAccess registryAccess, String pack, String packDimensionKey) {
|
||||
@@ -362,6 +588,7 @@ public final class ModdedDimensionManager {
|
||||
List.of(),
|
||||
false);
|
||||
|
||||
boolean loadEventStarted = false;
|
||||
try {
|
||||
generator.bindLevel(level);
|
||||
Handle handle = new Handle(dimensionId, pack, packDimensionKey, seed, level, generator);
|
||||
@@ -371,9 +598,11 @@ public final class ModdedDimensionManager {
|
||||
+ "': the level was registered concurrently");
|
||||
}
|
||||
server.getPlayerList().addWorldborderListener(level);
|
||||
loadEventStarted = true;
|
||||
ModdedEngineBootstrap.loader().fireDynamicLevelLoad(server, level);
|
||||
return handle;
|
||||
} catch (Throwable error) {
|
||||
rollbackInjection(server, serverAccess, key, level, generator, error);
|
||||
rollbackInjection(server, serverAccess, key, level, generator, loadEventStarted, error);
|
||||
if (error instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
@@ -386,7 +615,17 @@ public final class ModdedDimensionManager {
|
||||
|
||||
private static void rollbackInjection(MinecraftServer server, ModdedServerAccess serverAccess,
|
||||
ResourceKey<Level> key, ServerLevel level,
|
||||
IrisModdedChunkGenerator generator, Throwable failure) {
|
||||
IrisModdedChunkGenerator generator, boolean loadEventStarted,
|
||||
Throwable failure) {
|
||||
if (loadEventStarted) {
|
||||
try {
|
||||
ModdedEngineBootstrap.loader().fireDynamicLevelUnload(server, level);
|
||||
} catch (Throwable cleanupError) {
|
||||
failure.addSuppressed(cleanupError);
|
||||
ModdedIrisLog.error("Iris failed to publish rollback unload for {}",
|
||||
key.identifier(), cleanupError);
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (serverAccess.hasLevel(server, key)) {
|
||||
ServerLevel removed = serverAccess.removeLevel(server, key);
|
||||
|
||||
@@ -38,6 +38,10 @@ public interface ModdedLoader {
|
||||
|
||||
void invalidateLevelCache(MinecraftServer server);
|
||||
|
||||
void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level);
|
||||
|
||||
void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level);
|
||||
|
||||
boolean clientEnvironment();
|
||||
|
||||
Path configDir();
|
||||
|
||||
+25
-3
@@ -26,12 +26,14 @@ 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;
|
||||
|
||||
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 int tickCounter = 0;
|
||||
|
||||
private ModdedPrimaryWorldRouter() {
|
||||
@@ -39,6 +41,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
|
||||
public static void clear() {
|
||||
routed.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,6 +51,7 @@ public final class ModdedPrimaryWorldRouter {
|
||||
public static void forget(UUID player) {
|
||||
if (player != null) {
|
||||
routed.remove(player);
|
||||
inFlight.remove(player);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,17 +86,35 @@ public final class ModdedPrimaryWorldRouter {
|
||||
List<ServerPlayer> players = new ArrayList<>(server.getPlayerList().getPlayers());
|
||||
for (ServerPlayer player : players) {
|
||||
UUID id = player.getUUID();
|
||||
if (routed.contains(id)) {
|
||||
if (routed.contains(id) || !inFlight.add(id)) {
|
||||
continue;
|
||||
}
|
||||
if (player.level() != overworld) {
|
||||
inFlight.remove(id);
|
||||
routed.add(id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
ModdedDimensionManager.teleport(player, server, primary, player.getX(), Double.MIN_VALUE, player.getZ());
|
||||
routed.add(id);
|
||||
CompletableFuture<Boolean> teleport = ModdedDimensionManager.teleportAsync(
|
||||
player,
|
||||
server,
|
||||
primary,
|
||||
player.getX(),
|
||||
Double.MIN_VALUE,
|
||||
player.getZ());
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
inFlight.remove(id);
|
||||
if (Boolean.TRUE.equals(success) && failure == null) {
|
||||
routed.add(id);
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
final class ModdedTeleportBounds {
|
||||
private ModdedTeleportBounds() {
|
||||
}
|
||||
|
||||
static int blockCoordinate(double coordinate) {
|
||||
return (int) Math.floor(coordinate);
|
||||
}
|
||||
|
||||
static int clampY(int minY, int maxY, int requestedY) {
|
||||
int minimumY = minimumY(minY, maxY);
|
||||
int maximumY = maximumY(minY, maxY);
|
||||
return Math.max(minimumY, Math.min(maximumY, requestedY));
|
||||
}
|
||||
|
||||
static int minimumY(int minY, int maxY) {
|
||||
requireStandingSpace(minY, maxY);
|
||||
return minY + 1;
|
||||
}
|
||||
|
||||
static int maximumY(int minY, int maxY) {
|
||||
requireStandingSpace(minY, maxY);
|
||||
return maxY - 2;
|
||||
}
|
||||
|
||||
private static void requireStandingSpace(int minY, int maxY) {
|
||||
if (maxY - minY < 3) {
|
||||
throw new IllegalArgumentException("Level height must provide support, feet, and head space.");
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-4
@@ -131,10 +131,26 @@ public final class IrisModdedCommands {
|
||||
return 0;
|
||||
}
|
||||
String dimensionId = level.dimension().identifier().toString();
|
||||
if (!ModdedDimensionManager.teleport(player, source.getServer(), dimensionId, 8.5D, Double.MIN_VALUE, 8.5D)) {
|
||||
fail(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 0;
|
||||
}
|
||||
MinecraftServer server = source.getServer();
|
||||
CompletableFuture<Boolean> teleport = ModdedDimensionManager.teleportAsync(
|
||||
player,
|
||||
server,
|
||||
dimensionId,
|
||||
8.5D,
|
||||
Double.MIN_VALUE,
|
||||
8.5D);
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
if (Boolean.TRUE.equals(success) && failure == null) {
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
ModdedIrisLog.error("Iris teleport into '{}' failed for {}",
|
||||
dimensionId, player.getUUID(), failure);
|
||||
}
|
||||
server.execute(() -> fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORT_FAILED_DIMENSION_IS_NOT_LOADED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId))));
|
||||
});
|
||||
ok(source, IrisLanguage.plain(ModdedCommandMessages.IRIS_MODDED_COMMANDS_TELEPORTING, MessageArgument.untrusted("value", player.getScoreboardName()), MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
return 1;
|
||||
}
|
||||
|
||||
+3
-2
@@ -107,7 +107,7 @@ public final class ModdedGuiHost implements GuiHost.Provider {
|
||||
}
|
||||
|
||||
@Override
|
||||
public GuiOverlay overlayFor(Engine engine) {
|
||||
public GuiOverlay overlayFor(Engine engine, UUID openerId) {
|
||||
if (engine == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -115,6 +115,7 @@ public final class ModdedGuiHost implements GuiHost.Provider {
|
||||
if (level == null || server == null) {
|
||||
return null;
|
||||
}
|
||||
return new ModdedVisionOverlay(server, level, engine, openers.get(engine));
|
||||
UUID resolvedOpenerId = openerId == null ? openers.get(engine) : openerId;
|
||||
return new ModdedVisionOverlay(server, level, engine, resolvedOpenerId);
|
||||
}
|
||||
}
|
||||
|
||||
+303
-110
@@ -44,6 +44,7 @@ import art.arcane.iris.engine.object.IrisSpawner;
|
||||
import art.arcane.iris.engine.object.IrisStructurePlacement;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.ModdedEngineBootstrap;
|
||||
import art.arcane.iris.modded.ModdedScheduler;
|
||||
import art.arcane.iris.modded.ModdedWorkspaceGenerator;
|
||||
import art.arcane.iris.util.common.parallel.BurstExecutor;
|
||||
import art.arcane.iris.util.common.parallel.MultiBurst;
|
||||
@@ -67,7 +68,6 @@ import net.minecraft.commands.SharedSuggestionProvider;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Relative;
|
||||
import org.zeroturnaround.zip.ZipUtil;
|
||||
|
||||
import java.awt.Desktop;
|
||||
@@ -79,6 +79,8 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Predicate;
|
||||
@@ -96,6 +98,7 @@ public final class ModdedStudioCommands {
|
||||
private static final String DEFAULT_TEMPLATE = "example";
|
||||
private static final UUID CONSOLE_OWNER = new UUID(0L, 0L);
|
||||
private static final Map<UUID, String> STUDIOS = new ConcurrentHashMap<>();
|
||||
private static final ModdedStudioTransitionQueue TRANSITIONS = new ModdedStudioTransitionQueue();
|
||||
private static final SuggestionProvider<CommandSourceStack> GENERATOR_KEYS = (CommandContext<CommandSourceStack> context, SuggestionsBuilder builder) -> {
|
||||
ModdedCommandFeedback.tab(context.getSource());
|
||||
try {
|
||||
@@ -192,6 +195,7 @@ public final class ModdedStudioCommands {
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
TRANSITIONS.clear();
|
||||
STUDIOS.clear();
|
||||
}
|
||||
|
||||
@@ -272,7 +276,7 @@ public final class ModdedStudioCommands {
|
||||
}
|
||||
ServerPlayer player = source.getPlayer();
|
||||
ModdedGuiHost.bindContext(source.getServer(), level, engine, player == null ? null : player.getUUID());
|
||||
VisionGUI.launch(engine);
|
||||
VisionGUI.launch(engine, player == null ? null : player.getUUID());
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_VISION_MAP_ON_SERVER_DISPLAY, MessageArgument.untrusted("value", level.dimension().identifier())));
|
||||
return 1;
|
||||
}
|
||||
@@ -402,126 +406,244 @@ public final class ModdedStudioCommands {
|
||||
String dimensionId = player == null ? studioConsoleDimensionId() : studioDimensionId(player);
|
||||
MinecraftServer server = source.getServer();
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_OPENING_STUDIO_SEED, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed)));
|
||||
Thread thread = new Thread(() -> openAsync(source, server, owner, dimensionId, pack, seed), "Iris Studio Open");
|
||||
thread.setDaemon(true);
|
||||
thread.start();
|
||||
CompletableFuture<Void> transition = TRANSITIONS.submit(
|
||||
owner,
|
||||
() -> openTransition(source, server, owner, dimensionId, pack, seed));
|
||||
reportOpenFailure(source, server, dimensionId, pack, transition);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static void openAsync(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) {
|
||||
private static CompletableFuture<Void> openTransition(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
UUID owner,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
long seed
|
||||
) {
|
||||
CompletableFuture<Void> transition = new CompletableFuture<>();
|
||||
ModdedScheduler scheduler = ModdedEngineBootstrap.schedulerOrNull();
|
||||
if (scheduler == null) {
|
||||
transition.completeExceptionally(new IllegalStateException(
|
||||
"Iris modded scheduler is unavailable for Studio open."));
|
||||
return transition;
|
||||
}
|
||||
scheduler.asyncIfRunning(() -> prepareStudioOpen(
|
||||
source,
|
||||
server,
|
||||
owner,
|
||||
dimensionId,
|
||||
pack,
|
||||
seed,
|
||||
transition),
|
||||
() -> transition.completeExceptionally(new IllegalStateException(
|
||||
"Iris modded scheduler rejected Studio open.")));
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static void prepareStudioOpen(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
UUID owner,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
long seed,
|
||||
CompletableFuture<Void> transition
|
||||
) {
|
||||
try {
|
||||
File packFolder = new File(ModdedPackCommands.packsRoot(), pack);
|
||||
if (!new File(packFolder, "dimensions/" + pack + ".json").isFile()) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_REQUIRED_PACK_IS_NOT_INSTALLED_INSTALL_THEN_RESTART,
|
||||
MessageArgument.untrusted("pack", pack))));
|
||||
return;
|
||||
throw new IllegalStateException("Required Studio pack '" + pack
|
||||
+ "' is not installed with dimensions/" + pack + ".json.");
|
||||
}
|
||||
IrisData data = IrisData.get(packFolder);
|
||||
IrisDimension dimension = data.getDimensionLoader().load(pack);
|
||||
if (dimension == null) {
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PACK_HAS_NO_DIMENSIONS_JSON, MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("pack2", pack))));
|
||||
return;
|
||||
throw new IllegalStateException("Studio pack '" + pack
|
||||
+ "' has no dimensions/" + pack + ".json definition.");
|
||||
}
|
||||
try {
|
||||
ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true);
|
||||
} catch (Throwable workspaceError) {
|
||||
ModdedIrisLog.error("Iris workspace write failed for {}", packFolder, workspaceError);
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_FAILED_WRITE_WORKSPACE,
|
||||
MessageArgument.untrusted("value", packFolder.getAbsolutePath()),
|
||||
MessageArgument.untrusted("value2", String.valueOf(workspaceError.getMessage())))));
|
||||
}
|
||||
server.execute(() -> {
|
||||
if (owner.equals(CONSOLE_OWNER)) {
|
||||
injectConsole(source, server, dimensionId, pack, seed);
|
||||
} else {
|
||||
injectAndTeleport(source, server, owner, dimensionId, pack, seed);
|
||||
}
|
||||
});
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris studio open failed for {}", pack, e);
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e)))));
|
||||
ModdedWorkspaceGenerator.writeWorkspace(data, packFolder, true);
|
||||
server.execute(() -> executeStudioOpen(
|
||||
source,
|
||||
server,
|
||||
owner,
|
||||
dimensionId,
|
||||
pack,
|
||||
seed,
|
||||
transition));
|
||||
} catch (Throwable failure) {
|
||||
transition.completeExceptionally(failure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void injectConsole(CommandSourceStack source, MinecraftServer server, String dimensionId, String pack, long seed) {
|
||||
ModdedDimensionManager.Handle handle;
|
||||
private static void executeStudioOpen(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
UUID owner,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
long seed,
|
||||
CompletableFuture<Void> transition
|
||||
) {
|
||||
ModdedDimensionManager.Handle handle = null;
|
||||
try {
|
||||
replaceExistingStudio(server, owner, dimensionId);
|
||||
handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris console studio injection failed for {} ({})", dimensionId, pack, e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_INJECTION_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return;
|
||||
}
|
||||
STUDIOS.put(CONSOLE_OWNER, dimensionId);
|
||||
ServerLevel studio = handle.level();
|
||||
int surface = studio.getMaxY();
|
||||
try {
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
if (engine != null) {
|
||||
surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
STUDIOS.put(owner, dimensionId);
|
||||
if (owner.equals(CONSOLE_OWNER)) {
|
||||
completeConsoleOpen(source, dimensionId, pack, seed, handle);
|
||||
transition.complete(null);
|
||||
return;
|
||||
}
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(owner);
|
||||
if (player == null) {
|
||||
throw new IllegalStateException("Studio owner disconnected before teleport.");
|
||||
}
|
||||
ServerLevel studio = handle.level();
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
if (engine == null) {
|
||||
throw new IllegalStateException("Studio engine is unavailable for " + dimensionId + ".");
|
||||
}
|
||||
int surfaceY = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
CompletableFuture<Boolean> teleport = ModdedDimensionManager.teleportAsync(
|
||||
player,
|
||||
server,
|
||||
studio,
|
||||
8.5D,
|
||||
surfaceY,
|
||||
8.5D);
|
||||
teleport.whenComplete((success, failure) -> server.execute(() -> {
|
||||
if (transition.isDone()) {
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
transition.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
transition.completeExceptionally(new IllegalStateException(
|
||||
"Studio native teleport did not complete successfully."));
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId),
|
||||
MessageArgument.untrusted("pack", pack),
|
||||
MessageArgument.untrusted("seed", seed)));
|
||||
transition.complete(null);
|
||||
}));
|
||||
} catch (Throwable failure) {
|
||||
transition.completeExceptionally(failure);
|
||||
if (handle != null && transition.isCompletedExceptionally()) {
|
||||
cleanupFailedOpen(server, owner, dimensionId, failure);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris console studio surface probe failed for {}", dimensionId, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void replaceExistingStudio(MinecraftServer server, UUID owner, String dimensionId) {
|
||||
if (ModdedDimensionManager.level(server, dimensionId) != null
|
||||
|| ModdedDimensionManager.handle(dimensionId) != null) {
|
||||
ModdedDimensionManager.remove(server, dimensionId, true);
|
||||
}
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
}
|
||||
|
||||
private static void cleanupFailedOpen(
|
||||
MinecraftServer server,
|
||||
UUID owner,
|
||||
String dimensionId,
|
||||
Throwable failure
|
||||
) {
|
||||
try {
|
||||
ModdedDimensionManager.remove(server, dimensionId, true);
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
} catch (Throwable cleanupFailure) {
|
||||
failure.addSuppressed(cleanupFailure);
|
||||
ModdedIrisLog.error("Iris failed to clean up Studio '{}' after open failure",
|
||||
dimensionId, cleanupFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private static void completeConsoleOpen(
|
||||
CommandSourceStack source,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
long seed,
|
||||
ModdedDimensionManager.Handle handle
|
||||
) {
|
||||
ServerLevel studio = handle.level();
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
int surface = engine == null
|
||||
? studio.getMinY() + 1
|
||||
: engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
surface = Math.max(studio.getMinY() + 1, Math.min(studio.getMaxY() - 2, surface));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_CONSOLE_STUDIO_OPEN_NOW_RUNS_SEED_TRANSIENT_NOT_WRITTEN_IRIS, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_ENTER_IT_WITH_EXECUTE_RUN_TP_S_8_5_8, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("surface", surface)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_PREGEN_IT_WITH_IRIS_PREGEN_START_RADIUS, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_REMOVE_IT_WITH_IRIS_STUDIO_CLOSE));
|
||||
}
|
||||
|
||||
private static void injectAndTeleport(CommandSourceStack source, MinecraftServer server, UUID owner, String dimensionId, String pack, long seed) {
|
||||
ServerPlayer player = server.getPlayerList().getPlayer(owner);
|
||||
if (player == null) {
|
||||
return;
|
||||
}
|
||||
ModdedDimensionManager.Handle handle;
|
||||
try {
|
||||
handle = ModdedDimensionManager.create(server, dimensionId, pack, pack, seed);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris studio injection failed for {} ({})", dimensionId, pack, e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_INJECTION_FAILED_2, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return;
|
||||
}
|
||||
STUDIOS.put(owner, dimensionId);
|
||||
ServerLevel studio = handle.level();
|
||||
int surface = studio.getMaxY();
|
||||
try {
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
if (engine != null) {
|
||||
surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
private static void reportOpenFailure(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
String dimensionId,
|
||||
String pack,
|
||||
CompletableFuture<Void> transition
|
||||
) {
|
||||
transition.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) {
|
||||
return;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris studio surface probe failed for {}", dimensionId, e);
|
||||
}
|
||||
player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.<Relative>of(), player.getYRot(), player.getXRot(), false);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_NOW_RUNS_SEED_USE_IRIS_STUDIO_CLOSE_WHEN, MessageArgument.untrusted("dimensionId", dimensionId), MessageArgument.untrusted("pack", pack), MessageArgument.untrusted("seed", seed)));
|
||||
Throwable cause = unwrapFailure(failure);
|
||||
ModdedIrisLog.error("Iris Studio open failed for '{}' ({})", dimensionId, pack, cause);
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED,
|
||||
MessageArgument.untrusted("value", cause.getClass().getSimpleName()),
|
||||
MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))));
|
||||
});
|
||||
}
|
||||
|
||||
private static int close(CommandSourceStack source) {
|
||||
ServerPlayer player = source.getPlayer();
|
||||
MinecraftServer server = source.getServer();
|
||||
UUID owner = player == null ? CONSOLE_OWNER : player.getUUID();
|
||||
// Commit the ownership drop only after removal succeeds: dropping it first orphaned a
|
||||
// still-registered studio that no command could ever remove again.
|
||||
String dimensionId = STUDIOS.get(owner);
|
||||
if (dimensionId == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
|
||||
return 0;
|
||||
}
|
||||
try {
|
||||
ModdedDimensionManager.remove(server, dimensionId, true);
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris studio close failed for {}", dimensionId, e);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED, MessageArgument.untrusted("value", e.getClass().getSimpleName()), MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(e))));
|
||||
return 0;
|
||||
}
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
TRANSITIONS.submit(owner, () -> closeTransition(source, server, owner));
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> closeTransition(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
UUID owner
|
||||
) {
|
||||
CompletableFuture<Void> transition = new CompletableFuture<>();
|
||||
server.execute(() -> {
|
||||
String dimensionId = STUDIOS.get(owner);
|
||||
if (dimensionId == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN));
|
||||
transition.complete(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
ModdedDimensionManager.remove(server, dimensionId, true);
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSED_WAS_EVACUATED_UNLOADED_ITS_REGION_DATA_DELETED,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
transition.complete(null);
|
||||
} catch (Throwable failure) {
|
||||
ModdedIrisLog.error("Iris Studio close failed for {}", dimensionId, failure);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_CLOSE_FAILED,
|
||||
MessageArgument.untrusted("value", failure.getClass().getSimpleName()),
|
||||
MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(failure))));
|
||||
transition.completeExceptionally(failure);
|
||||
}
|
||||
});
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static int status(CommandSourceStack source) {
|
||||
MinecraftServer server = source.getServer();
|
||||
List<ModdedDimensionManager.Handle> handles = ModdedDimensionManager.handles();
|
||||
@@ -568,31 +690,102 @@ public final class ModdedStudioCommands {
|
||||
return 0;
|
||||
}
|
||||
MinecraftServer server = source.getServer();
|
||||
String dimensionId = STUDIOS.get(player.getUUID());
|
||||
if (dimensionId == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN_2));
|
||||
return 0;
|
||||
}
|
||||
ServerLevel studio = ModdedDimensionManager.level(server, dimensionId);
|
||||
if (studio == null) {
|
||||
STUDIOS.remove(player.getUUID());
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO));
|
||||
return 0;
|
||||
}
|
||||
int surface = studio.getMaxY();
|
||||
try {
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
if (engine != null) {
|
||||
surface = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
ModdedIrisLog.error("Iris tpstudio surface probe failed", e);
|
||||
}
|
||||
player.teleportTo(studio, 8.5D, surface, 8.5D, java.util.Set.<Relative>of(), player.getYRot(), player.getXRot(), false);
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TELEPORTED_YOUR_STUDIO, MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
UUID owner = player.getUUID();
|
||||
CompletableFuture<Void> transition = TRANSITIONS.submit(
|
||||
owner,
|
||||
() -> teleportToStudio(source, server, owner));
|
||||
reportTeleportFailure(source, server, transition);
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static CompletableFuture<Void> teleportToStudio(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
UUID owner
|
||||
) {
|
||||
CompletableFuture<Void> transition = new CompletableFuture<>();
|
||||
server.execute(() -> {
|
||||
if (transition.isDone()) {
|
||||
return;
|
||||
}
|
||||
String dimensionId = STUDIOS.get(owner);
|
||||
if (dimensionId == null) {
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOU_DO_NOT_HAVE_OPEN_STUDIO_USE_IRIS_STUDIO_OPEN_2));
|
||||
transition.complete(null);
|
||||
return;
|
||||
}
|
||||
ServerLevel studio = ModdedDimensionManager.level(server, dimensionId);
|
||||
if (studio == null) {
|
||||
STUDIOS.remove(owner, dimensionId);
|
||||
IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_YOUR_STUDIO_DIMENSION_IS_NO_LONGER_LOADED_USE_IRIS_STUDIO));
|
||||
transition.complete(null);
|
||||
return;
|
||||
}
|
||||
ServerPlayer activePlayer = server.getPlayerList().getPlayer(owner);
|
||||
Engine engine = IrisModdedCommands.engineFor(studio);
|
||||
if (activePlayer == null || engine == null) {
|
||||
transition.completeExceptionally(new IllegalStateException(
|
||||
"Studio player or engine is unavailable for teleport."));
|
||||
return;
|
||||
}
|
||||
int surfaceY = engine.getMinHeight() + engine.getHeight(8, 8, false) + 2;
|
||||
CompletableFuture<Boolean> teleport = ModdedDimensionManager.teleportAsync(
|
||||
activePlayer,
|
||||
server,
|
||||
studio,
|
||||
8.5D,
|
||||
surfaceY,
|
||||
8.5D);
|
||||
teleport.whenComplete((success, failure) -> server.execute(() -> {
|
||||
if (transition.isDone()) {
|
||||
return;
|
||||
}
|
||||
if (failure != null) {
|
||||
transition.completeExceptionally(failure);
|
||||
return;
|
||||
}
|
||||
if (!Boolean.TRUE.equals(success)) {
|
||||
transition.completeExceptionally(new IllegalStateException(
|
||||
"Studio native teleport did not complete successfully."));
|
||||
return;
|
||||
}
|
||||
IrisModdedCommands.ok(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_TELEPORTED_YOUR_STUDIO,
|
||||
MessageArgument.untrusted("dimensionId", dimensionId)));
|
||||
transition.complete(null);
|
||||
}));
|
||||
});
|
||||
return transition;
|
||||
}
|
||||
|
||||
private static void reportTeleportFailure(
|
||||
CommandSourceStack source,
|
||||
MinecraftServer server,
|
||||
CompletableFuture<Void> transition
|
||||
) {
|
||||
transition.whenComplete((ignored, failure) -> {
|
||||
if (failure == null) {
|
||||
return;
|
||||
}
|
||||
Throwable cause = unwrapFailure(failure);
|
||||
ModdedIrisLog.error("Iris Studio teleport failed", cause);
|
||||
server.execute(() -> IrisModdedCommands.fail(source, IrisLanguage.plain(
|
||||
ModdedCommandMessages.MODDED_STUDIO_COMMANDS_STUDIO_OPEN_FAILED,
|
||||
MessageArgument.untrusted("value", cause.getClass().getSimpleName()),
|
||||
MessageArgument.trusted("errorMessage", IrisLanguage.errorDetail(cause)))));
|
||||
});
|
||||
}
|
||||
|
||||
private static Throwable unwrapFailure(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause instanceof CompletionException && cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause;
|
||||
}
|
||||
|
||||
private static int version(CommandSourceStack source, String pack) {
|
||||
File folder = resolvePack(source, pack);
|
||||
if (folder == null) {
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Iris is a World Generator for Minecraft Servers
|
||||
* Copyright (c) 2026 Arcane Arts (Volmit Software)
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
final class ModdedStudioTransitionQueue {
|
||||
private final Object lock = new Object();
|
||||
private final Map<UUID, CompletableFuture<Void>> tails = new HashMap<>();
|
||||
|
||||
CompletableFuture<Void> submit(UUID owner, Supplier<CompletableFuture<Void>> transition) {
|
||||
Objects.requireNonNull(owner, "Studio transition owner");
|
||||
Objects.requireNonNull(transition, "Studio transition");
|
||||
synchronized (lock) {
|
||||
CompletableFuture<Void> previous = tails.get(owner);
|
||||
CompletableFuture<Void> admission = previous == null
|
||||
? CompletableFuture.completedFuture(null)
|
||||
: previous.handle((ignored, failure) -> null);
|
||||
CompletableFuture<Void> current = admission.thenCompose((ignored) -> transition.get());
|
||||
tails.put(owner, current);
|
||||
current.whenComplete((ignored, failure) -> remove(owner, current));
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
void clear() {
|
||||
synchronized (lock) {
|
||||
tails.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void remove(UUID owner, CompletableFuture<Void> transition) {
|
||||
synchronized (lock) {
|
||||
tails.remove(owner, transition);
|
||||
}
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -24,12 +24,13 @@ import art.arcane.iris.core.gui.GuiOverlay;
|
||||
import art.arcane.iris.engine.IrisComplex;
|
||||
import art.arcane.iris.engine.framework.Engine;
|
||||
import art.arcane.iris.engine.framework.render.RenderType;
|
||||
import art.arcane.iris.modded.ModdedDimensionManager;
|
||||
import art.arcane.iris.modded.ModdedIrisLog;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.level.levelgen.Heightmap;
|
||||
import net.minecraft.world.phys.Vec3;
|
||||
|
||||
import java.awt.Desktop;
|
||||
@@ -37,6 +38,8 @@ import java.io.File;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
@@ -80,11 +83,14 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
|
||||
@Override
|
||||
public void teleport(double worldX, double worldZ) {
|
||||
int blockX = (int) worldX;
|
||||
int blockZ = (int) worldZ;
|
||||
int blockX = (int) Math.floor(worldX);
|
||||
int blockZ = (int) Math.floor(worldZ);
|
||||
server.execute(() -> {
|
||||
ServerPlayer player = opener == null ? null : server.getPlayerList().getPlayer(opener);
|
||||
if (player == null) {
|
||||
if (opener != null) {
|
||||
return;
|
||||
}
|
||||
List<ServerPlayer> players = level.players();
|
||||
if (players.isEmpty()) {
|
||||
return;
|
||||
@@ -92,8 +98,24 @@ public final class ModdedVisionOverlay implements GuiOverlay {
|
||||
player = players.get(0);
|
||||
}
|
||||
int surfaceY = engine.getMinHeight() + engine.getHeight(blockX, blockZ, false) + 2;
|
||||
int safeY = Math.max(surfaceY, level.getHeight(Heightmap.Types.MOTION_BLOCKING, blockX, blockZ) + 1);
|
||||
player.teleportTo(level, blockX + 0.5D, safeY, blockZ + 0.5D, java.util.Set.of(), player.getYRot(), player.getXRot(), false);
|
||||
CompletableFuture<Boolean> teleport = ModdedDimensionManager.teleportAsync(
|
||||
player,
|
||||
server,
|
||||
level,
|
||||
blockX + 0.5D,
|
||||
surfaceY,
|
||||
blockZ + 0.5D,
|
||||
System.nanoTime() + TimeUnit.SECONDS.toNanos(10L));
|
||||
UUID playerId = player.getUUID();
|
||||
teleport.whenComplete((success, failure) -> {
|
||||
if (failure != null) {
|
||||
ModdedIrisLog.error("Iris Vision teleport failed for {} at {},{}",
|
||||
playerId, blockX, blockZ, failure);
|
||||
} else if (!Boolean.TRUE.equals(success)) {
|
||||
ModdedIrisLog.warn("Iris Vision teleport did not complete for {} at {},{}",
|
||||
playerId, blockX, blockZ);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class ModdedDimensionManagerTeleportTest {
|
||||
@Test
|
||||
public void blockCoordinatesUseFloorAcrossZeroAndChunkBoundaries() {
|
||||
assertEquals(0, ModdedTeleportBounds.blockCoordinate(0.99D));
|
||||
assertEquals(-1, ModdedTeleportBounds.blockCoordinate(-0.01D));
|
||||
assertEquals(-17, ModdedTeleportBounds.blockCoordinate(-16.01D));
|
||||
assertEquals(16, ModdedTeleportBounds.blockCoordinate(16D));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void teleportYReservesSupportFeetAndHeadSpace() {
|
||||
assertEquals(-63, ModdedTeleportBounds.clampY(-64, 320, -100));
|
||||
assertEquals(318, ModdedTeleportBounds.clampY(-64, 320, 400));
|
||||
assertEquals(72, ModdedTeleportBounds.clampY(-64, 320, 72));
|
||||
}
|
||||
|
||||
@Test(expected = IllegalArgumentException.class)
|
||||
public void teleportYRejectsWorldsWithoutThreeVerticalBlocks() {
|
||||
ModdedTeleportBounds.clampY(0, 2, 1);
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package art.arcane.iris.modded;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedDynamicLevelLifecycleContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void sharedManagerPublishesDynamicLoadAndUnloadAroundRegistration() throws IOException {
|
||||
String manager = commonSource("ModdedDimensionManager.java");
|
||||
String loader = commonSource("ModdedLoader.java");
|
||||
String injection = method(manager, "private static Handle inject(");
|
||||
String removal = method(manager, "public static boolean remove(");
|
||||
String injectionRollback = method(manager, "private static void rollbackInjection(");
|
||||
String removalRollback = method(manager, "private static void rollbackRemoval(");
|
||||
|
||||
assertTrue(loader.contains("void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level);"));
|
||||
assertTrue(loader.contains("void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level);"));
|
||||
assertBefore(injection, "serverAccess.putLevelIfAbsent(server, key, level);",
|
||||
"fireDynamicLevelLoad(server, level);");
|
||||
assertBefore(removal, "fireDynamicLevelUnload(server, level);",
|
||||
"serverAccess.removeLevel(server, key);");
|
||||
assertTrue(injectionRollback.contains("fireDynamicLevelUnload(server, level);"));
|
||||
assertTrue(removalRollback.contains("fireDynamicLevelLoad(server, level);"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void everyLoaderUsesItsNativeLifecycleBus() throws IOException {
|
||||
String fabric = loaderSource("fabric", "art/arcane/iris/fabric/FabricModdedLoader.java");
|
||||
String forge = loaderSource("forge", "art/arcane/iris/forge/ForgeModdedLoader.java");
|
||||
String neoForge = loaderSource("neoforge", "art/arcane/iris/neoforge/NeoForgeModdedLoader.java");
|
||||
|
||||
assertTrue(fabric.contains("ServerLevelEvents.LOAD.invoker().onLevelLoad(server, level);"));
|
||||
assertTrue(fabric.contains("ServerLevelEvents.UNLOAD.invoker().onLevelUnload(server, level);"));
|
||||
assertTrue(forge.contains("LevelEvent.Load.BUS.post(new LevelEvent.Load(level));"));
|
||||
assertTrue(forge.contains("LevelEvent.Unload.BUS.post(new LevelEvent.Unload(level));"));
|
||||
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));"));
|
||||
assertTrue(neoForge.contains("NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));"));
|
||||
}
|
||||
|
||||
private static String commonSource(String file) throws IOException {
|
||||
return Files.readString(commonRoot().resolve("art/arcane/iris/modded").resolve(file))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String loaderSource(String loader, String relative) throws IOException {
|
||||
Path adaptersRoot = commonRoot().getParent().getParent().getParent().getParent();
|
||||
return Files.readString(adaptersRoot.resolve(loader).resolve("src/main/java").resolve(relative))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static Path commonRoot() {
|
||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
if (root == null || root.isBlank()) {
|
||||
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
|
||||
}
|
||||
return Path.of(root);
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
if (start < 0) {
|
||||
throw new IllegalArgumentException("Missing source method " + signature);
|
||||
}
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}' && --depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source method " + signature);
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source token " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source token " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -65,7 +65,7 @@ public class ModdedLifecycleFailureContractTest {
|
||||
String reinjection = method(source, "private static void reinjectPersistentDimensions(");
|
||||
String failure = catchBlock(reinjection);
|
||||
|
||||
assertTrue(failure.contains("LOGGER.error("));
|
||||
assertTrue(failure.contains("ModdedIrisLog.error("));
|
||||
assertFalse(failure.contains("e.toString()"));
|
||||
assertFalse(failure.contains("throw new IllegalStateException("));
|
||||
assertTrue(reinjection.contains("injected++;"));
|
||||
@@ -152,7 +152,7 @@ public class ModdedLifecycleFailureContractTest {
|
||||
assertBefore(stop, "\"world engines\"", "\"dimension manager\"");
|
||||
assertBefore(stop, "\"server state\"", "if (failure != null)");
|
||||
assertFalse(stop.contains("throw"));
|
||||
assertTrue(stop.contains("LOGGER.error(\"Iris modded shutdown completed with failures\", failure);"));
|
||||
assertTrue(stop.contains("ModdedIrisLog.error(\"Iris modded shutdown completed with failures\", failure);"));
|
||||
|
||||
String runStage = method(source, "private static Throwable runStopStage(");
|
||||
assertTrue(runStage.contains("catch (Throwable stageFailure)"));
|
||||
@@ -207,21 +207,21 @@ public class ModdedLifecycleFailureContractTest {
|
||||
String bootstrapSource = source("ModdedEngineBootstrap.java");
|
||||
String unload = method(bootstrapSource, "public static void levelUnloaded(ServerLevel level)");
|
||||
String failure = catchBlock(unload);
|
||||
assertTrue(failure.contains("LOGGER.error("));
|
||||
assertTrue(failure.contains("ModdedIrisLog.error("));
|
||||
assertTrue(failure.contains("throw "));
|
||||
|
||||
String managerSource = source("ModdedDimensionManager.java");
|
||||
String remove = method(managerSource, "public static boolean remove(MinecraftServer server, String dimensionId, boolean wipeStorage)");
|
||||
assertTrue(remove.contains("ModdedWorldEngines.evictOrThrow(level);"));
|
||||
assertFalse(remove.contains("ModdedWorldEngines.evict(level);"));
|
||||
assertTrue(remove.contains("generatorUnbound = true;"));
|
||||
assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, generatorUnbound, e);"));
|
||||
assertTrue(remove.contains("unloadEventStarted = true;"));
|
||||
assertTrue(remove.contains("rollbackRemoval(server, serverAccess, key, level, generator, unloadEventStarted, e);"));
|
||||
|
||||
String rollback = method(managerSource, "private static void rollbackRemoval(");
|
||||
assertTrue(rollback.contains("serverAccess.hasLevel(server, key)"));
|
||||
assertTrue(rollback.contains("generator.bindLevel(level);"));
|
||||
assertTrue(rollback.contains("failure.addSuppressed(rollbackFailure);"));
|
||||
assertTrue(rollback.contains("LOGGER.error("));
|
||||
assertTrue(rollback.contains("ModdedIrisLog.error("));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+8
@@ -45,6 +45,14 @@ public class ModdedPlatformPathsTest {
|
||||
public void invalidateLevelCache(MinecraftServer server) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clientEnvironment() {
|
||||
return false;
|
||||
|
||||
+1
-1
@@ -186,7 +186,7 @@ public class IrisModdedStructureCommandTest {
|
||||
assertTrue(command.contains("ModdedLocateCommands.registeredStructureUnavailableMessage("));
|
||||
assertTrue(command.contains("engine.getData().getStructureLoader().getPossibleKeys()"));
|
||||
assertTrue(command.contains("IrisStructureLocator.hasLocatableEditablePlacement(engine, key)"));
|
||||
assertTrue(command.contains("LOGGER.info(\"[Iris goto unregistered] [{}] {} - {}\""));
|
||||
assertTrue(command.contains("ModdedIrisLog.info(\"[Iris goto unregistered] [{}] {} - {}\""));
|
||||
assertTrue(command.contains("UNREGISTERED(\"unregistered\")"));
|
||||
assertFalse(command.contains("DatapackIngestService"));
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedStudioTeleportContractTest {
|
||||
private static final String SOURCE_ROOT_PROPERTY = "iris.moddedCommonSources";
|
||||
|
||||
@Test
|
||||
public void studioOpenSerializesReplacementAndWaitsForTheNativePath() throws IOException {
|
||||
String source = source("command/ModdedStudioCommands.java");
|
||||
String open = method(source, "private static int open(");
|
||||
String execute = method(source, "private static void executeStudioOpen(");
|
||||
|
||||
assertTrue(open.contains("TRANSITIONS.submit("));
|
||||
assertFalse(open.contains("orTimeout("));
|
||||
assertFalse(open.contains("deadlineNanos"));
|
||||
assertBefore(execute, "replaceExistingStudio(", "ModdedDimensionManager.create(");
|
||||
assertBefore(execute, "ModdedDimensionManager.create(", "ModdedDimensionManager.teleportAsync(");
|
||||
assertFalse(execute.contains("deadlineNanos"));
|
||||
assertFalse(source.contains("player.teleportTo(studio"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void studioTeleportAndVisionShareWarmFutureSemantics() throws IOException {
|
||||
String studio = source("command/ModdedStudioCommands.java");
|
||||
String vision = source("command/ModdedVisionOverlay.java");
|
||||
String dimensions = source("ModdedDimensionManager.java");
|
||||
|
||||
assertTrue(studio.contains("private static CompletableFuture<Void> teleportToStudio("));
|
||||
assertTrue(studio.contains("ModdedDimensionManager.teleportAsync("));
|
||||
assertTrue(dimensions.contains("TELEPORT_WARM_RADIUS = 0"));
|
||||
assertTrue(dimensions.contains("addTicketAndLoadWithRadius(\n"
|
||||
+ " TELEPORT_WARM_TICKET,\n"
|
||||
+ " chunkPos,\n"
|
||||
+ " TELEPORT_WARM_RADIUS)"));
|
||||
assertTrue(dimensions.contains("removeTicketWithRadius(\n"
|
||||
+ " TELEPORT_WARM_TICKET,\n"
|
||||
+ " chunkPos,\n"
|
||||
+ " TELEPORT_WARM_RADIUS)"));
|
||||
assertTrue(vision.contains("Math.floor(worldX)"));
|
||||
assertTrue(vision.contains("Math.floor(worldZ)"));
|
||||
assertTrue(vision.contains("if (opener != null)"));
|
||||
assertTrue(vision.contains("ModdedDimensionManager.teleportAsync("));
|
||||
assertFalse(vision.contains("level.getHeight("));
|
||||
assertFalse(vision.contains("player.teleportTo("));
|
||||
}
|
||||
|
||||
private static String source(String relative) throws IOException {
|
||||
String root = System.getProperty(SOURCE_ROOT_PROPERTY);
|
||||
if (root == null || root.isBlank()) {
|
||||
throw new IllegalStateException("Missing test property " + SOURCE_ROOT_PROPERTY);
|
||||
}
|
||||
return Files.readString(Path.of(root).resolve("art/arcane/iris/modded").resolve(relative))
|
||||
.replace("\r\n", "\n");
|
||||
}
|
||||
|
||||
private static String method(String source, String signature) {
|
||||
int start = source.indexOf(signature);
|
||||
if (start < 0) {
|
||||
throw new IllegalArgumentException("Missing source method " + signature);
|
||||
}
|
||||
int open = source.indexOf('{', start);
|
||||
int depth = 0;
|
||||
for (int index = open; index < source.length(); index++) {
|
||||
char current = source.charAt(index);
|
||||
if (current == '{') {
|
||||
depth++;
|
||||
} else if (current == '}' && --depth == 0) {
|
||||
return source.substring(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("Unclosed source method " + signature);
|
||||
}
|
||||
|
||||
private static void assertBefore(String source, String first, String second) {
|
||||
int firstIndex = source.indexOf(first);
|
||||
int secondIndex = source.indexOf(second);
|
||||
assertTrue("Missing source token " + first, firstIndex >= 0);
|
||||
assertTrue("Missing source token " + second, secondIndex >= 0);
|
||||
assertTrue(first + " must precede " + second, firstIndex < secondIndex);
|
||||
}
|
||||
}
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
package art.arcane.iris.modded.command;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
public class ModdedStudioTransitionQueueTest {
|
||||
@Test
|
||||
public void sameOwnerTransitionsRunInSubmissionOrder() {
|
||||
ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue();
|
||||
UUID owner = UUID.randomUUID();
|
||||
CompletableFuture<Void> firstGate = new CompletableFuture<>();
|
||||
AtomicInteger starts = new AtomicInteger();
|
||||
|
||||
CompletableFuture<Void> first = queue.submit(owner, () -> {
|
||||
starts.incrementAndGet();
|
||||
return firstGate;
|
||||
});
|
||||
CompletableFuture<Void> second = queue.submit(owner, () -> {
|
||||
starts.incrementAndGet();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
|
||||
assertEquals(1, starts.get());
|
||||
assertFalse(second.isDone());
|
||||
firstGate.complete(null);
|
||||
assertTrue(first.isDone());
|
||||
assertTrue(second.isDone());
|
||||
assertEquals(2, starts.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void differentOwnersDoNotBlockEachOther() {
|
||||
ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue();
|
||||
CompletableFuture<Void> firstGate = new CompletableFuture<>();
|
||||
|
||||
CompletableFuture<Void> first = queue.submit(UUID.randomUUID(), () -> firstGate);
|
||||
CompletableFuture<Void> second = queue.submit(
|
||||
UUID.randomUUID(),
|
||||
() -> CompletableFuture.completedFuture(null));
|
||||
|
||||
assertFalse(first.isDone());
|
||||
assertTrue(second.isDone());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedTransitionDoesNotPoisonTheOwnerQueue() {
|
||||
ModdedStudioTransitionQueue queue = new ModdedStudioTransitionQueue();
|
||||
UUID owner = UUID.randomUUID();
|
||||
CompletableFuture<Void> failure = CompletableFuture.failedFuture(
|
||||
new IllegalStateException("expected"));
|
||||
AtomicInteger starts = new AtomicInteger();
|
||||
|
||||
CompletableFuture<Void> first = queue.submit(owner, () -> failure);
|
||||
CompletableFuture<Void> second = queue.submit(owner, () -> {
|
||||
starts.incrementAndGet();
|
||||
return CompletableFuture.completedFuture(null);
|
||||
});
|
||||
|
||||
assertTrue(first.isCompletedExceptionally());
|
||||
assertTrue(second.isDone());
|
||||
assertEquals(1, starts.get());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.level.block.state.BlockState;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.level.LevelEvent;
|
||||
import net.neoforged.neoforge.event.level.block.BreakBlockEvent;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.loading.FMLEnvironment;
|
||||
@@ -77,6 +78,16 @@ public final class NeoForgeModdedLoader implements ModdedLoader {
|
||||
server.markWorldsDirty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelLoad(MinecraftServer server, ServerLevel level) {
|
||||
NeoForge.EVENT_BUS.post(new LevelEvent.Load(level));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void fireDynamicLevelUnload(MinecraftServer server, ServerLevel level) {
|
||||
NeoForge.EVENT_BUS.post(new LevelEvent.Unload(level));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean clientEnvironment() {
|
||||
return FMLEnvironment.getDist().isClient();
|
||||
|
||||
Reference in New Issue
Block a user